
这个项目严格来说是去年秋招间隙做出来的起因特别简单用常见的刷题产品总觉得不顺手错题本太散、没有知识点维度的统计、还总被推荐一些早就掌握的题。于是我就想着干脆自己造一个智能面试刷题系统既能满足日常刷题需求又顺便把前后端分离的架构完整走一遍。整个项目从设计到第一版上线大概用了三周技术栈选的是 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。表名关键字段说明userid, username, password_hash, nickname, role用户表role 区分普通用户/管理员knowledge_pointid, name, parent_id, weight知识点树weight 表示重要度questionid, type, difficulty, point_id, stem, options, answer, analysis, status题库type 区分选择题/简答题/编程题answer_recordid, user_id, question_id, point_id, difficulty, is_correct, answer_content, use_time, created_at答题流水表业务核心wrong_bookid, user_id, question_id, error_count, last_wrong_at, review_status错题状态表exam_sessionid, 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(ListAnswerRecord 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 模拟面试与智能组卷的轻量实现模拟面试模式是为了给用户一个完整的作答节奏。用户选择岗位方向和难度后后端按知识点权重进行配额抽题保证一套试卷覆盖多个知识点而不是全抽到同一个点。组卷逻辑听起来复杂落实下来其实就是一个按知识点配额的抽样算法先算出每个知识点要抽多少题再从该知识点的题目池里随机抽抽过的题要放到排除集合里避免重复。核心思路用代码写是这样MapLong, Integer quotaMap buildQuotaMap(knowledgePoints, totalCount); for (Map.EntryLong, Integer entry : quotaMap.entrySet()) { ListQuestion 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 写入 Pinia401 后循环跳转登录页白名单配置漏了登录页在路由守卫里显式放行 /login接口返回跨域错误开发代理没配置Vite proxy 指向正确后端地址直接访问异步路由刷新 404Nginx 没有 fallback配置 try_files $uri $uri/ /index.html错题本出现重复记录没有唯一索引 / 没做 upsert加联合唯一索引改 upsert SQL统计图数据比实际少事务未提交或条件不一致统一使用 created_at 并按天分组模拟面试出现重复题目组卷抽样没有排除已选题用排除集合记录已选题目 ID这些坑基本都属于知道原理后一分钟解决不知道原理要查半天的类型。我把它们写进项目 README 的部署清单里后面维护或给其他人接手时会省掉很多沟通成本。6.3 做完这个项目我才明白的几件事第一接口设计比实现接口更花时间但收益最大。我前期把接口文档定义清楚后前后端开发几乎不需要互相等待遇到的返工也比以往项目少很多。第二数据表设计决定功能上限。answer_record 冗余字段这个决定让后面的统计、推荐、错题分析都变得简单如果当时图省事不冗余现在每个统计接口都要回表 join性能和可读性都会下降。第三前后端分离不是为了炫技它带来的真正价值是让用户端、管理端、未来可能的客户端共享一套业务能力把重复代码压缩到最低。最后再分享一个经验。这个项目最有价值的不是某个页面写得多漂亮而是那套刷题记录—掌握度计算—推荐的数据链路。你在实现类似系统时不管智能功能做多深都要先把 answer_record 这张事实表设计好让它能完整表达每一次刷题行为。数据扎实了后面加规律提醒、加复习计划、加任何智能玩法都只是在一个稳定的地基上添砖加瓦。我做完这个项目后再刷题最大的变化是心里清楚自己到底该补哪一块而不是凭感觉乱翻题库。