资讯动态

普法闯关学习平台实战:Spring Boot 3 + Vue 3完整实现

发布时间:2026/9/26 13:34:04 来源:尧图企业网站定制
看到“普法战争牢九门”这个题先别急着往历史方向联想。这个词在法考备考圈里流传已久法律职业资格考试覆盖的九大部门法被考生们戏称为“法律九门”因为这九门科目知识量大、备考周期长、通过率又低很多人自嘲是在“坐牢”于是就有了“牢九门”的说法。把这些抽象的法律知识体系做成一次闯关每一门都是一道关卡每一次刷题都是一场战斗——这就是标题里“战争”二字的由来。不过今天这篇文章不聊历史也不聊考试政策只聊工程实现。我想做一件很有意思的事把这套“九门”业务模型转化为一个可运行的“普法闯关学习平台”。该平台需要具备科目管理、题库维护、智能组卷、在线答题、错题本、闯关进度等核心能力。无论你是在做在线教育系统、知识竞赛平台还是企业内部培训工具这套设计思路都能直接迁移。读完这篇教程你将获得三样东西第一一个清晰的业务建模方法知道如何把复杂知识域拆成九大模块并落地到数据库第二一套基于 Spring Boot 3 Vue 3 的前后端分离最小实现照抄即可跑通第三一份完整的排查清单和工程化建议避免在真实项目中踩坑。1. 这个项目真正要解决的问题很多初学者拿到类似需求时第一反应是做一张题目表然后写几个增删改查接口就完事。但如果需求是“一个覆盖九门科目的闯关学习平台”你会发现事情没那么简单。首先需要管理的业务实体已经不止“题目”一个。有用户、科目、题目、试卷、答题记录、错题、闯关进度、积分等级这些实体之间还有复杂的关联关系。其次答题不是简单的“用户选一个选项后端判断对错”它涉及到组卷策略、题目难度控制、错题自动归集、闯关解锁条件、成绩统计等多个环节。最后如果后续要扩展 AI 推荐题目、知识图谱分析、小程序端数据库设计和代码结构从一开始就要留好余地。我见过不少在线教育项目前期只做了题目表和答题接口等到要加“错题本”功能时发现历史答题记录根本没存只能让用户重新答题。要加“模拟考试”时发现组卷逻辑写死在 Service 里无法支持多人并发组卷。这些问题不是开发能力不够而是业务建模阶段没有把核心流程想清楚。所以这篇文章的核心结论是闯关学习类系统的难点不在于单表 CRUD而在于“题目的组织方式”和“用户学习路径”这两个抽象层面。我们把九门科目映射为九个独立模块把每门科目的学习路径映射为“刷题—闯关—错题—再练习”的闭环才能真正支撑起一个可落地的平台。2. 业务模型拆解九大部门法如何变成九大模块在设计系统之前先理解“九门”的业务含义。法律职业资格考试的传统学科划分可以概括为刑法、民法、行政法、商经知、理论法、国际法、民事诉讼法、刑事诉讼法和行政诉讼法。这九门学科各有独立的底层逻辑和知识边界。从软件工程视角看这就是一个典型的领域模型序号科目编码科目名称设计定位1CRIMINAL刑法犯罪构成与刑罚体系2CIVIL民法民事法律关系与合同侵权3ADMIN行政法行政主体与行政行为4BUSINESS商经知公司、合同、知识产权实务5THEORY理论法法理学、宪法与法治思想6INTERNATIONAL国际法国际公法与私法常识7CPL民诉法民事纠纷解决程序8SPL刑诉法刑事追诉与审判程序9APL行诉法行政争议救济程序在系统里这九个模块并不是孤立的。它们共享同一套用户体系但在题目组织、闯关进度、成绩统计上是相互独立的。简单说用户在小程序或网页里看到一个九宫格每个格子对应一门科目点击进入后开始该科目的答题闯关。这个设计有一个容易被忽视的好处当九门科目被建模为可配置数据源时新增一门科目不需要改代码。比如企业客户要求把“税务知识”作为第十门挂进去只要往科目表里插入一条记录再上传对应题目即可。这就是把业务域和基础设施解耦的价值。与之相对的错误设计是把科目写死成 Java 枚举类枚举里写九个字段每个科目一个 Service。这样看似“面向对象”实际上每增加一个科目都要重新发布版本完全违背了知识平台持续运营的需求。3. 技术选型与系统架构这个项目采用前后端分离架构后端负责核心业务逻辑和数据持久化前端负责交互与展示。选择这套方案的原因只有一个它是目前中小型知识平台迭代效率最高的组合。3.1 后端技术栈组件选型说明基础框架Spring Boot 3.x提供自动配置、内嵌容器、生态完善ORMMyBatis-Plus单表 CRUD 效率高支持 LambdaQueryWrapper数据库MySQL 8.x存储用户、科目、题目、答题记录等关系数据缓存Redis缓存热门题目和用户会话可选认证方案Sa-Token 或 Spring Security简单场景可以先不做用 token 即可Spring Boot 版本请以你本地的实际环境为准本文演示的是通用思路代码结构在 3.x 各小版本之间基本一致。3.2 前端技术栈组件选型说明核心框架Vue 3 Vite构建速度快组合式 API 写起来简洁UI 组件Element Plus表格、表单、弹窗等开箱即用HTTP 客户端Axios统一处理 token 和响应拦截3.3 架构分层后端按经典四层结构组织src/main/java/com/example/pufawar ├── controller // 接口层接收请求、参数校验、返回Result ├── service // 业务层开户、组卷、判题、错题归集 ├── mapper // 数据访问层MyBatis-Plus Mapper └── entity // 实体层对应数据库表在小型系统中四层结构看起来有点“重”但对于这个项目非常必要。原因在于组卷、判题、错题归集都不是简单 SQL 能完成的它们需要事务和业务编排。如果没有 Service 层约束这些逻辑会散落在 Controller 里后续测试和维护都会变得痛苦。4. 数据库设计与数据初始化数据库设计是这个项目最关键的一步。核心表一共六张用户表、科目表、题目表、试卷表、试卷题目关联表、错题本表。4.1 建表语句-- 文件路径src/main/resources/db/init.sql CREATE DATABASE IF NOT EXISTS pufa_war DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE pufa_war; -- 用户表 CREATE TABLE sys_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, nickname VARCHAR(50), level INT DEFAULT 1, experience INT DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 科目表 CREATE TABLE subject ( id BIGINT AUTO_INCREMENT PRIMARY KEY, code VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(100) NOT NULL, sort_order INT DEFAULT 0, description VARCHAR(255) ); -- 题目表 CREATE TABLE question ( id BIGINT AUTO_INCREMENT PRIMARY KEY, subject_id BIGINT NOT NULL, type TINYINT COMMENT 1-单选 2-多选 3-判断, content TEXT NOT NULL, options_json TEXT, answer VARCHAR(500), analysis TEXT, difficulty TINYINT DEFAULT 1 COMMENT 1-简单 2-中等 3-困难, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 试卷表 CREATE TABLE paper ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, subject_id BIGINT NOT NULL, title VARCHAR(100), total_score INT DEFAULT 0, score INT DEFAULT 0, status TINYINT DEFAULT 0 COMMENT 0-进行中 1-已完成, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 试卷题目关联表 CREATE TABLE paper_question ( id BIGINT AUTO_INCREMENT PRIMARY KEY, paper_id BIGINT NOT NULL, question_id BIGINT NOT NULL, user_answer VARCHAR(500), correct TINYINT DEFAULT 0 COMMENT 0-错误 1-正确, score INT DEFAULT 0 ); -- 错题本表 CREATE TABLE wrong_book ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, question_id BIGINT NOT NULL, wrong_count INT DEFAULT 1, last_wrong_time DATETIME DEFAULT CURRENT_TIMESTAMP );4.2 初始化九门科目数据-- 文件路径src/main/resources/db/data.sql INSERT INTO subject (code, name, sort_order, description) VALUES (CRIMINAL, 刑法, 1, 犯罪构成与刑罚体系), (CIVIL, 民法, 2, 民事法律关系与合同侵权), (ADMIN, 行政法, 3, 行政主体与行政行为), (BUSINESS, 商经知, 4, 公司、合同、知识产权实务), (THEORY, 理论法, 5, 法理学、宪法与法治思想), (INTERNATIONAL, 国际法, 6, 国际公法与私法常识), (CPL, 民诉法, 7, 民事纠纷解决程序), (SPL, 刑诉法, 8, 刑事追诉与审判程序), (APL, 行诉法, 9, 行政争议救济程序);这里要特别说明options_json字段。传统的题目表喜欢为四个选项建立四个字段比如 option_a、option_b、option_c、option_d。一旦遇到多选题、判断题、案例分析题表结构就不够用了。用 JSON 存储选项的优点是可以适配单选、多选、判断、填空等多种题型坏处是查询时不能直接按选项内容筛选。对题库系统来说“按内容筛选选项”极少出现所以 JSON 方案是正确的取舍。我在生产环境也见过直接用text字段存 JSON 的写法配合后端ObjectMapper序列化和反序列化效率并不低。不要为了“规范化”把简单问题复杂化。5. 后端核心实现科目、组卷与闯关5.1 项目依赖配置pom.xml!-- 文件路径pom.xml -- parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.4/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里使用mybatis-plus-spring-boot3-starter版本请以 Maven 中央仓库最新稳定版为准如果本地下载失败可以检查镜像源配置。5.2 统一响应与科目接口首先定义一个统一返回结构保证前端拿到标准格式// 文件路径src/main/java/com/example/pufawar/common/Result.java package com.example.pufawar.common; import lombok.Data; Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }然后写科目查询接口。这里用 MyBatis-Plus 的LambdaQueryWrapper避免手写拼接 SQL// 文件路径src/main/java/com/example/pufawar/controller/SubjectController.java package com.example.pufawar.controller; import com.example.pufawar.common.Result; import com.example.pufawar.entity.Subject; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; Service public class SubjectServiceImpl extends ServiceImplSubjectMapper, Subject { } RestController RequestMapping(/api/subject) public class SubjectController { private final IServiceSubject subjectService; public SubjectController(IServiceSubject subjectService) { this.subjectService subjectService; } GetMapping(/list) public ResultListSubject list() { ListSubject subjects subjectService.list( Wrappers.SubjectlambdaQuery().orderByAsc(Subject::getSortOrder) ); return Result.success(subjects); } }注意这个示例里我使用了IService作为注入类型这是 MyBatis-Plus 的通用接口。实际上更规范的做法是注入自定义的SubjectService接口这里为了演示简洁直接用了默认实现。在真实项目中建议每个实体都有自己的 Service 接口和实现类。5.3 智能组卷核心逻辑组卷是学习平台的核心功能之一。常见的组卷策略有两种随机抽题和按难度比例抽题。这里实现一个简单但实用的版本按难度比例从指定科目抽题。// 文件路径src/main/java/com/example/pufawar/service/QuizService.java package com.example.pufawar.service; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.example.pufawar.entity.Question; import com.example.pufawar.mapper.QuestionMapper; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.Collections; import java.util.List; Service public class QuizService { private final QuestionMapper questionMapper; public QuizService(QuestionMapper questionMapper) { this.questionMapper questionMapper; } /** * 按难度比例组卷 * param subjectId 科目ID * param total 题目总数 * return 题目列表 */ public ListQuestion generatePaper(Long subjectId, int total) { int easyCount (int) (total * 0.5); int mediumCount (int) (total * 0.3); int hardCount total - easyCount - mediumCount; ListQuestion paper new ArrayList(); paper.addAll(randomPick(subjectId, 1, easyCount)); paper.addAll(randomPick(subjectId, 2, mediumCount)); paper.addAll(randomPick(subjectId, 3, hardCount)); Collections.shuffle(paper); return paper; } private ListQuestion randomPick(Long subjectId, Integer difficulty, int count) { ListQuestion pool questionMapper.selectList( Wrappers.QuestionlambdaQuery() .eq(Question::getSubjectId, subjectId) .eq(Question::getDifficulty, difficulty) ); Collections.shuffle(pool); return pool.size() count ? pool.subList(0, count) : pool; } }这段代码有两个值得注意的设计点。第一Collections.shuffle后取前 N 条避免每次都写复杂的ORDER BY RAND()因为ORDER BY RAND()在数据量大时会全表扫描性能很差。第二当题库中某难度题目数量不足时subList不会越界因为先判断了pool.size()与count的大小关系。5.4 判题与错题归集判题不能只看答案是否一致还要把答错的题目自动写入错题本。// 文件路径src/main/java/com/example/pufawar/service/AnswerService.java package com.example.pufawar.service; import com.example.pufawar.entity.Question; import com.example.pufawar.entity.WrongBook; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; Service public class AnswerService { private final QuestionService questionService; private final WrongBookService wrongBookService; public AnswerService(QuestionService questionService, WrongBookService wrongBookService) { this.questionService questionService; this.wrongBookService wrongBookService; } Transactional(rollbackFor Exception.class) public boolean submitAnswer(Long userId, Long questionId, String userAnswer) { Question question questionService.getById(questionId); boolean correct normalize(question.getAnswer()) .equals(normalize(userAnswer)); if (!correct) { WrongBook wrongBook wrongBookService.getOne( Wrappers.WrongBooklambdaQuery() .eq(WrongBook::getUserId, userId) .eq(WrongBook::getQuestionId, questionId) ); if (wrongBook null) { WrongBook book new WrongBook(); book.setUserId(userId); book.setQuestionId(questionId); book.setWrongCount(1); wrongBookService.save(book); } else { wrongBook.setWrongCount(wrongBook.getWrongCount() 1); wrongBookService.updateById(wrongBook); } } return correct; } private String normalize(String answer) { return answer null ? : answer.trim().toUpperCase(); } }这里把答案统一转成大写再比较是为了兼容用户手输小写字母的情况。事务注解保证了“写入错题本”和“记录答题结果”要么一起成功要么一起回滚避免出现用户答错了但错题本没有记录的现象。6. 前端九宫格与答题流程实现后端接口就绪后前端要解决两个核心体验问题九门科目的九宫格展示以及进入某一门后的答题交互。6.1 科目九宫格页面!-- 文件路径src/views/SubjectGrid.vue -- template div classcontainer h2普法闯关 · 九门科目/h2 div classgrid div v-for(item, index) in subjects :keyitem.id classcard clickenterSubject(item) span classnum{{ index 1 }}/span span classname{{ item.name }}/span span classdesc{{ item.description }}/span /div /div /div /template script setup import { ref, onMounted } from vue; import { useRouter } from vue-router; import axios from axios; const subjects ref([]); const router useRouter(); const fetchSubjects async () { const { data } await axios.get(/api/subject/list); subjects.value data.data; }; const enterSubject (item) { router.push({ path: /subject/${item.id}/quiz }); }; onMounted(fetchSubjects); /script style scoped .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; max-width: 900px; margin: 0 auto; } .card { border: 1px solid #e5e7eb; border-radius: 12px; padding: 24px; text-align: center; cursor: pointer; transition: box-shadow 0.2s; } .card:hover { box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); } /style6.2 答题页面核心逻辑答题页面最重要的不是 UI而是“状态管理”当前题目是第几题、用户选择了什么、是否已经提交、提交后是否正确。用一个简单的reactive对象管理状态即可。!-- 文件路径src/views/QuizPage.vue -- template div h3{{ current.title }}/h3 div v-for(option, idx) in current.options :keyidx label input typeradio :valueoption.key v-modelselectedAnswer :disabledsubmitted / {{ option.key }}. {{ option.text }} /label /div button clicksubmit提交答案/button p v-ifsubmitted result {{ result ? 回答正确 : 回答错误已进入错题本 }} /p /div /template script setup import { ref } from vue; import axios from axios; const current ref({ id: 1, title: 关于犯罪构成四要件以下表述正确的是, options: [ { key: A, text: 犯罪主体是唯一要件 }, { key: B, text: 四个要件缺一不可 }, { key: C, text: 犯罪客体等同于犯罪对象 }, { key: D, text: 主观方面只看故意 } ] }); const selectedAnswer ref(); const submitted ref(false); const result ref(false); const submit async () { const { data } await axios.post(/api/answer/submit, { questionId: current.value.id, userAnswer: selectedAnswer.value }); result.value data.data; submitted.value true; }; /script这里的演示是静态数据真实项目应从后端接口按科目拉取题目列表然后逐题渲染。你可以把fetchQuestion替换为axios.get(/api/question/next?subjectId subjectId)让后端负责返回下一题。7. 运行验证接口测试与预期结果7.1 启动后端先确保 MySQL 中已执行了前面的init.sql和data.sql然后启动 Spring Bootmvn spring-boot:run启动成功后终端会出现 Tomcat started on port 8080 的日志。7.2 测试科目列表接口curl -X GET http://localhost:8080/api/subject/list预期返回值{ code: 200, message: success, data: [ { id: 1, code: CRIMINAL, name: 刑法, sortOrder: 1, description: 犯罪构成与刑罚体系 }, { id: 2, code: CIVIL, name: 民法, sortOrder: 2, description: 民事法律关系与合同侵权 } ] }7.3 测试组卷接口curl -X POST http://localhost:8080/api/quiz/generate \ -H Content-Type: application/json \ -d {subjectId: 1, total: 10}预期返回一个包含 10 道题的 JSON 数组并且题目难度比例约为 5:3:2。如果没有插入题目数据这里会返回空数组这属于预期行为。7.4 测试提交答案接口curl -X POST http://localhost:8080/api/answer/submit \ -H Content-Type: application/json \ -d {userId: 1, questionId: 1, userAnswer: B}预期返回{code: 200, data: true}或{code: 200, data: false}。如果答案为 false可以查询wrong_book表确认错题记录是否写入。如果运行失败第一步先看控制台有没有 MyBatis 的 SQL 日志没有日志就检查application.yml中数据源配置是否指向了正确的数据库。8. 常见问题与排查方法问题现象可能原因排查方式解决方案插入数据后中文乱码数据库连接 URL 缺少字符集参数查看application.yml中 jdbc 地址添加?useUnicodetruecharacterEncodingutf8MyBatis-Plus 查询为空表名与实体类映射不一致开启 mybatis-plus 日志打印 SQL在实体类添加TableName注解前端跨域请求失败后端端口与前端端口不一致浏览器 F12 查看 CORS 错误后端配置CorsFilter或前端使用 Vite 代理并发组卷重复shuffle 后选择逻辑写在了事务外检查组卷方法是否有事务加Transactional必要时加乐观锁错题本重复插入查询记录时没有使用userId questionId联合条件看wrong_book表数据在数据库增加联合唯一索引跨域配置是前后端分离开发中最高频的问题。推荐在 Vite 的配置文件里加代理而不是在后端放开跨域。这样生产环境部署时前后端可以走同一个域名还能顺便隐藏后端真实端口。9. 最佳实践与工程建议9.1 题库数据要区分“内容安全”与“运营内容”法律题目涉及条文和真题上传前需要有专业审核流程。在工程层面建议给题目表增加status字段默认0审核通过后变为1前端只展示status 1的题目。这可以避免运营录入不完整时题目出现在线上。9.2 组卷接口要做缓存保护热门科目可能同时被大量用户请求组卷。如果每次请求都从数据库查全部题目再 shuffle数据库压力会非常大。推荐方案是把某科目某难度的题目 ID 列表缓存到 Redis组卷时直接从缓存中取 ID、再按 ID 批量查题目详情。// 伪代码Redis 缓存题目ID列表 String key subject:questions: subjectId :difficulty: difficulty; ListLong questionIds redisTemplate.opsForList().range(key, 0, -1);缓存代码不展开但设计上必须注意题目变更后要删除对应 key否则新题不会出现在组卷池中。9.3 答题接口要做防重复提交判题接口属于写操作而且会更新错题本。用户连续点击两次“提交”按钮可能导致一条答题记录重复。最简单的方式是前端提交后立即将按钮设为disabled更稳妥的做法是后端记录每次提交的questionId userId paperId并增加联合唯一索引。9.4 数据权限与最小授权学习平台虽然没有复杂的多租户需求但后台管理接口必须区分角色。运营人员只能操作题库和科目不能查看用户敏感信息普通用户接口只能通过 token 拿到自己的 userId绝不能从前端传入他人的 userId。否则一个恶意用户可以不断修改 userId 来查询别人的错题本和成绩记录。9.5 日志与监控在组卷和判题这两个核心链路中加入链路追踪日志[quiz] userId10001 subjectId1 total10 cost23ms [answer] userId10001 questionId88 correctfalse cost5ms如果后续答题人数增长通过分析这些日志可以判断组卷瓶颈在数据库还是在 shuffle 算法上。10. 总结与后续扩展方向这篇文章把“普法战争牢九门”从一个备考圈的梗做成了一个可以落地的闯关学习平台。我们完成了业务建模、数据库设计、九门科目初始化、后端科目接口、组卷逻辑、判题与错题归集、前端九宫格与答题页面以及完整的接口验证流程。核心收获是知识域可以抽象为可配置的科目模块题目组织要支持策略化组卷答题链路必须与错题归集放在同一个事务里。下一步你可以从三个方向继续深化。第一个方向是推荐系统基于用户错题数据和答题时长用简单的协同过滤给用户推荐下一组题。第二个方向是知识点图谱给每道题打上“知识点标签”分析用户在一个科目里的薄弱知识点分布。第三个方向是考试模式增加计时、倒计时和成绩排名可以接入 WebSocket 实现同场竞技。如果你正在做在线教育类系统建议优先实现“科目配置化”和“错题闭环”这两个基础能力。它们对数据结构的改动比较大后期再补会非常痛苦。其他功能都可以在现有模型上平滑增加。以上步骤中的 SQL、Java 和 Vue 代码都可以直接复制到你的项目中运行顺序是先建库建表再初始化科目数据然后启动后端最后启动前端。建议收藏备用后面扩展 AI 组卷或小程序端时这份设计会帮你省下大量重构时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑