高校学生编程能力大赛分为区赛和国赛,并设有本科组和高职组两个组别,各组别都有六大赛区:西北赛区、西南赛区、东南赛区、东北赛区、东部赛区,中部赛区。本系统主要是用于服务大赛的整个流程,保证各组别赛区比赛正常进行。
-
前期准备:
- 管理员下发评委、主持人账号(之前有的直接下发,没有的则创建后下发)
- 管理员设置报名时间
-
报名环节
- 学校申请报名后下发学校账号(往届参加过的不用)
- 学校为学生申请选手账号,参赛人数不超过3名。
-
笔试环节
- 主持人提前在系统内进行笔试座位号抽签
- 笔试结束后, 需要由主持人进行成绩导入
- 筛选出前30名晋级
-
实战能力比试
- 主持人对晋级选手进行分组抽签(同一组的选手不能来自同一个学校)
- 每组实战结束后,5位评委对其打分
-
快问快答环节
- 本环节选手按环节三中签号依次上场。选手上台的顺序是:A1、B1、A2、B2 …… 15A、15B。
- 评委对选手进行快问快答, 由五名评委对选手回答打分
-
成绩现场导出
- 比赛结束, 由主持人现场导出最终成绩表
- 筛选前五名进入晋级国赛(若有放弃国赛名额的情况,系统将通知有关选手及时确认顺延递补晋级)
注:实战能力比试、快问快答环节、成绩现场导出环节跟区赛一致
- 前期准备
- 管理员下发评委、主持人账号
- 管理员开启国赛
- 实战能力比试
- 主持人对国赛选手进行分组抽签(同一组的选手不能来自同一个学校)
- 每组实战结束后,5位评委对其打分
- 快问快答环节
- 本环节选手按环节三中签号依次上场。选手上台的顺序是:A1、B1、A2、B2 …… 15A、15B。
- 评委对选手进行快问快答, 由五名评委对选手回答打分
- 成绩现场导出
- 比赛结束, 由主持人现场导出最终成绩表
在redis中存储每个组别赛区此时的进程,如下图。
当调用相关接口时,查询redis,检验是否符合当前进程。具体如何实现请查阅 CheckProcessAspect
流程细分
-
written:笔试环节
- seat_draw:座位号抽签
- post_written_score:上传笔试成绩
- score_filter:成绩筛选
- next:主持人推进
-
practice:实战对决环节
- group_draw:分组抽签
- rate:评委打分
- next:主持人推进
-
q_and_a:快问快答环节
- rate:评委打分
- next:主持人推进
-
final:最终环节
- score_export:成绩导出
- next:完赛
简写介绍
- 组别
- BK:本科
- GZ:高职
- 赛区
- NW:西北赛区
- SW:西南赛区
- NE:东北赛区
- SE:东南赛区
- C:中部赛区
- E:东部赛区
- N:全国
处理进入应用的HTTP请求并返回响应
管理员:
-
主持比赛前的事件
- 下发主持人、评委及学校账号
- 设置报名时间、开启国赛
- 修改放弃国赛资格规定时间
-
监控比赛情况
-
监控赛区进程
-
监控各账号的状态
-
监控评委打分情况、选手得分情况
-
-
备份、还原数据库
注意:管理员拥有主持人的全部权限。
主持人:推进比赛
- 开启比赛
- 推进下一环节
- 座位号抽签
- 通过excel上传笔试成绩
- 按笔试成绩筛选
- 分组抽签
- 导出成绩
- 查询每组实战对决分数
- 查询快问快答得分
评委:对选手打分
- 实战能力比试环节
- 查询分组选手
- 对每组选手打分
- 快问快答环节
- 查询选手
- 对选手打分
学校:
- 为学生报名
- 查询往届学生获奖情况
选手:
- 查询座位号
- 查询笔试成绩
- 查询组号
- 放弃国赛资格
用户:
- 登录
- 重置用户名、密码
- 修改密码
- 查询个人往届成绩
- 上传图片
- 查询用户信息
- 修改用户信息
MYSQL数据:
- 查询所有备份数据
- 备份mysql数据
- 恢复mysql数据
-
下发主持人、评委及学校账号
- 管理员提交规定格式的excel文件赋予其角色(权限)
- 账号存在,直接赋予其相关角色(权限)。注意:此时“是否重置密码”为必填项
- 账号不存在,则创建账号后下发。
- 密码:随机获得8位密码,利用BCrypt加密加盐后存储到数据库
-
设置报名时间、修改放弃国赛资格规定时间:将管理员提交的时间转换为时间戳存储到redis
-
开启国赛
- 根据存储到redis中的一个字段"finish_competition_num"是否等于12判断区赛是否全部结束
- 将本科组、高职组相关进程(practice:group_draw)存储到redis中,主持人便可在比赛时开启分组抽签。
-
监控赛区进程:根据group、zone分页查询,有四种情况
-
group、zone都不输入:查询全部
-
输入group:查询组别的全部赛区进程
-
输入zone:查询地区的全部组别进程
-
都输入:具体查询某个组别赛区的进程
-
-
监控各账号的状态:直接分页查询
-
监控评委打分情况、选手得分情况:根据选手名、评委名、组别、赛区分页查询
-
设置为游客身份:根据组别、赛区将账号设置为游客角色(除晋级国赛选手)
-
开启比赛
- 首先根据存储在redis中的报名时间,判断报名是否已结束
- 再根据redis中是否有本组别赛区进程信息,判断比赛是否已经进行
- 将本组别赛区进程信息(written:seat_draw)存储到redis
-
推进下一环节
- 从redis获得本组别赛区进程信息,检验环节步骤是否为next
- 获得下一进程存储到redis
- 如果step为rate,开启每一小时自动备份数据库
-
座位号抽签
- 读取组别赛区选手总数
- 创建一个1~n的List
- 利用Collections.shuffle()打乱List
- 按顺序给选手分配座位号
-
通过excel上传笔试成绩:直接按格式读取成绩数据,存储到mysql
-
按笔试成绩筛选
- 查询成绩单后按笔试成绩降序排序
- 将笔试成绩存储到mysql
- 从contestant表中删除淘汰选手
- 从score_info表中删除淘汰选手的成绩单
-
分组抽签
- 首先创建一个映射来跟踪每个学校的选手
- 此时还有一个List students存储未分组的学生
- 循环直到students列表的大小等于6
- 首先从students取出学生A,再查询schoolStudents与之不同的学校,从中取出学生B组成一组
- 剩下6名选手随机分组可能发生同校同组的情况,需要回退
- 按之前的逻辑进出循环,当剩下两名选手来自同一学校时,回退两步
- 考虑到后面评委要频繁查询分组名单,所以将分组信息存进redis
- 更新成绩单分组信息
附分组抽签算法
public List<SignGroup> groupDraw(List<Student> students) {
// 创建一个映射来跟踪每个学校的选手
Map<String, List<Student>> schoolStudents = new HashMap<>();
for (Student student : students) {
schoolStudents.computeIfAbsent(student.getSchool(), k -> new ArrayList<>()).add(student);
}
int signNum = 1;
Collections.shuffle(students);// 打乱选手顺序
List<SignGroup> signGroups = new ArrayList<>();// 存放分组信息
while (students.size() > 6) {
Student A = students.remove(0);
schoolStudents.get(A.getSchool()).remove(A);
for (String school : new ArrayList<>(schoolStudents.keySet())) {
if (!schoolStudents.get(school).isEmpty() && !Objects.equals(school, A.getSchool())) {
Student B = schoolStudents.get(school).remove(0);
students.remove(B);
signGroups.add(new SignGroup(signNum++, A, B));
break;
}
}
}
// 剩下6名选手随机分组可能发生同校同组的情况,需要回退
Stack<Student> stack = new Stack<>();
while (!students.isEmpty()) {
Student A = students.remove(0);
schoolStudents.get(A.getSchool()).remove(A);
stack.push(A);
boolean flag = true;
for (String school : new ArrayList<>(schoolStudents.keySet())) {
if (!schoolStudents.get(school).isEmpty() && !Objects.equals(school, A.getSchool())) {
Student B = schoolStudents.get(school).remove(0);
students.remove(B);
stack.push(B);
signGroups.add(new SignGroup(signNum++, A, B));
flag = false;
break;
}
}
// 剩下两名选手来自同一学校,回退两步
if (flag) {
students.addAll(stack);
schoolStudents.get(stack.peek().getSchool()).add(stack.pop());
schoolStudents.get(stack.peek().getSchool()).add(stack.pop());
schoolStudents.get(stack.peek().getSchool()).add(stack.pop());
schoolStudents.get(stack.peek().getSchool()).add(stack.pop());
signGroups.remove(signGroups.size() - 1);
signGroups.remove(signGroups.size() - 1);
signNum -= 2;
}
}
return signGroups;
}
-
导出成绩
- 按降序排序最终成绩
- 根据组别区分区赛和国赛,采用不用的成绩导出模板
- 使用iTextPdf为Pdf模板填充表单项
-
查询每组实战对决分数:根据uid查询
-
查询快问快答得分:根据uid查询
-
查询分组选手
- 检验查询范围是否异常
- 从redis中查询分组信息json字符串
- 解析为列表后返回
-
对每组选手打分
- 检验是否重复打分
- 插入选手得分信息
- 判断管理员是否在监控打分,在则发送评委打分信息
- 将rate_times(几位评委已打分)+1后存储到redis
- 判断五位评委是否打分完毕
- 计算平均分后插入选手成绩单
- 判断管理员是否在监控选手得分,在则发送选手得分信息
- 删除rate_times
- 将rate_nums(已打分选手)+1后存储到redis
- 判断30位选手是否全部打分完毕
- 删除rate_nums
- 推进流程至next
- 关闭每一小时自动备份数据库,开启每天00:00自动备份数据库
- 删除存储在redis中的分组签号信息
- 存储个人签号
-
查询选手:同“查询分组选手”一样的逻辑
-
对选手打分:同“对每组选手打分”的逻辑,不同点在于最后一步只删除了“个人签号”
-
查询座位号、组号:直接按uid查询
-
放弃国赛资格
- 检验是否为国赛选手
- 校验是否在规定的时间内
- 从contestant表中删除放弃国赛资格的选手信息
- 从redis中获得顺延递补的选手排名序号
- 保存下一位排名序号
- 添加下一位选手信息
-
删除未晋级选手:将排序低于5的选手从contestant表中删除
-
登录
- 通过用户名查询账号,检验账号是否存在、启用
- 校验密码
- 利用satoken登录
- 将group、zone、role存储到sasession中,方便后续直接调用
-
重置用户名、密码
- 检查能否修改用户名(一年只能修改一次)
- 检查用户名是否已存在
- 检查两次密码是否一致
- 强制退出,重新登录
-
修改密码
- 检验旧密码是否正确
- 检验新密码是否一致
- 强制退出,重新登录
-
查询用户信息:直接按uid查询
-
修改用户信息:直接修改
-
添加往届成绩:按group、zone将成绩单存储到pre_score中
-
查询往届成绩(管理员):可按姓名、组别、赛区、届数、排名分页查询
-
查询个人往届成绩:直接按uid查询
-
计算最终成绩
-
判断是区赛还是国赛
-
按规定的比例计算最终成绩
-
-
添加学生成绩
- 查询学生成绩
- 按成绩降序排序
- 判断区赛还是国赛
- 第1、2名设置一等奖、第3~5名设置二等奖
-
查询学生成绩:可按姓名、学校、届数、奖项分页查询
-
查询笔试成绩:直接按uid查询
-
删除笔试成绩:按group、zone删除
-
删除成绩单:按group、zone删除成绩单、实战对决成绩、快问快打成绩
sakoken自定义权限加载接口实现类
- 返回一个账号所拥有的权限码集合:直接根据uid查询permission表
- 返回一个账号所拥有的角色标识集合:直接根据uid查询role表
-
查询备份数据:根据id查询
-
查询所有备份数据
-
备份mysql数据库
- 从url中获得ip、端口号
- 获得数据库文件名称
- 获得备份命令
- 判断文件路径是否存在,不存在就创建
- 备份信息存放到数据库
- 获取Runtime实例,备份数据库
-
恢复数据库
-
获得备份路径文件名
-
获得恢复命令
-
获取Runtime实例,恢复数据库
-
-
ErrorMessage:错误信息常量
-
Group:组别常量
-
Zone:赛区常量
-
Process:进程常量
-
Step:步骤常量
-
Item:项目常量
-
Prize:奖项常量
-
Role:角色常量
-
Supervise:监控常量
-
Time:时间常量
各种异常
JacksonObjectMapper对象映射器
- 基于jackson将Java对象转为json
- 将json转为Java对象
-
AliOssProperties:阿里云属性
-
MysqlProperties:mysql属性
- PageResult:分页查询结果
- Result:http相应结果
-
AliOssUtil:上传文件
-
ConvertUtil:转换工具包
- 角色中文---->角色数字
- 角色数字、数字字符---->角色中文
- 地区中文---->地区缩写英语单词
- 地区缩写英语单词---->地区中文
- 组别中文---->组别缩写英语单词
- 组别缩写英语单词---->组别中文
- 进程英语---->进程中文
- 步骤英语---->步骤中文
- 字符串时间戳---->LocalDateTime
- LocalDateTime---->字符串时间戳
- 状态数字字符---->状态中文
- 分组签号---->序号
-
RandomStringUtils:获得长度为n包含大小写英文、数字及".$@!%*?&"的随机字符串(特殊字符在末尾一位)
-
RedisUtil:获得各种键名
-
AfterCompetition
- 切面AfterCompetitionAspect的注解
-
CheckProcess
- 切面CheckProcessAspect的注解
- 参数
- process:进程
- step:步骤
-
CheckQueryProcess、
- 切面CheckQueryProcessAspect的注解
- 参数
- process:进程
- item:查询项目
-
CheckProcessAspect:
-
Before:检验调用接口时是否符合当前进程
- 获取方法签名
- 获得注解参数(process、step)
- 判断是管理员还是主持人或评委(判断group、zone来源)
- 设置group、zone
- 匹配redis中的进程
- 匹配:放行
- 不匹配:拦截
-
After:自动跳转到下一step(步骤)
- 判断是否为打分环节,是则return
- 获取下一步
- 更新进程信息
- 如果step为rate,开启每一小时自动备份数据库
-
- CheckQueryProcessAspect:检验调用接口时是否符合当前进程
- 获取方法签名
- 获得注解参数(process、item)
- 判断进程,获得flag标记
- 笔试环节:
- 检验是否为区赛,不为区赛则抛异常
- 如果item等于score,flag=3
- 如果item等于num,flag=1
- 实战对决环节:flag=5
- 快问快答环节:flag=7
- 笔试环节:
- 获得当前组别赛区进程
- 遍历processStep字符串数组
- <flag时查询到,表示未到能查询的进程
-
AfterCompetitionAspect:比赛结束后对数据处理,用于导出成绩接口处
-
判断是主持人还是管理员调用导出成绩接口
-
完赛数量加一
-
将成绩存储到学校的学生成绩
-
将成绩存储到往届成绩
-
删除成绩信息
-
判断区赛还是国赛
-
区赛
- 从contestant中删除未晋级选手
- 将选手、笔试阶段淘汰的选手、主持人、评委设置为游客身份
- 删除笔试成绩
- 设置默认放弃国赛资格时间段
-
国赛
- 从contestant中删除全部选手
- 将选手、主持人和评委设置为游客身份
- 删除本组本赛区redis进程信息
- 判断本科组、高职组是否都完赛
- 备份数据
- 关闭数据库自动备份
- 清除redis数据
-
-
附processStep字符串数组
- written:seat_draw
- written:post_written_score
- written:score_filter
- written:next
- practice:group_draw
- practice:rate
- practice:next
- q_and_a:rate
- q_and_a:next
- final:score_export
- final:next
- OssConfiguration
- 注册AliOssUtil
- ThreadPoolConfiguration
- 配置ThreadPoolTaskExecutor,适用于复杂异步任务处理
- ThreadPoolTaskSchedulerConfig
- 配置ThreadPoolTaskScheduler,专注于定时任务调度
- TopicRabbitMQConfig:配置主题消息队列
- 消息队列
- 用户状态消息队列
- 评委打分消息队列
- 交换机:主题交换机
- 捆绑:通过路由键捆绑主题交换机与消息队列
- 消息队列
- WebMvcConfiguration
- 注册 Sa-Token 路由拦截器
- 拓展spring mvc框架的消息转换器
- WebSocketConfiguration
- 注册user端点
- 注册superviseStatus端点
- 注册superviseRate端点
- UserWebsocketHandshakeInterceptor
- 解析token,检验是否登录
- 将uid存储到attributes
- AdminWebSocketHandshakeInterceptor
- 在UserWebsocketHandshakeInterceptor的基础上添加管理员身份验证
-
GlobalExceptionHandler:全局异常处理器
-
UserWebSocketHandler
- 连接建立
- 更新用户状态为在线
- 判断管理员是否在监视账号状态
- 构建json状态信息
- 向消息队列发送用户在线消息
- 保存webSocketSession
- 关闭连接
- 更新用户状态为离线
- 判断管理员是否在监视账号状态
- 构建json状态信息
- 向消息队列发送用户离线消息
- 移除webSocketSession
- 连接建立
-
SuperviseStatusWebSocketHandler
- 连接建立:开启向用户状态消息队列发送状态消息
- 关闭连接:关闭向用户状态消息队列发送状态消息
- 发送用户状态信息
-
SuperviseRateWebSocketHandler
- 连接建立:开启向评委打分消息队列发送打分消息
- 关闭连接:关闭向评委打分消息队列发送打分消息
- 发送评委打分或选手得分信息
-
consumer
-
StatusInfoQueueReceiver
- 监听用户状态消息队列
- 判断管理员是否在监控
- 发送用户状态信息
-
RateInfoQueueReceiver
- 监听评委打分消息队列
- 判断管理员是否在监控
- 发送评委打分信息
-
-
provider
- AutoBackupsService
- 启动自动备份(通过传cron表达式设置定时任务)
- 关闭自动备份
- DeleteExpireData
- 每天00:00:00删除过期数据
-
Java
-
Spring Boot
-
MySQL
-
Redis
-
SaToken
-
Websocket
-
RabbitMQ
-
Prometheus
-
Grafana
详细划分每个环节有哪些步骤,确保比赛有条不紊地正常地进行。详细内容请见下图。
如何实现比赛按规定的流程进行在项目介绍时已说明
为了便于下发账号和导入笔试成绩,我使用了poi库,按照规定的格式读取信息下发账号、导入成绩。
下发账号请见AdminService下的1号标签
导入成绩请见HostService下的4号标签
思路:管理员要监控后台,就需要获得实时信息。当发生相关事件时,首先需要服务器判断管理员是否开启监控,开启就主动向浏览器发送变化的信息,前端收到后展示信息。这就需要用到websocket全双工通信协议了。
实现:
那么服务器如何判断管理员是否开启监控呢?
很简单,在redis中存储一个键值对,键名为监控的事件,值表示是否开启监控。
键名如图所示,值为 “1” 表示开启;值为 “0” 表示关闭
流程
- 管理员调用相关接口,建立websocket连接
- 在afterConnectionEstablished方法里开启监控(将值设置为“1”) 见handler
- 当发生相关事件时,程序会从redis中取出相关的值,判断是否开启监控
- 开启:将实时信息推送到相关的消息队列
- 关闭:忽视实时信息
- 消息队列接受到实时信息,同样也要判断是否开启监控 见rabbitMQ
- 开启:将实时信息通过websocket发送到浏览器
- 关闭:丢弃实时信息
- 前端接收后展示实时信息
- 管理员断开websocket连接
- 在afterConnectionClosed方法里关闭监控(将值设置为“0”)
为了避免和减少服务器宕机造成的损失,我入门了Prometheus+Grafana,用他们监控系统和资源性能指标。由于Prometheus要学很多东西,我没有那么多时间,我就只实现了监控功能,未实现检测告警功能。
之前都是用JWT自己写登录认证,也没接触过多角色鉴权。通过这次的考核,让我了解到了SaToken这款国产开源轻量级的Java权限认证框架。令我印象最深的是他是国产的。之前用的开源框架的官方文档都是用英语编写的,对于我这种英语不好的人非常不友好,以至于我都是在网上查阅资料学习使用。但这次,SaToken却给了我不一样的体验(看中文官方文档好爽啊)。
但是由于对cookie的不了解,同时之前也没用过cookie自动提交token值(以前都是在请求头添加token值),因此在刚开始学习使用SaToken进行登录验证的时候,我就遇到了问题。我手动添加token的值到请求头(不想每次测试接口都登录),误认为系统关闭了,下次系统开启时,token的值仍然有效(之前用JWT自己写登录认证的时候就是这样的)。但事实是token失效了,登录认证失败。在查阅了相关资料后,我知道了SaToken在登录认证后为这个账号创建了一个Token凭证,且通过 Cookie 上下文返回给了前端。apifox就会自动保存token,调用其他接口时,就不用手动添加token值了。
这次的考核项目,我只用了SaToken的登录认证和角色鉴权(非常的方便),他还有好多令我好奇的功能等待着我去了解。希望学长能分享一下其他经常用到的功能。
之所以学习使用websocket是为了实现记录用户最后一次在线时间和管理员实时监控用户状态、评委打分的业务。
在刚开始学习使用Websocket时我同样遇到了问题。我一开始采用的是基于注解的方式 (@ServerEndpoint)实现websocket,但是却访问不到websocket的端点,在查阅了资料后,仍然无法解决。于是我只好采用更加复杂的实现WebSocketHandler接口的方式实现websocket,这次的实现非常的顺利,没有出差错。
顺利连接上了websocket端点,新的问题又随之出现。如何登录认证和角色鉴权呢???首先我询问AI能否用SaToken对ws协议进行认证鉴权呢,答案是否定的:SaToken主要用于处理HTTP请求的认证与授权。由于想偷懒(不想每次发送websocket连接,都手动添加token),于是我尝试不添加token,看看apifox能否自动发送cookie,结果也是否定的。于是我只好老老实实在发送websocket连接请求时填写token,然后在beforeHandshake方法中解析token进行手动登录认证。
websocket的用处远不止此,我迫切地想学习他的其他用处,但奈何时间不均须。暑假有机会再学习其他用处吧。
为了解耦用户状态、评委打分、和选手得分信息改变和发送WebSocket消息发送之间的逻辑及平滑处理高峰期的流量,避免直接冲击到监控系统,我使用了消息队列作为中间件。这样,即使有很多信息同时更新,也不会对WebSocket服务器造成过大压力。
RabbitMQ的入门学习倒是非常的顺利,除了配置环境花了点时间外,其余都是一气呵成。
当然RabbitMQ我也只是刚刚入门,还有好多功能等待着我去学习。毕竟学无止境嘛,不能满足于当下,与时俱进才是出路!!!
为了监控系统、资源性能指标,当性能指标异常时,及时发送告警通知相关人员(未实现该功能),我入门了Prometheus和Grafana。
这其中也遇到了不少困难。
- 由于我查找到的那篇资料是介绍监控linux系统的(下载的exporter为node_exporter),于是我不得不再找一篇安装wmi exporter的资料并与之整合。
- 被监控的ip地址的填写:资料上演示的ip地址是以10开头的(我之前不知道这是本地ip地址),我就填写的localhost或127.0.0.1。结果就是连接不上,我以为是我配置写错了,检查半天也没找出原因。只好在网上搜索别的资料,发现要填写本地ip地址,才能连接的上。
- docker运行容器时,挂载的配置
- 数据库建表考虑不周:经常在编写业务逻辑时,更改表的字段
- 实体类命名杂乱:有时候不知道该如何命名
- 项目经验不足:有些结构不是很规范、不知道哪里需要解耦
本次的项目考核,让我受益匪浅。不仅让我学到了许多的东西,比如新的技术栈,也巩固我对.md文件的编写能力,同时也激发了我对新技术的渴望,希望用他们编写出一个成熟的的系统(好奇长啥样,没了解过)。我很享受学习完一项新技术后的充实感(学习过程有点痛苦)。我知道我在java后端方向的学习才刚开始起步,不过我会坚持下去的!