绿色健康小清新

耐得住寂寞,守得住繁华

在线考试系统

前台代码

 👉manager-protal


后台代码

 👉 ​ manager-service


视频演示

 👉b站视频

项目基本功能

 本在线考试系统主要完成了

  1. 用户注册,根据用户名和密码实现注册
  2. 用户登录,分为账号密码登录,短信登录,邮箱登录
  3. 用户信息完善,用户基本信息填写(头像,名称,年龄,地区,学号,手机号,邮箱号)。
  4. 用户信息搜索,根据输入的关键字进行信息检索,查看用户的数据。可以通过聚合条件检索。管理员可以查看完整数据和统计信息。
  5. 用户管理,管理员可以查看用户的相关数据,并进行管理员的分配。
  6. 考生管理,管理员考生考试的管理,可以查看考生考试的信息,考生试卷,重置试卷,再次考试,无条件删除考生订阅信息。
  7. 学科管理,管理员可以查看所有学科信息,添加学科,修改学科信息,删除(是否启用)学科。
  8. 试题管理,管理员可以查看所有试题信息,添加试题,修改试题信息,删除(是否启用)学科。
  9. 试卷管理,管理员可以查看所有试卷信息,添加试卷,修改试卷信息,删除(是否启用)试卷。可以预览试卷,并且可以选择是否发布试卷。
  10. 考试管理,发布好的试卷会进入考试管理,管理员可以为试卷设置考试试卷,取消试卷发布和预览试卷。
  11. 答题管理,管理员可以查看每个试题的答题情况,并进行答题情况统计(答题人数,答题总数,错误率)
  12. 考试订阅,考生可以查看已经发布了的考试,可以订阅已发布并且设置了考试时间的考试,并且有条件的删除订阅
  13. 在线考试答题,考生可以查看已订阅的考试信息,答题次数,并且进行答题,考完之后可以查看试卷和分数,有条件的删除订阅。
  14. 成绩查询,考生可以查看订阅试卷的基本信息,分数以及答题次数。
  15. 单个考生的数据统计,考生可以查看自己不同学科的近6次考试情况的统计,考试试卷分布图,学科考试次数比例图,不同学科的答题数。
  16. 全部考生的数据统计,管理员可以查看全部考生不同学科的近20次考试情况的统计,考试试卷分布图,学科考试次数比例图,不同学科的答题数。
  17. 用户数据统计,管理员可以查看全部用户的地理分布情况,不同年龄的用户数量分布,近6个月的用户注册数和平均年龄。
  18. 日志管理,管理员可以查看关于考试的日志记录。


系统架构

 在线考试系统主要采用Vue+SpringBoot+SpringCloud+Mybatis框架开发。内部采用标准的MVC架构进行基本框架搭建。通过Ngnix进行反向代理,服务器采用Docker进行统一管理,使用FastDFS完成远程的文件上传。具体的使用技术请看👉技术选型





技术选型

前台

技术介绍
HTML,CSS,LESSemm…没什么好说的
Vue.js2.6项目的前台是完全基于Vue进行搭建的
Npm前端安装包工具
Webpack前端模块打包工具
Vue-cliVue的脚手架,用于构建基本项目架构
Vue-routerVue的路由工具
VuexVue的状态管理模式,集中式存储管理
Element-uiVue的一些基本组件库
axiosajax的框架,用于异步请求
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发送验证码,并且设置2分钟的过期时期保存在redis中

    1
    2
    3
    4
    5
    //发送消息
    this.amqpTemplate.convertAndSend(this.authCodeProperties.getExchangeName(), "authCode.phone", authInfo);

    //将验证码放入redis中
    this.redisTemplate.opsForValue().set(this.authCodeProperties.getPhoneName()+phone,authcode,2, TimeUnit.MINUTES);
  • 邮箱验证码通过rabbitmq发送验证码,并且设置2分钟的过期时期保存在redis中

    1
    2
    3
    4
    5
    //发送消息
    this.amqpTemplate.convertAndSend(this.authCodeProperties.getExchangeName(), "authCode.email", authInfo);

    //将验证码放入redis中
    this.redisTemplate.opsForValue().set(this.authCodeProperties.getEmailName()+email,authcode,2, TimeUnit.MINUTES);

 👉 点击查看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());
}
}
  • 当接收到数据时,会对数据进行一个简单的空判断,复杂的判断在前台和传递数据的时候已经校验过了
  • 数据没问题,就会调用阿里云的手机验证码服务,对对应的手机号发送验证码

上传微服务

  • 上传图像回显url,并修改数据库
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);
//返回url,进行回显
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
/**
* @Description 接受新增和修改用户信息的消息
* @date 2020/7/17 23:30
* @param id
* @return void
*/
@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);
}

/**
* @Description 接受删除用户信息的消息
* @date 2020/7/17 23:31
* @param id
* @return void
*/
@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
/**
* @author codekiller
* @date 2020/7/16 20:17
* @Description 用户的搜索服务接口
*/
public interface IUserSearchService {


/**
* @Description 构建用户数据
* @date 2020/7/17 20:18
* @param user
* @return top.codekiller.manager.search.pojo.UserInfo
*/
UserInfo buildUserInfo(User user);


/**
* @Description 检索
* @date 2020/7/17 21:20
* @param searchRequest
* @return top.codekiller.manager.search.pojo.result.user.SearchResult
*/
SearchResult search(SearchRequest searchRequest);


/**
* @Description 存储新的用户信息和更新
* @date 2020/7/17 22:36
* @param id
* @return void
*/
void save(Long id);

/**
* @Description 删除用户信息
* @date 2020/7/17 22:36
* @param id
* @return void
*/
void delete(Long id);
}

一共四个服务,就是增删改查!


ps:

 项目有很多的不足,有很多都是应该好好完善的和修改的,我也懒得再去修改和重构了。因为是第一次完全靠自己写一个项目,很多规范一开始做的不是很好。

 当在写上传和用户等微服务时,很多东西没有注意到,比如就说权限管理那块;还有状态码相关,一开始全部用的自带状态码,用来用去发现就那几个…😒,后来大部分用得都是自建状态码。还有异常处理,实在懒得去做太多处理了,一个字:就是懒!封装做的也不是太好!

 索性在写试卷微服务时,有了一定的改善,代码规范稍微好了一点,也有了一定的套路。但还是有很大的不足。

 怎么说呢,因为有期末考试的缘故,也花了好长时间去复习,所以断断续续做了一个多月。习惯了写后端,这个前台也确实花了我不少时间,也是第一次完全用Vue去构建前台,虽然也有些不规范,并且很多功能没有去添加。但是做下来,也算有所收获吧。那就不亏!

 不知道下次要多久再去构建这么一个完整项目了,今年要多学些技术,明年就要考研了,一切都要时间,都需要去慢慢磨。这是一次不算很好,但也绝对不糟糕的体验,以后会继续努力!

-------------本文结束感谢您的阅读-------------
六经蕴籍胸中久,一剑十年磨在手

欢迎关注我的其它发布渠道