数据模型设计
竞赛管理的核心在于状态流转与题目关联。我们设计了 tb_exam 存储竞赛基础信息,tb_exam_question 维护竞赛与题目的多对多关系。
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)
);
列表查询实现
后端接口
列表页需要支持分页、标题模糊搜索以及时间范围筛选。Controller 层直接调用 Service 返回标准表格数据。
@RestController
@RequestMapping("/exam")
public class ExamController extends BaseController {
@Autowired
private IExamService examService;
// 获取竞赛列表
@GetMapping("/list")
public TableDataInfo list(ExamQueryDTO examQueryDTO) {
return getDataTable(examService.list(examQueryDTO));
}
}
请求参数继承通用的分页对象 PageDomain,并补充业务查询字段。返回值 VO 中需要注意 ID 的序列化问题。由于雪花算法生成的 ID 可能超过 JavaScript Number 的安全整数范围,我们将 Long 类型转为 String 处理。
@Getter
@Setter
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ExamVO {
@JsonSerialize(using = ToStringSerializer.class)
private Long examId;
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 Integer status;
private String createName; // 通过联表查询获取创建人昵称
private LocalDateTime createTime;
}
Mapper 层使用 XML 编写复杂查询,利用 MyBatis 的动态 SQL 处理可选条件。
<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 的日期范围选择器收集时间区间。注意,组件返回的是 Date 数组,需转换为 ISO 字符串传给后端。
<template>
<el-form inline="true">
<el-form-item label="竞赛名称">
<el-input v-model="params.title" placeholder="请输入竞赛名称" />
</el-form-item>
<el-form-item label="创建日期">
<el-date-picker
v-model="datetimeRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始日期"
end-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>
<!-- 表格展示略 -->
</template>
<script setup>
// 时间处理逻辑
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;
}
</script>
创建竞赛流程
竞赛创建分为两步:先保存基本信息生成竞赛 ID,再关联题目。这种设计避免了空竞赛的存在,也允许用户随时退出而不产生脏数据。
基础信息保存
后端需校验标题唯一性及时间逻辑(开始时间不能早于当前时间,且不能晚于结束时间)。
@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 和题目 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);
}
前端在点击'添加题目'时,若尚未保存基本信息(无 examId),则提示用户先保存。选中题目后,将 ID 集合封装为 DTO 发送请求。
详情编辑与删除
详情查看
详情页需要聚合竞赛信息与题目列表。题目列表只需展示 ID、标题、难度,无需冗余信息。
@Override
public ExamDetailVO detail(Long examId) {
ExamDetailVO examDetailVO = new ExamDetailVO();
Exam exam = getExam(examId);
BeanUtil.copyProperties(exam, examDetailVO);
// 按顺序获取题目 ID
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)
);
examDetailVO.setExamQuestionList(BeanUtil.copyToList(questionList, QuestionVO.class));
return examDetailVO;
}
编辑与删除
编辑时同样需要校验标题唯一性(排除自身)和时间逻辑。删除操作必须确保竞赛未发布且未开始。
@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);
}
前端在删除题目时,会重新拉取详情以刷新列表。为了避免重复显示已选题目,我们在查询题库时增加排除逻辑,将已关联的题目 ID 从搜索结果中过滤掉。
<!-- 题库查询排除已选 ID -->
<if test="excludeIdSet !=null and !excludeIdSet.isEmpty()">
<foreach collection="excludeIdSet" open=" AND tq.question_id NOT IN( " close=" ) " item="id" separator=",">
#{id}
</foreach>
</if>
状态管理与发布
竞赛的生命周期包含未发布、已发布、已结束三种状态。发布前必须校验是否已关联题目,且结束时间未过。
@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);
}
撤销发布逻辑类似,仅将状态置回未发布即可。前端根据状态动态控制操作按钮的显隐,例如已开赛状态下禁止编辑或删除。

