数据库设计
竞赛管理涉及竞赛基础信息与题目关联,核心表结构如下:
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 层直接复用通用的 BaseController,Service 层调用分页插件进行数据检索。前端传入的 DTO 需要继承 pageDomain 以支持分页参数。
@RestController
@RequestMapping("/exam")
public class ExamController extends BaseController {
@Autowired
private IExamService examService;
@GetMapping("/list")
public TableDataInfo list(ExamQueryDTO examQueryDTO) {
return getDataTable(examService.list(examQueryDTO));
}
}
Mapper 层由于查询条件较为复杂(包含多表关联),采用 XML 方式编写 SQL。注意日期时间的比较逻辑以及用户昵称的关联查询。
<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>
VO 序列化细节
返回给前端的 VO 对象中,examId 使用了 @JsonSerialize(using = ToStringSerializer.class)。这是因为雪花算法生成的 ID 可能超过 JavaScript Number 类型的精度范围,转为字符串传输可避免精度丢失。同时,日期字段使用 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") 统一格式。

新增与编辑竞赛
业务逻辑
新增竞赛分为两步:先保存竞赛基本信息获取 ID,再关联题目。这样设计是为了防止出现无标题的题目集合,同时也便于在添加过程中随时回滚或退出。
校验机制
在 Service 层需重点处理以下校验:
- 标题唯一性:排除当前编辑记录后检查是否存在重复标题。
- 时间逻辑:开始时间不能早于当前时间,且必须早于结束时间。
- 状态保护:一旦竞赛发布或已开始,禁止修改题目或基本信息。
@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);
}
}
前端交互
前端页面采用分步式布局,先填写基础信息并保存,随后通过弹窗选择题目。时间选择器返回的是数组,提交时需拆分为 startTime 和 endTime 两个参数。
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('基本信息保存成功')
}
题目关联管理
批量添加
后端接收题目 ID 集合,首先校验竞赛是否存在及状态是否允许修改。接着批量查询题目信息,确保所有 ID 有效,最后利用自定义的 saveBatch 方法将竞赛与题目的关系写入中间表。
@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);
}
题目删除与过滤
删除题目时同样需要校验竞赛状态。在前端搜索题目时,为了避免重复添加,后端接口增加了 excludeIdStr 参数,用于排除已选中的题目 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);
}
删除操作
删除竞赛前同样校验状态,若已发布则禁止删除。删除时会级联清理 tb_exam_question 中的关联数据,保证数据一致性。



