红魔咖啡馆

头发越掉越多,头发越掉越少

0%

【KouOJ】后端部分

后端

项目架构

1
2
3
4
5
6
7
8
9
backend/
    manage.py
    config/
    apps/
        accounts/
        problems/
        submissions/
        judge/
    utils/

manage.py

Django项目的执行入口,经常用于各种命令行操作的执行,进入本项目

这个文件里的内容基本固定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys


def main():
    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
    try:
        from django.core.management import execute_from_command_line
    except ImportError as exc:
        raise ImportError(
            "Couldn't import Django. Is it installed and available on your "
            "PYTHONPATH environment variable? Did you forget to activate a "
            "virtual environment?"
        ) from exc
    execute_from_command_line(sys.argv)


if __name__ == "__main__":
    main()

其中较为重要的是第八行,它设置了本项目的配置文件位于config/setting.py

config

该文件夹内存储了整个项目的配置文件

setting.py

存储了DJango自带的功能、第三方库、数据库连接、用户模型等各种配置,以及我们自己写的各种业务模块

因此我们在后面每创建一个新app,都要放进该文件的对应位置

其中AUTH_USER_MODEL字段指定了使用的用户模型,格式为app_label.ModelName

urls.py

负责存储API总路由,并进行路由的分发,如目前

1
2
3
4
5
6
7
8
urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/auth/login/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
    path("api/auth/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
    path("api/auth/", include("apps.accounts.urls")),
    path("api/", include("apps.problems.urls")),
    path("api/submissions/", include("apps.submissions.urls")),
]

分别存储了Django-admin接口,登录接口,accounts这个app的接口,problems这个app的接口,submissions这个app的接口

这里不写具体业务逻辑

apps

写业务代码的目录,对于一个OJ系统,我们有许多个不同业务,需要将他们拆开为独立的模块分别处理

如这里我们拆成了如下系统

1
2
3
4
5
backend/apps/
    accounts/
    problems/
    submissions/
    judge/

对于每个模块,在Django+DRF中最常见的分层如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
models.py
定义数据库长什么样

serializers.py
定义 API 输入输出长什么样

views.py
定义请求来了以后做什么

urls.py
定义URL对应哪个view

admin.py
定义后台管理页面怎么展示

accounts/

负责用户表、注册接口、用户信息接口、Django-admin用户管理

架构:

1
2
3
4
5
6
7
8
9
backend/apps/accounts/
	__init__.py
    models.py
    serializers.py
    views.py
    urls.py
    admin.py
    apps.py
    migrations/

负责的链路

accounts负责了用户的创建和获取,具体链路如下:

注册

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
POST /api/auth/register/
        |
        v
config/urls.py
        |
        v
accounts/urls.py
        |
        v
RegisterView
        |
        v
RegisterSerializer
        |
        v
User.objects.create_user()
        |
        v
accounts_user 表

获取当前用户

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GET /api/auth/me/
Authorization: Bearer xxx
        |
        v
JWT 认证
        |
        v
MeView
        |
        v
request.user
        |
        v
UserSerializer
        |
        v
返回用户 JSON

__init__.py

告诉Python这个accounts是一个python包

apps.py

1
2
3
4
5
6
from django.apps import AppConfig

class AccountsConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "apps.accounts"
    label = "accounts"

指出该app的python路径是app.accounts,和settings.py一致

在Django内叫accounts,默认主键为BigAutoField

models.py

用户表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from django.contrib.auth.models import AbstractUser
from django.db import models


class User(AbstractUser):
    class Role(models.TextChoices):
        USER = "user", "普通用户"
        ADMIN = "admin", "管理员"

    role = models.CharField(max_length=20, choices=Role.choices, default=Role.USER)
    solved_count = models.PositiveIntegerField(default=0)
    submit_count = models.PositiveIntegerField(default=0)

    @property
    def is_admin(self):
        return self.role == self.Role.ADMIN or self.is_staff

可以直接继承Django内的用户系统AbstractUser,内部已经包含如下字段:

1
2
3
4
5
6
7
8
9
10
username
password
email
first_name
last_name
is_staff
is_active
is_superuser
last_login
date_joined

继承后可以继续增加自己的字段

下面我们创建了一个Role类,用于定义枚举用户类型,这是Django推荐的枚举写法,用来限制role字段只能在设定的固定值来选

因此定义字段role = models.CharField(max_length=20, choices=Role.choices, default=Role.USER)

后面property定义了一个管理员属性,即判断role是管理员或Django内自带字段是管理员,就默认这个用户是管理员,权限判断时直接调用request.user.is_admin就可以判断是否是管理员

admin.py

负责后台信息展示

1
2
3
4
5
6
7
8
9
10
11
12
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin

from .models import User

@admin.register(User)
class CustomUserAdmin(UserAdmin):
    fieldsets = UserAdmin.fieldsets + (
        ("OJ 信息", {"fields": ("role", "solved_count", "submit_count")}),
    )
    list_display = ("id", "username", "email", "role", "is_staff", "is_active")
    list_filter = ("role", "is_staff", "is_active")

首先通过第六行将Uder模型注册到admin后台

在自定义类中继承自带的UserAdmin类,并定义自己的展示区域,如oj信息,可以编辑role、solved_count、submit_count

serializers.py

用于JSON与Django model之间的转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from django.contrib.auth import get_user_model
from rest_framework import serializers

User = get_user_model()


class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ("id", "username", "email", "role", "solved_count", "submit_count")


class RegisterSerializer(serializers.ModelSerializer):
    password = serializers.CharField(write_only=True, min_length=6)

    class Meta:
        model = User
        fields = ("id", "username", "email", "password")

    def create(self, validated_data):
        return User.objects.create_user(**validated_data)

有两个serializers,第一个UserSerializer用于把用户对象返回给前端,则接口/api/auth/me/返回

1
2
3
4
5
6
7
8
{
  "id": 1,
  "username": "alice",
  "email": "alice@example.com",
  "role": "user",
  "solved_count": 0,
  "submit_count": 0
}

第二个RegisterSerializer用于注册,注意重定义了password为writeonly,意思为前端提交password,但是后端响应的时候不会把password返回给前端

创建用户时使用create_user可以自动将明文密码哈希加密

views.py

负责接口逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from rest_framework import generics
from rest_framework.permissions import AllowAny, IsAuthenticated

from .serializers import RegisterSerializer, UserSerializer


class RegisterView(generics.CreateAPIView):
    serializer_class = RegisterSerializer
    permission_classes = [AllowAny]


class MeView(generics.RetrieveAPIView):
    serializer_class = UserSerializer
    permission_classes = [IsAuthenticated]

    def get_object(self):
        return self.request.user

第一个是负责创建对象的API,对应POST /api/auth/register

serializer_class指定注册时用哪个处理数据

permission_classes指定权限是所有人

第二个是负责获取一个对象的API,对应GET /api/auth/me,指定权限是已登录用户,获取当前登录用户

urls.py

accounts自己的路由

1
2
3
4
5
6
7
8
from django.urls import path

from .views import MeView, RegisterView

urlpatterns = [
    path("register/", RegisterView.as_view(), name="register"),
    path("me/", MeView.as_view(), name="me"),
]

里面定义了自己的路由路径,即最后会存在

1
2
/api/auth/register/
/api/auth/me/

这两个路径

登录在哪里?

在config的urls内,我们写了

1
2
path("api/auth/login/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
path("api/auth/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),

这两个views来自于simplejwt的视图

登录时前端传username和password,后端返回refresh和access

problems/

该模块负责题目的管理,包括标签、测试点、题目列表、题目详情、题目管理权限

架构:

1
2
3
4
5
6
7
backend/apps/problems/
    models.py
    serializers.py
    views.py
    urls.py
    admin.py
    permissions.py

负责的链路

通过后台添加题目:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Django Admin 添加题目
        |
        v
MySQL 保存 problems_problem/problems_testcase
        |
        v
GET /api/problems/
        |
        v
ProblemViewSet查询数据库
        |
        v
ProblemListSerializer转JSON
        |
        v
浏览器看到题目列表

查看题目详情:

1
2
3
4
5
6
7
GET /api/problems/id
        |
        v
ProblemDetailSerializer
        |
        v
题目详情+tags+sample_cases

models.py

向problems相关表内添加数据

tag表:

1
2
3
4
5
6
7
8
class Tag(models.Model):
    name = models.CharField(max_length=40, unique=True)

    class Meta:
        ordering = ["name"]

    def __str__(self):
        return self.name

tag表内只有一个字段需要填充,标签名,注意要将unique设置为True,不能重复

其中meta用于设置默认按name列排序,__str__用于后台调试直接返回标签名,而不是数字

题目表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Problem(models.Model):
    class Difficulty(models.TextChoices):
        EASY = "easy", "简单"
        MEDIUM = "medium", "中等"
        HARD = "hard", "困难"

    title = models.CharField(max_length=120)
    description = models.TextField()
    input_description = models.TextField(blank=True)
    output_description = models.TextField(blank=True)
    difficulty = models.CharField(
        max_length=20,
        choices=Difficulty.choices,
        default=Difficulty.EASY,
    )
    time_limit = models.PositiveIntegerField(default=2000, help_text="单位:ms")
    memory_limit = models.PositiveIntegerField(default=128, help_text="单位:MB")
    is_public = models.BooleanField(default=True)
    tags = models.ManyToManyField(Tag, blank=True, related_name="problems")
    created_by = models.ForeignKey(
    	settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="created_problems",
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    
    class Meta:
        ordering = ["id"]

    def __str__(self):
        return f"{self.id}. {self.title}"

题目表内的列会多一点,

前四个为题目与对应描述,描述使用了TextField,适合容纳长文本,且blank设为True,允许为空

第五个为难度,前面有一个difficulty子类,用于设定可供选择的难度枚举,在这里可以直接设为按照该枚举选择

时空限制设为PositiveIntegerField,因为只允许正数

是否公开设为BooleanField,表示是否公开

tags为多对多关系,所以使用ManyToManyField,其中related_name允许进行反查

created_by是一个外键,指向题目作者,使用ForeignKey,

settings.AUTH_USER_MODEL指向我们要用的用户模型,on_delete设置为setnull,使得用户注销后题目还存在,只是将用户设置为null

后面两个时间用DateTimeField,并自动填入

meta设置为按照id排序,str用于调试时按格式返回结果

测试点表:

1
2
3
4
5
6
7
8
9
10
11
12
13
class TestCase(models.Model):
    problem = models.ForeignKey(Problem, on_delete=models.CASCADE, related_name="test_cases")
    input_data = models.TextField(blank=True)
    output_data = models.TextField(blank=True)
    is_sample = models.BooleanField(default=False)
    score = models.PositiveIntegerField(default=0)
    order = models.PositiveIntegerField(default=0)

    class Meta:
        ordering = ["order", "id"]

    def __str__(self):
        return f"{self.problem_id} - case {self.id}"

用来表示一个测试点

其中problem是外键,表示某个测试点属于哪个题,满足一对多关系,on_delete=models.CASCADE意思是删除后下面的测试点也一起删除,related_name设为test_cases用于从题目反查测试点

is_sample用于表示测试点是否设为样例,详情接口里只会返回该字段为True的

order是测试点的顺序

因此meta里排序首先按照order,再按照id排序

admin.py

用于控制后台添加题目及相关信息

首先说明,装饰器@admin.register(...)用于将括号内的模型注册到DjangoAdmin,并用下面的配置类控制它

problemAdmin:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class TestCaseInline(admin.TabularInline):
    model = TestCase
    extra = 1
    fields = ("order", "input_data", "output_data", "is_sample", "score")
@admin.register(Problem)
class ProblemAdmin(admin.ModelAdmin):
    list_display = ("id", "title", "difficulty", "is_public", "created_at")
    list_filter = ("difficulty", "is_public", "tags")
    search_fields = ("title", "description")
    filter_horizontal = ("tags",)
    inlines = [TestCaseInline]

    def save_model(self, request, obj, form, change):
        if not obj.created_by:
            obj.created_by = request.user
        super().save_model(request, obj, form, change)

list_display用于控制题目列表页能显示哪些列

list_filter用于控制用户可以按照哪些字段筛选题目

search_fields控制后台搜索框能搜哪些字段,即你在搜索框里输入的内容,只会在你指定的字段内搜索

filter_horizontal将选择的字段以左右两个备选框的形式呈现,可以用来展示可选与已选

inlines设定了一个列表,里面存储了TestCaseInline类,由于testcase有外键指向problem,所以默认了一个测试点必须属于一道题,因此可以inline在同一个页面直接编辑测试点,

  • admin.TabularInline指用表格形式展示内联元素
  • extra意为默认额外显示几个空白表单
  • fields意为inline表单内显示哪些字段,以及顺序是什么

save_model函数用于自动设置出题人

TagAdmin:

1
2
3
4
@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
    list_display = ("id", "name")
    search_fields = ("name",)

标签后台,显示id与name,搜索字段能搜到name

TestCaseAdmin:

1
2
3
4
@admin.register(TestCase)
class TestCaseAdmin(admin.ModelAdmin):
    list_display = ("id", "problem", "is_sample", "score", "order")
    list_filter = ("is_sample",)

单独显示测试样例,可以在后台看所有测试点

右侧可以筛选是否样例

serializers.py

负责五块内容的JSON和Django模型转换

TagSerializer:

1
2
3
4
class TagSerializer(serializers.ModelSerializer):
    class Meta:
        model = Tag
        fields = ("id", "name")

将tag模型转换为JSON,其中ModelSerializer是DRF的便捷写法,可以根据Django模型自动生成字段

根据下面的meta类来确定基于的模型与需要的字段

SampleCaseSerializer:

1
2
3
4
class SampleCaseSerializer(serializers.ModelSerializer):
    class Meta:
        model = TestCase
        fields = ("id", "input_data", "output_data", "order")

与上面相同,但注意,这里字段只展示了用户需要看到的内容

ProblemListSerializer:

1
2
3
4
5
6
7
8
9
10
11
12
13
class ProblemListSerializer(serializers.ModelSerializer):
    tags = TagSerializer(many=True, read_only=True)

    class Meta:
        model = Problem
        fields = (
            "id",
            "title",
            "difficulty",
            "time_limit",
            "memory_limit",
            "tags",
        )

用于题目列表页,只需要返回需要的字段

tags那行用于获取题目的tag,由于符合一对多的特点,所以要将many设为True,另外要保证该字段只用于输出,而不会写入

ProblemDetailSerialize:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class ProblemDetailSerializer(serializers.ModelSerializer):
    tags = TagSerializer(many=True, read_only=True)
    tag_ids = serializers.PrimaryKeyRelatedField(
        source="tags",
        queryset=Tag.objects.all(),
        many=True,
        write_only=True,
        required=False,
    )
    sample_cases = serializers.SerializerMethodField()

    class Meta:
        model = Problem
        fields = (
            "id",
            "title",
            "description",
            "input_description",
            "output_description",
            "difficulty",
            "time_limit",
            "memory_limit",
            "is_public",
            "tags",
            "tag_ids",
            "sample_cases",
            "created_at",
            "updated_at",
        )
        read_only_fields = ("created_at", "updated_at")

    def get_sample_cases(self, obj):
        samples = obj.test_cases.filter(is_sample=True)
        return SampleCaseSerializer(samples, many=True).data

用于查看题目详情,实现创建于修改题目

tags用于展示标签,tag_ids负责处理样例id与题目之间的关联,前端只需要告诉后端该题目关联哪些标签即可

queryset=Tag.objects.all()用于保证传入的tagid要在tag表内找到,many指一道题可以有多个标签,write_only指ids只用于前端提交,不在响应接口内返回

sample_cases是通过一个方法计算出来的字段:

使用filter筛选样例测试点,在用serializers转换为JSON

read_only_fields指定了前端不能修改的字段

TestCaseSerializer:

1
2
3
4
class TestCaseSerializer(serializers.ModelSerializer):
    class Meta:
        model = TestCase
        fields = ("id", "problem", "input_data", "output_data", "is_sample", "score", "order")

用于管理测试点,管理员可以通过API创建测试点

views.py

负责判断前端发送请求时,后端如何操作(如何查询数据库,用哪个serializers)

这里一共有三个ViewSet,用于题目、标签和测试点

这里使用了DRF的ViewSet,可以将一组相关接口放到一个类里,默认提供GET、POST等常见方法,这样就不用手写每个接口的函数了

ProblemViewSet:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class ProblemViewSet(viewsets.ModelViewSet):
    permission_classes = [IsAdminOrReadOnly]
    filterset_fields = ("difficulty", "tags")
    search_fields = ("title", "description")
    ordering_fields = ("id", "created_at", "difficulty")

    def get_queryset(self):
        queryset = Problem.objects.prefetch_related("tags", "test_cases")
        user = self.request.user
        if not (user.is_authenticated and user.is_admin):
            queryset = queryset.filter(is_public=True)
        return queryset

    def get_serializer_class(self):
        if self.action == "list":
            return ProblemListSerializer
        return ProblemDetailSerializer

    def perform_create(self, serializer):
        serializer.save(created_by=self.request.user)

permission_classes 自定义权限,IsAdminOrReadOnly指普通用户只可读,管理员可以进行其他操作

filterset_fields筛选字段,可以通过类似GET /api/problems/?difficulty=easy请求来筛选

search_fields是DRF的搜索功能,可以通过类似GET /api/problems/?search=A+B请求来在给定的字段查询

ordering_fields是排序功能,可以通过类似GET /api/problems/?ordering=id请求来按给定字段排序

get_queryset用于决定这个接口能查到哪些题目

  • 其中prefetch_related用于预加载相关资源,防止频繁查询
  • 后面根据用户来过滤公开题,若当前用户不是管理,queryset就需要筛选只公开的题目

get_serializer_class决定当前接口用哪个serializers,如果是list接口则调用problemlist,否则调用problemdetail

perform_create在创建对象时被调用,当管理员请求POST /api/problems/,DRF会用serializer校验前端传来的数据,然后调用perform_create,然后将数据保存到数据库,且额外传入创建者是当前用户

TagViewSet:

1
2
3
4
5
class TagViewSet(viewsets.ModelViewSet):
    queryset = Tag.objects.all()
    serializer_class = TagSerializer
    permission_classes = [IsAdminOrReadOnly]
    search_fields = ("name",)

标签接口,与上面类似

TestCaseViewSet:

1
2
3
4
5
6
class TestCaseViewSet(viewsets.ModelViewSet):
    serializer_class = TestCaseSerializer
    permission_classes = [IsAdminUser]

    def get_queryset(self):
        return TestCase.objects.select_related("problem")

测试点管理接口,测试点普通用户不给看,所以设置权限仅管理员

最后返回时用到了select_related,用于外键关系,可以一块将problem查出来

permission.py

该文件负责定义题目和标签接口的访问权限

1
2
3
4
5
6
7
from rest_framework.permissions import SAFE_METHODS, BasePermission

class IsAdminOrReadOnly(BasePermission):
    def has_permission(self, request, view):
        if request.method in SAFE_METHODS:
            return True
        return bool(request.user and request.user.is_authenticated and request.user.is_admin)

DRF内,每个权限类都会继承BasePermission,并实现has_haspermission,它返回布尔值表示允许或拒绝访问,当返回拒绝时,会报403错误码

SAFE_METHODS是预定义好的一个元组,这里面的方法通常不会修改数据,都是读操作,因此如果请求方法属于这些方法,就可以直接放行

否则就只能是已登录管理员才能用

这里的request.user.is_admin来自model.py

urls.py

用于结合视图变为真正的url

1
2
3
4
5
6
7
8
9
10
from rest_framework.routers import DefaultRouter

from .views import ProblemViewSet, TagViewSet, TestCaseViewSet

router = DefaultRouter()
router.register("problems", ProblemViewSet, basename="problem")
router.register("tags", TagViewSet, basename="tag")
router.register("test-cases", TestCaseViewSet, basename="test-case")

urlpatterns = router.urls

可以发现,这里没有使用path显示指定,而是使用router,可以根据viewsset来自动生成url

DefaultRouter()是DRF的路由生成器,可以根据你注册的viewset自动生成路由

如下面使用router.register,指定视图可以直接生成路由,语法如下:

router.register(URL前缀, viewset, 路由名的基础名)

并且这些生成的路由会接上config里的urls.py,根据内部的path("api/", include("apps.problems.urls"))可知最后前面还会加上前缀/api/

最后将router自动生成的url交给Django:urlpatterns = router.urls

注意,前面account里不用router,是因为它们不是简单的CRUD,而且不是viewset,所以可以直接设置path

submissions/

负责用户提交代码、查看判题记录、保存判题结果等

架构:

1
2
3
4
5
6
backend/apps/submissions/
    models.py
    serializers.py
    views.py
    urls.py
    admin.py

分为提交、测试结果两个模型

负责的链路

提交请求入库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
前端 POST /api/submissions/
Authorization: Bearer xxx

{
  "problem": 1,
  "language": "python3",
  "code": "print(...)"
}
        |
        v
SubmissionCreateSerializer
        |
        v
校验 problem 是否存在且公开
校验 language 是否在 choices 里
校验 code 是否存在
        |
        v
create()
        |
        v
user = request.user
submit_count + 1
        |
        v
Submission.objects.create(...)
        |
        v
数据库新增一条 submissions_submission
status 默认 PENDING

提交详情返回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
GET /api/submissions/1/
Authorization: Bearer xxx
        |
        v
SubmissionReadSerializer
        |
        v
读取 Submission
读取 user.username
读取 problem.title
读取 results
        |
        v
返回 JSON

model.py

submission:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Submission(models.Model):
    class Language(models.TextChoices):
        PYTHON3 = "python3", "Python 3"

    class Status(models.TextChoices):
        PENDING = "PENDING", "等待判题"
        JUDGING = "JUDGING", "正在判题"
        ACCEPTED = "ACCEPTED", "答案正确"
        WRONG_ANSWER = "WRONG_ANSWER", "答案错误"
        TIME_LIMIT_EXCEEDED = "TIME_LIMIT_EXCEEDED", "运行超时"
        RUNTIME_ERROR = "RUNTIME_ERROR", "运行错误"
        SYSTEM_ERROR = "SYSTEM_ERROR", "系统错误"

    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="submissions")
    problem = models.ForeignKey(Problem, on_delete=models.CASCADE, related_name="submissions")
    language = models.CharField(max_length=20, choices=Language.choices, default=Language.PYTHON3)
    code = models.TextField()
    status = models.CharField(max_length=30, choices=Status.choices, default=Status.PENDING)
    score = models.PositiveIntegerField(default=0)
    time_used = models.PositiveIntegerField(default=0, help_text="单位:ms")
    memory_used = models.PositiveIntegerField(default=0, help_text="单位:KB,初版可为空")
    error_message = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    judged_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        ordering = ["-id"]

    def __str__(self):
        return f"#{self.id} {self.user} {self.problem} {self.status}"

language类用于枚举可使用的语言

status类用于枚举判题状态

user是外键,表示提交属于哪个用户,用户一被删除,他的提交记录也被删除

problem是外键,表示这次提交对应哪道题

language表示提交的语言,字段设为上面language类的枚举

status表示提交状态,以上面的status类枚举,这里先默认为pending,后面其他的部分对该字段进行修改

meta设为按照id降序排列,最新提交在前

JudgeResult:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class JudgeResult(models.Model):
    submission = models.ForeignKey(Submission, on_delete=models.CASCADE, related_name="results")
    testcase = models.ForeignKey(TestCase, on_delete=models.CASCADE, related_name="judge_results")
    status = models.CharField(max_length=30, choices=Submission.Status.choices)
    time_used = models.PositiveIntegerField(default=0)
    memory_used = models.PositiveIntegerField(default=0, help_text="单位:KB")
    output = models.TextField(blank=True)
    error_message = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["id"]

    def __str__(self):
        return f"submission {self.submission_id} case {self.testcase_id}: {self.status}"

显示某个测试点的判题结果

submission是外键,记录该测试点结果属于哪次提交,若提交被删除,则下面的结果也会被删除

testcase是外键,表明当前结果对应哪个测试点,若测试点被删除,则对应他的提交jie’guo

status使用枚举,每个测试点也可以有状态

serializers.py

将提交记录在JSON和Django model之间转换

JudgeResultSerializer:

1
2
3
4
class JudgeResultSerializer(serializers.ModelSerializer):
    class Meta:
        model = JudgeResult
        fields = ("id", "testcase", "status", "time_used", "memory_used", "output", "error_message")

将单条测试点结果转为JSON

SubmissionCreateSerializer:

1
2
3
4
5
6
7
8
9
10
11
12
class SubmissionCreateSerializer(serializers.ModelSerializer):
    problem = serializers.PrimaryKeyRelatedField(queryset=Problem.objects.filter(is_public=True))

    class Meta:
        model = Submission
        fields = ("id", "problem", "language", "code")

    def create(self, validated_data):
        user = self.context["request"].user
        user.submit_count += 1
        user.save(update_fields=["submit_count"])
        return Submission.objects.create(user=user, **validated_data)

提交代码时的serializers,前端请求POST /api/submissions/

其中,problem处接受前端提交的problem的ID,后端根据ID找到公开的,对应problem的对象,前端只需要提交problem、language和code即可

create是创建提交时的逻辑,DRF在视图内调用serializers时,会把request放到context里,我们可以通过这个拿到当前用户,没提交一次,用户提交数+1,且更新字段submit_count,返回时创建submission

SubmissionReadSerializer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

class SubmissionReadSerializer(serializers.ModelSerializer):
    username = serializers.CharField(source="user.username", read_only=True)
    problem_title = serializers.CharField(source="problem.title", read_only=True)
    results = JudgeResultSerializer(many=True, read_only=True)

    class Meta:
        model = Submission
        fields = (
            "id",
            "username",
            "problem",
            "problem_title",
            "language",
            "code",
            "status",
            "score",
            "time_used",
            "memory_used",
            "error_message",
            "results",
            "created_at",
            "judged_at",
        )

用于查看提交列表和详情,对应的方法是

1
2
GET /api/submissions/
GET /api/submissions/{id}/

user从user模块那里取值,返回的是用户名

problem_title从problem模块那里取值

results从model中的judgeresult拿,且一对多所以要把many设为True

views.py

负责提交接口的访问权限,记录查询范围和serializers的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class SubmissionViewSet(
    mixins.CreateModelMixin,
    mixins.ListModelMixin,
    mixins.RetrieveModelMixin,
    viewsets.GenericViewSet,
):
    permission_classes = [IsAuthenticated]
    filterset_fields = ("problem", "status", "language")
    ordering_fields = ("id", "created_at")

    def get_queryset(self):
        queryset = Submission.objects.select_related("user", "problem").prefetch_related("results")
        user = self.request.user
        if user.is_admin:
            return queryset
        return queryset.filter(user=user)

    def get_serializer_class(self):
        if self.action == "create":
            return SubmissionCreateSerializer
        return SubmissionReadSerializer

这里我们没有用ModelViewSet,因为我们不需要全套CRUD,为了避免出现问题,故自己写

使用mixins添加动作:

Mixin 提供的动作 URL CreateModelMixin create POST /api/submissions/ ListModelMixin list GET /api/submissions/ RetrieveModelMixin retrieve GET /api/submissions/{id}/

permission_classes指明了提交相关接口必须登录使用

filterset_fields允许前端按照条件筛选记录

get_queryset决定了用户能看到哪些提交,前两个是外键,使用select_related,最后一个反向一对多,用prefetch_related,获取当前用户,若是管理员则可以看到所有人的提交,否则普通用户只能看到自己的

若普通用户访问了别人的提交,DRF返回404(并非403,可以避免泄露这个ID是否存在)

get_serializer_class选择使用哪个serializers

urls.py

路由

1
2
3
4
router = DefaultRouter()
router.register("", SubmissionViewSet, basename="submission")

urlpatterns = router.urls

这里router里是空字符串,因为总路由里已经包含了api/submissions/

Judge/

找出等待判题的提交、运行代码、对比标准输出、更新提交状态

架构:

1
2
3
backend/apps/judge/
    services.py
    management/commands/run_judge.py

目前只实现python3的支持,因为不需要编译等操作,不需要另外添加docker容器

services.py

判题的核心,负责拿出pending代码,运行代码,对比答案,保存结果,更新判题状态

RunResult:

1
2
3
4
5
6
@dataclass
class RunResult:
    status: str
    output: str = ""
    error_message: str = ""
    time_used: int = 0

数据容器,保存运行一次代码的结果,包括状态、标准输出、错误信息、运行时间

normalize_output:

1
2
def normalize_output(value):
    return value.replace("\r\n", "\n").strip()

去掉行尾回车空格,注意Windows和Linux下的换行不同

run_python_code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
def run_python_code(code, input_data, timeout_seconds):
    with tempfile.TemporaryDirectory() as temp_dir:
        code_path = Path(temp_dir) / "main.py"
        code_path.write_text(code, encoding="utf-8")

        started_at = time.perf_counter()
        try:
            completed = subprocess.run(
                [settings.JUDGE_PYTHON_COMMAND, str(code_path)],
                input=input_data,
                text=True,
                capture_output=True,
                timeout=timeout_seconds,
                cwd=temp_dir,
            )
        except subprocess.TimeoutExpired:
            time_used = int((time.perf_counter() - started_at) * 1000)
            return RunResult(status=Submission.Status.TIME_LIMIT_EXCEEDED, time_used=time_used)

        time_used = int((time.perf_counter() - started_at) * 1000)
        if completed.returncode != 0:
            return RunResult(
                status=Submission.Status.RUNTIME_ERROR,
                output=completed.stdout,
                error_message=completed.stderr[-2000:],
                time_used=time_used,
            )
        return RunResult(
            status=Submission.Status.ACCEPTED,
            output=completed.stdout,
            time_used=time_used,
        )

运行用户代码

首先我们创建一个临时文件,将用户代码写入,在单独的进程里进行,防止在Django进程执行会影响主服务

subprocess.run用于创建子进程并运行代码:

  • [settings.JUDGE_PYTHON_COMMAND, str(code_path)]执行类似pyhton [临时文件].py

  • input讲测试点输入传给用户的标准输入

  • capture_output=True捕获标准输出和标准错误

  • timeout=timeout_seconds限定运行时间,超时可以抛TLE

  • cwd=temp_dir让代码在临时目录进行

  • 1
    2
    3
    started_at = time.perf_counter()
    ...
    time_used = int((time.perf_counter() - started_at) * 1000)

​ 用于记录代码执行时间,默认是秒,乘1000转为毫秒

  • 用一个try…except捕获超时异常,若超时返回使用时间并返回TLE
  • completed.returncode捕获返回码,若非零表示异常退出,即RE,错误信息从stderr获取,只保留后面2000个字符,防止爆库
  • 最后没有运行问题,返回ACCEPT,这里的AC指运行没有问题,而不代表答案正确

acquire_pending_submission:

1
2
3
4
5
6
7
8
9
10
11
12
13
def acquire_pending_submission():
    with transaction.atomic():
        submission = (
            Submission.objects.select_for_update(skip_locked=True)
            .filter(status=Submission.Status.PENDING)
            .order_by("id")
            .first()
        )
        if submission is None:
            return None
        submission.status = Submission.Status.JUDGING
        submission.save(update_fields=["status"])
        return submission

该函数负责抓取状态为PENDING的提交,改为JUDGING

transaction.atomic()使用了Django的事务管理,保证两个动作是一组操作,防止混乱

select_for_update(skip_locked=True)将选中行上锁,若某行被其他事务锁住,就跳过,防止将来多个判题机同时跑,不会抢同一个提交

按照id排列,遵循FIFO的原则

judge_submission:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
def judge_submission(submission):
    test_cases = list(submission.problem.test_cases.all())
    if not test_cases:
        submission.status = Submission.Status.SYSTEM_ERROR
        submission.error_message = "该题目没有测试点。"
        submission.judged_at = timezone.now()
        submission.save(update_fields=["status", "error_message", "judged_at"])
        return submission

    timeout_seconds = max(1, submission.problem.time_limit / 1000)
    total_score = 0
    max_time = 0
    final_status = Submission.Status.ACCEPTED
    final_error = ""

    JudgeResult.objects.filter(submission=submission).delete()

    for test_case in test_cases:
        run_result = run_python_code(submission.code, test_case.input_data, timeout_seconds)
        status = run_result.status

        if status == Submission.Status.ACCEPTED:
            if normalize_output(run_result.output) == normalize_output(test_case.output_data):
                total_score += test_case.score
            else:
                status = Submission.Status.WRONG_ANSWER

        JudgeResult.objects.create(
            submission=submission,
            testcase=test_case,
            status=status,
            time_used=run_result.time_used,
            memory_used=0,
            output=run_result.output[-2000:],
            error_message=run_result.error_message,
        )

        max_time = max(max_time, run_result.time_used)
        if status != Submission.Status.ACCEPTED:
            final_status = status
            final_error = run_result.error_message
            break

    submission.status = final_status
    submission.score = 100 if final_status == Submission.Status.ACCEPTED else total_score
    submission.time_used = max_time
    submission.error_message = final_error
    submission.judged_at = timezone.now()
    submission.save(
        update_fields=["status", "score", "time_used", "error_message", "judged_at"]
    )
    return submission

核心判题函数

  • 首先获取测试点列表,若没有测试点,直接返回SYSTEM_ERROR
  • timeout_seconds是时间限制,数据库内单位是ms,需要转化为s
  • 判题前先删除旧的测试点结果
  • 接下来遍历所有测试点,调用上面的函数使用同一份代码,得到不同输出
  • 若状态为运行AC,则判断比较输出,若一致则当前测试点ACCEPT,不一致返回WA
  • 创建judgeresult,保存每个测试点的结果
  • 最后更新总提交状态,所有结果写回submission,若全部通过为AC,中途失败为其他,

process_next_submission:

取任务用的封装函数,供后面的命令调用

1
2
3
4
5
def process_next_submission():
    submission = acquire_pending_submission()
    if submission is None:
        return None
    return judge_submission(submission)

run_judge.py

这个文件的作用是判断有无PENDING提交,若有就调用判题机,否则等待,即不断检测提交

放在了backend/apps/judge/management/commands/run_judge.py这个路径,因为是Django自定义命令的固定结构,可以让manage.py调用到该代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Command(BaseCommand):
    help = "Run the simple KouOJ judge worker."

    def add_arguments(self, parser):
        parser.add_argument("--once", action="store_true", help="Only process one pending submission.")
        parser.add_argument("--sleep", type=float, default=1.0, help="Sleep seconds when queue is empty.")

    def handle(self, *args, **options):
        self.stdout.write(self.style.SUCCESS("Judge worker started."))
        while True:
            submission = process_next_submission()
            if submission:
                self.stdout.write(f"Judged submission #{submission.id}: {submission.status}")
            elif options["once"]:
                self.stdout.write("No pending submission.")
                return
            else:
                time.sleep(options["sleep"])

            if options["once"]:
                return

BaseCommand是Django自己的命令类,自定义命令都需要继承它,定义自己的类,类名必须是Command

  • help是命令说明
  • add_argument方法用于添加参数:
    • --once只处理一个提交
    • --sleep会不断查询判题任务,默认每隔一秒查询一次
  • handle执行命令,当我们通过manage运行命令时,Django会调用handle这个函数
  • self.stdout.write是Django推荐的输出方式
  • 死循环让判题机一直运行
  • 循环内调用process_next_submission(),位于services内的判题逻辑
    • 如果判到了提交,则在终端输出状态信息
    • 若没有任务且是sleep模式,继续运行,查询间隔为1秒

注意,API服务与判题机要分为两个终端,是两个独立进程

日志功能

流程

1
2
3
4
5
6
7
8
9
OperationLog 数据表

中间件自动记录 API 请求

关键业务主动记录详细操作

Django Admin 查询日志

配置文件日志记录系统异常

设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
from django.conf import settings
from django.db import models


class OperationLog(models.Model):
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="operation_logs",
    )
    action = models.CharField(max_length=50)
    method = models.CharField(max_length=10)
    path = models.CharField(max_length=255)
    status_code = models.PositiveSmallIntegerField()

    ip_address = models.GenericIPAddressField(null=True, blank=True)
    user_agent = models.CharField(max_length=500, blank=True)

    object_type = models.CharField(max_length=50, blank=True)
    object_id = models.CharField(max_length=50, blank=True)
    detail = models.JSONField(default=dict, blank=True)

    duration_ms = models.PositiveIntegerField(default=0)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-created_at"]
        indexes = [
            models.Index(fields=["user", "created_at"]),
            models.Index(fields=["action", "created_at"]),
            models.Index(fields=["status_code"]),
        ]

    def __str__(self):
        username = self.user.username if self.user else "anonymous"
        return f"{username} {self.action} {self.path}"

字段含义:

  • user:操作用户,允许匿名。
  • action:例如 CREATE_SUBMISSIONUPDATE_PROFILE
  • method/path:请求方法与接口地址。
  • status_code:200、400、500 等。
  • object_type/object_id:操作的题目、提交或题解。
  • detail:安全的补充信息。
  • duration_ms:接口处理时间,便于排查慢请求。
  • indexes:提高后台日志查询速度。

注册后台

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from django.contrib import admin

from .models import OperationLog


@admin.register(OperationLog)
class OperationLogAdmin(admin.ModelAdmin):
    list_display = (
        "id",
        "user",
        "action",
        "method",
        "path",
        "status_code",
        "duration_ms",
        "created_at",
    )
    list_filter = ("action", "method", "status_code", "created_at")
    search_fields = ("user__username", "path", "object_id")
    readonly_fields = (
        "user",
        "action",
        "method",
        "path",
        "status_code",
        "ip_address",
        "user_agent",
        "object_type",
        "object_id",
        "detail",
        "duration_ms",
        "created_at",
    )

然后进行迁移

审计中间件

可以自动记录

1
POST/PATCH/PUT/DELETE

返回 4xx/5xx 的请求

用户、IP、路径、状态码、耗时

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
import time

from rest_framework_simplejwt.authentication import JWTAuthentication

from .models import OperationLog


class OperationLogMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        started_at = time.perf_counter()

        response = self.get_response(request)

        duration_ms = int(
            (time.perf_counter() - started_at) * 1000
        )

        if self.should_record(request, response):
            self.create_log(request, response, duration_ms)

        return response
    # 记录需要的
    def should_record(self, request, response):
        if not request.path.startswith("/api/"):
            return False

        if request.path.startswith("/api/audit/"):
            return False

        write_methods = {"POST", "PUT", "PATCH", "DELETE"}

        return (
            request.method in write_methods
            or response.status_code >= 400
        )
    # 获得JWT用户
    def get_user(self, request):
        if request.user.is_authenticated:
            return request.user

        try:
            authentication = JWTAuthentication()
            result = authentication.authenticate(request)

            if result is not None:
                user, token = result
                return user
        except Exception:
            pass

        return None
    # 获取ip
    def get_ip_address(self, request):
        forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")

        if forwarded_for:
            return forwarded_for.split(",")[0].strip()

        return request.META.get("REMOTE_ADDR")
    # 生成操作名称
    def get_action(self, request):
        action_map = {
            "POST": "CREATE",
            "PUT": "UPDATE",
            "PATCH": "UPDATE",
            "DELETE": "DELETE",
        }

        if request.path == "/api/auth/login/":
            return "LOGIN"

        if request.path == "/api/auth/register/":
            return "REGISTER"

        if request.path == "/api/auth/change-password/":
            return "CHANGE_PASSWORD"

        if request.path == "/api/submissions/":
            return "CREATE_SUBMISSION"

        return action_map.get(request.method, "REQUEST_ERROR")
    # 创建日志
    def create_log(self, request, response, duration_ms):
        try:
            OperationLog.objects.create(
                user=self.get_user(request),
                action=self.get_action(request),
                method=request.method,
                path=request.path[:255],
                status_code=response.status_code,
                ip_address=self.get_ip_address(request),
                user_agent=request.META.get(
                    "HTTP_USER_AGENT",
                    "",
                )[:500],
                duration_ms=duration_ms,
                detail={
                    "query": request.GET.dict(),
                },
            )
        except Exception:
            # 日志失败不能影响正常业务请求
            pass

记录过程

1
2
3
4
5
6
记录开始时间
→ 执行真正的接口
→ 获得响应
→ 计算耗时
→ 判断是否需要记录
→ 写入 OperationLog
  • should_record用于判断哪些请求需要记录,普通的get 200不会记录
  • get_user用于单独获取JWT用户
  • get_ip_address用于获取ip
  • get_action用于生成操作名称
  • create_log用于创建日志

然后在settings里注册中间件

请求流程示例

用户注册:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
前端 POST /api/auth/register/
        |
        v
config/urls.py
        |
        v
accounts/urls.py
        |
        v
accounts/views.py
        |
        v
accounts/serializers.py
        |
        v
accounts/models.py
        |
        v
MySQL 用户表

查看题目列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
前端 GET /api/problems/
        |
        v
config/urls.py
        |
        v
problems/urls.py
        |
        v
problems/views.py
        |
        v
problems/serializers.py
        |
        v
problems/models.py
        |
        v
MySQL 题目表

提交代码判题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
前端 POST /api/submissions/
        |
        v
submissions/views.py
        |
        v
Submission 表新增一条 PENDING 记录
        |
        v
run_judge worker 发现 PENDING
        |
        v
judge/services.py 运行代码
        |
        v
更新 Submission/JudgeResult

数据库设计

创建数据库:

1
CREATE DATABASE kouoj DEFAULT CHARACTER SET utf8m64

给该数据库创建一个专门用户kouoj_user

1
2
CREATE USER 'kouoj_user'@'localhost' IDENTIFIED BY 'password'; 
GRANT ALL ON kouoj.* TO 'kouoj_user'@'localhost';

包含表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
+--------------------------------+
| Tables_in_kouoj                |
+--------------------------------+
| accounts_user                  |
| accounts_user_groups           |
| accounts_user_user_permissions |
| auth_group                     |
| auth_group_permissions         |
| auth_permission                |
| django_admin_log               |
| django_content_type            |
| django_migrations              |
| django_session                 |
| problems_problem               |
| problems_problem_tags          |
| problems_tag                   |
| problems_testcase              |
| submissions_judgeresult        |
| submissions_submission         |
+--------------------------------+

其中,前三个是我们写的account.User生成的表,负责记录用户,用户组和权限关系

后七个是Django自带的表,负责权限,日志,session等

后四个是存储problems相关信息的表

最后两个是存储submissions相关信息的表

Accounts部分

这部分表记录了用户相关信息,包括一张表:user(用户)

它们的列结构为:

字段 类型 说明 id BigAutoField 主键 password varchar 加密后的密码 last_login datetime 上次登录时间 is_superuser bool 是否超级管理员 username varchar 用户名 first_name varchar 名 last_name varchar 姓 email varchar 邮箱 is_staff bool 是否允许进入 Django Admin is_active bool 账号是否启用 date_joined datetime 注册时间 role varchar 角色,user / admin solved_count int unsigned 通过题目数 submit_count int unsigned 提交次数

Problems部分

这部分的表是最核心的,包括三张表:tag(标签)、problem(题目)、testcase(测试点)

其中tag与problem是多对多,testcase与problem是一对多

拆成三张表有利于灵活设计,不然一个列里要放好几个测试点对应关系很别扭

它们的列结构为:

problems:

字段 类型 说明 id BigAutoField 主键 title varchar(120) 题目标题 description longtext 题目描述 input_description longtext 输入说明 output_description longtext 输出说明 difficulty varchar(20) 难度:easy / medium / hard time_limit int unsigned 时间限制,单位 ms memory_limit int unsigned 内存限制,单位 MB is_public bool 是否公开 created_by_id bigint 创建者用户 ID,外键 created_at datetime 创建时间 updated_at datetime 更新时间

tag:

字段 类型 说明 id BigAutoField 主键 name varchar(40) 标签名,唯一

problems_tags:自动创建的中间表,用于problems和tags之间的多对多关联

字段 类型 说明 id BigAutoField 主键 problem_id bigint 题目 ID,外键 tag_id bigint 标签 ID,外键

testcase:

字段 类型 说明 id BigAutoField 主键 problem_id bigint 所属题目 ID,外键 input_data longtext 输入数据 output_data longtext 标准输出 is_sample bool 是否样例 score int unsigned 测试点分值 order int unsigned 测试点顺序

Submission部分

记录提交相关信息,包括两张表:submission(提交信息)、judgeresult(测试点结果)

关系是submission一对多judgeresult

submission:

字段 类型 说明 id BigAutoField 主键 user_id bigint 提交用户 ID,外键 problem_id bigint 题目 ID,外键 language varchar(20) 语言,目前为 python3 code longtext 用户提交的代码 status varchar(30) 判题状态 score int unsigned 得分 time_used int unsigned 运行时间,单位 ms memory_used int unsigned 内存,单位 KB,初版暂未真正统计 error_message longtext 错误信息 created_at datetime 提交时间 judged_at datetime 判题完成时间

judgeresult:

字段 类型 说明 id BigAutoField 主键 submission_id bigint 所属提交 ID,外键 testcase_id bigint 所属测试点 ID,外键 status varchar(30) 当前测试点状态 time_used int unsigned 当前测试点运行时间,单位 ms output longtext 用户程序输出 error_message longtext 当前测试点错误信息 created_at datetime 创建时间

表间关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
accounts_user
    id
     |
     | created_by_id
     v
problems_problem

accounts_user
    id
     |
     | user_id
     v
submissions_submission

problems_problem
    id
     |
     | problem_id
     v
problems_testcase

problems_problem
    id
     |
     | problem_id
     v
problems_problem_tags
     ^
     | tag_id
problems_tag
    id

submissions_submission
    id
     |
     | submission_id
     v
submissions_judgeresult
     ^
     | testcase_id
problems_testcase
    id

操作示意

以下命令在./backend 路径内进行

创建超管:python manage.py createsuperuser

运行服务器:pyhton manage.py runserver

运行判题机:python manage.py run_judge [--once/--sleep]

URL输入http://localhost:8000/admin/进入Django后台管理

使用curl测试

注册:

1
2
3
curl -X POST http://127.0.0.1:8000/api/auth/register/ `
  -H "Content-Type: application/json" `
  -d "{\"username\":\"alice\",\"password\":\"123456\",\"email\":\"alice@example.com\"}"

登录:

1
2
3
curl -X POST http://127.0.0.1:8000/api/auth/login/ `
  -H "Content-Type: application/json" `
  -d "{\"username\":\"alice\",\"password\":\"123456\"}"

可以获得accesstoken

提交代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzc5MjgyOTIxLCJpYXQiOjE3NzkyNzU3MjEsImp0aSI6IjQ0M2VhNmFiNWIzMTQ3ZjZiODc5NWViNDYyZjM0M2Q0IiwidXNlcl9pZCI6IjIifQ.7ieJ8bwhO-a9KfyoWyR8sXJqUroJXc4g5rYhUkY12r8"

$body = @{
    problem = 1
    language = "python3"
    code = "a, b = map(int, input().split())`nprint(a + b)"
} | ConvertTo-Json

Invoke-RestMethod `
    -Uri "http://127.0.0.1:8000/api/submissions/" `
    -Method Post `
    -Headers @{ Authorization = "Bearer $token" } `
    -ContentType "application/json" `
    -Body $body

查看提交详情:

1
2
curl -H "Authorization: Bearer 你的access_token" `
  http://127.0.0.1:8000/api/submissions/1/

测试

判题机测试:

  • ACCEPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzc5MjgyOTIxLCJpYXQiOjE3NzkyNzU3MjEsImp0aSI6IjQ0M2VhNmFiNWIzMTQ3ZjZiODc5NWViNDYyZjM0M2Q0IiwidXNlcl9pZCI6IjIifQ.7ieJ8bwhO-a9KfyoWyR8sXJqUroJXc4g5rYhUkY12r8"

$body = @{
    problem = 1
    language = "python3"
    code = "a, b = map(int, input().split())`nprint(a + b)"
} | ConvertTo-Json

Invoke-RestMethod `
    -Uri "http://127.0.0.1:8000/api/submissions/" `
    -Method Post `
    -Headers @{ Authorization = "Bearer $token" } `
    -ContentType "application/json" `
    -Body $body
  • WRONG ANWSER
1
2
3
4
5
6
7
8
9
10
11
12
13
14
 $token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzc5MjgyOTIxLCJpYXQiOjE3NzkyNzU3MjEsImp0aSI6IjQ0M2VhNmFiNWIzMTQ3ZjZiODc5NWViNDYyZjM0M2Q0IiwidXNlcl9pZCI6IjIifQ.7ieJ8bwhO-a9KfyoWyR8sXJqUroJXc4g5rYhUkY12r8"

$body = @{
    problem = 1
    language = "python3"
    code = "a, b = map(int, input().split())`nprint(1)"
} | ConvertTo-Json

Invoke-RestMethod `
    -Uri "http://127.0.0.1:8000/api/submissions/" `
    -Method Post `
    -Headers @{ Authorization = "Bearer $token" } `
    -ContentType "application/json" `
    -Body $body
  • RUNTIME_ERROR
1
2
3
4
5
6
7
8
9
10
11
12
13
14
 $token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzc5MjgyOTIxLCJpYXQiOjE3NzkyNzU3MjEsImp0aSI6IjQ0M2VhNmFiNWIzMTQ3ZjZiODc5NWViNDYyZjM0M2Q0IiwidXNlcl9pZCI6IjIifQ.7ieJ8bwhO-a9KfyoWyR8sXJqUroJXc4g5rYhUkY12r8"

$body = @{
    problem = 1
    language = "python3"
    code = "a, b = map(int, input().split())`nprint(1/0)"
} | ConvertTo-Json

Invoke-RestMethod `
    -Uri "http://127.0.0.1:8000/api/submissions/" `
    -Method Post `
    -Headers @{ Authorization = "Bearer $token" } `
    -ContentType "application/json" `
    -Body $body
  • TIME_LIMIT_EXCEED
1
2
3
4
5
6
7
8
9
10
11
12
13
14
 $token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzc5MjgyOTIxLCJpYXQiOjE3NzkyNzU3MjEsImp0aSI6IjQ0M2VhNmFiNWIzMTQ3ZjZiODc5NWViNDYyZjM0M2Q0IiwidXNlcl9pZCI6IjIifQ.7ieJ8bwhO-a9KfyoWyR8sXJqUroJXc4g5rYhUkY12r8"

$body = @{
    problem = 1
    language = "python3"
    code = "while True:`n    pass"
} | ConvertTo-Json

Invoke-RestMethod `
    -Uri "http://127.0.0.1:8000/api/submissions/" `
    -Method Post `
    -Headers @{ Authorization = "Bearer $token" } `
    -ContentType "application/json" `
    -Body $body