这个项目严格来说是去年秋招间隙做出来的,起因特别简单:用常见的刷题产品总觉得不顺手,错题本太散、没有知识点维度的统计、还总被推荐一些早就掌握的题。于是我就想着干脆自己造一个智能面试刷题系统,既能满足日常刷题需求,又顺便把前后端分离的架构完整走一遍。整个项目从设计到第一版上线大概用了三周,技术栈选的是 Spring Boot + Vue 3,前端走 RESTful API 跟后端通信,核心特色是答题记录驱动的一套推荐逻辑和可视化统计。这篇文章把我当时的决策过程、数据库设计、实现细节、以及上线后踩过的坑都整理出来,理论和代码都有,对正在做毕设或想加深架构理解的开发者会比较有参考价值。
1. 项目的真实起点:一个刷题工具的需求演进
1.1 痛点分析:为什么需要专门做一套刷题系统
刷题这件事,看起来极其简单,注册一个账号然后对着题库一题一题点就是了。但真正坚持用一个月以后会发现,盲目刷题有一个非常致命的问题:你根本不知道自己哪里薄弱,只会被动地按照题目顺序一路点下去,今天错的知识点明天就忘,下周又错一遍。
我在做这个项目之前,专门观察了自己和身边几个准备面试的同学的使用习惯。大家刷题时最有价值的动作其实是"提交答案那一刻的判断过程"和"看解析后的复盘过程"。但市面上通用产品大多只把"判断对错"这一步留给了你,错题回收、知识点统计、复习提醒这些环节要么很弱,要么藏得很深。所以我自己做的第一版需求就非常明确:要有一条完整的"刷题—记录—分析—推荐—再刷题"闭环,而不是一个单纯堆砌题目的文档系统。
这个系统的用户其实只有两类:一类是求职者,需要在短期内快速补足薄弱知识点;另一类是运营题库的人,需要一个趁手的工具把题目和知识点维护好。毕设或者个人项目最怕的就是功能无限膨胀,所以我在立项时就把边界卡得很死:不做社区、不做社交、不做课程售卖,只做跟"有效刷题"强相关的功能。
1.2 功能边界划定与角色设计
第一版的功能清单是这样定的。用户端只有五个核心模块:注册登录、刷题练习、模拟面试、错题本、学习统计。管理端是三个:题库管理、知识点管理、用户数据概览。
登录注册这个模块没什么好说的,唯一要注意的是密码不能明文存,我用的是标准加盐哈希。刷题练习是主入口,用户可以选择按顺序刷、按知识点刷、或者直接进入智能推荐模式。模拟面试是一个独立的会话式场景,按照岗位方向和难度生成一套题,答题过程中有倒计时,结束后给出得分和解析。错题本不是简单收藏夹,它要记录每个错题的错误次数、最近错误时间,并且可以一键把错题重新变成一套练习卷。学习统计则把答题记录聚合起来,按天输出刷题热力图,按知识点输出正确率和掌握度雷达图。
管理端的题库管理要覆盖题目新增、编辑、上下架、批量导入。知识点管理维护一棵知识点树,每个题目可以挂到一个叶子节点上。用户数据概览我做得比较克制,只展示注册用户数、刷题总量、平均正确率这些基础指标。管理端和用户端共用同一套后端接口,权限靠 JWT 里的 role 字段区分,后端在最外层拦截器里做角色校验,这样两个前端应用都能保持比较薄的逻辑。
1.3 为什么坚持前后端分离
很多在校同学做系统时习惯把页面模板和后端渲染混在一起,理由是这样简单,减少跨域问题。但我个人强烈建议这类偏业务型的项目一开始就走前后端分离,原因有三个。
第一,前端交互复杂度决定了它不适合服务端渲染。刷题页面有倒计时、选项状态、实时统计图表、答题记录更新,这些都是典型的 SPA 场景,用传统模板一个个同步渲染会非常痛苦。第二,一个后端 API 能同时喂给用户端、管理端,甚至以后接一个小程序,代码不需要重写。第三,前后端分离以后,接口边界变得清晰,我可以先把接口文档定好,再让两个部分并行推进,整个开发节奏会快很多。
当然也要清醒认识到分离的代价:跨域配置、登录态管理、接口联调,都是以前不需要操心的东西。这些问题在后面的各部分里都有对应解法,不存在无解的情况,只是需要多学几种新技能而已。
2. 技术选型与架构设计:前后端分离的骨架搭建
2.1 后端用什么、为什么不后悔
后端技术栈是 Spring Boot 3.x + Java 17 + MyBatis-Plus + MySQL 8 + Redis。为什么这么选,其实是在"学习价值"和"落地速度"之间做平衡。
如果只是想跑通一个项目,用 Node.js 写接口可能更快;但考虑到系统涉及复杂的数据统计、事务处理和权限模型,Java 生态的稳定性是实打实的优势。Spring Boot 帮我把绝大多数配置自动化了,我只需要关注 Controller、Service、Mapper 这一层业务代码。MyBatis-Plus 的单表 CRUD 和分页插件非常好用,条件构造器写动态查询也很顺手,复杂统计我用自定义 XML 写 SQL,两边互不干扰。
Redis 我用在两个地方。第一是缓存知识点统计结果,统计接口如果每次都去 MySQL 聚合全表,数据量大了以后响应会明显变慢,所以我在写入 answer_record 时会同步把对应知识点的统计缓存删除,下次读取时再重新计算。第二是存放模拟面试的上下文,一场面试过程中的进度、剩余时间、题目列表都存在 Redis 里,过期时间设成两小时,既能防止接口频繁命中数据库,也能自然清理掉不活跃的会话。
2.2 前端选型与工程化配置
前端用了 Vue 3 + Vite + Pinia + Vue Router + Element Plus,这套组合现在基本是中小型管理系统的默认选项,但选它不纯粹是因为流行。
Vue 3 的组合式 API 对答题页这种逻辑密集的页面特别友好。选项式 API 写小页面没问题,一旦页面里有倒计时、答题状态、抽题逻辑、提交反馈,逻辑就会散落在各个生命周期钩子里;用 setup 语法可以把同一块业务的 ref、computed、watch 聚在一起,维护成本低一个量级。Pinia 比 Vuex 轻,省掉 mutation 这层概念,在个人项目里少写不少样板代码。Element Plus 我只按需引入,不整包加载,避免首屏时间被组件库拖垮。
Vite 带来的体验提升是感知最强的。以前用 Webpack 启动一套前后端分离项目,冷启动十几秒都是常态,改一行代码还要等编译。Vite 基于原生 ESM 实现按需编译,项目不管多大,首启基本都是秒级,热更新也是毫秒级反馈。开发刷题系统要反复在两个页面之间跳转调整样式,这个体验优势能省下大量时间。
2.3 接口规范、权限模型与跨域处理
前后端分离项目能不能顺利联调,接口规范起决定性作用。我定义了一个很简单的统一返回包装类,后端所有接口出参都是 { code, message, data },业务异常通过全局异常处理器转成对应的 code,前端只需要在响应拦截器里统一判断 code。分页接口统一接受 page 和 size,返回 PageResult,里面包含 total、records 两个字段,避免每个接口各搞一套返回结构。
资源命名走 RESTful 风格:/api/questions、/api/answer-records、/api/exam-sessions。这类系统里面试题目和答题记录都是完整资源,用 RESTful 的方式描述非常自然。前端请求路径不再出现动词,接口语义也更清晰。
权限模型没有上 Spring Security,而是用 JWT 加拦截器的轻量方案。登录成功后,后端返回一个包含 userId 和 role 的 JWT,前端放在 localStorage 里,每次请求通过 axios 拦截器加在 Authorization 头。后端拦截器对所有 /api/** 请求做 token 校验,管理端接口额外检查 role 是否为管理员,解析失败或角色不对直接返回 401 和 403。这套方案对中小项目完全够用,代码少,也容易读懂。
跨域问题我分开处理。开发环境用 Vite 的 proxy 把 /api 代理到后端地址,浏览器看到的是同源请求,不存在跨域。生产环境用 Nginx 反向代理,直接把对 /api 的请求转发到 Java 服务,同样不需要后端开启 CORS。这样处理的好处是减少攻击面,也更贴近真实线上部署的形态。而且开发环境和生产环境的行为保持一致,前端代码不需要根据环境维护两套 baseURL,排查接口问题时只需要抓 Nginx 日志和 Java 应用日志两处就够了。
3. 数据模型设计与核心业务实现
3.1 数据库到底建了几张表
我把表控制在六张核心表,不建一堆中间表来增加维护成本。设计原则只有一个:所有后续要做统计和推荐的数据,必须在产生那一刻就能支撑查询,避免统计时到处 join。
| 表名 | 关键字段 | 说明 |
|---|---|---|
| user | id, username, password_hash, nickname, role | 用户表,role 区分普通用户/管理员 |
| knowledge_point | id, name, parent_id, weight | 知识点树,weight 表示重要度 |
| question | id, type, difficulty, point_id, stem, options, answer, analysis, status | 题库,type 区分选择题/简答题/编程题 |
| answer_record | id, user_id, question_id, point_id, difficulty, is_correct, answer_content, use_time, created_at | 答题流水表,业务核心 |
| wrong_book | id, user_id, question_id, error_count, last_wrong_at, review_status | 错题状态表 |
| exam_session | id, user_id, mode, difficulty, question_ids, status, score | 模拟面试会话表 |
这里我想强调 answer_record 这张表。它本质上是一张事实表,每一行代表一次真实的刷题行为。字段里冗余了 point_id 和 difficulty,虽然打破了"尽量不冗余"的洁癖,但对统计性能帮助很大:按知识点算正确率时不需要再回表查 question 表,直接 group by point_id 就行。建议在这张表上建立联合索引 (user_id, point_id, created_at),这是整个系统最高频的查询路径。当初如果不建这个索引,统计数据一多,接口响应时间会从毫秒级一路涨到秒级。
3.2 刷题主流程:一次完整答题的数据流转
一次完整答题的数据流转,是整个项目里最核心的一条链路。用户点"开始刷题"后,前端向后端请求推荐题目;后端根据推荐策略返回一道题;用户提交答案;后端判断对错,写入 answer_record,更新掌握度缓存和错题本;最后把正确答案、解析和新掌握度返回给前端。
提交答案的处理逻辑用代码写是这样:
@Transactional public AnswerResult submitAnswer(SubmitRequest request) { Question question = questionMapper.selectById(request.getQuestionId()); boolean correct = question.getAnswer().equalsIgnoreCase(request.getAnswer()); AnswerRecord record = new AnswerRecord(); record.setUserId(request.getUserId()); record.setQuestionId(question.getId()); record.setPointId(question.getPointId()); record.setDifficulty(question.getDifficulty()); record.setCorrect(correct); record.setUseTime(request.getUseTime()); answerRecordMapper.insert(record); masteryCache.evict(request.getUserId(), question.getPointId()); if (!correct) { wrongBookMapper.upsert(request.getUserId(), question.getId()); } return new AnswerResult(question, correct); }注意事务注解一定不能少。一开始我漏了事务,结果出现用户提交答案后,answer_record 写入了而错题本没有更新的情况,自我排查时非常难受。因为在同一事务里,要么全部成功,要么全部回滚,这条链路绝不能出现中间状态。
3.3 错题本与刷题统计的底层逻辑
错题本用 upsert 语义实现,这是一个容易想错的地方。如果每次答错都 insert 一条错题记录,同一个题在错题本里会出现多条,展示时还要去重,逻辑非常别扭。正确做法是在错题表里用 (user_id, question_id) 建唯一索引,答错时执行"存在就累加错误次数,不存在才插入"的更新语句。
复习状态我是用一个 review_status 字段管理的:初始值是等待复习,用户把错题重新做对一次后变成已掌握,下次再错再回退。这个字段我一开始没想清楚,直接把错题表当收藏夹用,后来加了字段才让"复习一下"变成可操作的动作。前端在错题本里提供筛选条件:只看待复习、只看已掌握,加上一键复习按钮,比单纯展示所有错题实用得多。
统计模块相对直接。按天刷题量用一个 SQL 就能查出来:
SELECT DATE_FORMAT(created_at, '%Y-%m-%d') AS day, COUNT(*) AS total FROM answer_record WHERE user_id = #{userId} GROUP BY day ORDER BY day;知识点掌握度统计类似,group by point_id 后计算正确率和答题量,前端再用雷达图渲染出来。这套统计链路不复杂,但前提是 answer_record 表的结构一开始就设计对,数据来源不能乱,统计结果才有意义。
4. 智能推荐策略:让刷题有方向而不是瞎碰
4.1 可解释的推荐比看不见的"玄学推荐"更重要
说到智能,很多人第一反应是上协同过滤,或者跑一个神经网络模型。但对这类个人项目来说,效果并不好:用户和题目的交互矩阵极度稀疏,冷启动问题根本绕不开,而且推荐结果无法解释,用户会困惑"为什么给我推这道题"。
我的设计原则是把推荐做成可解释的。推荐一道题时,前端可以明确展示推荐理由,比如"你最近关于二分查找的掌握度偏低,推荐完成这道题"。这种可解释性既让用户信服,也让代码逻辑更好维护。推荐链路只依赖 answer_record 和 knowledge_point 两张表,可控性很强,后续想调整规则也只是改一个 Service 的事情。从实际使用反馈来看,用户对这种"知道为什么推题"的接受度,比我预期高很多。
4.2 掌握度计算与推荐优先级
掌握度是整个推荐策略的核心指标。我不采用简单的"历史正确率",因为正确率没法体现时间衰减:很久以前错过的知识点,如果最近一直做对,应该认为它正在被掌握。所以用带时间衰减的加权平均,核心逻辑如下:
public double calculateMastery(List<AnswerRecord> records) { double sumWeight = 0.0; double sumCorrect = 0.0; for (AnswerRecord record : records) { double days = Duration.between(record.getCreatedAt(), LocalDateTime.now()).toDays(); double weight = Math.exp(-days / 7.0); sumWeight += weight; sumCorrect += record.isCorrect() ? weight : 0.0; } return sumWeight > 0 ? sumCorrect / sumWeight : 0.5; }衰减周期设成 7 天,相当于一周前的答题权重衰减到约 37%,两周前基本忽略。这个参数我一开始是拍脑袋定的,但实测体验比纯正确率平滑很多,后来也就没有再调。需要注意的是,掌握度计算不要放在每次请求里实时扫全表,正确做法是答题提交时更新知识点的掌握度缓存,推荐接口直接读缓存。
推荐优先级分三层。第一层是最薄弱知识点下、且重要度 weight 高的未做题目,直接强推荐。第二层是掌握度在 0.4 到 0.6 之间的知识点,说明处于边界状态,需要加强。第三层是近期完全没有刷过题的知识点,用随机池捞一两道来保持覆盖面。每次后端生成一个候选题池,在池内随机选一道返回,避免用户连续刷到同一题。这套规则我用一个策略接口封装,每层策略只负责筛选候选题,后面想加"按岗位过滤"或者"按题目热度排序",也只是新增策略类的问题。
4.3 模拟面试与智能组卷的轻量实现
模拟面试模式是为了给用户一个完整的作答节奏。用户选择岗位方向和难度后,后端按知识点权重进行配额抽题,保证一套试卷覆盖多个知识点,而不是全抽到同一个点。
组卷逻辑听起来复杂,落实下来其实就是一个按知识点配额的抽样算法:先算出每个知识点要抽多少题,再从该知识点的题目池里随机抽,抽过的题要放到排除集合里避免重复。核心思路用代码写是这样:
Map<Long, Integer> quotaMap = buildQuotaMap(knowledgePoints, totalCount); for (Map.Entry<Long, Integer> entry : quotaMap.entrySet()) { List<Question> pool = questionMapper.selectByPointAndDifficulty( entry.getKey(), difficulty, excludedIds); selected.addAll(randomPick(pool, entry.getValue())); }选择题提交后立刻自动判分;简答题初版采用关键词匹配评分,把参考答案里预先标注的核心关键词和用户答案做包含比对,按命中率给分;编程题因为在线判题工程量太大,第一版只展示参考答案让用户自评。分数统计和面试报告仍然完整生成,用户可以翻看每个知识点的表现。
5. 前端落地与体验打磨
5.1 SPA应用的结构规划
前端项目结构按照"视图、状态、网络"三层来划分。src/views 只放页面组件,src/stores 放全局状态,src/api 统一封装网络请求。API 层不对业务页面暴露 axios 实例,只暴露 getQuestionById、submitAnswer 这样的语义化方法。页面组件里不出现任何请求方法,逻辑只认 store 和 api 层的返回值。
views 下目前有 Login、Dashboard、QuestionBank、Practice、MockInterview、Stats、AdminQuestion、AdminKnowledge 这些页面。路由用 Vue Router 管理,菜单和路由表一一对应,用户端和管理端通过不同的路由前缀和导航菜单隔离。目录结构清爽之后,新加页面很少会牵扯到旧代码,这个收益在开发到中后期体现得非常明显。
5.2 登录态与路由守卫的处理
前后端分离最容易出问题的是登录态。我的处理方案是:token 同时存在 localStorage 和 Pinia 里,页面刷新时从 localStorage 恢复,保证刷新后不丢登录状态。axios 请求拦截器统一在请求头里加 Authorization,响应拦截器遇到 401 时清掉用户状态并跳转到登录页。
核心代码:
http.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } return config }) http.interceptors.response.use( res => res.data, err => { if (err.response?.status === 401) { useUserStore().logout() router.push('/login') } return Promise.reject(err) } )路由守卫是第二道防线。在 beforeEach 里判断,如果访问的是需要登录的页面且本地没有 token,直接跳登录页。要注意把登录页、注册页加入白名单,否则会出现"登录时跳转登录页"的循环问题。这个坑我踩过,表现形式是用户输完账号密码刚要点登录,页面就被守卫踢回登录页,现象非常诡异,排查了几分钟才发现是白名单漏配导致的。
5.3 从交互细节到性能优化
性能优化我做了五件事。组件库按需引入,用 Vite 的插件自动把未使用的 Element Plus 组件摇掉;路由懒加载,每个页面拆成独立 chunk,初始加载只下载首屏资源;题目列表用分页加条件筛选,不做一次性全量加载;ECharts 图表按需引入,只注册用到的雷达图和热力图模块;长列表的统计页用虚拟滚动,避免几千个节点同时渲染。
交互细节上,答题页最值得打磨。选项点击后要有即时选中态,提交过程中按钮置灰防重复提交,倒计时离开页面时销毁定时器,防止内存泄漏。还有一个容易忽略的点:用户从刷题页跳到别的路由再回来,答题状态要能恢复,我通过把当前题目ID和已选答案存在 Pinia 里来解决。这些细节不复杂,但用户感知非常明显,也是产品打磨和半成品之间的核心差别。
6. 部署实战与高频踩坑记录
6.1 Nginx + Spring Boot 的部署组合
部署方案很简单:后端打成 jar 包,用 systemd 守护进程跑在 8080 端口;前端 npm run build 后把 dist 目录扔给 Nginx。Nginx 同时承担静态文件服务和反向代理,配置如下:
server { listen 80; server_name your-domain.com; root /var/www/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }这个配置有两个容易踩的坑。一个是 proxy_pass 后面的路径,因为后端接口也是 /api 开头,所以保留了 /api/ 后缀,避免路径拼接错误;另一个是 try_files 的 fallback 一定要落到 /index.html,否则前端路由刷新时会出现 404。生产环境上线后,我还加了 systemd 的自动重启机制,Java 进程如果意外退出会被自动拉起来,省掉半夜爬起来重启服务的情况。
6.2 典型问题与排查速查表
我把上线前后遇到的高频问题整理成一张表,基本覆盖了这个项目里出现过的所有典型故障。
| 现象 | 原因 | 解法 |
|---|---|---|
| 页面刷新后回到登录页 | 没有从 localStorage 恢复 token | 初始化时从 localStorage 写入 Pinia |
| 401 后循环跳转登录页 | 白名单配置漏了登录页 | 在路由守卫里显式放行 /login |
| 接口返回跨域错误 | 开发代理没配置 | Vite proxy 指向正确后端地址 |
| 直接访问异步路由刷新 404 | Nginx 没有 fallback | 配置 try_files $uri $uri/ /index.html |
| 错题本出现重复记录 | 没有唯一索引 / 没做 upsert | 加联合唯一索引,改 upsert SQL |
| 统计图数据比实际少 | 事务未提交或条件不一致 | 统一使用 created_at 并按天分组 |
| 模拟面试出现重复题目 | 组卷抽样没有排除已选题 | 用排除集合记录已选题目 ID |
这些坑基本都属于"知道原理后一分钟解决,不知道原理要查半天"的类型。我把它们写进项目 README 的部署清单里,后面维护或给其他人接手时会省掉很多沟通成本。
6.3 做完这个项目我才明白的几件事
第一,接口设计比实现接口更花时间,但收益最大。我前期把接口文档定义清楚后,前后端开发几乎不需要互相等待,遇到的返工也比以往项目少很多。第二,数据表设计决定功能上限。answer_record 冗余字段这个决定,让后面的统计、推荐、错题分析都变得简单,如果当时图省事不冗余,现在每个统计接口都要回表 join,性能和可读性都会下降。第三,前后端分离不是为了炫技,它带来的真正价值是让"用户端、管理端、未来可能的客户端"共享一套业务能力,把重复代码压缩到最低。
最后再分享一个经验。这个项目最有价值的不是某个页面写得多漂亮,而是那套"刷题记录—掌握度计算—推荐"的数据链路。你在实现类似系统时,不管智能功能做多深,都要先把 answer_record 这张事实表设计好,让它能完整表达每一次刷题行为。数据扎实了,后面加规律提醒、加复习计划、加任何"智能"玩法,都只是在一个稳定的地基上添砖加瓦。我做完这个项目后再刷题,最大的变化是心里清楚自己到底该补哪一块,而不是凭感觉乱翻题库。