基于 Java Spring 的在线 OJ 系统竞赛管理模块实现
基于 Java Spring Boot 和 Vue.js 的在线 OJ 系统中竞赛管理模块的实现方案。内容涵盖数据库表结构设计、后端 RESTful API 开发(包括竞赛 CRUD、题目关联、状态管理)、以及前端页面交互逻辑。重点解决了竞赛时间校验、发布状态控制、题目批量添加与删除、以及前后端数据格式转换等关键技术点。

基于 Java Spring Boot 和 Vue.js 的在线 OJ 系统中竞赛管理模块的实现方案。内容涵盖数据库表结构设计、后端 RESTful API 开发(包括竞赛 CRUD、题目关联、状态管理)、以及前端页面交互逻辑。重点解决了竞赛时间校验、发布状态控制、题目批量添加与删除、以及前后端数据格式转换等关键技术点。

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 bigint unsigned comment '更新人',
update_time datetime comment '更新时间',
primary key(exam_question_id)
);
@RestController
@RequestMapping("/exam")
public class ExamController extends BaseController {
@Autowired
private IExamService examService;
// exam/list
@GetMapping("/list")
public TableDataInfo list(ExamQueryDTO examQueryDTO) {
return getDataTable(examService.list(examQueryDTO));
}
}
前端传入查询条件(标题,开始时间,结束时间),继承分页域对象以支持分页。
调用 Mapper 进行查询,使用分页插件。
返回类型包含 Long 类型的 examId,需转换为字符串序列化以避免前端精度丢失。日期时间字段使用 @JsonFormat 注解指定格式。
@Getter
@Setter
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ExamVO {
@JsonSerialize(using = ToStringSerializer.class)
private Long examId;
private String title;
private LocalDateTime startTime;
private LocalDateTime endTime;
private Integer status;
private String createName;
private LocalDateTime createTime;
}
使用 XML 方式处理复杂查询条件。
<?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>
使用 Element Plus 组件构建搜索表单和表格。
<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>
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
}
@PostMapping("/add")
public R<String> add(@RequestBody ExamAddDTO examAddDTO) {
return R.ok(examService.add(examAddDTO));
}
判断竞赛标题是否重复,检查开始时间和结束时间逻辑。
@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);
}
}
模板包含基本信息输入框和时间选择器,以及题目添加区域。
先保存竞赛基本信息获取 ID,再关联题目。防止数据不一致。
@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);
}
@Getter
@Setter
public class ExamQuestAddDTO {
private Long examId;
private LinkedHashSet<Long> questionIdSet;
}
点击添加题目弹出对话框,展示题目列表供选择。提交时校验是否已保存竞赛 ID。
@GetMapping("/detail")
public R<ExamDetailVO> detail(Long examId) {
return R.ok(examService.detail(examId));
}
@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;
}
通过 examId 获取竞赛信息,关联查询题目列表并按顺序排序。
验证竞赛是否存在且未发布,更新标题和时间。
@DeleteMapping("/question/delete")
public R<Void> questionDelete(Long examId, Long questionId) {
return toR(examService.questionDelete(examId, questionId));
}
检查竞赛状态,若已发布则禁止删除题目。
删除后重新请求详情刷新列表。注意初始化空列表以防赋值错误。
仅允许在竞赛开始前删除,同时级联删除关联的题目记录。
发布前检查结束时间是否过期及是否包含题目。撤销发布同样检查状态。
@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);
}
调用对应 API 接口并刷新列表。新增竞赛时需初始化 examQuestionList 避免报错。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online