微服务项目->在线oj系统(Java-Spring)--竞赛管理 - 教程

表结构创建

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:已发布',
-- exam_question 这个竞赛下所有的题目都存进来并且用&分隔开 10
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
@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));
    }
}
前端传入参数(DTO)

因为需要查询,所以需要传入查询条件(标题,开始时间,结束时间)

因为是分页查询,所以需要继承之前写的pageDomain

返回值类型:TableDataInfo和之前的题库列表一样

Service

首先是之前使用的分页插件的使用,然后就是调用mapper进行查询

前端返回值(VO)

注解的作用:

它会将 Long 类型的 examId 先转换为字符串,然后再进行序列化。

(由于雪花算法产生的值会超过Long的范围)

在 JavaScript 等语言中,JavaScript 的 Number 类型在处理非常大的整数时,可能会出现精度丢失的情况 。将 Java 中的 Long 类型(尤其是比较大的 Long 值)序列化为字符串,可以避免在前端处理时出现的精度问题。

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") 是 Jackson 库中的注解,用于指定 LocalDateTime 类型的 endTime 字段在序列化为 JSON 时的日期时间格式,这里设置为 “年 - 月 - 日 时:分: 秒” 的格式,能让日期时间数据在 JSON 传输时按照指定格式呈现,方便前后端对日期时间格式的统一处理。

因为我们数据库中的数据为int类型,但是我们可以通过多表查询,将管理员匿称返回给前端,所以用String类型

Mapper

由于我们这里查询条件比较繁琐,所以我们这里使用xml方式来解决




    
请求测试

前端代码开发

页面半成品代码

<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> 是日期时间范围选择器组件:

这是一个判断,如果已经开始则为已开赛,否则未开赛

根据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()
}

因为时间范围是一个数组的形式,但是后端需要的是2个参数(开始时间和结束时间)所以我们需要单独赋值,而其他的因为双向绑定,所以不需要

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
}

增加竞赛

一、不包含题目的竞赛

后端代码
Controller
@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));
    }
    @PostMapping("/add")
    public R add(@RequestBody ExamAddDTO examAddDTO) {
        return R.ok(examService.add(examAddDTO));
    }
}
DTO

Service

首先我们需要判断竞赛标题是否重复,竞赛开始时间和结束时间判断

然后将DTO中内容复制给Exam类

原因:Exam类继承了BeanEntity类以及使用了雪花算法,可以提高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) {
        //1、竞赛标题是否重复进行判断   2、竞赛开始、结束时间进行判断
        List examList = examMapper
                .selectList(new LambdaQueryWrapper()
                        .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,为后面的增加题目做准备

前端代码

模板代码


<script setup>
import { examAddService } from "@/apis/exam"
import { getQuestionListService } from "@/apis/question"
import Selector from "@/components/QuestionSelector.vue"
import router from '@/router'
import { reactive, ref } from "vue"
import { Plus } from "@element-plus/icons-vue"
import { useRoute } from 'vue-router';
const type = useRoute().query.type
const formExam = reactive({
  examId: '',
  title: '',
  examDate: ''
})
// 返回
function goBack() {
  router.go(-1)
}
const params = reactive({
  pageNum: 1,
  pageSize: 10,
  difficulty: '',
  title: ''
})
</script>

api请求

import service from '@/utils/request'
export function getExamListService(params) {
  return service({
    url: "/exam/list",
    method: "get",
    params,
  });
}
export function examAddService(params = {}) {
  return service({
    url: "/exam/add",
    method: "post",
    data: params,
  });
}
export function addExamQuestionService(params = {}) {
  return service({
    url: "/exam/question/add",
    method: "post",
    data: params,
  });
}

代码分析

基本信息

这里是我们之前学的输入框双向绑定,以及时间框

goBack()是点击想要事件,返回上一级路由

保存按键

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('基本信息保存成功')
}

二、包含题目的竞赛

这里为什么要先保存后新增呢?

1.为了防止太多没有竞赛名字的题目集合存在,导致最后不知道到底是哪个

2.添加一个题目即可存在这个竞赛中,不用害怕突然退出导致的重新添加

后端代码
 Controller
@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));
    }
    @PostMapping("/add")
    public R add(@RequestBody ExamAddDTO examAddDTO) {
        return R.ok(examService.add(examAddDTO));
    }
}
Service
 @Override
    public boolean questionAdd(ExamQuestAddDTO examQuestAddDTO) {
        Exam exam = getExam(examQuestAddDTO.getExamId());
        checkExam(exam);
        Set questionIdSet = examQuestAddDTO.getQuestionIdSet();
        if (CollectionUtil.isEmpty(questionIdSet)) {
            return true;
        }
        List questionList = questionMapper.selectBatchIds(questionIdSet);
        if (CollectionUtil.isEmpty(questionList) || questionList.size() < questionIdSet.size()) {
            throw new ServiceException(ResultCode.EXAM_QUESTION_NOT_EXISTS);
        }
        return saveExamQuestion(exam, questionIdSet);
    }
DTO
@Getter
@Setter
public class ExamQuestAddDTO {
    private Long examId;
    private LinkedHashSet questionIdSet;
}

需要传入竞赛id和题目id集合

细节分析:

首先判断这个竞赛是否存在,如果存在则返回Exam,否则抛出异常资源不存在

 private Exam getExam(Long examId) {
        Exam exam = examMapper.selectById(examId);
        if (exam == null) {
            throw new ServiceException(ResultCode.FAILED_NOT_EXISTS);
        }
        return exam;
    }

检查一下竞赛是否开启,如果开启了则不能进行添加

获得问题列表,如果没有题目,则直接返回即可

通过问题ids进行批量查找,如果有找不到的题目,则直接抛出异常,资源不存在

这个方法是批量进行插入操作,将问题批量插入

这个方法是批量进行插入操tb_exam_questionxam_question中插入数据(竞赛id,题目id,题目顺序)----》》先将数据统一存在一个列表里面,然后一起插入,但是因为mybatis-plus中没有对应的批量插入方法,所以我们继承其他类提高的savaBatch方法

第一个参数是要操作是数据库,第二个参数是数据库里面参数的类型

前端代码

在点击添加题目后会弹出这样的一个弹框

我们仔细一看,可以去Element*中查找可得

就是将我们之前的题目列表在一个弹框中展示

下面实现添加点击事件

async function getQuestionList() {
    const result = await getQuestionListService(params)
    console.log(result)
    questionList.value = result.rows
    total.value = result.total
}
const dialogVisible = ref(false)
function addQuestion() {
    if (formExam.examId === null || formExam.examId === '') {
        ElMessage.error('请先保存竞赛基本信息')
    } else {
        getQuestionList()
        dialogVisible.value = true
    }
}

由于这里需要判断是否以及保存(examId),所以我们保存的时候需要进行赋值

多选框

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 detail(Long examId) {
        return R.ok(examService.detail(examId));
    }
VO

我们这里需要竞赛标题,竞赛的开始时间和结束时间,以及问题列表(由于只需要问题id,难度,标题)所以使用QuestionVO

@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 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

我们首先通过examId来获得竞赛,然后将竞赛内容复制给返回值

然后通过竞赛id进行查询竞赛题目的题目id并根据order进行排序

之后通过题目id进行查询问题列表,将查询到的列表复制给questionVOList来进行返回值处理

最后把questionVOList赋值给examDetailVO 

 @Override
    public ExamDetailVO detail(Long examId) {
        ExamDetailVO examDetailVO = new ExamDetailVO();
        Exam exam = getExam(examId);
        BeanUtil.copyProperties(exam, examDetailVO);
        ListexamQuestionList = examQuestionMapper.selectList(new LambdaQueryWrapper()
                .select(ExamQuestion::getQuestionId)
                .eq(ExamQuestion::getExamId,examId)
                .orderByAsc(ExamQuestion::getQuestionOrder)
        );
        if (CollectionUtil.isEmpty(examQuestionList)) {
            return examDetailVO;
        }
       List questionIdList= examQuestionList.stream().map(ExamQuestion::getQuestionId).toList();
        List questionList=questionMapper.selectList(new LambdaQueryWrapper()
                .select(Question::getQuestionId,Question::getTitle,Question::getDifficulty)
                .in(Question::getQuestionId,questionIdList)
        );
        List questionVOList=new ArrayList<>();
     questionVOList= BeanUtil.copyToList(questionList,QuestionVO.class);
        examDetailVO.setExamQuestionList(questionVOList);
        return examDetailVO;
    }

这里总的来说就是从一堆表里面通过关系去查询结果,然后将内容进行截断赋值给要返回的类型

前端代码

创建请求函数

点击编辑按钮的时候,会进行路由,为了携带examid以及type,我们在路由上面携带

进来之后,我们首先从路由上获得examId,然后对formExam的竞赛ID进行赋值(为了不要点击保存就可以使用)获得返回值,对formExam进行赋值

注意:由于后端是分为开始时间和结束时间,而前端只有一个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

DTO

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 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()
                .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 getExamDetail() {
    const examId = useRoute().query.examId
    console.log(examId)
    if (examId) {
        formExam.examId = examId
        getExamDetailById(examId)
    }
}

由于添加题目之后也需要重新请求详细信息,所以也修改代码

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 list(QuestionQueryDTO questionQueryDTO) {
        String excludeIdStr = questionQueryDTO.getExcludeIdStr();
        if (StrUtil.isNotEmpty(excludeIdStr)) {
            String[] excludeIdArr = excludeIdStr.split(Contants.SPLIT_SEM);
            Set 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<Long>类型的数据,最后将参数赋值给DTO进行数据库查询

这里修改之前的xml文件




    

这样我们搜索的时候就可以排除我们集合中的questionid

然后修改前端代码

我们进行查询前先查找已经选择的题目id,然后进行查找

竞赛删除

后端代码

Controller
   @DeleteMapping("/delete")
    public R 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()
                .eq(ExamQuestion::getExamId, examId));
        return examMapper.deleteById(exam);
    }

前端代码

async function onDelete(examId) {
  await delExamService(examId)
  params.pageNum = 1
  getExamList()
}

竞赛发布与撤销发布

后端代码

 @PutMapping("/publish")
    public R publish(Long examId) {
        return toR(examService.publish(examId));
    }
    @PutMapping("/cancelPublish")
    public R 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);
        }
        //select count(0) from tb_exam_question where exam_id = #{examId}
        Long count = examQuestionMapper
                .selectCount(new LambdaQueryWrapper()
                        .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);
    }

这里逻辑简单不做多余讲解

前端代码

Exam.vue

import{ publishExamService,cancelPublishExamService} from '../apis/exam'
async function publishExam(examId) {
  await publishExamService(examId)
  getExamList()
}
async function cancelPublishExam(examId) {
  await cancelPublishExamService(examId)
  getExamList()
}

update.vue

我们在新增竞赛的时候发现,当我们新增页面或者之前没有选择题目的竞赛的时候,我们点击新增题目会报错,这是因为examQuestionList为null

至此我们B端竞赛管理结束

posted on 2025-10-04 16:25  slgkaifa  阅读(5)  评论(0)    收藏  举报

导航