1. 为什么我会去做一个分级阅读平台
做这个项目的起因很简单:当时在做一个面向青少年的英语学习类产品,教学团队反馈了一个很现实的问题——班级里学生的英语水平差距很大,有人能直接读原版《哈利·波特》,有人还在啃小学词汇。用同一篇阅读材料上课,只会出现"一人读得飞快,其他人全程查词典"的局面。这个问题背后其实就是一个需求:英语阅读内容必须按学习者的真实水平进行分级匹配,否则教学效果无从谈起。
市面上不是没有分级阅读产品,但大多偏重内容本身,很少有把"测评—定级—推荐—阅读—追踪—反馈"这整条链路做成一个完整系统的。我打算做的这个平台,核心目标是实现一套可落地的在线英语阅读分级系统:学生先做定级测评,系统根据测评结果推送合适难度的文章,阅读过程中支持记录生词、标记进度、完成阅读理解题,教师和管理员可以在后台管理工作内容。技术栈选型很明确——Java SpringBoot + Vue3 + MyBatis + MySQL,前后端分离,也就是标题里写的这套组合。
这篇文章我不会去复述源码里的每一行代码,而是从设计和实现的角度,把这套系统拆开来讲:为什么这么选型、数据库怎么建模、后端接口怎么规划、前端阅读器怎么做体验、部署上线会遇到哪些坑。如果你正准备做一个类似的在线教育类系统,或者只是想参考一个完整的全栈项目来积累实战经验,这篇内容应该能帮上不少忙。
2. 技术选型:SpringBoot、Vue3、MyBatis、MySQL的组合逻辑
2.1 为什么整套系统扛在一个SpringBoot上
当时做技术选型时,后端其实考虑过两个方向:一个是Spring Cloud那套微服务,另一个就是单体应用SpringBoot。最后选了SpringBoot,原因很简单——这个系统的业务规模根本不需要微服务。微服务解决的是团队协作、独立部署、流量隔离的问题,它带来的服务发现、配置中心、链路追踪这些复杂度,对于一个小团队维护的项目来说完全是负资产。
SpringBoot真正吸引我的是它的"默认配置+约定优于配置"思路。一个在线教育平台的核心后端能力,无非就是用户认证、内容管理、测评逻辑、阅读记录这几块,SpringBoot一个应用就能全部承载。而且它的生态非常成熟,Spring Security做权限、Spring Validation做参数校验、Spring AOP做日志记录,几乎不需要额外找库。内嵌Tomcat也让部署变得很轻松——打完Jar包直接扔服务器上就能跑,这在当时验证概念阶段效率优势非常明显。
2.2 前端选Vue3而不是Vue2或React,出于什么考虑
前端框架的选型,我核心考虑的是两点:组件复用效率和团队上手成本。React当然也很好,但团队当时对Vue更熟,选Vue3是顺势而为。Vue3相比Vue2最大的变化是Composition API,它把组件里逻辑相关的代码聚合在一起,不再是散落在data、methods、computed里的碎片,对于阅读器这种逻辑密集型页面来说,代码组织方式要舒服得多。
另外Vue3配合Vite开发,冷启动速度提升非常明显。以前Vue2配Webpack,每次改完代码等热更新少说两三秒,Vite几乎是毫秒级刷新,这在调阅读器样式时的体验差别非常大。组合式函数(Composables)是我用得最多的特性,比如把"用户登录状态检测""阅读进度上报""生词本操作"这些跨组件共享的逻辑都抽成了hook函数,代码复用率比Vue2时代高了一个档次。
2.3 MyBatis在ORM选型中赢在哪里
ORM框架我也纠结过,MyBatis和JPA(Hibernate)都认真考虑过。最后选MyBatis,很重要的一个原因是这个项目的查询逻辑包含大量定制化SQL。比如"根据用户的测评等级,从文章表中筛选出难度匹配且未被阅读过的文章",这种多条件、多表关联的查询,用JPA的Specification写起来非常繁琐,但MyBatis直接写XML里的动态SQL,几个if标签就搞定了。
更关键的是,MyBatis对SQL的控制是百分百透明的。在MySQL上写的SQL是什么样,最终执行的就是什么样,没有JPA那种自动生成SQL的"惊喜"。做在线教育平台,需要频繁优化查询性能,这种可控性是刚需。再加上MyBatis的分页插件PageHelper非常成熟,列表查询里的分页逻辑一行注解就能完成,开发效率很高。
2.4 MySQL为什么是这个量级项目的数据库正解
数据库我也没考虑过别的选项。MySQL在这个项目里几乎是必然选择:数据量级完全可以预期(用户注册量、文章数量、阅读记录在初期都是万级到十万级的规模),部署运维成本低,团队对它足够熟悉。InnoDB引擎支持事务,这对测评记录、阅读进度这类需要数据一致性的场景是底线保障。
在存储引擎细节上,我给所有核心业务表都设置了utf8mb4字符集。这里有个容易忽略的点:utf8mb4才能完整存储emoji和生僻字符。阅读平台的文章内容如果包含特殊符号、数学公式,或者用户在评论里发了emoji,用utf8mb3会直接报错或丢失字符。这个坑我在别的项目里踩过,所以这里从一开始就定了utf8mb4。
2.5 前后端分离,而不是服务端渲染
关于架构形态,我坚持前后端分离而不是传统的服务端渲染页面。原因有两个:第一,阅读器这种交互密集型的页面,需要前端做大量状态管理、DOM更新,服务端渲染这种活儿干不了;第二,前后端分离让两端的开发进度彻底解耦,我这边后端定义好接口契约,前端团队可以同时基于Mock数据开发,整个项目周期几乎可以压缩三分之一。
3. 数据库建模:分级、阅读内容、学习行为怎么落库
3.1 核心实体梳理
在线英语阅读分级平台的数据库设计,我把它分成三条主线:
用户与权限线:用户表(保存基本信息)、角色表(学生、教师、管理员)、用户角色关联表。这个模型不需要引入复杂的RBAC框架,三张表搞定绝大多数场景。
内容与分级线:文章表(保存标题、内容、来源)、分级标签表(维护分级体系)、文章分级别关联。文章正文我采用了HTML格式存储,因为阅读器前端需要以HTML的方式渲染排版,同时为了加载性能,正文在进入数据库之前会做一道清理流程,把无用的样式、脚本都洗掉。
学习行为线:测评记录表(保存测评题目、得分、定级结果)、阅读记录表(每一篇文章的阅读进度、耗时、完成状态)、生词本表(学生标记的生词及释义)。
这三条线之间通过外键逻辑关联,直接支撑起核心的业务流程。值得强调的是,测评记录和阅读记录这两张表是学习数据的核心资产,它们决定了后续个性化推荐和学习报告是否可信。
3.2 分级体系的数据表达
分级的核心逻辑在数据库层面其实很简单:每个学生有一个等级字段,每篇文章有一个难度等级字段,匹配就是等级对齐的过程。
我会采用类似CEFR(欧标)的六级分级——A1、A2、B1、B2、C1、C2。每一级在表里对应一个难度编码,附上描述性的属性,比如词汇难度区间、平均句长区间、语法复杂度级别。文章入库时,管理员需要给文章标注难度等级,如果要做精细化的自动评定,可以在后端实现一个评估模块,但我在这个项目里选择先人工标注,保证数据质量。
学生测评定级后,user表里会写入用户当前等级。阅读推荐时,不需要什么复杂的推荐算法——就是把用户等级和文章等级对齐,再配合未读条件和分页查询,一个MyBatis动态SQL就完成了。
3.3 关键表结构的设计细节
这张文章表的字段设计值得展开看一下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| title | varchar(200) | 文章标题 |
| content | longtext | 正文内容,HTML格式 |
| difficulty_level | varchar(10) | 难度等级编码(A1-C2) |
| word_count | int | 正文字数 |
| estimated_minutes | int | 预计阅读时长 |
| category | varchar(50) | 分类(文学、科普、新闻等) |
| status | tinyint | 发布状态(0草稿/1上架/2下架) |
| create_time / update_time | datetime | 时间戳 |
每个字段都不是随便定的。word_count和estimated_minutes用于前端展示和教师布置任务时参考;status字段实现了内容上下架审核流程,管理员未经审核的文章不会出现在学生端;create_time/update_time是所有表的标配,为后面做数据分析和增量同步打基础。
阅读记录表的设计更有讲究。它必须要有user_id和article_id做联合唯一索引,保证同一学生对同一篇文章只能有一条累计阅读记录。进度字段用decimal类型保存百分比,每次阅读间隔10秒上报一次,后端做合并更新而不是插入新记录,这样既能避免数据库爆炸,又能拿到相对准确的阅读数据。
3.4 索引与查询性能的取舍
索引设计上,我遵循了"查询驱动索引"的原则。阅读记录表的联合唯一索引article_id+user_id是核心索引,支撑"判断用户是否读过某篇"的高频查询;文章表的difficulty_level+status联合索引,支撑阅读推荐时的过滤查询。实际测试下来,十万篇文章和五十万条阅读记录的规模下,单次查询都能保持在几十毫秒级别,完全够用。
这里也要提醒一点:不要把索引设计得过多。每个索引都会拖慢写入速度并占用磁盘空间。我当时就是一开始给所有字段都加了索引,后来压测时发现写入性能明显下降,删掉一批低频查询的索引后才恢复正常。
4. 后端接口设计:从定级测评到学习闭环
4.1 用户认证与授权体系
接口设计这块,我遵循的是RESTful风格,把每个业务动作都抽象成对资源的操作。用户模块用了Spring Security+JWT的方案,登录成功后发放一个带过期时间的Token,之后每次请求在Header里带上Token,由拦截器统一校验。
权限控制上用了两个注解:@PreAuthorize(方法级权限控制)和自定义拦截器做路径级控制。例如:
@PreAuthorize("hasRole('TEACHER') or hasRole('ADMIN')") @PostMapping("/articles") public Result createArticle(@RequestBody @Valid ArticleDTO dto) { ... }学生调用阅读上报接口时,只能操作自己的数据。后端会在Service层强制校验userId必须取自当前登录用户,而不是拿前端传的JSON里的userId,这个是一开始就要守住的安全底线。否则任何人只要改一下请求体里的userId,就能给别人伪造阅读记录。
4.2 定级测评的接口怎么设计
定级测评我设计成了两个接口:查询测评题量和提交测评结果。
测评题库是提前维护好的,题目按难度分组,学生从简单的题目往后做,连续答错两道就提前终止。提交测评结果时后端不只需要计算总分,更关键的是根据答题模式推断出zpd(最近发展区)——这个区间范围内的文章学生读起来最合适。这一段逻辑看起来简单,但其实是整个系统的核心智力资产。
计算逻辑大概是:答对的题目按难度计分,最终得分映射到六个等级。我并没有用复杂的自适应测试算法,因为题量不够时算法效果有限,反而让系统显得"笨"。对大多数项目来说,一套规则清晰、解释性强的硬编码逻辑比黑盒算法更可靠。
4.3 文章推荐和阅读进度的接口设计
推荐文章的接口看起来简单,但隐含了很多边界条件:
@GetMapping("/articles/recommend") public Result recommendArticles(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size) { User user = getCurrentUser(); String level = user.getDifficultyLevel(); // 查询该等级下未读过的文章,并排除已下架内容 return articleService.recommendByLevel(level, user.getId(), page, size); }这个接口背后对应一条MyBatis动态SQL,核心是一句NOT EXISTS子查询,把阅读记录表中已经出现的article_id全部排除掉。这个需求和PageHelper分页插件配合得非常好,每页返回十篇,前端可以直接滚动加载。
阅读进度的上报接口设计成了段落级别的标记:前端记录用户读到了文章的第几段,每隔几秒上报一次。上报频率用节流控制,避免频繁请求压垮后端,而且后端要做合并更新逻辑——不需要每次请求都写数据库,而是当段落变化超过一个阈值才落库。这个细节没做好,生产环境很容易被打满。
4.4 生词本到底是怎么实现的
生词本功能看起来是个小功能,但它的接口设计很有代表性。用户在阅读器里选中一个单词,前端调用后端接口记录生词。但单纯记一个单词没有意义,有价值的是一次完整的上下文记忆——所以生词表里我除了存单词本身,还存了单词出现的那一句话、所在文章ID、当时的时间点,方便后续做复习回顾。
查询生词本用分页列表,每个生词附带文章标题、所在句段和用户对该词的掌握状态。状态字段用枚举:未掌握/认识中/已掌握,这是为后续复习计划做准备的。等到后面迭代时接算法,直接拿这套底层数据做间隔重复复习。
4.5 全局异常与统一返回结构设计
接口健壮性也是后端设计的重点。我定了统一的返回结构Result类,code、msg、data三个固定字段,让前端能用一种方式处理所有接口的返回结果。配套做了全局异常处理器,用@RestControllerAdvice捕获所有异常:
- 业务异常(BizException):比如用户等级未定、文章已下架,返回明确的业务错误码
- 参数校验异常:@Valid校验失败时返回第一个错误信息
- 兜底异常:系统内部错误统一记日志,返回"系统繁忙"
这套统一异常处理机制后来帮了很大的忙,排查问题时看日志一眼就能分辨出是业务逻辑出错了还是程序bug。
5. Vue3前端实现:阅读器体验与学习路径可视化
5.1 项目初始化与工程结构
前端我用的Vite创建Vue3项目,选了JavaScript而不是TypeScript(对维护便利性和上手成本来说,当时团队的TS经验还不够)。工程结构上按模块划分目录,views放页面组件,components放公共组件,composables放复用逻辑,api目录统一管理所有后端接口调用。
关键依赖包括:Vue Router(路由管理)、Pinia(状态管理)、Axios(HTTP请求)、Element Plus(UI组件库)。路由配置里重点实现了登录鉴权守卫:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { next('/login') } else if (token && to.path === '/login') { next('/') } else { next() } })这个守卫逻辑不复杂,但它是整个前端的门卫,没有它后面的所有页面都裸奔。
5.2 阅读器是前端的核心战场
阅读器是本项目前端最复杂的组件,它的本质是一个封装了排版和交互逻辑的富文本阅读框架。
文章正文以HTML格式返回后,我并没有直接v-html渲染,而是先做了一道处理:把HTML里的临时样式清掉,统一用项目自己的CSS控制排版——正文字号、行高、段落间距、字体族都在阅读器样式文件里定义。这样保证同一篇文章在任何设备上阅读体验一致。
阅读器的段落点击标记功能,实现思路是:把文章内容按段落拆成数组,渲染时给每个段落包裹可点击层,用户点击某一段高亮它,同时把段落序号上报给后端。翻页逻辑我采用了虚拟滚动的方案,一次只渲染当前屏内可见的段落,大大减少了大文本的DOM节点数量,滚动性能很流畅。这是个值得记住的经验:一次性渲染全文的套路在小文章上没问题,文章超过几万字后浏览器帧率会明显下降。
划词翻译与生词收藏的交互也花了不少功夫。鼠标选中文本后弹出浮动操作条,支持"加入生词本"和"翻译"两个操作。翻译功能并没有接外部API,而是内置了一个基础词汇库,命中则返回释义,未命中则提示用户参考词典App。这样精简实现了核心功能,不在辅助功能上毫无限制地投入。
5.3 测评流程的状态管理
测评页面是我把Pinia用得很深的一个场景。整个测评流程有多个步骤:初始说明→逐题作答→提前终止→结果计算→展示定级结果。用组件本地状态管理变量会非常碎片化,我把它放到了Pinia的store里:
export const useAssessmentStore = defineStore('assessment', { state: () => ({ currentStep: 0, questions: [], answers: [], currentDifficulty: 'A1', wrongStreak: 0 }), actions: { async fetchQuestions() { ... }, submitAnswer(answer) { ... }, async finishAssessment() { ... } } })这样切换到任何页面,测评进度和状态都保留在全局store里,刷新页面也能恢复。这种多步骤、有状态的流程,用全局状态管理比组件props链要清晰得多。
5.4 学习数据看板的实现
学生的个人中心我做了学习数据看板,展示几个核心指标:阅读文章总数、累计阅读时长、掌握生词数量、当前等级。为了直观,我自己用ECharts画了阅读量的折线图和等级分布图。ECharts在Vue3里封装成了组件,传入option配置即可渲染,几乎没有学习成本。
但说实话,这段前端工作让我最感慨的不是图表库,而是数据本身的价值。当看到学习数据以可视化的方式呈现出来时,才真正感觉到:这个平台不只是一个阅读工具,它已经成为一个能观察到学习行为和进步轨迹的数据产品了。
6. 多角色权限设计:教师端与运营后台怎么管控内容
6.1 三种角色的权限边界
系统的用户角色分为三类,每个角色看到的功能菜单完全不同:
| 角色 | 核心功能 | 权限边界 |
|---|---|---|
| 学生 | 测评定级、阅读文章、生词本、学习看板 | 无管理权限,只能操作自己的数据 |
| 教师 | 查看学生阅读统计、布置阅读任务、审核测评结果 | 可查看班级数据,不可修改文章和系统配置 |
| 管理员 | 文章维护、分级标注、用户管理、系统配置 | 全部权限 |
前端的权限控制用路由meta信息实现,在路由配置的meta字段里声明allowedRoles,配合路由守卫做角色校验。后端同样在接口层做鉴权,双层控制,防止有人单纯靠修改前端路由绕过限制。
6.2 管理后台的文章审批流
管理员发布一篇文章,业务流程是:新增文章→填入标题正文→选择等级→保存草稿→提交审核→审核通过后上架。我在文章表里用status字段表达这个状态机:0草稿、1待审核、2已上架、3已下架。
这个状态机看起来很朴素,但它把内容管理从"直接改库"变成了"有流程的审批"。教师审核文章时看到的是纯排版过的渲染效果,而不是原始HTML代码,审核通过后学生端立即可见。这套机制上线后,内容质量明显比没有审核时稳定得多——至少不会出现排版错乱的文章直接给学生看的情况。
6.3 数据权限的隔离
教师查看学生数据时,数据权限需要严格限定在本班级范围内。我在数据库设计时就给用户表增加了class_id字段(班级ID),教师查询学生列表时,SQL强制拼接class_id条件。这个字段在服务端获取,不信任前端传参。后端开发中有一条铁律:用户的身份和所属关系永远以服务端Session或Token解析为准,不能信任何前端传上来的数据。基于此做数据隔离,才经得起安全审查。
7. 部署上线是试金石:MySQL、Nginx和那些值得记录的坑
7.1 部署架构与Nginx配置
这套系统的部署形态很清楚:一台Linux服务器,部署MySQL(端口3306)、后端SpringBoot应用(Jar包,端口8080)、前端静态资源(由Nginx托管,端口80)。
Nginx在这里承担了三件事:托管前端静态文件、反向代理后端API、处理跨域。配置核心思想就是:前端页面请求/api路径的接口时,Nginx将其转发到本机的8080端口:
location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }这样一个Nginx配置同时解决了前端部署和后端代理,浏览器端不需要任何跨域配置,因为所有请求都是同源的。跨域问题在开发环境存在,由后端配置CorsFilter解决,生产环境走Nginx代理,效果最佳。
7.2 我踩过的几个坑
第一个坑:MySQL时区问题。后端应用和数据库的时区不一致,导致插入的时间字段差了8小时。排查了很久,最后发现是MySQL的time_zone参数设置为SYSTEM而服务器是UTC时区。解决方案是在MySQL连接串上强制指定serverTimezone=Asia/Shanghai,彻底根治。
第二个坑:MyBatis二级缓存。开发环境一切正常,生产环境部署后发现文章内容更新了,学生端却显示旧数据。查下来是MyBatis的二级缓存默认开启,文章更新后缓存没有正确失效。这个问题解决方式是:对于这类必须实时一致的数据,直接关闭二级缓存,只保留一级缓存(一级缓存也是默认开启的)。维护数据一致性,在大多数业务场景下都比缓存带来的性能收益更重要。
第三个坑:大文本文章HTTP传输超时。阅读器请求一篇文章时,如果文章特别长,Nginx默认的proxy_read_timeout是60秒,但后端从数据库查出几万字的HTML并传输,耗时可能超过这个值,导致请求超时被中断。解决方案是给/api/阅读相关接口单独设置更长的超时时间,同时优化SQL只查出必要字段,减少无效传输。
第四个坑:前端部署路径下的子路由刷新404问题。前端用Vue Router的history模式,直接刷新二级路由页面时,Nginx会去磁盘上找对应的物理路径,找不到就返回404。这个问题需要在Nginx里配置try_files $uri $uri/ /index.html,把刷新请求回退到index入口。不配这一行,任何深链接刷新都会白屏。
7.3 性能优化与扩展方向
当并发上来之后,最先需要优化的是数据库连接池。默认的HikariCP配置可以调整池大小,但我实际测试下来,这个量级的系统核心瓶颈通常不在数据库而在网络带宽和前端资源加载。给静态资源配置Nginx gzip压缩后,阅读器页面加载速度提升非常明显。
后续如果要扩展,我认为有几个方向值得探索:一是在线测评的题库建设与自适应测试算法;二是基于海量阅读记录的个性化推荐,从简单的等级匹配升级为基于兴趣标签的协同过滤;三是教师端的数据看板增强,比如班级学情横向对比、个体成长曲线预测。这些方向在数据模型上都完全可以兼容,说明当初的库表设计有足够的扩展空间。
8. 最后一点个人体会
做完这个项目再回头看,感受最深的是:这种全栈项目真正有价值的部分,不在用了多少热门技术,而在于把一个看似简单的"阅读"场景拆成了可落地、可维护、可迭代的产品闭环。定级测评、内容分级、阅读追踪这三个核心模块环环相扣,每个模块的接口设计、数据库建模、前端交互,都在反复迭代中打磨出了自己的答案。
如果你正在参考这个项目练手,个人建议是按这个顺序来:先根据业务场景把数据库表设计理清楚,再写后端接口定义好返回结构,最后做前端交互。每一步都把"为什么这样设计"想清楚,比照着源码敲一遍收获要大得多。踩过的坑都写在上面了,希望你能绕开它们,少熬几个夜。