跳到主要内容
基于 Java-Spring 的在线 OJ 系统竞赛管理功能开发 | 极客日志
Java 大前端 java 算法
基于 Java-Spring 的在线 OJ 系统竞赛管理功能开发 在线 OJ 系统竞赛管理模块涵盖数据库设计、后端接口及前端交互。后端采用 Java Spring Boot 结合 MyBatis Plus,实现竞赛列表查询、新增、编辑、删除及发布状态管理。前端基于 Vue 与 Element Plus 构建,支持时间范围筛选、分页展示及题目关联操作。核心逻辑包括竞赛时间校验、题目批量插入、已发布状态锁定及雪花算法 ID 处理。通过前后端分离架构完成竞赛全生命周期管理功能。
城市逃兵 发布于 2026/3/16 更新于 2026/6/13 37 浏览在线 OJ 系统竞赛管理模块
表结构创建
create table tb_exam (
exam_id bigint unsigned not null comment '竞赛 id(主键)' ,
title varchar (50 ) not null comment '竞赛标题' ,
start_time datetime not null comment '竞赛开始时间' ,
end_time datetime not null comment '竞赛结束时间' ,
status tinyint not null default '0' comment '是否发布 0:未发布 1:已发布' ,
create_by bigint unsigned not null comment '创建人' ,
create_time datetime not null comment '创建时间' ,
update_by bigint unsigned comment '更新人' ,
update_time datetime comment '更新时间' ,
primary key (exam_id)
);
create table tb_exam_question (
exam_question_id bigint unsigned not null comment '竞赛题目关系 id(主键)' ,
question_id bigint unsigned not null comment '题目 id(主键)' ,
exam_id bigint unsigned not null comment '竞赛 id(主键)' ,
question_order int not null comment '题目顺序' ,
create_by bigint unsigned not null comment '创建人' ,
create_time datetime not null comment '创建时间' ,
update_by unsigned comment ,
update_time datetime comment ,
(exam_question_id)
);
bigint
'更新人'
'更新时间'
primary key
竞赛列表
后端代码开发
Controller @RestController
@RequestMapping("/exam")
public class ExamController extends BaseController {
@Autowired
private IExamService examService;
@GetMapping("/list")
public TableDataInfo list (ExamQueryDTO examQueryDTO) {
return getDataTable(examService.list(examQueryDTO));
}
}
DTO 前端传入参数需要包含查询条件(标题,开始时间,结束时间)。因为是分页查询,所以需要继承之前写的 pageDomain。
Service
VO
@JsonSerialize(using = ToStringSerializer.class):将 Long 类型的 examId 先转换为字符串,然后再进行序列化。由于雪花算法产生的值会超过 Long 的范围,在 JavaScript 等语言中处理非常大的整数时可能出现精度丢失,转为字符串可避免此问题。
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss"):指定 LocalDateTime 类型的 endTime 字段在序列化为 JSON 时的日期时间格式,方便前后端统一处理。
数据库数据为 int 类型,但通过多表查询可将管理员昵称返回给前端,所以 VO 中使用 String 类型。
Mapper <?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace ="com.bite.system.mapper.exam.ExamMapper" >
<select resultType ="com.bite.system.model.exam.vo.ExamVO" >
SELECT te.exam_id, te.title, te.start_time, te.end_time, te.create_time, ts.nick_name as create_name, te.status
FROM tb_exam te
LEFT JOIN tb_sys_user ts ON te.create_by = ts.user_id
<where >
<if test ="title !=null and title !='' " >
AND te.title LIKE CONCAT('%',#{title},'%')
</if >
<if test ="startTime != null and startTime != '' " >
AND te.start_time >= #{startTime}
</if >
<if test ="endTime != null and endTime != ''" >
AND te.end_time < = #{endTime}
</if >
</where >
ORDER BY te.create_time DESC
</select >
</mapper >
前端代码开发
页面半成品代码 <template>
<el-form inline="true">
<el-form-item label="创建日期">
<el-date-picker v-model="datetimeRange" type="datetimerange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期"></el-date-picker>
</el-form-item>
<el-form-item label="竞赛名称">
<el-input v-model="params.title" placeholder="请您输入要搜索的竞赛名称" />
</el-form-item>
<el-form-item>
<el-button @click="onSearch" plain>搜索</el-button>
<el-button @click="onReset" plain type="info">重置</el-button>
<el-button type="primary" :icon="Plus" plain @click="onAddExam">添加竞赛</el-button>
</el-form-item>
</el-form>
<!-- 表格 -->
<el-table :data="examList">
<el-table-column prop="title" label="竞赛标题"/>
<el-table-column prop="startTime" label="竞赛开始时间" />
<el-table-column prop="endTime" label="竞赛结束时间" />
<el-table-column label="是否开赛">
<template #default="{ row }">
<div v-if="!isNotStartExam(row)">
<el-tag type="warning">已开赛</el-tag>
</div>
<div v-else>
<el-tag type="info">未开赛</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="是否发布">
<template #default="{ row }">
<div v-if="row.status == 0">
<el-tag type="danger">未发布</el-tag>
</div>
<div v-if="row.status == 1">
<el-tag type="success">已发布</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="createName" label="创建用户" />
<el-table-column prop="createTime" label="创建时间" />
<el-table-column label="操作">
<template #default="{ row }">
<el-button v-if="isNotStartExam(row) && row.status == 0" type="text" @click="onEdit(row.examId)">编辑 </el-button>
<el-button v-if="isNotStartExam(row) && row.status == 0" type="text" @click="onDelete(row.examId)">删除 </el-button>
<el-button v-if="row.status == 1 && isNotStartExam(row)" type="text" @click="cancelPublishExam(row.examId)">撤销发布</el-button>
<el-button v-if="row.status == 0 && isNotStartExam(row)" type="text" @click="publishExam(row.examId)">发布</el-button>
<el-button type="text" v-if="!isNotStartExam(row)">已开赛,不允许操作</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页区域 -->
<el-pagination background size="small" layout="total, sizes, prev, pager, next, jumper" :total="total" v-model:current-page="params.pageNum" v-model:page-size="params.pageSize" :page-sizes="[5, 10, 15, 20]" @size-change="handleSizeChange" @current-change="handleCurrentChange" />
</template>
<script setup>
import { Plus } from '@element-plus/icons-vue'
function isNotStartExam(exam) {
const now = new Date();
return new Date(exam.startTime) > now
}
</script>
<el-date-picker> 是日期时间范围选择器组件。
isNotStartExam 判断函数:如果当前时间晚于竞赛开始时间则为已开赛,否则未开赛。
根据 status 的值不同去展示是否发布。
如果已经开赛并且没到开始时间可以撤销发布,否则已经开赛不允许修改。
分页功能支持调整每页条数和跳转。
查询重置功能 function onSearch ( ) {
params.pageNum = 1
getExamList ()
}
function onReset ( ) {
params.pageNum = 1
params.pageSize = 10
params.title = ''
params.startTime = ''
params.endTime = ''
datetimeRange.value .length = 0
getExamList ()
}
async function getExamList ( ) {
if (datetimeRange.value [0 ] instanceof Date ) {
params.startTime = datetimeRange.value [0 ].toISOString ()
}
if (datetimeRange.value [1 ] instanceof Date ) {
params.endTime = datetimeRange.value [1 ].toISOString ()
}
const result = await getExamListService (params)
examList.value = result.rows
total.value = result.total
}
因为时间范围是一个数组的形式,但是后端需要的是 2 个参数(开始时间和结束时间),所以需要单独赋值,而其他的因为双向绑定,所以不需要。
增加竞赛
一、不包含题目的竞赛
后端代码
Controller @RestController
@RequestMapping("/exam")
public class ExamController extends BaseController {
@Autowired
private IExamService examService;
@PostMapping("/add")
public R<String> add (@RequestBody ExamAddDTO examAddDTO) {
return R.ok(examService.add(examAddDTO));
}
}
Service 首先我们需要判断竞赛标题是否重复,竞赛开始时间和结束时间判断。然后将 DTO 中内容复制给 Exam 类。
@Override
public String add (ExamAddDTO examAddDTO) {
checkExamSaveParams(examAddDTO, null );
Exam exam = new Exam ();
BeanUtil.copyProperties(examAddDTO, exam);
examMapper.insert(exam);
return exam.getExamId().toString();
}
private void checkExamSaveParams (ExamAddDTO examSaveDTO, Long examId) {
List<Exam> examList = examMapper.selectList(new LambdaQueryWrapper <Exam>()
.eq(Exam::getTitle, examSaveDTO.getTitle())
.ne(examId != null , Exam::getExamId, examId));
if (CollectionUtil.isNotEmpty(examList)) {
throw new ServiceException (ResultCode.FAILED_ALREADY_EXISTS);
}
if (examSaveDTO.getStartTime().isBefore(LocalDateTime.now())) {
throw new ServiceException (ResultCode.EXAM_START_TIME_BEFORE_CURRENT_TIME);
}
if (examSaveDTO.getStartTime().isAfter(examSaveDTO.getEndTime())) {
throw new ServiceException (ResultCode.EXAM_START_TIME_AFTER_END_TIME);
}
}
原因:Exam 类继承了 BeanEntity 类以及使用了雪花算法,可以提高 id 和创建时间和创建人。最后返回竞赛 ID,为后面的增加题目做准备。
前端代码 模板代码包含竞赛信息模块、添加竞赛题目模块、题目配置模块及提交任务区域。API 请求封装了获取列表、新增竞赛、添加竞赛题目等服务。
async function saveBaseInfo ( ) {
const fd = new FormData ()
for (let key in formExam) {
if (key === 'examDate' ) {
fd.append ('startTime' , formExam.examDate [0 ]);
fd.append ('endTime' , formExam.examDate [1 ]);
} else {
fd.append (key, formExam[key])
}
}
await examAddService (fd)
ElMessage .success ('基本信息保存成功' )
}
二、包含题目的竞赛
为了防止太多没有竞赛名字的题目集合存在,导致最后不知道到底是哪个。
添加一个题目即可存在这个竞赛中,不用害怕突然退出导致的重新添加。
后端代码
Service @Override
public boolean questionAdd (ExamQuestAddDTO examQuestAddDTO) {
Exam exam = getExam(examQuestAddDTO.getExamId());
checkExam(exam);
Set<Long> questionIdSet = examQuestAddDTO.getQuestionIdSet();
if (CollectionUtil.isEmpty(questionIdSet)) {
return true ;
}
List<Question> questionList = questionMapper.selectBatchIds(questionIdSet);
if (CollectionUtil.isEmpty(questionList) || questionList.size() < questionIdSet.size()) {
throw new ServiceException (ResultCode.EXAM_QUESTION_NOT_EXISTS);
}
return saveExamQuestion(exam, questionIdSet);
}
private Exam getExam (Long examId) {
Exam exam = examMapper.selectById(examId);
if (exam == null ) {
throw new ServiceException (ResultCode.FAILED_NOT_EXISTS);
}
return exam;
}
首先判断这个竞赛是否存在,如果存在则返回 Exam,否则抛出异常资源不存在。
检查一下竞赛是否开启,如果开启了则不能进行添加。
获得问题列表,如果没有题目,则直接返回即可。
通过问题 ids 进行批量查找,如果有找不到的题目,则直接抛出异常,资源不存在。
批量插入操作,将问题批量插入 tb_exam_question 中(竞赛 id,题目 id,题目顺序)。先将数据统一存在一个列表里面,然后一起插入,但是因为 mybatis-plus 中没有对应的批量插入方法,所以我们继承其他类提高的 saveBatch 方法。
前端代码 点击添加题目后会弹出弹框,将之前的题目列表在一个弹框中展示。
const dialogVisible = ref (false )
function addQuestion ( ) {
if (formExam.examId === null || formExam.examId === '' ) {
ElMessage .error ('请先保存竞赛基本信息' )
} else {
getQuestionList ()
dialogVisible.value = true
}
}
async function getQuestionList ( ) {
const result = await getQuestionListService (params)
console .log (result)
questionList.value = result.rows
total.value = result.total
}
function handleRowSelect (selection ) {
questionIdSet.value = []
selection.forEach (element => {
questionIdSet.value .push (element.questionId )
});
}
async function submitSelectQuestion ( ) {
if (questionIdSet.value && questionIdSet.value .length < 1 ) {
ElMessage .error ('请先选择要提交的题目' )
return false
}
const examQ = reactive ({ examId : formExam.examId , questionIdSet : questionIdSet.value })
console .log (examQ)
await addExamQuestionService (examQ);
dialogVisible.value = false
ElMessage .success ('竞赛题目添加成功' )
}
竞赛详情
后端代码
Controller @GetMapping("/detail")
public R<ExamDetailVO> detail (Long examId) {
return R.ok(examService.detail(examId));
}
VO @Getter
@Setter
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ExamDetailVO {
private String title;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endTime;
private List<QuestionVO> examQuestionList;
}
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class QuestionVO {
@JsonSerialize(using = ToStringSerializer.class)
private Long questionId;
private String title;
private Integer difficulty;
private String createName;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
}
Service @Override
public ExamDetailVO detail (Long examId) {
ExamDetailVO examDetailVO = new ExamDetailVO ();
Exam exam = getExam(examId);
BeanUtil.copyProperties(exam, examDetailVO);
List<ExamQuestion> examQuestionList = examQuestionMapper.selectList(new LambdaQueryWrapper <ExamQuestion>()
.select(ExamQuestion::getQuestionId)
.eq(ExamQuestion::getExamId, examId)
.orderByAsc(ExamQuestion::getQuestionOrder));
if (CollectionUtil.isEmpty(examQuestionList)) {
return examDetailVO;
}
List<Long> questionIdList = examQuestionList.stream().map(ExamQuestion::getQuestionId).toList();
List<Question> questionList = questionMapper.selectList(new LambdaQueryWrapper <Question>()
.select(Question::getQuestionId, Question::getTitle, Question::getDifficulty)
.in(Question::getQuestionId, questionIdList));
List<QuestionVO> questionVOList = new ArrayList <>();
questionVOList = BeanUtil.copyToList(questionList, QuestionVO.class);
examDetailVO.setExamQuestionList(questionVOList);
return examDetailVO;
}
这里总的来说就是从一堆表里面通过关系去查询结果,然后将内容进行截断赋值给要返回的类型。
前端代码 创建请求函数。点击编辑按钮的时候,会进行路由,为了携带 examId 以及 type,我们在路由上面携带。
进来之后,首先从路由上获得 examId,然后对 formExam 的竞赛 ID 进行赋值。注意:由于后端是分为开始时间和结束时间,而前端只有一个 examDate,所以需要特殊处理。
async function getExamDetail ( ) {
const examId = useRoute ().query .examId
console .log (examId)
if (examId) {
formExam.examId = examId
const examDetail = await getExamDetailService (examId)
Object .assign (formExam, examDetail.data )
formExam.examDate = [examDetail.data .startTime , examDetail.data .endTime ]
}
}
竞赛编辑
竞赛基本信息编辑
后端代码
Controller @PostMapping("/edit")
public R<Void> edit (@RequestBody ExamEditDTO examEditDTO) {
return toR(examService.edit(examEditDTO));
}
Service @Override
public int edit (ExamEditDTO examEditDTO) {
Exam exam = getExam(examEditDTO.getExamId());
checkExam(exam);
checkExamSaveParams(examEditDTO, examEditDTO.getExamId());
exam.setTitle(examEditDTO.getTitle());
exam.setStartTime(examEditDTO.getStartTime());
exam.setEndTime(examEditDTO.getEndTime());
return examMapper.updateById(exam);
}
相同竞赛 id 的时候可以同样的标题,但不同竞赛 ID 标题不能相同。
竞赛题目信息编辑
后端代码
Controller @DeleteMapping("/question/delete")
public R<Void> questionDelete (Long examId, Long questionId) {
return toR(examService.questionDelete(examId, questionId));
}
Service @Override
public int questionDelete (Long examId, Long questionId) {
Exam exam = getExam(examId);
checkExam(exam);
if (Contants.TRUE.equals(exam.getStatus())) {
throw new ServiceException (ResultCode.EXAM_IS_PUBLISH);
}
return examQuestionMapper.delete(new LambdaQueryWrapper <ExamQuestion>()
.eq(ExamQuestion::getExamId, examId)
.eq(ExamQuestion::getQuestionId, questionId));
}
首先查看这个竞赛是否存在。
因为在比赛开始后,我们不能进行删除题目操作,所以检查是否已经开始。
判断是否已经开赛,如果已经开赛则不能修改(双重保险)。
去删除 tb_exam_question 中竞赛 id 相同且题目 Id 相同的数据。
前端代码 async function deleteExamQuestion (examId, questionId ) {
await delExamQuestionService (examId, questionId)
getExamDetailById (examId)
ElMessage .success ('竞赛题目删除成功' )
}
async function getExamDetailById (examId ) {
const examDetail = await getExamDetailService (examId)
formExam.examQuestionList = []
Object .assign (formExam, examDetail.data )
formExam.examDate = [examDetail.data .startTime , examDetail.data .endTime ]
}
注意:由于当我们删除最后一个题目的时候,我们会导致 examQuestionList 为空,导致赋值的时候无法找到,所以我们需要提前设置一下。
由于添加题目之后也需要重新请求详细信息,所以也修改代码:
async function submitSelectQuestion ( ) {
if (questionIdSet.value && questionIdSet.value .length < 1 ) {
ElMessage .error ('请先选择要提交的题目' )
return false
}
const examQ = reactive ({ examId : formExam.examId , questionIdSet : questionIdSet.value })
console .log (examQ)
await addExamQuestionService (examQ);
dialogVisible.value = false
getExamDetailById (formExam.examId )
ElMessage .success ('竞赛题目添加成功' )
}
我们现在发现,我们添加题目之后,我们点击添加题目之后还是会显示出来,这对用户不友好,所以我们继续修改后端代码和前端代码。
@Override
public List<QuestionVO> list (QuestionQueryDTO questionQueryDTO) {
String excludeIdStr = questionQueryDTO.getExcludeIdStr();
if (StrUtil.isNotEmpty(excludeIdStr)) {
String[] excludeIdArr = excludeIdStr.split(Contants.SPLIT_SEM);
Set<Long> excludeIdSet = Arrays.stream(excludeIdArr)
.map(Long::valueOf)
.collect(Collectors.toSet());
questionQueryDTO.setExcludeIdSet(excludeIdSet);
}
PageHelper.startPage(questionQueryDTO.getPageNum(), questionQueryDTO.getPageSize());
return questionMapper.selectQuestionList(questionQueryDTO);
}
得到前端的参数后,先将 excludeIdStr 按照分隔符进行分割为数组,然后将数组转为 Set 类型的数据,最后将参数赋值给 DTO 进行数据库查询。
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace ="com.bite.system.mapper.question.QuestionMapper" >
<select resultType ="com.bite.system.model.question.vo.QuestionVO" >
SELECT tq.question_id, tq.title, tq.difficulty, ts.nick_name as create_name, tq.create_time
FROM tb_question tq
LEFT JOIN tb_sys_user ts ON tq.create_by = ts.user_id
<where >
<if test ="difficulty !=null " >
AND difficulty = #{difficulty}
</if >
<if test ="title !=null and title !='' " >
AND title LIKE CONCAT('%',#{title},'%')
</if >
<if test ="excludeIdSet !=null and !excludeIdSet.isEmpty()" >
<foreach collection ="excludeIdSet" open =" AND tq.question_id NOT IN( " close =" ) " item ="id" separator ="," >
#{id}
</foreach >
</if >
</where >
ORDER BY create_time DESC
</select >
</mapper >
这样我们搜索的时候就可以排除我们集合中的 questionId。
然后修改前端代码,我们进行查询前先查找已经选择的题目 id,然后进行查找。
竞赛删除
后端代码
Controller @DeleteMapping("/delete")
public R<Void> delete (Long examId) {
return toR(examService.delete(examId));
}
Service 我们这里只需要保证是在开始之前进行删除即可,调用数据库删除竞赛里面的问题,然后删除竞赛。
@Override
public int delete (Long examId) {
Exam exam = getExam(examId);
if (Contants.TRUE.equals(exam.getStatus())) {
throw new ServiceException (ResultCode.EXAM_IS_PUBLISH);
}
checkExam(exam);
examQuestionMapper.delete(new LambdaQueryWrapper <ExamQuestion>()
.eq(ExamQuestion::getExamId, examId));
return examMapper.deleteById(exam);
}
前端代码 async function onDelete (examId ) {
await delExamService (examId)
params.pageNum = 1
getExamList ()
}
竞赛发布与撤销发布
后端代码 @PutMapping("/publish")
public R<Void> publish (Long examId) {
return toR(examService.publish(examId));
}
@PutMapping("/cancelPublish")
public R<Void> cancelPublish (Long examId) {
return toR(examService.cancelPublish(examId));
}
@Override
public int publish (Long examId) {
Exam exam = getExam(examId);
if (exam.getEndTime().isBefore(LocalDateTime.now())) {
throw new ServiceException (ResultCode.EXAM_IS_FINISH);
}
Long count = examQuestionMapper.selectCount(new LambdaQueryWrapper <ExamQuestion>()
.eq(ExamQuestion::getExamId, examId));
if (count == null || count <= 0 ) {
throw new ServiceException (ResultCode.EXAM_NOT_HAS_QUESTION);
}
exam.setStatus(Contants.TRUE);
return examMapper.updateById(exam);
}
@Override
public int cancelPublish (Long examId) {
Exam exam = getExam(examId);
checkExam(exam);
if (exam.getEndTime().isBefore(LocalDateTime.now())) {
throw new ServiceException (ResultCode.EXAM_IS_FINISH);
}
exam.setStatus(Contants.FALSE);
return examMapper.updateById(exam);
}
前端代码 import { publishExamService, cancelPublishExamService } from '../apis/exam'
async function publishExam (examId ) {
await publishExamService (examId)
getExamList ()
}
async function cancelPublishExam (examId ) {
await cancelPublishExamService (examId)
getExamList ()
}
在新增竞赛的时候发现,当我们新增页面或者之前没有选择题目的竞赛的时候,我们点击新增题目会报错,这是因为 examQuestionList 为 null。至此 B 端竞赛管理功能开发完成。
相关免费在线工具 Keycode 信息 查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
Escape 与 Native 编解码 JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
JavaScript / HTML 格式化 使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
JavaScript 压缩与混淆 Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
加密/解密文本 使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
Gemini 图片去水印 基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online