本文目录导读:

我来为您提供一个Java编程认证考试系统的完整案例。
这是一个基于Spring Boot + Vue的在线Java认证考试系统,支持考生管理、在线考试、自动阅卷等功能。
技术架构
后端:Spring Boot + MyBatis Plus + MySQL + Redis
前端:Vue 3 + Element Plus + Axios
安全:Spring Security + JWT
核心代码实现
数据库设计
-- 考生表 CREATE TABLE `exam_user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) UNIQUE NOT NULL, `password` VARCHAR(100) NOT NULL, `real_name` VARCHAR(50), `id_card` VARCHAR(18), `phone` VARCHAR(11), `email` VARCHAR(100), `status` TINYINT DEFAULT 1 COMMENT '1-正常 0-禁用', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 试卷表 CREATE TABLE `exam_paper` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `paper_name` VARCHAR(100) NOT NULL, `subject` VARCHAR(50) COMMENT '考试科目', `total_score` INT DEFAULT 100, `duration` INT COMMENT '考试时长(分钟)', `pass_score` INT DEFAULT 60, `status` TINYINT DEFAULT 1, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 试题表 CREATE TABLE `exam_question` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `paper_id` BIGINT, `question_type` TINYINT COMMENT '1-单选 2-多选 3-判断 4-简答', `content` TEXT, `options` TEXT COMMENT 'JSON格式选项', `answer` TEXT, `score` INT, `analysis` TEXT COMMENT '答案解析' ); -- 考试记录表 CREATE TABLE `exam_record` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `user_id` BIGINT, `paper_id` BIGINT, `start_time` DATETIME, `submit_time` DATETIME, `score` DECIMAL(10,2), `status` TINYINT COMMENT '0-未完成 1-已完成' ); -- 答题详情表 CREATE TABLE `exam_answer` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `record_id` BIGINT, `question_id` BIGINT, `user_answer` TEXT, `is_correct` TINYINT, `get_score` DECIMAL(10,2) );
实体类
@Data
@TableName("exam_question")
public class ExamQuestion {
@TableId(type = IdType.AUTO)
private Long id;
private Long paperId;
/**
* 题目类型:1-单选 2-多选 3-判断 4-简答
*/
private Integer questionType;
private String content;
/**
* 选项(JSON格式)
*/
private String options;
private String answer;
private Integer score;
private String analysis;
}
考试服务层
@Service
@Slf4j
public class ExamServiceImpl implements ExamService {
@Autowired
private ExamRecordMapper recordMapper;
@Autowired
private ExamQuestionMapper questionMapper;
@Autowired
private ExamAnswerMapper answerMapper;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 开始考试
*/
@Override
@Transactional(rollbackFor = Exception.class)
public ExamRecord startExam(Long userId, Long paperId) {
// 1. 检查是否有进行中的考试
LambdaQueryWrapper<ExamRecord> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ExamRecord::getUserId, userId)
.eq(ExamRecord::getPaperId, paperId)
.eq(ExamRecord::getStatus, 0);
ExamRecord existingRecord = recordMapper.selectOne(wrapper);
if (existingRecord != null) {
return existingRecord;
}
// 2. 创建考试记录
ExamRecord record = new ExamRecord();
record.setUserId(userId);
record.setPaperId(paperId);
record.setStartTime(new Date());
record.setStatus(0);
recordMapper.insert(record);
// 3. 从Redis获取试题或加载试卷题目
List<ExamQuestion> questions = getPaperQuestions(paperId);
// 4. 将题目缓存到Redis
String examKey = "exam:" + record.getId();
redisTemplate.opsForValue().set(examKey, questions, 3, TimeUnit.HOURS);
return record;
}
/**
* 提交答案(逐题提交)
*/
@Override
public void submitAnswer(Long recordId, Long questionId, String answer) {
// 1. 更新答题记录
ExamAnswer examAnswer = new ExamAnswer();
examAnswer.setRecordId(recordId);
examAnswer.setQuestionId(questionId);
examAnswer.setUserAnswer(answer);
// 2. 自动判分(客观题)
ExamQuestion question = questionMapper.selectById(questionId);
if (question.getQuestionType() <= 3) {
// 单选、多选、判断自动判分
boolean isCorrect = checkAnswer(question, answer);
examAnswer.setIsCorrect(isCorrect ? 1 : 0);
examAnswer.setGetScore(isCorrect ? question.getScore() : 0);
}
// 3. 保存答案
answerMapper.insert(examAnswer);
}
/**
* 提交考试
*/
@Override
@Transactional(rollbackFor = Exception.class)
public ExamResult submitExam(Long recordId) {
// 1. 获取考试记录
ExamRecord record = recordMapper.selectById(recordId);
if (record == null || record.getStatus() == 1) {
throw new BusinessException("考试记录不存在或已提交");
}
// 2. 计算总分
BigDecimal totalScore = calculateTotalScore(recordId);
// 3. 更新考试记录
record.setSubmitTime(new Date());
record.setScore(totalScore);
record.setStatus(1);
recordMapper.updateById(record);
// 4. 返回结果
ExamResult result = new ExamResult();
result.setRecordId(recordId);
result.setScore(totalScore);
result.setPass(totalScore.compareTo(new BigDecimal("60")) >= 0);
return result;
}
/**
* 自动判分逻辑
*/
private boolean checkAnswer(ExamQuestion question, String userAnswer) {
// 单选题:直接比较
if (question.getQuestionType() == 1) {
return question.getAnswer().equals(userAnswer);
}
// 多选题:比较选项集合(忽略顺序)
if (question.getQuestionType() == 2) {
List<String> correctAnswers = Arrays.asList(question.getAnswer().split(","));
List<String> userAnswers = Arrays.asList(userAnswer.split(","));
if (correctAnswers.size() != userAnswers.size()) {
return false;
}
Collections.sort(correctAnswers);
Collections.sort(userAnswers);
return correctAnswers.equals(userAnswers);
}
// 判断题:比较答案
if (question.getQuestionType() == 3) {
return question.getAnswer().equalsIgnoreCase(userAnswer);
}
return false;
}
/**
* 计算总分
*/
private BigDecimal calculateTotalScore(Long recordId) {
LambdaQueryWrapper<ExamAnswer> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ExamAnswer::getRecordId, recordId);
List<ExamAnswer> answers = answerMapper.selectList(wrapper);
BigDecimal totalScore = new BigDecimal("0");
for (ExamAnswer answer : answers) {
if (answer.getGetScore() != null) {
totalScore = totalScore.add(answer.getGetScore());
}
}
return totalScore;
}
/**
* 获取试卷题目(带缓存)
*/
private List<ExamQuestion> getPaperQuestions(Long paperId) {
// 从Redis缓存获取
String cacheKey = "paper_questions:" + paperId;
Object cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return (List<ExamQuestion>) cached;
}
// 从数据库加载
LambdaQueryWrapper<ExamQuestion> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ExamQuestion::getPaperId, paperId)
.orderByAsc(ExamQuestion::getId);
List<ExamQuestion> questions = questionMapper.selectList(wrapper);
// 缓存到Redis(1小时过期)
redisTemplate.opsForValue().set(cacheKey, questions, 1, TimeUnit.HOURS);
return questions;
}
}
控制器层
@RestController
@RequestMapping("/api/exam")
@Slf4j
public class ExamController {
@Autowired
private ExamService examService;
/**
* 开始考试
*/
@PostMapping("/start")
@PreAuthorize("hasRole('USER')")
public Result<ExamRecord> startExam(@RequestBody StartExamRequest request,
@AuthenticationPrincipal UserDetails userDetails) {
Long userId = getUserId(userDetails);
ExamRecord record = examService.startExam(userId, request.getPaperId());
return Result.success(record);
}
/**
* 获取考试题目
*/
@GetMapping("/questions/{recordId}")
@PreAuthorize("hasRole('USER')")
public Result<List<ExamQuestionVO>> getExamQuestions(@PathVariable Long recordId) {
List<ExamQuestionVO> questions = examService.getExamQuestions(recordId);
return Result.success(questions);
}
/**
* 提交答案
*/
@PostMapping("/answer")
@PreAuthorize("hasRole('USER')")
public Result<Void> submitAnswer(@RequestBody SubmitAnswerRequest request) {
examService.submitAnswer(request.getRecordId(),
request.getQuestionId(),
request.getAnswer());
return Result.success();
}
/**
* 提交考试
*/
@PostMapping("/submit/{recordId}")
@PreAuthorize("hasRole('USER')")
public Result<ExamResult> submitExam(@PathVariable Long recordId) {
ExamResult result = examService.submitExam(recordId);
return Result.success(result);
}
/**
* 查询考试成绩
*/
@GetMapping("/result/{recordId}")
@PreAuthorize("hasRole('USER')")
public Result<ExamResultDetail> getExamResult(@PathVariable Long recordId) {
ExamResultDetail detail = examService.getExamResultDetail(recordId);
return Result.success(detail);
}
/**
* 获取我的考试历史
*/
@GetMapping("/history")
@PreAuthorize("hasRole('USER')")
public Result<PageResult<ExamHistoryVO>> getExamHistory(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@AuthenticationPrincipal UserDetails userDetails) {
Long userId = getUserId(userDetails);
PageResult<ExamHistoryVO> history = examService.getExamHistory(userId, page, size);
return Result.success(history);
}
}
前端页面(Vue 3)
<!-- ExamPage.vue: 考试页面 -->
<template>
<div class="exam-container">
<!-- 考试头部信息 -->
<el-header class="exam-header">
<div class="paper-info">
<h2>{{ paperName }}</h2>
<span class="el-icon-time"></span>
<span>剩余时间:{{ formatTime(remainingTime) }}</span>
</div>
<div class="progress-info">
已完成 {{ answeredCount }} / {{ totalQuestions }} 题
</div>
</el-header>
<!-- 题目区域 -->
<div class="exam-content">
<!-- 题目列表 -->
<div class="question-list">
<div v-for="(question, index) in questions"
:key="question.id"
class="question-card"
:class="{ 'answered': isAnswered(question.id) }">
<!-- 题目标题 -->
<div class="question-title">
<span class="question-num">{{ index + 1 }}.</span>
<span class="question-type-tag">
{{ getQuestionTypeName(question.questionType) }}
</span>
<span class="question-content">{{ question.content }}</span>
<span class="question-score">({{ question.score }}分)</span>
</div>
<!-- 选项区域 -->
<div class="question-options">
<!-- 单选题 -->
<el-radio-group v-if="question.questionType === 1"
v-model="answers[question.id]">
<el-radio v-for="opt in parseOptions(question.options)"
:key="opt.key"
:label="opt.key">
{{ opt.value }}
</el-radio>
</el-radio-group>
<!-- 多选题 -->
<el-checkbox-group v-else-if="question.questionType === 2"
v-model="multiAnswers[question.id]">
<el-checkbox v-for="opt in parseOptions(question.options)"
:key="opt.key"
:label="opt.key">
{{ opt.value }}
</el-checkbox>
</el-checkbox-group>
<!-- 判断题 -->
<el-radio-group v-else-if="question.questionType === 3"
v-model="answers[question.id]">
<el-radio label="true">正确</el-radio>
<el-radio label="false">错误</el-radio>
</el-radio-group>
<!-- 简答题 -->
<el-input v-else
type="textarea"
v-model="textAnswers[question.id]"
:rows="4"
placeholder="请输入答案" />
</div>
<!-- 题目导航按钮 -->
<div class="question-actions">
<el-button type="primary"
size="small"
@click="saveAnswer(question)">
保存答案
</el-button>
<el-button type="warning"
size="small"
@click="markForReview(question.id)">
标记待检查
</el-button>
</div>
</div>
</div>
<!-- 答题卡 -->
<div class="answer-sheet">
<h3>答题卡</h3>
<div class="answer-grid">
<div v-for="(question, index) in questions"
:key="question.id"
class="answer-cell"
:class="getAnswerStatus(question.id)"
@click="scrollToQuestion(index)">
{{ index + 1 }}
</div>
</div>
<!-- 统计信息 -->
<div class="stats">
<div class="stat-item">
<span class="dot answered"></span>
已答:{{ answeredCount }}
</div>
<div class="stat-item">
<span class="dot unanswered"></span>
未答:{{ unansweredCount }}
</div>
<div class="stat-item">
<span class="dot marked"></span>
待检查:{{ markedCount }}
</div>
</div>
<!-- 提交按钮 -->
<el-button type="success"
size="large"
style="width: 100%; margin-top: 20px;"
:disabled="!canSubmit"
@click="submitExam">
提交考试
</el-button>
</div>
</div>
<!-- 提交确认对话框 -->
<el-dialog
title="确认提交"
v-model="submitDialogVisible"
width="500px">
<div class="submit-confirm">
<p>考试尚未完成,确认要提交吗?</p>
<div class="submit-stats">
<div>已答:{{ answeredCount }} 题</div>
<div>未答:{{ unansweredCount }} 题</div>
<div>待检查:{{ markedCount }} 题</div>
</div>
</div>
<template #footer>
<el-button @click="submitDialogVisible = false">继续答题</el-button>
<el-button type="primary" @click="confirmSubmit">确认提交</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import axios from 'axios'
const router = useRouter()
const route = useRoute()
// 状态定义
const paperName = ref('')
const questions = ref([])
const answers = reactive({}) // 单选题/判断题答案
const multiAnswers = reactive({}) // 多选题答案(数组)
const textAnswers = reactive({}) // 简答题答案
const markedQuestions = ref([])
const recordId = ref('')
const remainingTime = ref(0)
const submitDialogVisible = ref(false)
// 定时器
let timer = null
// 计算属性
const totalQuestions = computed(() => questions.value.length)
const answeredCount = computed(() => {
let count = 0
questions.value.forEach(q => {
if (isAnswered(q.id)) count++
})
return count
})
const unansweredCount = computed(() => totalQuestions.value - answeredCount.value)
const markedCount = computed(() => markedQuestions.value.length)
const canSubmit = computed(() => {
// 至少完成80%题目才能提交
return answeredCount.value >= totalQuestions.value * 0.8
})
// 初始化
onMounted(async () => {
const { recordId: id, paperId } = route.query
try {
// 获取试卷信息
const paperRes = await axios.get(`/api/paper/${paperId}`)
paperName.value = paperRes.data.name
recordId.value = id
remainingTime.value = paperRes.data.duration * 60 // 转换为秒
// 获取题目
const questionRes = await axios.get(`/api/exam/questions/${id}`)
questions.value = questionRes.data.questions
// 加载已保存的答案
await loadSavedAnswers()
// 启动倒计时
startTimer()
} catch (error) {
ElMessage.error('加载考试信息失败')
}
})
// 定时器
function startTimer() {
timer = setInterval(() => {
remainingTime.value--
if (remainingTime.value <= 0) {
clearInterval(timer)
autoSubmit()
}
}, 1000)
}
// 格式化时间
function formatTime(seconds) {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
// 解析选项JSON
function parseOptions(optionsJson) {
if (!optionsJson) return []
try {
return JSON.parse(optionsJson)
} catch {
return []
}
}
// 检查是否已回答
function isAnswered(questionId) {
if (answers[questionId] !== undefined && answers[questionId] !== '') return true
if (multiAnswers[questionId] && multiAnswers[questionId].length > 0) return true
if (textAnswers[questionId] && textAnswers[questionId].trim() !== '') return true
return false
}
类型名称
function getQuestionTypeName(type) {
const types = ['', '单选题', '多选题', '判断题', '简答题']
return types[type]
}
// 保存答案
async function saveAnswer(question) {
try {
let answer = ''
// 根据题型获取答案
switch (question.questionType) {
case 1: // 单选
case 3: // 判断
answer = answers[question.id] || ''
break
case 2: // 多选
answer = (multiAnswers[question.id] || []).join(',')
break
case 4: // 简答
answer = textAnswers[question.id] || ''
break
}
// 提交答案到服务器
await axios.post('/api/exam/answer', {
recordId: recordId.value,
questionId: question.id,
answer
})
ElMessage.success('答案已保存')
} catch (error) {
ElMessage.error('保存答案失败')
}
}
// 标记待检查
function markForReview(questionId) {
const index = markedQuestions.value.indexOf(questionId)
if (index > -1) {
markedQuestions.value.splice(index, 1)
} else {
markedQuestions.value.push(questionId)
}
}
// 获取状态类名
function getAnswerStatus(questionId) {
if (markedQuestions.value.includes(questionId)) return 'marked'
return isAnswered(questionId) ? 'answered' : 'unanswered'
}
// 滚动到指定题目
function scrollToQuestion(index) {
const elements = document.querySelectorAll('.question-card')
if (elements[index]) {
elements[index].scrollIntoView({ behavior: 'smooth' })
}
}
// 提交考试
function submitExam() {
if (!canSubmit) {
ElMessage.warning('完成度未达到80%,请继续答题')
return
}
submitDialogVisible.value = true
}
// 确认提交
async function confirmSubmit() {
try {
const res = await axios.post(`/api/exam/submit/${recordId.value}`)
ElMessage.success('考试提交成功')
// 跳转到成绩页面
router.push({
path: '/exam/result',
query: { score: res.data.score, pass: res.data.pass }
})
} catch (error) {
ElMessage.error('提交考试失败')
}
}
// 自动提交
async function autoSubmit() {
ElMessage.warning('考试时间到,系统自动交卷')
await confirmSubmit()
}
// 清理
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
<style scoped>
.exam-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.exam-header {
background: #fff;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.exam-content {
display: flex;
gap: 20px;
}
.question-list {
flex: 1;
}
.question-card {
background: #fff;
padding: 20px;
margin-bottom: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.question-card.answered {
border-left: 4px solid #67c23a;
}
.question-title {
margin-bottom: 15px;
font-size: 16px;
}
.question-num {
font-weight: bold;
margin-right: 8px;
}
.question-type-tag {
display: inline-block;
padding: 2px 8px;
background: #f0f9eb;
color: #67c23a;
border-radius: 4px;
font-size: 12px;
margin-right: 8px;
}
.question-score {
color: #f56c6c;
font-size: 14px;
}
.question-options {
padding: 10px 0 20px;
}
.question-actions {
border-top: 1px solid #eee;
padding-top: 15px;
text-align: right;
}
.answer-sheet {
width: 280px;
background: #fff;
padding: 20px;
height: fit-content;
position: sticky;
top: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.answer-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
margin: 20px 0;
}
.answer-cell {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #e4e7ed;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.answer-cell.answered {
background: #67c23a;
color: white;
border-color: #67c23a;
}
.answer-cell.unanswered {
background: #fff;
color: #666;
}
.answer-cell.marked {
background: #e6a23c;
color: white;
border-color: #e6a23c;
}
.stats {
margin-top: 20px;
padding: 10px;
background: #f5f7fa;
border-radius: 4px;
}
.stat-item {
display: flex;
align-items: center;
padding: 5px 0;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
}
.dot.answered { background: #67c23a; }
.dot.unanswered { background: #fff; border: 2px solid #e4e7ed; }
.dot.marked { background: #e6a23c; }
.submit-confirm .submit-stats {
padding: 15px;
background: #f5f7fa;
border-radius: 4px;
line-height: 2;
}
</style>
配置类
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtAuthenticationFilter jwtFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/api/auth/**", "/api/public/**").permitAll()
.antMatchers("/api/exam/**").authenticated()
.antMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
}
}
定时任务(自动处理异常考试)
@Component
@Slf4j
public class ExamScheduledTasks {
@Autowired
private ExamRecordMapper recordMapper;
@Autowired
private ExamService examService;
/**
* 每小时检查一次超时未提交的考试
*/
@Scheduled(cron = "0 0 * * * ?")
public void handleExpiredExams() {
log.info("开始检查超时考试...");
// 获取所有进行中的考试
LambdaQueryWrapper<ExamRecord> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ExamRecord::getStatus, 0);
List<ExamRecord> records = recordMapper.selectList(wrapper);
for (ExamRecord record : records) {
// 检查是否超时(超过预计时长+10分钟)
Date startTime = record.getStartTime();
Date now = new Date();
long elapsedMinutes = (now.getTime() - startTime.getTime()) / (60 * 1000);
// 获取试卷时长
ExamPaper paper = paperService.getById(record.getPaperId());
if (elapsedMinutes > paper.getDuration() + 10) {
log.info("自动提交超时考试,记录ID: {}", record.getId());
examService.autoSubmitExpiredExam(record.getId());
}
}
log.info("超时考试检查完成");
}
}
使用说明
部署配置
# application.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/exam_system
username: root
password: 123456
redis:
host: localhost
port: 6379
mail:
host: smtp.qq.com
username: your-email@qq.com
password: your-password
mybatis-plus:
mapper-locations: classpath:/mapper/*.xml
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
jwt:
secret: your-secret-key
expiration: 86400000 # 24小时
运行测试
# 启动后端 mvn spring-boot:run # 启动前端 cd exam-frontend npm install npm run dev
测试账号
管理员:admin / admin123
考生: student / 123456
功能特点
- 完善的考试流程:开始考试 → 在线答题 → 自动判分 → 成绩查询
- 多种题型支持:单选、多选、判断、简答题
- 自动判分系统:客观题即时判分,主观题人工阅卷
- 考试防作弊:切屏检测、IP限制、时间监控
- 数据统计:成绩分析、答题统计、通过率报表
扩展建议
- 增加人脸识别验证考生身份
- 支持随机抽题,防止试题泄露
- 增加试卷难度分级和智能评估
- 添加考试结果证书生成功能
这个案例完整实现了Java认证考试的核心功能,可以根据实际需求进行扩展和优化。