【KouOJ】后端部分

  • ~44.85K 字
  1. 1. 后端
    1. 1.1. 项目架构
    2. 1.2. manage.py
    3. 1.3. config
      1. 1.3.1. setting.py
      2. 1.3.2. urls.py
    4. 1.4. apps
      1. 1.4.1. accounts/
        1. 1.4.1.1. 负责的链路
        2. 1.4.1.2. __init__.py
        3. 1.4.1.3. apps.py
        4. 1.4.1.4. models.py
        5. 1.4.1.5. admin.py
        6. 1.4.1.6. serializers.py
        7. 1.4.1.7. views.py
        8. 1.4.1.8. urls.py
        9. 1.4.1.9. 登录在哪里?
      2. 1.4.2. problems/
        1. 1.4.2.1. 负责的链路
        2. 1.4.2.2. models.py
        3. 1.4.2.3. admin.py
        4. 1.4.2.4. serializers.py
        5. 1.4.2.5. views.py
        6. 1.4.2.6. permission.py
        7. 1.4.2.7. urls.py
      3. 1.4.3. submissions/
        1. 1.4.3.1. 负责的链路
        2. 1.4.3.2. model.py
        3. 1.4.3.3. serializers.py
        4. 1.4.3.4. views.py
        5. 1.4.3.5. urls.py
      4. 1.4.4. Judge/
        1. 1.4.4.1. services.py
        2. 1.4.4.2. run_judge.py
    5. 1.5. 日志功能
      1. 1.5.1. 流程
      2. 1.5.2. 设计
      3. 1.5.3. 注册后台
      4. 1.5.4. 审计中间件
    6. 1.6. 请求流程示例
    7. 1.7. 数据库设计
      1. 1.7.1. Accounts部分
      2. 1.7.2. Problems部分
      3. 1.7.3. Submission部分
      4. 1.7.4. 表间关系
    8. 1.8. 操作示意
      1. 1.8.1. 使用curl测试
    9. 1.9. 测试

后端

项目架构

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
22
#!/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
22
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
18
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
5
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
21
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
11
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
22
23
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让代码在临时目录进行

  • ```python started_at = time.perf_counter() … time_used = int((time.perf_counter() - started_at) * 1000)

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

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

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

    **acquire_pending_submission:**

    ```PYTHON
    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
6
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
22
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_SUBMISSION、UPDATE_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
赞助喵
非常感谢您的喜欢!
赞助喵