Memory OJ 在线判题系统

本文最后更新于:2 个月前

技术选型

架构设计

编码开发

很好啊,前后端项目模板跑起来了:

image-20240117230852510

2024年5月5日

框架构建

安装 | Vue CLI (vuejs.org)

Arco Design Vue

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<BasicLayout />
</template>

<style></style>
<script>
import BasicLayout from "@/layouts/BasicLayout";

export default {
components: { BasicLayout },
};
</script>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<GlobalHeader />
</template>

<script>
import GlobalHeader from "@/components/GlobalHeader";

export default {
name: "BasicLayout",
components: { GlobalHeader },
};
</script>

<style scoped></style>

1
2
3
4
5
6
7
8
9
10
11
12
<template>
<div></div>
</template>

<script>
export default {
name: "GlobalHeader",
};
</script>

<style scoped></style>

image-20240505172201305

路由跳转

router 下的 index.ts:

1
2
3
4
5
6
7
8
9
10
import { createRouter, createWebHistory } from "vue-router";
import { routes } from "@/router/routes";

const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes,
});

export default router;

router 下的 router.ts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { RouteRecordRaw } from "vue-router";
import HomeView from "@/views/HomeView.vue";

export const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "首页",
component: HomeView,
},
{
path: "/about",
name: "关于",
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () =>
import(/* webpackChunkName: "about" */ "../views/AboutView.vue"),
},
];

动态导航栏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<a-menu
mode="horizontal"
:selected-keys="selectedKeys"
@menu-item-click="doMenuClick"
>
<a-menu-item
key="0"
:style="{ padding: 0, marginRight: '38px' }"
disabled
>
<div class="title-bar">
<img class="logo" src="../assets/oj-logo.svg" />
<div class="title">OJ</div>
</div>
</a-menu-item>
<a-menu-item v-for="item in routes" :key="item.path">
{{ item.name }}
</a-menu-item>
</a-menu>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { ref } from "vue";
import { useRouter } from "vue-router";
import { routes } from "@/router/routes";

const router = useRouter();
// 默认主页
const selectedKeys = ref(["/"]);

// 路由跳转后,更新选中的菜单项
router.afterEach((to, from, failure) => {
selectedKeys.value = [to.path];
});

// 跳转路由
const doMenuClick = (key: string) => {
router.push({
path: key,
});
};

效果如下:

image-20240505180151544

全局状态管理

vuex/examples/classic/shopping-cart/store/index.js at main · vuejs/vuex (github.com)

开始 | Vuex (vuejs.org)

store 下的 user.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// initial state
import { StoreOptions } from "vuex";

export default {
namespaced: true,
state: () => ({
loginUser: {
userName: "未登录",
},
}),
actions: {
async getLoginUser({ commit, state }, payload) {
commit("updateUser", { userName: "memory" });
},
},
mutations: {
updateUser(state, payload) {
state.loginUser = payload;
},
},
} as StoreOptions<any>;

index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
import { createStore } from "vuex";
import user from "@/store/user";

export default createStore({
state: {},
getters: {},
mutations: {},
actions: {},
modules: {
user,
},
});

main.ts

1
2
3
4
import store from "./store";

createApp(App).use(ArcoVue).use(store).use(router).mount("#app");

store 原理(执行流程)

1
2
3
4
5
6
// 使用 store
setTimeout(() => {
store.dispatch("user/getLoginUser", {
userName: "回忆如初",
});
}, 3000);

这样引入 store,执行 dispatch方法,根据 actions 下的路径,提供参数,执行 mutations 下的方法。

1
2
3
4
5
actions: {
async getLoginUser({ commit, state }, payload) {
commit("updateUser", payload);
},
},
1
2
3
4
5
mutations: {
updateUser(state, payload) {
state.loginUser = payload;
},
},

mutations 改变了 state 的值,根据传入的参数改变了。

我们尝试在页面获取 store 值,并展示:

1
2
3
4
5
<a-col flex="100px">
<div>
{{ store.state.user?.loginUser?.userName ?? "未登录" }}
</div>
</a-col>

效果如下:

image-20240505182741049

另外,action 这里可以写死,不接受参数:

1
2
3
4
5
actions: {
async getLoginUser({ commit, state }, payload) {
commit("updateUser", { userName: "memory" });
},
},

全局权限管理

关键在于这段逻辑,App.vue 下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import router from "@/router";
import store from "@/store";

router.beforeEach((to, from, next) => {
// 仅管理员可见,判断当前用户是否有权限
if (to.meta?.access === "canAdmin") {
if (store.state.user?.loginUser?.role !== "admin") {
next("/noAuth");
return;
}
}
next();
});

设置路由访问权限,必须为管理员可见:

1
2
3
4
5
6
7
8
{
path: "/auth",
name: "管理员可见",
component: AuthView,
meta: {
access: "canAdmin",
},
},

默认用户权限,测试用:

1
2
3
4
5
6
state: () => ({
loginUser: {
userName: "未登录",
role: "admin",
},
}),

image-20240505190305372

我们发现能正常跳转页面,但这样做:

1
2
3
4
5
6
7
// 使用 store
setTimeout(() => {
store.dispatch("user/getLoginUser", {
userName: "回忆如初",
role: "noAdmin",
});
}, 3000);

三秒过后,该用户不是管理员权限,访问一个管理员可见的页面,直接重定向:

image-20240505190310211

隐藏菜单

构造通用的导航栏组件,根据配置控制菜单栏的显隐

1
2
3
4
5
6
7
8
{
path: "/hide",
name: "隐藏页面",
component: noAuthView,
meta: {
hideInMenu: true,
},
},

过滤,仅展示显示在菜单上的路由数组

1
2
3
<a-menu-item v-for="item in visibleRoutes" :key="item.path">
{{ item.name }}
</a-menu-item>
1
2
3
4
5
6
import { routes } from "@/router/routes";

// 展示在菜单上的路由数组
const visibleRoutes = routes.filter((item, index) => {
return !item.meta?.hideInmenu;
});

除了 根据配置权限隐藏菜单,还需要根据用户权限,只有具有相关权限的用户,才能看到该菜单。

检测用户权限:

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
import ACCESS_ENUM from "@/access/accessEnum";

/**
* 检查权限(判断当前登录用户是否具有某个权限)
* @param loginUser 当前登录用户
* @param needAccess 需要有的权限
* @return boolean 有无权限
*/
const checkAccess = (loginUser: any, needAccess = ACCESS_ENUM.NOT_LOGIN) => {
// 获取当前登录用户具有的权限(如果没有 loginUser,则表示未登录)
const loginUserAccess = loginUser?.userRole ?? ACCESS_ENUM.NOT_LOGIN;
if (needAccess === ACCESS_ENUM.NOT_LOGIN) {
return true;
}
// 如果用户登录才能访问
if (needAccess === ACCESS_ENUM.USER) {
// 如果用户没登录,那么表示无权限
if (loginUserAccess === ACCESS_ENUM.NOT_LOGIN) {
return false;
}
}
// 如果需要管理员权限
if (needAccess === ACCESS_ENUM.ADMIN) {
// 如果不为管理员,表示无权限
if (loginUserAccess !== ACCESS_ENUM.ADMIN) {
return false;
}
}
return true;
};

export default checkAccess;

使用计算属性,使得用户信息发生变更时,触发菜单栏的重新渲染,。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 展示在菜单上的路由数组
const visibleRoutes = computed(() => {
return routes.filter((item, index) => {
// 根据配置过滤菜单
if (item.meta?.hideInmenu) {
return false;
}
//根据用户权限过滤菜单
if (
!checkAccess(store.state.user?.loginUser, item.meta?.access as string)
) {
return false;
}

return true;
});
});

全局项目入口

1
2
3
4
5
6
7
const doInit = () => {
console.log("项目全局入口");
};

onMounted(() => {
doInit();
});

库表设计

题目表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- 题目表
create table if not exists question
(
id bigint auto_increment comment 'id' primary key,
title varchar(512) null comment '标题',
content text null comment '内容',
tags varchar(1024) null comment '标签列表(json 数组)',
answer text null comment '题目答案',
submitNum int default 0 not null comment '题目提交数',
acceptedNum int default 0 not null comment '题目通过数',
judgeCase text null comment '判题用例(json 数组)',
judgeConfig text null comment '判题配置(json 对象)',
thumbNum int default 0 not null comment '点赞数',
favourNum int default 0 not null comment '收藏数',
userId bigint not null comment '创建用户 id',
createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间',
updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
isDelete tinyint default 0 not null comment '是否删除',
index idx_userId (userId)
) comment '题目' collate = utf8mb4_unicode_ci;

数据库字段存 json 字符串,便于扩展:判题用例判题配置判题信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 题目配置
*/
@Data
public class JudgeConfig {

/**
* 时间限制(ms)
*/
private Long timeLimit;

/**
* 内存限制(KB)
*/
private Long memoryLimit;

/**
* 堆栈限制(KB)
*/
private Long stackLimit;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 题目用例
*/
@Data
public class JudgeCase {

/**
* 输入用例
*/
private String input;

/**
* 输出用例
*/
private String output;
}

题目提交表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 题目提交表
create table if not exists question_submit
(
id bigint auto_increment comment 'id' primary key,
language varchar(128) not null comment '编程语言',
code text not null comment '用户代码',
judgeInfo text null comment '判题信息(json 对象)',
status int default 0 not null comment '判题状态(0 - 待判题、1 - 判题中、2 - 成功、3 - 失败)',
questionId bigint not null comment '题目 id',
userId bigint not null comment '创建用户 id',
createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间',
updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
isDelete tinyint default 0 not null comment '是否删除',
index idx_questionId (questionId),
index idx_userId (userId)
) comment '题目提交';

枚举类构建

构建枚举类:判题信息判题状态编程语言

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public enum JudgeInfoMessageEnum {

ACCEPTED("成功", "Accepted"),
WRONG_ANSWER("答案错误", "Wrong Answer"),
COMPILE_ERROR("Compile Error", "编译错误"),
MEMORY_LIMIT_EXCEEDED("", "内存溢出"),
TIME_LIMIT_EXCEEDED("Time Limit Exceeded", "超时"),
PRESENTATION_ERROR("Presentation Error", "展示错误"),
WAITING("Waiting", "等待中"),
OUTPUT_LIMIT_EXCEEDED("Output Limit Exceeded", "输出溢出"),
DANGEROUS_OPERATION("Dangerous Operation", "危险操作"),
RUNTIME_ERROR("Runtime Error", "运行错误"),
SYSTEM_ERROR("System Error", "系统错误");

..............................
}
1
2
3
4
5
6
7
8
public enum QuestionSubmitLanguageEnum {

JAVA("java", "java"),
CPLUSPLUS("cpp", "cpp"),
GOLANG("go", "go");

.............................
}
1
2
3
4
5
6
7
8
9
10
public enum QuestionSubmitStatusEnum {

// 0 - 待判题、1 - 判题中、2 - 成功、3 - 失败
WAITING("等待中", 0),
RUNNING("判题中", 1),
SUCCEED("成功", 2),
FAILED("失败", 3);

...............................
}

校验题目是否合法:

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
/**
* 校验题目是否合法
* @param question
* @param add
*/
@Override
public void validQuestion(Question question, boolean add) {
if (question == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
String title = question.getTitle();
String content = question.getContent();
String tags = question.getTags();
String answer = question.getAnswer();
String judgeCase = question.getJudgeCase();
String judgeConfig = question.getJudgeConfig();
// 创建时,参数不能为空
if (add) {
ThrowUtils.throwIf(StringUtils.isAnyBlank(title, content, tags), ErrorCode.PARAMS_ERROR);
}
// 有参数则校验
if (StringUtils.isNotBlank(title) && title.length() > 80) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "标题过长");
}
if (StringUtils.isNotBlank(content) && content.length() > 8192) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "内容过长");
}
if (StringUtils.isNotBlank(answer) && answer.length() > 8192) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "答案过长");
}
if (StringUtils.isNotBlank(judgeCase) && judgeCase.length() > 8192) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "判题用例过长");
}
if (StringUtils.isNotBlank(judgeConfig) && judgeConfig.length() > 8192) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "判题配置过长");
}
}

QuestionVO 封装了脱敏 Question 的题目答案 answer 和判题用例 judgeCase 字段,以下是包装类 QuestionVO 和原类Question 的字段属性对比:

1
2
3
4
5
6
7
8
9
10
/**
* 标签列表
*/
private List<String> tags;

/**
* 判题配置(json 对象)
*/
private JudgeConfig judgeConfig;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 标签列表(json 数组)
*/
private String tags;

/**
* 判题用例(json 数组)
*/
private String judgeCase;

/**
* 判题配置(json 对象)
*/
private String judgeConfig;

QuestionVO 内置了包装类与原对象互相转换的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 对象转包装类
*
* @param question
* @return
*/
public static QuestionVO objToVo(Question question) {
if (question == null) {
return null;
}
QuestionVO questionVO = new QuestionVO();
BeanUtils.copyProperties(question, questionVO);
// 转题目标签为 List
List<String> tagList = JSONUtil.toList(question.getTags(), String.class);
questionVO.setTags(tagList);
// 转判题配置为 JudgeConfig 对象
String judgeConfigStr = question.getJudgeConfig();
questionVO.setJudgeConfig(JSONUtil.toBean(judgeConfigStr, JudgeConfig.class));
return questionVO;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 包装类转对象
*
* @param questionVO
* @return
*/
public static Question voToObj(QuestionVO questionVO) {
if (questionVO == null) {
return null;
}
Question question = new Question();
BeanUtils.copyProperties(questionVO, question);
// 转题目标签为 json 数组
List<String> tagList = questionVO.getTags();
if (tagList != null) {
question.setTags(JSONUtil.toJsonStr(tagList));
}
// 转判题配置为 json 对象
JudgeConfig voJudgeConfig = questionVO.getJudgeConfig();
if (voJudgeConfig != null) {
question.setJudgeConfig(JSONUtil.toJsonStr(voJudgeConfig));
}
return question;
}

封装查询请求参数 QuestionQueryRequest,封装获取查询包装类,动态判断用户根据哪些字段查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 拼接查询条件
queryWrapper.like(StringUtils.isNotBlank(title), "title", title);
queryWrapper.like(StringUtils.isNotBlank(content), "content", content);
queryWrapper.like(StringUtils.isNotBlank(answer), "answer", answer);
if (CollectionUtils.isNotEmpty(tags)) {
for (String tag : tags) {
queryWrapper.like("tags", "\"" + tag + "\"");
}
}
queryWrapper.eq(ObjectUtils.isNotEmpty(id), "id", id);
queryWrapper.eq(ObjectUtils.isNotEmpty(userId), "userId", userId);
queryWrapper.eq("isDelete", false);
queryWrapper.orderBy(SqlUtils.validSortField(sortField), sortOrder.equals(CommonConstant.SORT_ORDER_ASC),
sortField);
return queryWrapper;

阶段性总结

通用的的业务校验流程:

  • 添加新题目,进行类成员变量字段 -> json 的转换,封装了 validQuestion 方法判断参数是否合法。为新题目设置 userId 等字段属性,执行 SQL 语句插入该新纪录并校验是否执行成功。

  • 删除题目,封装 DeleteRequest 删除请求参数,携带删除题目 id,首先根据题目 id 查询数据库,判断该题目是否存在;根据查询得到的题目记录的 userId,判断执行该操作的用户是否为出题用户或者管理员,否则不予执行删除操作。执行 SQL 语句删除记录并校验是否执行成功。

  • 更新题目(管理员),进行类成员变量字段 -> json 的转换,封装了 validQuestion 方法判断参数是否合法。在更新题目记录之前,根据题目 id 检查该题目是否存在,再执行 SQL 语句更新该记录并校验是否执行成功。

  • 编辑题目(普通用户),在管理员更新题目信息中,添加了用户信息校验,只有出题用户和管理员可编辑:

1
2
3
4
5
6
7
8
9
User loginUser = userService.getLoginUser(request);
long id = questionEditRequest.getId();
// 判断是否存在
Question oldQuestion = questionService.getById(id);
ThrowUtils.throwIf(oldQuestion == null, ErrorCode.NOT_FOUND_ERROR);
// 仅本人或管理员可编辑
if (!oldQuestion.getUserId().equals(loginUser.getId()) && !userService.isAdmin(loginUser)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
  • 根据 id 获取题目,校验用户是否为本人或管理员,否则拿不到题目信息
1
2
3
4
@Override
public boolean isAdmin(User user) {
return user != null && UserRoleEnum.ADMIN.getValue().equals(user.getUserRole());
}
  • 根据 id 获取题目信息(脱敏),并关联查询用户信息
1
return ResultUtils.success(questionService.getQuestionVO(question, request));
1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public QuestionVO getQuestionVO(Question question, HttpServletRequest request) {
QuestionVO questionVO = QuestionVO.objToVo(question);
// 1. 关联查询用户信息
Long userId = question.getUserId();
User user = null;
if (userId != null && userId > 0) {
user = userService.getById(userId);
}
UserVO userVO = userService.getUserVO(user);
questionVO.setUserVO(userVO);
return questionVO;
}
  • 封装查询请求参数类 QuestionQueryRequest,封装获取查询包装类方法 getQueryWrapper,批量分页获取题目列表(脱敏),并关联查询用户信息:
1
2
3
Page<Question> questionPage = questionService.page(new Page<>(current, size),
questionService.getQueryWrapper(questionQueryRequest));
return ResultUtils.success(questionService.getQuestionVOPage(questionPage, request));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1. 关联查询用户信息
Set<Long> userIdSet = questionList.stream().map(Question::getUserId).collect(Collectors.toSet());
Map<Long, List<User>> userIdUserListMap = userService.listByIds(userIdSet).stream()
.collect(Collectors.groupingBy(User::getId));
// 填充信息
List<QuestionVO> questionVOList = questionList.stream().map(question -> {
QuestionVO questionVO = QuestionVO.objToVo(question);
Long userId = question.getUserId();
User user = null;
if (userIdUserListMap.containsKey(userId)) {
user = userIdUserListMap.get(userId).get(0);
}
questionVO.setUserVO(userService.getUserVO(user));
return questionVO;
}).collect(Collectors.toList());
questionVOPage.setRecords(questionVOList);
  • 获取用户创建的题目列表,基本同上,查询请求参数 QuestionQueryRequest 携带该用户信息:
1
2
User loginUser = userService.getLoginUser(request);
questionQueryRequest.setUserId(loginUser.getId());
  • 简单的获取所有题目信息列表,仅管理员可用
  • 用户提交题目,封装提交题目请求参数 QuestionSubmitAddRequest,判断编程语言是否合法、题目是否存在,直接存入题目提交信息,再异步执行判题服务:
1
2
3
4
5
6
7
8
9
10
11
12
QuestionSubmit questionSubmit = new QuestionSubmit();
questionSubmit.setUserId(userId);
questionSubmit.setQuestionId(questionId);
questionSubmit.setCode(questionSubmitAddRequest.getCode());
questionSubmit.setLanguage(language);
// 设置初始状态
questionSubmit.setStatus(QuestionSubmitStatusEnum.WAITING.getValue());
questionSubmit.setJudgeInfo("{}");
boolean save = this.save(questionSubmit);
if (!save){
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "数据插入失败");
}
1
2
3
4
5
Long questionSubmitId = questionSubmit.getId();
// 执行判题服务
CompletableFuture.runAsync(() -> {
judgeService.doJudge(questionSubmitId);
});
  • 封装提交题目查询参数 QuestionSubmitQueryRequest,分页查询已提交的题目,只有提交者本人和管理员才可以看到提交代码
1
2
3
4
5
6
// 脱敏:仅本人和管理员能看见自己(提交 userId 和登录用户 id 不同)提交的代码
long userId = loginUser.getId();
// 处理脱敏
if (userId != questionSubmit.getUserId() && !userService.isAdmin(loginUser)) {
questionSubmitVO.setCode(null);
}
  • 分页查询已提交题目列表:
1
2
3
4
// 封装用户相关信息
List<QuestionSubmitVO> questionSubmitVOList = questionSubmitList.stream()
.map(questionSubmit -> getQuestionSubmitVO(questionSubmit, loginUser))
.collect(Collectors.toList());

2024年5月4日

核心功能:提交代码,权限校验;代码沙箱,判题服务;判题规则,结果比对验证;任务调度

核心业务流程:时序图

报错解决指南

前端生成请求接口后报错

image-20240201115405878

这是因为 OpenAPI 生成的请求接口是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* getLoginUser
* @returns BaseResponse_LoginUserVO_ OK
* @throws ApiError
*/
public static getLoginUserUsingGet(): CancelablePromise<BaseResponse_LoginUserVO_> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/user/get/login',
errors: {
401: `Unauthorized`,
403: `Forbidden`,
404: `Not Found`,
},
});
}

修改 OpenAPIConfig 即可:

image-20240201115824731

1
2
3
4
5
6
7
8
9
10
11
export const OpenAPI: OpenAPIConfig = {
BASE: 'http://localhost:8101/',
VERSION: '1.0',
WITH_CREDENTIALS: false,
CREDENTIALS: 'include',
TOKEN: undefined,
USERNAME: undefined,
PASSWORD: undefined,
HEADERS: undefined,
ENCODE_PATH: undefined,
};

Memory OJ 在线判题系统
http://example.com/2024/01/15/Memory OJ 在线判题系统/
作者
Memory
发布于
2024年1月15日
更新于
2024年2月27日
许可协议