前台代码
👉manager-protal
后台代码
👉 manager-service
视频演示
👉b站视频
项目基本功能
本在线考试系统主要完成了
- 用户注册,根据用户名和密码实现注册
- 用户登录,分为账号密码登录,短信登录,邮箱登录
- 用户信息完善,用户基本信息填写(头像,名称,年龄,地区,学号,手机号,邮箱号)。
- 用户信息搜索,根据输入的关键字进行信息检索,查看用户的数据。可以通过聚合条件检索。管理员可以查看完整数据和统计信息。
- 用户管理,管理员可以查看用户的相关数据,并进行管理员的分配。
- 考生管理,管理员考生考试的管理,可以查看考生考试的信息,考生试卷,重置试卷,再次考试,无条件删除考生订阅信息。
- 学科管理,管理员可以查看所有学科信息,添加学科,修改学科信息,删除(是否启用)学科。
- 试题管理,管理员可以查看所有试题信息,添加试题,修改试题信息,删除(是否启用)学科。
- 试卷管理,管理员可以查看所有试卷信息,添加试卷,修改试卷信息,删除(是否启用)试卷。可以预览试卷,并且可以选择是否发布试卷。
- 考试管理,发布好的试卷会进入考试管理,管理员可以为试卷设置考试试卷,取消试卷发布和预览试卷。
- 答题管理,管理员可以查看每个试题的答题情况,并进行答题情况统计(答题人数,答题总数,错误率)
- 考试订阅,考生可以查看已经发布了的考试,可以订阅已发布并且设置了考试时间的考试,并且有条件的删除订阅
- 在线考试答题,考生可以查看已订阅的考试信息,答题次数,并且进行答题,考完之后可以查看试卷和分数,有条件的删除订阅。
- 成绩查询,考生可以查看订阅试卷的基本信息,分数以及答题次数。
- 单个考生的数据统计,考生可以查看自己不同学科的近6次考试情况的统计,考试试卷分布图,学科考试次数比例图,不同学科的答题数。
- 全部考生的数据统计,管理员可以查看全部考生不同学科的近20次考试情况的统计,考试试卷分布图,学科考试次数比例图,不同学科的答题数。
- 用户数据统计,管理员可以查看全部用户的地理分布情况,不同年龄的用户数量分布,近6个月的用户注册数和平均年龄。
- 日志管理,管理员可以查看关于考试的日志记录。
系统架构
在线考试系统主要采用
Vue+SpringBoot+SpringCloud+Mybatis框架开发。内部采用标准的MVC架构进行基本框架搭建。通过Ngnix进行反向代理,服务器采用Docker进行统一管理,使用FastDFS完成远程的文件上传。具体的使用技术请看👉技术选型
![]()
技术选型
前台
技术 | 介绍 |
---|
HTML,CSS,LESS | emm…没什么好说的 |
Vue.js2.6 | 项目的前台是完全基于Vue进行搭建的 |
Npm | 前端安装包工具 |
Webpack | 前端模块打包工具 |
Vue-cli | Vue的脚手架,用于构建基本项目架构 |
Vue-router | Vue的路由工具 |
Vuex | Vue的状态管理模式,集中式存储管理 |
Element-ui | Vue的一些基本组件库 |
axios | ajax的框架,用于异步请求 |
v-charts | 构建统计视图 |
vue-quill-editor | 基于Vue的富文本框架 |
vue-particles | 粒子特效 |
后台
| 介绍 |
---|
SpringBoot | 该项目每个微服务内部都是使用SpringBoot进行搭建的,emmm,直接牛逼 |
SpringCloud | 该项目是由好几个微服务组成的,微服务之间的注册和调用等是通过SpringCloud来完成的。使用到了Eureka,Zuul,Ribbon,Feign |
MybatisPlus | 该项目使用MybatisPlus来完成对mysql的持久层操作 |
SpringData | 该项目虽然没有使用JPA来完成对mysql的操作,但是其他数据库(MongoDB,redis,ElasticSearch)都是使用SpringData来操作的 |
JWT | 该项目用jwt实现单点登录,对用户的请求进行认证。采用的是无状态登录 |
Rsa | 一个非对称机密算法,将token的载荷和秘钥进行加密放入签名域 |
FastDFS | 一个轻量级的分布式文件系统,用于项目上传图片等文件 |
RabbitMQ | 该技术是基于AMQP协议的消息代理软件,通过该技术实现了手机,验证码的发送以及数据库之间数据的同步 |
Mysql | 该项目用Mysql来存储主要的数据(用户信息,学科信息,发布的试卷信息,用户订阅的考试信息,用户的考试亲狂) |
MD5 | 一个不可逆加密算法,该项目用md5来实现对用户密码的加密 |
Druid | 该项目使用Druid来作为mysql的数据源 |
MongoDB | 该项目用MongoDB来存储关于试卷的数据(试题信息,试卷信息)以及日志信息 |
ElasticSearch | 该项目用ElasticSearch来存储用于搜索的用户数据,并实现搜索和聚合等功能 |
Redis | 该项目用Redis来做部分数据的缓存,并且用redis来存储手机和邮箱的验证码信息 |
Nginx | 该项目用Nginx来实现反向代理 |
Quartz | 定时任务框架,该项目用Quartz来实现某些操作的定 |
Swagger2 | 该项目用swagger2实现对RESTful风格的api进行统一描述和可视化调用 |
Lombok | 该项目使用Lombok来简化实体类和日志 |
Logback | 该项目使用logback来实现日志的输出和持久化 |
Hibernate-validator | 该项目使用hibernate-validator来进行部分实体类的数据校验 |
Docker | 该项目使用的服务器是用docker进行统一管理的 |
阿里云短信服务服务 | 该项目使用的短信服务是由阿里云提供的 |
Git | 该项目用git来进行版本管理 |
其实,我这个项目不应该使用JWT完成单点登录的,最好是使用SpringSecurity和OAuth2来完成权限和登录的控制,一开始对项目的整体预估不足,贪图简单,就直接使用了JWT+Rsa来写了,写到后来权限控制那块很难控制了,无奈呀~。没办法,都成型了,也懒得重构了,这个项目就这样吧。下次注意!
![]()
项目目录框架
前台
![]()
后台
![]()
单微服务目录框架
![]()
这里已考试微服务为例
用户微服务介绍
数据库
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| CREATE TABLE `tb_user` ( `id` bigint(64) NOT NULL COMMENT '雪花算法生成id', `name` varchar(10) DEFAULT NULL COMMENT '用户的名称', `age` int(3) DEFAULT '0' COMMENT '用户的年龄', `area_province` varchar(10) DEFAULT NULL COMMENT '用户的地区-省', `area_city` varchar(10) DEFAULT NULL COMMENT '用户的地区-市', `area_county` varchar(10) DEFAULT NULL COMMENT '用户的地区-县', `status` tinyint(1) NOT NULL COMMENT '是否为管理员,1是,0不是', `username` varchar(32) NOT NULL COMMENT '用户名', `sno` varchar(32) DEFAULT NULL COMMENT '用户的学号', `password` varchar(32) NOT NULL COMMENT '密码,加密存储', `phone` varchar(11) DEFAULT NULL COMMENT '用户的手机号', `email` varchar(50) DEFAULT NULL COMMENT '用户的邮箱', `image` varchar(100) DEFAULT NULL COMMENT '用户的头像地址', `created` datetime NOT NULL COMMENT '创建时间', `salt` varchar(32) NOT NULL COMMENT '密码加密的salt值', `version` bigint(20) DEFAULT '0' COMMENT '版本,乐观锁', `deleted` tinyint(1) DEFAULT '0' COMMENT '逻辑删除,1删除,0没删除', PRIMARY KEY (`id`), UNIQUE KEY `username` (`username`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户表';
|
API
![]()
手机,邮箱验证码获取接口
![]()
这里就两个接口,一个是获取手机验证码,一个是获取邮箱验证码
👉 点击查看rabbitmq接受消息代码
用户基本的DML操作服务接口
![]()
DML操作无非就是赠删改操作,但是看我们的API接口却并没有DELETE的操作,这是为什么呢?
![]()
仔细看我们的用户数据库表,我是使用的逻辑删除!
剩下的接口就不说了
考试微服务介绍
数据库
学科表
1 2 3 4 5 6 7 8 9 10 11 12
| CREATE TABLE `tb_subject` ( `id` bigint(64) NOT NULL COMMENT '雪花算法生成id', `name` varchar(10) DEFAULT NULL COMMENT '学科的名称', `note` varchar(100) DEFAULT NULL COMMENT '学科的备注信息', `icon` varchar(50) NOT NULL COMMENT '学科的图标', `index` varchar(100) NOT NULL COMMENT '学科的前台路径', `created` datetime NOT NULL COMMENT '创建时间', `version` bigint(20) DEFAULT '0' COMMENT '版本,乐观锁', `deleted` tinyint(1) DEFAULT '0' COMMENT '逻辑删除,1删除,0没删除', PRIMARY KEY (`id`), UNIQUE KEY `name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='学科表';
|
试题表
![]()
试题的信息是存放在MongoDB中的
- select:是用于存储选择题的选项的,判断题不存在
- answer:试题的正确答案索引
- type:试题的类型,0:选择题,1:判断题
- subject:学科的id
- note:试题的备注信息
试卷表
![]()
试卷的信息也是存放在MongoDB中的
- name: 试卷名字
- subject:试卷的学科id
- school:出题学校的名字
- creatoe:出题人的用户名(用户名不可变)
- astrict:试卷的答题限制时间
- select:选择题的题目id
- judge:判断题的题目id
- selectScore:每到选择题的分数
- judgeScore:每到判断题的分数
- note:备注信息
- publicsh:是否发布
发布试卷记录表
1 2 3 4 5 6 7 8 9 10 11
| CREATE TABLE `tb_public_test` ( `id` bigint(64) NOT NULL COMMENT '雪花算法生成id', `test_id` varchar(100) NOT NULL COMMENT '试卷的id', `start_time` timestamp NOT NULL DEFAULT '2020-01-01 00:00:00' COMMENT '试卷的开始时间', `end_time` timestamp NOT NULL DEFAULT '2020-01-01 00:00:00' COMMENT '试卷的结束时间', `status` int(3) NOT NULL COMMENT '试卷的状态,-2:已删除,-1:初始化,0:未开始,1:开启中,2:已结束', `created` datetime NOT NULL COMMENT '创建时间', `version` bigint(20) DEFAULT '0' COMMENT '版本,乐观锁', `deleted` tinyint(1) DEFAULT '0' COMMENT '逻辑删除,1删除,0没删除', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='试卷发布状态表';
|
我给start_time和end_time设置了一个过去的初始化时间
用户订阅试卷表
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| CREATE TABLE `tb_subscribe_exam` ( `id` bigint(64) NOT NULL COMMENT '雪花算法生成id', `user_id` bigint(64) NOT NULL COMMENT '订阅的用户id', `test_id` varchar(100) NOT NULL COMMENT '试卷的id', `status` int(3) DEFAULT '0' COMMENT '订阅记录的状态0:未考试,1:正在考试,2:已考试,3:再次考试', `score` double(6,1) DEFAULT '0.0' COMMENT '试卷的分数', `begin_work_time` timestamp NOT NULL DEFAULT '2020-01-01 00:00:00' COMMENT '开始答题时间', `finish_work_time` timestamp NOT NULL DEFAULT '2020-01-01 00:00:00' COMMENT '结束答题时间', `frequency` int(3) DEFAULT '0' COMMENT '考试的次数', `created` datetime NOT NULL COMMENT '创建时间', `version` bigint(20) DEFAULT '0' COMMENT '版本,乐观锁', `deleted` tinyint(1) DEFAULT '0' COMMENT '逻辑删除,1删除(取消订阅后的状态),0没删除(点击订阅后的状态)', PRIMARY KEY (`id`), KEY `user_id` (`user_id`), CONSTRAINT `tb_subscribe_exam_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `tb_user` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户订阅试卷表';
|
试题答题情况表
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| CREATE TABLE `tb_exam_answer_situation` ( `id` bigint(64) NOT NULL COMMENT '雪花算法生成id', `subscribe_exam_id` bigint(64) NOT NULL COMMENT '订阅的考试id', `topic_id` varchar(100) NOT NULL COMMENT '试题id', `user_answer` varchar(200) NOT NULL COMMENT '用户的答案,-1为未答题', `answer_situation` int(3) NOT NULL COMMENT '用户的答题情况。-1:未答题,0:答错,1:答对', `score` double(6,1) DEFAULT '0.0' COMMENT '试题的得分', `created` datetime NOT NULL COMMENT '创建时间', `version` bigint(20) DEFAULT '0' COMMENT '版本,乐观锁', `deleted` tinyint(1) DEFAULT '0' COMMENT '逻辑删除,1删除,0没删除', PRIMARY KEY (`id`), KEY `subscribe_exam_id` (`subscribe_exam_id`), CONSTRAINT `tb_exam_answer_situation_ibfk_1` FOREIGN KEY (`subscribe_exam_id`) REFERENCES `tb_subscribe_exam` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户答题具体情况';
|
用户每一个答题都会生成一条记录,与订阅试卷表示一对多的关系
日志记录表
![]()
用于记录用户,管理员的一些试卷操作。(比如:答题,取消试卷订阅,重置试卷等)
API
![]()
Api以及具体的实现代码太多了,就不多说了
消息服务介绍
以手机验证码为例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| @RabbitListener(bindings = @QueueBinding( value = @Queue(value="MANAGER_PHONE_SMS_QUEUE",durable = "true"), exchange = @Exchange(value="MANAGER_EXCHANGE_SMS",ignoreDeclarationExceptions = "true",type=ExchangeTypes.TOPIC), key = "authCode.phone" )) public void sendPhoneAuthCode(Map<String,String> msg) throws ClientException { if(CollectionUtils.isEmpty(msg)){ return ; } String phone = msg.get("phone"); String authcode = msg.get("authcode");
if(StringUtils.isAllBlank(phone,authcode)){ return ; } log.info("接收到 {} 的验证码 {},准备发送",phone,authcode);
if(!StringUtils.isEmpty(phone)&&!StringUtils.isEmpty(authcode)) { JsonObject jsonObject = new JsonObject(); jsonObject.addProperty("authcode", authcode); this.sendPhoneSmsUtils.sendSms(phone,jsonObject.toString(),this.smsProperties.getSignName(),this.smsProperties.getVerifyCodeTemplate()); } }
|
- 当接收到数据时,会对数据进行一个简单的空判断,复杂的判断在前台和传递数据的时候已经校验过了
- 数据没问题,就会调用阿里云的手机验证码服务,对对应的手机号发送验证码
上传微服务
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| public String uploadImage(MultipartFile file) { String originName=file.getOriginalFilename(); String contentType=file.getContentType(); if(!uploadProperties.getContentTypes().contains(contentType)){ log.info("文件类型不合法: {}",originName); return null; }
try { BufferedImage bufferedImage= ImageIO.read(file.getInputStream()); if(bufferedImage==null){ log.info("文件的内容不合法: {}",originName); return null; } String suffix=StringUtils.substringAfterLast(originName,"."); StorePath storePath=fastFileStorageClient.uploadFile(file.getInputStream(),file.getSize(),suffix,null); String url=uploadProperties.getImageUrl()+storePath.getFullPath(); log.info("上传成功: {},带分组路径: {}",originName,url);
this.userClient.updateImage(url); return url; } catch (IOException e){ log.info("服务器内部错误,图片上传失败:{}",originName); e.printStackTrace(); }
return null;
}
|
1 2 3
| StorePath storePath=this.fastFileStorageClient.uploadImageAndCrtThumbImage(file.getInputStream(),file.getSize(),suffix,null);
String thumbImagePath =uploadProperties.getImageUrl()+storePath.getGroup()+"/"+thumbImageConfig.getThumbImagePath(storePath.getPath());
|
搜索微服务
API
![]()
加一个搜索查询API
监听
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
|
@RabbitListener(bindings = @QueueBinding( value=@Queue(value="MANAGER.SEARCH.SAVE.QUEUE",durable = "true"), exchange = @Exchange(value="MANAGER.EXCANGE.USER.SEARCH" ,ignoreDeclarationExceptions = "true" ,type = ExchangeTypes.TOPIC), key = {"user.insert","user.update"} )) public void save(Long id){ if(id==null){ throw new NullPointerException("新增(更新)检索用户信息的id为空"); } this.userSearchService.save(id); }
@RabbitListener(bindings = @QueueBinding( value=@Queue(value="MANAGER.SEARCH.DELETE.QUEUE",durable = "true"), exchange = @Exchange(value="MANAGER.EXCANGE.USER.SEARCH" ,ignoreDeclarationExceptions = "true" ,type = ExchangeTypes.TOPIC), key = {"item.delete"} )) public void delete(Long id){ if(id==null){ throw new NullPointerException("删除检索用户信息的id为空"); } this.userSearchService.delete(id); }
|
当新增,修改,删除用户时,会通知ElasticSearch进行数据修改,是数据库信息同步
服务
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
|
public interface IUserSearchService {
UserInfo buildUserInfo(User user);
SearchResult search(SearchRequest searchRequest);
void save(Long id);
void delete(Long id); }
|
一共四个服务,就是增删改查!
ps:
项目有很多的不足,有很多都是应该好好完善的和修改的,我也懒得再去修改和重构了。因为是第一次完全靠自己写一个项目,很多规范一开始做的不是很好。
当在写上传和用户等微服务时,很多东西没有注意到,比如就说权限管理那块;还有状态码相关,一开始全部用的自带状态码,用来用去发现就那几个…😒,后来大部分用得都是自建状态码。还有异常处理,实在懒得去做太多处理了,一个字:就是懒!封装做的也不是太好!
索性在写试卷微服务时,有了一定的改善,代码规范稍微好了一点,也有了一定的套路。但还是有很大的不足。
怎么说呢,因为有期末考试的缘故,也花了好长时间去复习,所以断断续续做了一个多月。习惯了写后端,这个前台也确实花了我不少时间,也是第一次完全用Vue去构建前台,虽然也有些不规范,并且很多功能没有去添加。但是做下来,也算有所收获吧。那就不亏!
不知道下次要多久再去构建这么一个完整项目了,今年要多学些技术,明年就要考研了,一切都要时间,都需要去慢慢磨。这是一次不算很好,但也绝对不糟糕的体验,以后会继续努力!