最近把之前维护的一个在线英语阅读分级平台重新翻出来梳理了一遍顺手把整个设计思路、核心代码逻辑和踩过的坑整理成这篇文字。这个项目用的是SpringBoot Vue MyBatis MySQL这套非常经典的组合前端页面有大量部分是用原生HTML CSS完成的业务上则是把英语阅读材料按难度分级学生先做水平测试系统根据测试结果推荐对应等级的文章阅读过程中可以记录生词、做练习、查看阅读进度。适合正在准备毕业设计、课设或者想拿一套完整业务练手SpringBoot Vue全栈的开发者参考。我会把项目从需求拆解到数据库建模、后端接口、前端联调、部署上线整个过程都讲一遍重点放在那些常规文档里不会写、但实际开发中一定会遇到的细节上。如果你手上已经拿到了这套2025版源码这篇文章可以直接当导读来用如果你是想自己从零复刻一个类似系统那这里的建模思路和代码结构也能帮上大忙。1. 项目整体设计与核心需求拆解1.1 分级阅读平台到底要解决什么问题很多人一上来就盯着“SpringBoot Vue”这套技术栈看反而忽略了业务本身。这个平台最核心的价值不是技术多新而是把“英语阅读材料”和“学习者的真实水平”做了一个匹配。传统做法是老师凭经验给学生发材料文章的词汇难度、句式复杂度、篇幅长度很难量化学生拿到手的材料要么太简单要么直接劝退。分级平台要做的就是给每一篇文章打上难度标签给每一个学生测出当前等级然后按等级推荐内容。从实际使用场景来看这个平台至少要覆盖三个角色管理员负责基础数据维护比如分类、标签、文章审核教师负责上传文章、配置题目、设定等级标准学生则是核心使用者需要完成测试、阅读文章、做练习、管理生词、查看学习报告。所以技术上的模块划分就要围绕这三类用户来做权限体系和数据隔离是首要考虑的问题。有的同学拿到源码之后容易困惑为什么表这么多为什么前端有Vue页面还有纯HTML/CSS页面其实这恰好是这个项目的正常状态。管理端为了快速开发用Vue来做数据驱动页面学生端部分页面为了追求展示效果和响应速度直接用HTML CSS写静态结构再通过axios请求后端接口动态填充数据。混合开发在实际外包项目里很常见不是代码乱而是分工不同。1.2 技术选型的底层逻辑SpringBoot在这个位置几乎是零悬念的选择。它内置了Tomcat简化了依赖管理Maven项目一键打包运行加上starter机制集成MyBatis、MySQL、JWT这些组件都是加依赖配配置的事。适合快速交付也适合学生阶段掌握主流的Java后端开发方式。Vue在2025年这个节点上主要是Vue 3 Vite的组合但如果你拿到的源码还是Vue 2 Webpack也别急着嫌弃。很多企业项目和课程设计仍然在跑Vue 2而且这个平台的业务复杂度远没到必须上Composition API才能解决的程度。拿到源码先看一眼package.json和node_modules里主要依赖版本再决定是用Options API的方式维护还是改写成setup语法。至于HTML CSS主要是用于学生端的阅读页面和部分落地页这类页面要求打开快、结构清晰不需要复杂交互原生写反而比组件化更轻。MyBatis在这个项目里负责数据库访问层。选它而不是JPA原因也很实在SQL可写性强排序、分组、多表关联、复杂的统计查询都可以直接用SQL控制出了问题也容易定位。JPA虽然省事但到了报表统计这类场景自动生成的SQL往往不够聪明。MySQL则承担了持久化角色存储文章内容、题目选项、进度记录、词汇表这些数据。这套组合最大的优势是路径短、案例多、排查资料丰富。任何一个环节报错从数据库连接失败到前端跨域网上都能找到对应解决方案这对学习者和开发者来说至关重要。1.3 功能模块的划分与数据流把整个系统拆开看核心功能其实可以归纳成五条业务线用户与权限、分级测评定级、文章内容管理、阅读行为记录、学习数据分析。数据流大致是这样的学生登录后先进入分级测试页面系统根据学生的答题正确率和用时通过后端分级算法算出初始等级写入用户表然后系统根据用户等级和阅读历史从文章表里检索出难度匹配且未读过的文章列表学生选择文章后进入阅读页面前端通过接口获取文章内容同时记录阅读开始时间退出或点击完成时后端记录本次阅读时长、字数、进度学生在阅读过程中的查词操作会进入生词本练习题的作答结果进入答题记录表最后个人中心里可以按时间段查询阅读量、阅读时长、词汇量增长曲线。这几个模块虽然彼此独立但又通过“用户ID”和“文章ID”这两条主线串联起来。所以在设计数据库的时候所有业务表都必须包含这两个外键字段这一点直接决定了后面的SQL写起来顺不顺。2. 数据库建模与MyBatis底层细节2.1 核心表结构设计与索引规划数据库是整个分级平台的基石表结构设计得合理后续写接口就是拼SQL的问题设计得不好后面统计阅读量、查排行榜的时候要各种临时表非常痛苦。我按这个项目的实际需要整理了核心表清单说明一下每张表的职责。表名主要字段职责说明sys_userid, username, password, role, level, avatar, create_time用户表role区分管理员/教师/学生level保存当前阅读等级articleid, title, content, category_id, tag_ids, difficulty_level, word_count, status, creator_id文章表status控制上架/下架/待审核difficulty_level是分级核心字段article_categoryid, name, parent_id分类表按话题或体裁分组exam_questionid, content, option_a, option_b, option_c, option_d, correct_answer, score, difficulty题目表用于分级测试和文章配套练习user_level_recordid, user_id, total_score, level_result, create_time测评定级表记录每次测试结果read_progressid, user_id, article_id, progress_rate, read_duration, last_position, read_status阅读进度表核心业务数据表记录到达位置和完成状态user_vocabularyid, user_id, word, meaning, source_article, create_time生词本表user_exam_recordid, user_id, paper_id, score, answer_detail, create_time答题记录表answer_detail存JSON格式的作答明细我实际操作中的一个经验是文章内容不要和文章元信息混在一张表里塞得太满尤其是当文章内容篇幅很长的时候列表页查询会非常慢。你只需要在列表页展示标题、分类、难度、字数这些字段可以单独拆一张article_info表正文放article_content表用article_id关联。如果项目对性能要求不高存一张表也跑得动但前期规划好总比后期拆表舒服。索引规划上有三个地方必须加索引read_progress表的(user_id, article_id)联合唯一索引避免重复进度记录article表的(category_id, difficulty_level)联合索引支撑列表筛选user_vocabulary表的(user_id, word)联合索引防止同一个词重复进生词本。尤其是联合索引MySQL在查询时候的索引最左前缀原则会直接决定你的SQL能不能走索引所以这里的排序很重要。写建表语句的时候把这些索引建好省得上线后频繁ALTER TABLE。2.2 MyBatis项目的XML与注解混合使用方式在MyBatis的使用上我建议简单查询用注解复杂查询用XML这个项目的源码也是这样组织的。拿阅读进度的查询来说单表查询直接加Select注解就行但是像统计用户阅读时长、按等级推荐文章这种涉及关联查询、动态排序的SQL就应该放在XML里写因为XML里可以用 标签动态拼接条件用 处理批量参数这些都是注解不好表达的。这里有一个新手最容易踩坑的细节动态SQL拼接条件时参数要用#{xxx}不要用${xxx}。#{}是预编译会对传入参数做安全处理防止SQL注入而${}是字符串替换相当于直接拼接SQL片段。比如分页排序里的排序字段名和排序方向因为不是数据而是控制语句才只能用${}但也必须通过白名单方式校验一下防止被恶意传参。这个点面试的时候也常被问。MyBatis还有一个容易忽略的配置项是驼峰命名自动映射。数据库字段往往用下划线命名比如create_timeJava实体类是createTime如果不在mybatis-config里开启mapUnderscoreToCamelCase查询返回的结果就会把createTime字段填成null。每次写ResultMap映射当然可以解决但全项目那么多表都手写ResultMap太累了不如在配置文件里统一开启这个选项。2.3 自增主键与批量插入的实战处理文章内容入库、题目批量导入这两种场景都涉及主键回填和批量操作。MyBatis的自增主键回填要用useGeneratedKeystrue和keyPropertyid这样执行完insert语句之后测试插入的ID会直接写回实体对象里。如果你一次性导入几百道题到exam_question表那就要用批量插入。批量插入的XML写法大概长这样insert idbatchInsertQuestions parameterTypelist useGeneratedKeystrue keyPropertyid INSERT INTO exam_question (content, option_a, option_b, option_c, option_d, correct_answer, score) VALUES foreach collectionlist itemitem separator, (#{item.content}, #{item.optionA}, #{item.optionB}, #{item.optionC}, #{item.optionD}, #{item.correctAnswer}, #{item.score}) /foreach /insert这里有个真实的坑MySQL连接的URL上如果没有加allowMultiQueriestrue那SQL语句里就不能写多条整语句用分号分隔的写法而用foreach拼接成一条超大VALUES是可以的。批量插入的数据量控制在500条左右性能最好短时间大量插入的时候这招很管用。还有个容易被忽略的场景是文章正文里有很多高亮标签比如存进数据库的时候直接原样存储但查询出来再塞进前端页面的v-html里要注意XSS风险。内容如果是用户生成的后端入库前一定要做HTML标签过滤至少把script标签和事件属性去掉。分级平台的文章内容是管理员录的风险相对低但凡是用户提交的内容安全过滤就不能省。2.4 数据库连接配置与SQL日志打印application.yml里的数据库配置是整个项目启动的第一步。SpringBoot 2.x和3.x的驱动类不一样2.x还是com.mysql.jdbc.Driver或com.mysql.cj.jdbc.Driver3.x需要引入独立版本驱动。所以拿到源码先确认SpringBoot版本再匹配数据库驱动不然会直接抛ClassNotFoundException或者关于驱动类的异常。很多同学调试SQL时根本看不到执行语句原因是没有配置SQL日志打印。在application.yml里加上这样一段配置就能在控制台看到MyBatis实际执行的SQL和传入参数mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl担心生产环境日志太多的可以用logging.level.com.example.mapperdebug来隔离控制只让Mapper包下面的debug日志打印出来。这几条配置在做联调时非常有价值学会之后就不必再盯着数据库看半天了。3. SpringBoot后端核心逻辑与关键落地3.1 分层架构与包结构规范SpringBoot项目的经典分层架构Controller负责接收前端请求和参数校验Service层写业务逻辑Mapper层专注数据库访问。这个项目在同一套代码里同时承载了学生端和管理端接口所以我是按模块分包而不是按分层分包。也就是说在controller目录下继续分成admin、student两个子包service也一样。这样做的好处是以后做模块拆分或升级微服务时每个模块的代码边界是清晰的。参数校验这一环节很多人图省事直接在Controller里手动if判断但更规范的做法是用JSR 303注解比如NotBlank、Email、Min配合Valid触发校验校验失败会自动返回400错误。可我在实际项目里发现很多场景下手动校验更高效因为规则不是简单非空判断而是关联了数据库的查询结果。比如文章上架时要检查该文章是否已经配置题目这种校验靠注解框架做不了还是要写在Service层。事务管理方面凡是涉及多表更新的操作比如提交分级测试结果时既要写user_level_record表又要更新sys_user表的level字段必须在Service层方法上加Transactional注解。默认的传播机制就是REQUIRED也就是有事务就在当前事务里执行没有就创建新事务统一回滚。这里提醒一句事务要加在Service层而不是Controller层因为只有Service层方法调用多个Mapper时事务才有实际意义。3.2 基于JWT的登录鉴权流程无论是学生、教师还是管理员都需要登录认证。最常见的方案就是JWT 拦截器。用户登录成功后后端生成一个包含用户ID、用户名、角色信息的token返回给前端前端把它存到localStorage或者Vuex里每次请求通过请求头Authorization携带后端写一个拦截器拦截所有需要登录的接口解析token并校验签名。在SpringBoot项目里实现这个流程一般要写三个东西TokenUtil工具类负责生成和解析token拦截器类实现HandlerInterceptor在preHandle里校验tokenWebMvcConfigurer配置类负责注册拦截器同时配置放行路径。放行路径很关键一般包括登录接口、注册接口、静态资源路径。这里的经验是所有不需要鉴权的接口一定要明确放行否则前端页面一打开就收到401排查起来会很绕。有一点要注意JWT一旦签发服务器端是无法主动让它失效的除非设置短过期时间或者引入服务端的会话状态管理。对于课程设计系统来说token有效期设置成24小时或7天都可以前端拿到401后自动跳转登录页即可。对于线上真实系统建议把token过期时间缩短然后配合双token刷新机制这样安全性更高。3.3 分级算法与词汇难度分析这个平台最核心的算法就是分级。“分级”不能靠拍脑袋必须有一个可解释的规则体系。我采用的是“复合难度打分”方案文章的难度分由三个维度计算——词汇难度、句法复杂度和篇章长度。词汇难度通过统计文章中单词的常见程度来做维护一个常用词表如果一个词不在常用词表里就认为它是生僻词生僻词占比越高分值越高。句法复杂度用句子的平均长度来近似句子越长说明从句越多阅读负担越大。篇章长度直接用word_count字段。综合评价后把文章分数映射到L1到L5五个等级对应《义务教育英语课程标准》到大学四级以上的水平范围。学生的定级则依赖于分级测试。前端每次随机抽取20道题题目难度分基础、进阶、挑战三档分别占不同的分值权重。学生答完后后端根据得分和平均每题消耗的时间换算总分再映射到等级。如果总分在80分以上就定级L560到79分定级L4以此类推。这里有一个关键逻辑用户初始等级存入sys_user表但每次重新测试后等级更新必须写在user_level_record表里同时保留历史记录这样个人中心的趋势图才能画出等级变化曲线。3.4 阅读进度与计时统计实现学生阅读文章时进度记录算是这个项目里交互最频繁的一环。我的做法是学生进入阅读页面时调一次“开始阅读”接口后端读一下read_progress表是否已有记录没有则插入一条status为reading后端定期更新心跳的位置。但如果每秒钟都向服务器写一条进度请求请求量会很大完全没必要。实际方案是前端每隔10秒调一次进度更新接口携带当前阅读进度比例和总耗时学生点击退出时再调一次结束接口后端更新最终进度。这个方案在真实系统里足够用还能顺便统计每个用户总的阅读时长。阅读时长的SQL统计如下对read_progress表的read_duration字段做SUM求和按用户ID分组。如果想按月统计阅读趋势再在create_time上做DATE_FORMAT格式化然后按年月分组。分级平台一般不需要实时在线状态所以这类定时上报已经能支撑数据看板的需求了。4. Vue前端实现与接口联调4.1 Vue项目初始化与目录结构Vue前端的搭建其实没什么玄学重点在于拿到源码后能不能快速理清目录结构。我一般是这么组织的src/api目录统一放请求接口文件src/router放路由配置src/store放全局状态管理Vuex或Piniasrc/views放页面组件src/components放复用组件src/utils放工具函数和axios封装。看到这个结构你就知道去哪改接口、去哪加页面、去哪改路由守卫了。在启动前端项目之前开发环境是必须先确认的。Node.js版本不能太老也不能太新Vue 3 Vite建议用Node 16以上20以下的版本Vue 2 Webpack建议用Node 14或16。实际的免坑经验是用npx同时使用nvm管理Node版本避免卸载重装。装完依赖之后启动服务如果端口被占用需要检查vue.config或者vite.config文件里的端口号改成自己的端口。4.2 axios封装与接口鉴权前端所有请求都走axios但绝对不要一上来就直接在各组件里零散调用axios。应该先封装一个request.js工具类统一设置baseURL、超时时间、请求拦截器和响应拦截器。请求拦截器里做的事是从localStorage中取出token放请求头的Authorization字段。响应拦截器里做的事是如果返回code为401就清掉本地存储并跳转登录页如果code为500就统一弹出错误提示如果code为200就返回业务数据。这样做的好处是任何页面都不用关心token和错误处理只管写业务逻辑就行。实际开发中经常会遇到接口通了但请求报跨域的问题。开发环境下最省事的办法是在vue.config.js里配置devServer.proxy把/api路径代理到后端服务地址比如target填http://localhost:8080这样前端发出的请求就是同源的绕过了浏览器跨域限制。生产环境下后端直接配CORS或者在Nginx里做反向代理二选一即可。4.3 路由守卫与动态路由这个平台的用户角色有管理员、教师、学生三类路由控制就显得格外重要。最简单的场景是未登录用户访问阅读页面时应该被路由守卫拦下来跳到登录页。这一步在router.beforeEach里实现router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.public) { next() return } if (!token) { next({ path: /login }) return } // 角色权限控制 if (to.meta.roles !to.meta.roles.includes(localStorage.getItem(role))) { next({ path: /403 }) return } next() })如果你拿到的源码里还用了动态路由也就是管理员登录后由后端接口返回菜单列表前端通过router.addRoute动态注册菜单那就说明权限设计更完整。实现动态路由的坑在于刷新页面时路由会丢失解决办法是登录后把用户菜单信息存到本地缓存中刷新时重新从缓存里读取并动态注册路由。4.4 HTML CSS页面在Vue项目中的融合这个平台里有一部分轻量页面是用原生HTML CSS实现的这些页面放在public目录下比如一些引导落地页、纯展示型的阅读页。Vue项目处理这种页面有几种方式一种是把HTML文件放到public目录下直接用a标签跳转另一种是写在Vue组件的template里但样式全放在