一、引言
在教育信息化和在线学习需求激增的背景下,开发一套开源、跨平台的考试答题系统具有重要价值。基于SpringBoot和Vue的前后端分离架构,结合uni-app跨端框架,可实现一套代码同时编译为App、微信小程序、H5等多端应用,降低开发成本的同时提升用户覆盖范围。本文将详细介绍该系统的技术架构、核心功能实现及开源生态设计,附完整代码示例。
源码:zs.xcxyms.top
二、技术架构选型
2.1后端技术栈
核心框架:SpringBoot 3.2.0(简化配置,快速构建微服务)
Web框架:Spring MVC(处理RESTful接口)
ORM工具:MyBatis-Plus 3.5.3(简化数据库操作,支持代码生成)
安全认证:Spring Security+JWT(基于令牌的认证机制)
缓存服务:Redis 6.0(缓存题库、考试状态等高频数据)
接口文档:Knife4j(基于OpenAPI 3.0,替代Swagger)
2.2前端技术栈
添加描述
添加描述
主框架:Vue 3.3.4(响应式组件化开发)
状态管理:Pinia(轻量级状态管理库)
UI组件库:
移动端:Vant 4.0(App端UI)
小程序:uni-app组件(跨端兼容)
跨端框架:uni-app 3.8.1(一套代码编译为App、小程序、H5)
路由管理:uni-simple-router(小程序路由适配)
2.3数据库设计
采用MySQL 8.0存储核心数据,主要表结构如下:
|表名|核心字段|功能说明|
|---------------------|--------------------------------------------------------------------------|--------------------------|
|`user`|`id`,`username`,`password`,`role`,`create_time`|用户账号与权限管理|
|`question_bank`|`id`,`question_type`,`content`,`options`,`answer`,`difficulty`|题库管理(支持单选、多选、判断、主观题)|
|`exam_paper`|`id`,`paper_name`,`total_score`,`duration`,`question_ids`|试卷定义(关联题库ID)|
|`exam_record`|`id`,`user_id`,`paper_id`,`score`,`submit_time`,`answer_details`|考试记录与答卷详情|
|`exam_room`|`id`,`paper_id`,`status`,`start_time`,`end_time`|考试场次管理(支持定时考试)|
三、核心功能模块实现
复制
复制
3.1用户认证与权限管理
3.1.1后端认证接口(SpringBoot)
java
//UserController.java用户登录接口
RestController
RequestMapping("/api/auth")
public class UserController{
Autowired
private UserService userService;
Autowired
private JwtTokenUtil jwtTokenUtil;
PostMapping("/login")
public Result<LoginVO>login(RequestBody LoginDTO loginDTO){
//1.验证账号密码
User user=userService.login(loginDTO.getUsername(),loginDTO.getPassword());
if(user==null){
return Result.error("账号或密码错误");
}
//2.生成JWT令牌
String token=jwtTokenUtil.generateToken(user);
//3.封装返回结果
LoginVO loginVO=new LoginVO();
loginVO.setToken(token);
loginVO.setUserInfo(UserInfoVO.convertFrom(user));
return Result.success(loginVO);
}
//其他接口如注册、修改密码等...
}
//JWT工具类核心代码
Component
public class JwtTokenUtil{
private static final String SECRET="exam_system_secret_key_2025";
private static final long EXPIRATION=24*60*60*1000;//24小时有效期
public String generateToken(User user){
Claims claims=Jwts.claims().setSubject(user.getUsername());
claims.put("userId",user.getId());
claims.put("role",user.getRole());
return Jwts.builder()
.setClaims(claims)
.setExpiration(new Date(System.currentTimeMillis()+EXPIRATION))
.signWith(SignatureAlgorithm.HS256,SECRET)
.compact();
}
public boolean validateToken(String token){
try{
Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token);
return true;
}catch(Exception e){
return false;
}
}
}
复制
复制
3.1.2前端登录组件(Vue 3+uni-app)
vue
<template>
<view class="login-container">
<input v-model="username"placeholder="账号"class="input"/>
<input v-model="password"placeholder="密码"type="password"class="input"/>
<button click="handleLogin"class="login-btn">登录</button>
</view>
</template>
<script setup>
import{ref,onMounted}from'vue';
import{useRouter}from'uni-simple-router';
import{request}from'/utils/request';
const username=ref('');
const password=ref('');
const router=useRouter();
const handleLogin=async()=>{
if(!username.value||!password.value){
uni.showToast({title:'请输入账号和密码',icon:'none'});
return;
}
try{
const res=await request({
url:'/api/auth/login',
method:'POST',
data:{
username:username.value,
password:password.value
}
});
if(res.code===200){
//存储令牌和用户信息
uni.setStorageSync('token',res.data.token);
uni.setStorageSync('userInfo',res.data.userInfo);
//跳转到首页
router.push('/pages/index/index');
}else{
uni.showToast({title:res.message,icon:'none'});
}
}catch(error){
console.error('登录失败',error);
uni.showToast({title:'登录失败,请重试',icon:'none'});
}
};
</script>
复制
复制
3.2题库与试卷管理
3.2.1题库CRUD接口(SpringBoot)
java
//QuestionBankController.java题库管理接口
RestController
RequestMapping("/api/question")
PreAuthorize("hasAnyRole('ADMIN','TEACHER')")//仅管理员和教师可访问
public class QuestionBankController{
Autowired
private QuestionBankService questionBankService;
//添加题目
PostMapping
public Result<String>addQuestion(RequestBody QuestionBank question){
boolean result=questionBankService.save(question);
return result?Result.success("添加成功"):Result.error("添加失败");
}
//查询题目列表(支持分页和条件查询)
GetMapping("/list")
public Result<IPage<QuestionBank>>list(
RequestParam(defaultValue="1")int page,
RequestParam(defaultValue="10")int size,
RequestParam(required=false)String keyword){
IPage<QuestionBank>pageData=questionBankService.listByPage(page,size,keyword);
return Result.success(pageData);
}
//批量导入题目(Excel导入)
PostMapping("/import")
public Result<String>importQuestions(RequestParam("file")MultipartFile file){
try{
int count=questionBankService.importFromExcel(file.getInputStream());
return Result.success("成功导入"+count+"道题目");
}catch(Exception e){
return Result.error("导入失败:"+e.getMessage());
}
}
}
复制
复制
3.2.2试卷生成逻辑(后端核心代码)
java
//ExamPaperService.java试卷生成算法
Service
public class ExamPaperServiceImpl implements ExamPaperService{
Autowired
private QuestionBankMapper questionBankMapper;
Autowired
private RedisTemplate<String,Object>redisTemplate;
Override
public ExamPaper generateRandomPaper(String paperName,int totalScore,int duration,
Map<String,Integer>difficultyCount){
//1.从Redis缓存获取题库(若没有则查询数据库)
List<QuestionBank>allQuestions=(List<QuestionBank>)redisTemplate.opsForValue()
.get("question_bank_all");
if(allQuestions==null){
allQuestions=questionBankMapper.selectList(null);
redisTemplate.opsForValue().set("question_bank_all",allQuestions,1,TimeUnit.HOURS);
}
//2.按难度和题型筛选题目(示例:随机抽取不同难度的题目)
Map<String,List<QuestionBank>>difficultyMap=allQuestions.stream()
.collect(Collectors.groupingBy(QuestionBank::getDifficulty));
List<QuestionBank>selectedQuestions=new ArrayList<>();
difficultyCount.forEach((difficulty,count)->{
List<QuestionBank>questions=difficultyMap.getOrDefault(difficulty,new ArrayList<>());
if(!questions.isEmpty()&&count>0){
//随机抽取count道题
Collections.shuffle(questions);
selectedQuestions.addAll(questions.subList(0,Math.min(count,questions.size())));
}
});
//3.计算总分(示例:单选题2分,多选题4分,主观题10分)
int actualScore=selectedQuestions.stream()
.mapToInt(q->"单选".equals(q.getQuestionType())?2:
"多选".equals(q.getQuestionType())?4:10)
.sum();
//4.保存试卷到数据库
ExamPaper paper=new ExamPaper();
paper.setPaperName(paperName);
paper.setTotalScore(actualScore);
paper.setDuration(duration);
//存储题目ID列表(用JSON格式)
paper.setQuestionIds(JSON.toJSONString(selectedQuestions.stream()
.map(QuestionBank::getId)
.collect(Collectors.toList())));
save(paper);
return paper;
}
}
复制
复制
3.3在线考试核心模块
3.3.1考试接口设计(前后端交互)
java
//ExamRoomController.java考试场次管理
RestController
RequestMapping("/api/exam/room")
public class ExamRoomController{
Autowired
private ExamRoomService examRoomService;
Autowired
private ExamRecordService examRecordService;
//创建考试场次
PostMapping
public Result<ExamRoom>createExamRoom(RequestBody ExamRoom examRoom){
examRoomService.save(examRoom);
return Result.success(examRoom);
}
//进入考试(获取试卷)
GetMapping("/{roomId}/enter")
public Result<ExamPaperVO>enterExam(PathVariable Long roomId,
RequestParam Long userId){
//1.验证考试场次状态
ExamRoom room=examRoomService.getById(roomId);
if(room==null||room.getStatus()!=1){//1表示进行中
return Result.error("考试场次不存在或已结束");
}
//2.验证用户是否已参加过考试
ExamRecord existedRecord=examRecordService.getOne(
new QueryWrapper<ExamRecord>()
.eq("user_id",userId)
.eq("room_id",roomId));
if(existedRecord!=null){
return Result.error("您已参加过该考试");
}
//3.获取试卷信息(含题目)
ExamPaper paper=examRoom.getPaper();
ExamPaperVO paperVO=ExamPaperVO.convertFrom(paper);
//4.记录考试开始(创建考试记录)
ExamRecord record=new ExamRecord();
record.setUserId(userId);
record.setRoomId(roomId);
record.setPaperId(paper.getId());
record.setStatus(1);//1表示进行中
examRecordService.save(record);
paperVO.setRecordId(record.getId());
return Result.success(paperVO);
}
//提交答卷
PostMapping("/submit")
public Result<String>submitExam(RequestBody ExamSubmitDTO submitDTO){
//1.计算得分(对比用户答案与正确答案)
int score=examRecordService.calculateScore(
submitDTO.getRecordId(),submitDTO.getAnswerDetails());
//2.更新考试记录状态
ExamRecord record=examRecordService.getById(submitDTO.getRecordId());
record.setScore(score);
record.setStatus(2);//2表示已提交
record.setSubmitTime(new Date());
record.setAnswerDetails(JSON.toJSONString(submitDTO.getAnswerDetails()));
examRecordService.updateById(record);
return Result.success("提交成功,得分:"+score);
}
}
复制
复制
3.3.2考试页面组件(Vue 3+uni-app)
vue
<template>
<view class="exam-container">
<!--考试标题与倒计时-->
<view class="exam-header">
<text class="title">{{paperInfo.paperName}}</text>
<text class="countdown":class="{'expired':isExpired}">
剩余时间:{{formatTime(remainingTime)}}
</text>
</view>
<!--题目列表-->
<view class="question-list">
<view v-for="(question,index)in questions":key="question.id"class="question-item">
<view class="question-number">{{index+1}}.</view>
<view class="question-content"v-html="question.content"></view>
<view class="question-options">
<view v-for="(option,optIndex)in question.options":key="optIndex"
class="option-item"
:class="{'selected':selectedAnswers[question.id]===option.value}"
click="selectAnswer(question.id,option.value)">
{{getOptionLetter(optIndex)}}.{{option.label}}
</view>
</view>
</view>
</view>
<!--提交按钮-->
<button class="submit-btn"click="submitExam":disabled="isSubmitting">
提交答卷
</button>
</view>
</template>
<script setup>
import{ref,onMounted,onUnmounted,watch}from'vue';
import{useRouter}from'uni-simple-router';
import{request}from'/utils/request';
import{useStore}from'pinia';
import examStore from'/stores/exam';
const router=useRouter();
const store=useStore(examStore);
//试卷数据
const paperInfo=ref({});
const questions=ref([]);
const recordId=ref('');
const remainingTime=ref(0);//剩余时间(秒)
const isExpired=ref(false);
const isSubmitting=ref(false);
const selectedAnswers=ref({});//存储用户选择的答案{questionId:'A',...}
//进入考试时获取试卷
const enterExam=async(roomId)=>{
try{
const userInfo=uni.getStorageSync('userInfo');
if(!userInfo){
router.push('/pages/login/login');
return;
}
const res=await request({
url:`/api/exam/room/${roomId}/enter`,
method:'GET',
data:{userId:userInfo.id}
});
if(res.code===200){
paperInfo.value=res.data;
recordId.value=res.data.recordId;
//解析题目列表(假设题目ID存储为JSON字符串)
const questionIds=JSON.parse(res.data.questionIds);
//从题库中获取题目详情(实际项目中可能通过接口批量获取)
const questionRes=await request({
url:'/api/question/list',
method:'GET',
params:{ids:questionIds.join(',')}
});
questions.value=questionRes.data.records;
//初始化倒计时
initCountdown(res.data.duration*60);//duration单位为分钟,转换为秒
}else{
uni.showToast({title:res.message,icon:'none'});
setTimeout(()=>{
router.back();
},1500);
}
}catch(error){
console.error('进入考试失败',error);
uni.showToast({title:'进入考试失败,请重试',icon:'none'});
setTimeout(()=>{
router.back();
},1500);
}
};
//初始化倒计时
const initCountdown=(totalSeconds)=>{
remainingTime.value=totalSeconds;
const timer=setInterval(()=>{
remainingTime.value--;
if(remainingTime.value<=0){
clearInterval(timer);
isExpired.value=true;
submitExam();//自动提交
}
},1000);
onUnmounted(()=>{
clearInterval(timer);
});
};
//选择答案
const selectAnswer=(questionId,answer)=>{
selectedAnswers.value[questionId]=answer;
};
//提交答卷
const submitExam=async()=>{
if(isSubmitting.value)return;
isSubmitting.value=true;
try{
const res=await request({
url:'/api/exam/room/submit',
method:'POST',
data:{
recordId:recordId.value,
answerDetails:selectedAnswers.value
}
});
uni.showToast({title:res.message,icon:'success'});
//跳转到成绩页面
setTimeout(()=>{
router.push(`/pages/exam/result?recordId=${recordId.value}`);
},1500);
}catch(error){
console.error('提交答卷失败',error);
uni.showToast({title:'提交失败,请重试',icon:'none'});
}finally{
isSubmitting.value=false;
}
};
//其他辅助函数(如时间格式化、选项字母转换等)
const formatTime=(seconds)=>{
const minutes=Math.floor(seconds/60);
const secs=seconds%60;
return`${minutes.toString().padStart(2,'0')}:${secs.toString().padStart(2,'0')}`;
};
const getOptionLetter=(index)=>{
return String.fromCharCode(65+index);//A,B,C,D...
};
//页面加载时获取参数并进入考试
const roomId=uni.getStorageSync('currentRoomId');
if(roomId){
enterExam(roomId);
}
</script>
复制
复制
四、跨端适配与性能优化
4.1跨端开发方案(uni-app实现)
通过uni-app的条件编译实现多端适配,示例如下:
vue
<template>
<view>
<!--App端特有功能:离线缓存考试数据-->
ifdef APP-PLUS
<view class="app-only">
<button click="clearLocalCache">清除本地考试缓存</button>
</view>
endif
<!--微信小程序特有功能:分享试卷-->
ifdef MP-WEIXIN
<view class="wechat-only">
<button open-type="share">分享试卷</button>
</view>
endif
<!--通用考试界面-->
<view class="common-exam-content">
<!--通用考试组件-->
</view>
</view>
</template>
<script>
//不同端的JS逻辑适配
export default{
methods:{
//App端本地存储操作
ifdef APP-PLUS
clearLocalCache(){
uni.clearStorage();
uni.showToast({title:'缓存已清除'});
},
endif
//小程序端分享逻辑
ifdef MP-WEIXIN
onShareAppMessage(){
return{
title:'邀请好友参加考试',
path:'/pages/exam/index'
};
}
endif
}
};
</script>
复制
复制
4.2性能优化策略
1.题库缓存:
后端使用Redis缓存高频访问的题库数据,有效期1小时
前端通过uni-storage缓存已加载的试卷,避免重复请求
java
//后端Redis缓存示例
Service
public class QuestionBankServiceImpl implements QuestionBankService{
Autowired
private RedisTemplate<String,Object>redisTemplate;
Autowired
private QuestionBankMapper questionBankMapper;
Override
public List<QuestionBank>getHotQuestions(int count){
//从Redis获取热门题目
List<QuestionBank>hotQuestions=(List<QuestionBank>)redisTemplate.opsForValue()
.get("hot_questions_"+count);
if(hotQuestions==null){
//数据库查询后缓存
hotQuestions=questionBankMapper.selectHotQuestions(count);
redisTemplate.opsForValue().set(
"hot_questions_"+count,hotQuestions,30,TimeUnit.MINUTES);
}
return hotQuestions;
}
}
复制
复制
2.考试防作弊:
前端监听页面切换事件,切出考试页面超过10秒自动交卷
后端记录考试IP地址,防止同一账号多端同时考试
vue
//前端防切屏逻辑
onMounted(){
//监听页面切换(App端和小程序端适配)
ifdef APP-PLUS
plus.webview.currentWebview().addEventListener('pause',()=>{
this.switchOutCount++;
if(this.switchOutCount>=2){//切出超过2次
uni.showToast({title:'考试已自动交卷',icon:'none'});
this.submitExam();
}
});
endif
ifdef MP-WEIXIN
wx.onAppHide(()=>{
this.hideTime=new Date().getTime();
});
wx.onAppShow(()=>{
if(this.hideTime){
const diff=new Date().getTime()this.hideTime;
if(diff>10000){//切出超过10秒
uni.showToast({title:'考试已自动交卷',icon:'none'});
this.submitExam();
}
}
});
endif
}
复制
复制
五、开源生态与部署指南
5.1开源项目结构
exam-system/
├──backend/后端项目(SpringBoot)
│├──src/
││├──main/
│││├──java/com/exam/
││││├──config/配置类(安全、数据库等)
││││├──controller/接口控制器
││││├──service/服务层
││││├──mapper/MyBatis映射
││││├──entity/实体类
││││├──dto/数据传输对象
││││├──vo/视图对象
││││├──utils/工具类
││││├──ExamSystemApplication.java启动类
│││└──resources/
│││├──mapper/MyBatis XML映射文件
│││├──application.yml配置文件
││└──test/测试类
├──frontend/前端项目(Vue 3+uni-app)
│├──src/
││├──pages/页面组件
││├──components/公共组件
││├──stores/Pinia状态管理
││├──utils/工具函数
││├──App.vue应用入口
││└──main.js主程序
│├──manifest.json应用配置
│└──pages.json页面路由配置
├──doc/文档
│├──database/数据库设计文档
│├──api/接口文档
│└──deploy.md部署指南
└──pom.xml后端Maven依赖
复制
复制
5.2部署流程
1.后端部署:
bash
编译后端项目
cd backend
mvn clean package-DskipTests
启动服务(默认端口8080)
java-jar target/exam-system-1.0.0.jar
2.前端部署:
bash
安装依赖
cd frontend
npm install
编译为App(Android/iOS)
npm run build:app-plus
编译为微信小程序
npm run build:mp-weixin
3.数据库初始化:
执行`doc/database/exam_system.sql`创建数据库和表结构
初始化管理员账号:`admin/123456`
六、总结与展望
本开源考试答题系统基于SpringBoot+Vue前后端分离架构,结合uni-app跨端框架,实现了一套代码多端运行的能力,涵盖用户管理、题库管理、试卷生成、在线考试、成绩统计等核心功能。系统采用JWT认证、Redis缓存、MyBatis-Plus等技术提升性能和开发效率,通过条件编译解决多端适配问题。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。