
前后端分离的在线英语阅读分级平台这个项目我前后改了四版才稳定下来。最早期版本是纯JSP的单体应用页面和接口混在一起后期加功能时改一个查询就要重启一次服务前端想调样式还得等后端编译完成。后来彻底重构为SpringBootVueMyBatisMySQL的前后端分离架构赎罪了至少一半的联调时间。这篇文章我会把这套系统的设计思路、核心分级算法、数据库表结构、部署步骤和踩过的坑完整写清楚不光是给毕业设计选型的同学参考如果你正打算做一个内容推荐类的全栈项目这套拆解也能直接复用。标题里的htmlcss很容易让人误以为前端只是静态页面实际是Vue组件化的单页应用所有页面模板仍然是HTMLCSS的写法只是被组件系统组织起来。整个平台解决的痛点很朴素市面上的英语阅读App要么文章没有难度标签要么有标签但不透明用户根本不知道这个四级中级是怎么评出来的。这个系统尝试把分级逻辑显性化文章和用户都被打上同一个标尺的难度值再由推荐模块去匹配。1. 英语阅读分级到底在做一件什么事1.1 分级阅读的底层逻辑不是感觉是公式我在做这个项目之前一度以为分级阅读就是把文章分成小学、初中、高中、大学四个难度后来查了蓝思Lexile分级体系的资料才意识到真正的分级是要把语言难度量化成数字的。蓝思体系用了两个核心维度句子长度和词汇频率。句子越长认知负担越大词汇越生僻阅读障碍越明显。它的计算公式大致是蓝思值 ≈ 句长加权 词频加权在我实现的简化版本里我把每篇文章的文本拆成句子统计平均句长单词数除以句子数再统计每个单词在常见词库中是否出现。常见词库我用的是BNC词频前3000词表不在词表里的单词标记为低频词低频词占比越高难度越大。最终得出一个难度分值映射到系统的五个等级入门200-400L、基础400-600L、进阶600-800L、熟练800-1000L、挑战1000L以上。这个计算逻辑不是靠人工一篇篇标注出来的而是在文章入库时由后端自动执行。这是在线平台和传统纸质分级读物的本质区别内容库每新增一篇文章难度值是即时生成的不需要编辑人为判断。1.2 在线平台要解决的三个核心问题把文章标了难度只是第一步平台真正要处理的是三件事文章难度标准化所有入库文章自动计算分值并归入等级统一标尺不依赖人工经验。用户水平动态评估新用户没有历史数据我设计了一个12道题的初始测评试卷答完直接输出初始等级。老用户则根据阅读完成率、生词标记次数、单篇阅读耗时三个维度动态调整等级。内容与用户的匹配推荐模块只推用户当前等级±1区间的文章太简单没有提升效果太难会劝退用户这个区间是留存率最高的。这三个问题的解决方案就是系统后端最核心的业务逻辑。很多类似项目把分级做成了文章表里一个简单的level字段推荐时按等级相等查询说实话那不算分级平台只能算打了标签的文章列表。真正的分级系统等级是一个可以计算、可以变化、可以互相匹配的量而不是一个死字段。1.3 谁需要这套系统的思路我总结下来以下三类人最适合参考这个项目正在做毕业设计或课程设计选题的同学这个项目覆盖了前后端分离、权限认证、动态SQL、数据统计这些被高频考察的点容易讲出深度。想从能跑通Demo进阶到能设计业务系统的初级全栈开发者分级算法给了你一个现成的业务复杂度来源不用编造需求。教育类、内容类产品方向的从业者分级匹配和推荐SQL的写法可以直接迁移到其他智力内容平台。2. 技术选型SpringBootVueMyBatisMySQL为什么能搭起来不打架2.1 前后端分离的收益不是代码好看是协作方式变了很多初学者对前后端分离有一个误解以为就是把HTML文件从后端目录挪到前端目录。不是这个意思。前后端分离的本质是前端只关心页面渲染和交互后端只关心数据处理和业务逻辑两者通过JSON格式的HTTP接口通信。在这个架构下前端不用管页面数据从哪来后端也不用管数据在页面上长什么样。这个项目我体会最深的收益是并行开发效率。重构后的第二周我和一个前端同事配合我负责定义API接口的出入参格式和Mock数据他直接照着Mock数据开写页面等后端真实接口好了再切换baseURL。整个过程没有因为等待依赖而阻塞过一天。当然也有代价最大的代价是出现了跨域问题。浏览器安全策略默认禁止前端页面访问非同源的接口这就引出了开发环境的代理配置和生产环境的Nginx反向代理。这些细节我会在部署章节详写。2.2 SpringBoot、Vue、MyBatis、MySQL各自解决哪一层的问题选这套组合不是因为它热门而是每一层都有不可替代的实用理由。SpringBoot负责后端接口和业务逻辑。我最早用SSHSpringMVCSpringHibernate做过项目光XML配置文件就要写五六个。SpringBoot把配置收敛到application.yml一个文件内嵌Tomcat打包成jar直接java -jar就能跑部署成本低到可以忽略。它的自动配置机制让我不需要手工声明大部分Bean开发体验对单人全栈项目极其友好。Vue负责前端单页应用。系统需要文章列表、阅读详情、用户中心、生词本等多个页面如果不用框架纯手写DOM操作会导致代码乱成一团。Vue的组件化思路让我可以把文章卡片封装成一个组件列表页和推荐页复用同一个组件改样式只改一处。Vue的路由机制配合后端返回的菜单权限可以实现在前端做页面级访问控制。MyBatis负责数据库访问层。选它而不是Spring Data JPA是因为这个系统涉及大量动态查询条件文章列表要按等级筛选、按关键词搜索、按难度排序组合条件很多。MyBatis的XML Mapper可以用where标签和if标签动态拼接SQL条件有无都对SQL语句零侵入。这种手写SQL可控性在复杂查询场景下是巨大的优势。MySQL存储所有业务数据。这个系统的数据量级上万篇文章、几千用户完全在MySQL的舒适区内5.7以上版本对JSON类型的支持足够好不需要引入专门的文档数据库。这三层加起来的核心逻辑就像一条流水线Vue把用户的点击行为变成HTTP请求SpringBoot接收请求并调用Service层处理业务Service层通过MyBatis与MySQL交互取回数据再逆向返回最终渲染成页面。2.3 顶层目录结构怎么规划我的项目采用了前端后端分离的两个独立工程目录reading-platform/ ├── frontend/ # Vue前端工程 │ ├── src/ │ │ ├── api/ # axios请求封装 │ │ ├── assets/ # 静态资源 │ │ ├── components/ # 通用组件 │ │ ├── router/ # 路由配置 │ │ ├── store/ # 全局状态管理 │ │ ├── views/ # 页面组件 │ │ ├── App.vue │ │ └── main.js │ ├── package.json │ ├── vue.config.js │ └── index.html │ ├── backend/ # SpringBoot后端工程 │ ├── src/main/java/ │ │ └── com/reading/ │ │ ├── controller/ # 接口层 │ │ ├── service/ # 业务逻辑层 │ │ ├── mapper/ # MyBatis Mapper接口 │ │ ├── entity/ # 实体类 │ │ ├── config/ # 配置类 │ │ └── util/ # 工具类 │ └── src/main/resources/ │ ├── mapper/ # MyBatis XML文件 │ ├── application.yml │ └── db/ # SQL初始化脚本 └── README.md后端没有用多模块Maven结构原因是项目规模不大多模块带来的依赖管理成本超过了收益。单模块打包出来的jar包更简单部署时也好描述。3. 数据库设计与MyBatis实战先画清楚表再写业务代码3.1 核心表结构与字段设计意图这个系统的表不多一开始我甚至想过只建三张表后来随着业务展开调整成了五张核心表。我把它们的作用和字段设计说明如下。用户表t_userid BIGINT PRIMARY KEY AUTO_INCREMENT username VARCHAR(50) UNIQUE NOT NULL password VARCHAR(100) NOT NULL -- BCrypt加密存储 nickname VARCHAR(50) level INT DEFAULT 1 -- 对应分级等级1-5 lexile_score INT DEFAULT 0 -- 用户当前蓝思值 role TINYINT DEFAULT 2 -- 1管理员 2普通用户 status TINYINT DEFAULT 1 create_time DATETIME等级字段单独存进用户表是为了查询效率。如果每次都要通过阅读记录去算用户等级用户列表接口和鉴权逻辑都会变复杂。所以我采用了实时计算定期落库的策略用户阅读完后端会更新等级但不实时改库而是通过一个定时任务批量重算。这样既保证了最终一致性又避免了频繁写入。文章表t_articleid BIGINT PRIMARY KEY AUTO_INCREMENT title VARCHAR(200) NOT NULL content LONGTEXT NOT NULL cover_url VARCHAR(255) level INT DEFAULT 3 lexile_score INT DEFAULT 0 word_count INT DEFAULT 0 keyword VARCHAR(500) -- 逗号分隔的关键词 status TINYINT DEFAULT 1 -- 1上架 0下架 create_time DATETIMEcontent字段用LONGTEXT是稳妥选择。VARCHAR最多能存65535字节英文文章倒是勉强够但有些长篇阅读理解超过这个长度就会插入失败。level字段是冗余存储因为蓝思值是一个连续值实际展示和筛选用离散的等级更直观。计算完成后同时写两个字段。阅读记录表t_reading_recordid BIGINT PRIMARY KEY AUTO_INCREMENT user_id BIGINT NOT NULL article_id BIGINT NOT NULL start_time DATETIME finish_time DATETIME duration_seconds INT DEFAULT 0 completion_rate DECIMAL(5,2) -- 0-100 阅读完成度 unknown_words INT DEFAULT 0 -- 本次阅读标记的生词数 UNIQUE KEY uk_user_article (user_id, article_id)这个表设计时我加了一个唯一约束uk_user_article确保同一个用户对同一篇文章只会有一条累计记录。用户反复阅读一篇文章时走的是ON DUPLICATE KEY UPDATE更新逻辑。这个设计让统计分析变得简单直观不需要在SQL层做子查询去重。生词表t_vocabularyid BIGINT PRIMARY KEY AUTO_INCREMENT user_id BIGINT NOT NULL word VARCHAR(100) NOT NULL definition VARCHAR(500) article_id BIGINT status TINYINT DEFAULT 0 -- 0待复习 1已掌握 create_time DATETIME UNIQUE KEY uk_user_word (user_id, word)生词本功能看着简单其实也有一个细节同一个单词用户可能在不同文章里标记多次如果不加唯一约束生词表会膨胀为脏数据。3.2 MyBatis动态SQL在文章筛选中的实际用法项目中最典型的动态查询场景在上架文章列表接口。前端传入的参数可能有等级、关键词、排序方式这三个条件都是可选的。用MyBatis的XML写出来就是select idselectArticleList resultTypecom.reading.entity.Article SELECT id, title, level, lexile_score, word_count, keyword FROM t_article where if testlevel ! null AND level #{level} /if if testkeyword ! null and keyword ! AND ( title LIKE CONCAT(%, #{keyword}, %) OR keyword LIKE CONCAT(%, #{keyword}, %) ) /if AND status 1 /where choose when testsortBy latest ORDER BY create_time DESC /when when testsortBy difficulty ORDER BY lexile_score ASC /when otherwise ORDER BY id DESC /otherwise /choose /selectwhere标签会自动去掉第一个条件前面的AND这几个if标签让Java端不用写一堆StringBuilder拼SQL的脏代码。这种写法也是MyBatis面试题里动态SQL的高频考点我的经验是与其背八股不如在一个真实项目里体会一次它是怎么消除维护痛点的。3.3 索引设计推荐查询为什么不能全表扫文章表最频繁的查询是按等级状态筛选文章所以我建了联合索引(level, status)。推荐模块的SQL会频繁使用这个条件没有索引时上万条数据可感知的变慢接近5万条以后会明显卡顿。阅读记录表的user_id也需要索引用户中心展示我的阅读历史时会用到。这里我没有建(user_id, article_id)联合索引因为唯一约束uk_user_article本身就是一个联合索引查询时可以直接复用。一个小提醒MySQL的联合索引遵循最左前缀原则(level, status)这个索引能覆盖只用level查询和levelstatus同时查询两个场景但如果单独用status做条件这个索引就失效了。设计时要把最常作为独立查询条件的字段放最左边。4. 分级算法与推荐逻辑项目真正的灵魂4.1 文章难度自动评分我给每篇文章算了个蓝思值前面提到的简化蓝思算法实现时拆成了两个步骤文本特征提取和分值计算。特征提取阶段用Java的正则表达式配合自然语言处理的简单分词public ArticleScore calculateScore(String content) { // 1. 按句子结束符分词句号、问号、感叹号 String[] sentences content.split([.!?]); int sentenceCount sentences.length; // 2. 统计单词总数和低频词数量 String[] words content.toLowerCase() .replaceAll([^a-z ], ) .trim().split(\\s); int wordCount words.length; int unknownCount 0; for (String word : words) { if (!commonWordSet.contains(word)) { unknownCount; } } // 3. 计算两个核心指标 double avgSentenceLength (double) wordCount / sentenceCount; double unknownRatio (double) unknownCount / wordCount; // 4. 套用评分公式 double score avgSentenceLength * 3.2 unknownRatio * 120; return new ArticleScore(score); }公式里的两个系数是我拿一批已标注难度的文章做回归测试反推出来的它不追求对标官方的蓝思值精确度但在区分这篇比那篇难这件事上做到了稳定一致。这里有一个很容易踩的坑英文文本里常见dont、its这类带撇号的单词正则如果处理不好会把词切碎。我在实现里先转成小写再用[^a-z ]保护撇号最后按空白符分割这样dont会被整体当作一个单词处理。4.2 用户水平动态评估机制用户等级不能一成不变。初始等级靠测评试卷之后要持续调整。我的调整策略是在用户完成一篇阅读后触发计算如果完成率达到80%以上且生词数小于文章总词数的5%视为读得太轻松尝试上调等级如果完成率低于40%或生词率超过15%视为读得太吃力下调等级中间区间的等级保持不变但蓝思值做小幅微调。这里有个设计细节值得说一下我没有在用户每读完一篇文章时立即重算并覆盖等级而是把计算结果写入一张t_user_level_record记录表再通过定时任务每小时汇总一次。这样既保留了用户水平变化的轨迹也避免高频写入导致行锁竞争。4.3 推荐匹配的SQL实现±1 等级的边界处理从用户当前等级±1区间筛选文章听起来简单但有一个边界问题用户如果是1级减一就是0级系统里不存在0级文章用户如果是5级加一就是6级也没有。我的Repository层是这样处理的select idselectRecommendArticles resultTypecom.reading.entity.Article SELECT id, title, level, lexile_score, word_count FROM t_article WHERE status 1 AND level BETWEEN #{minLevel} AND #{maxLevel} AND id NOT IN ( SELECT article_id FROM t_reading_record WHERE user_id #{userId} ) ORDER BY CASE WHEN level #{userLevel} THEN 0 WHEN level BETWEEN #{minLevel} AND #{userLevel} THEN 1 ELSE 2 END, RAND() LIMIT #{limit} /select这里用了BETWEEN并传入两个边界值。Java端在传参前先做Math.max和Math.min的钳制保证minLevel最低为1、maxLevel最高为5。NOT IN子查询过滤掉已读过的文章配合阅读记录表上的user_id索引联表性能在数据量1万条以下没有压力。4.4 定时任务为什么把等级落库SpringBoot里做定时任务非常简单在启动类加EnableScheduling然后在Service方法上加Scheduled(cron 0 0 * * * ?)表示每小时执行一次。这个定时任务做的事情是读取t_user_level_record最近一小时的数据按用户分组取最新等级值回写到t_user.level字段。之所以不实时写我吃过一次亏。早期版本是在阅读接口里同步更新用户等级结果遇到一个用户连续快速阅读5篇文章5次请求几乎同时到达每次都要先查等级记录、再计算、再更新最后在数据库层面产生了死锁报警。改成定时批量更新之后读接口只写阅读记录负载一下子降下来了。5. 前端页面与交互Vue怎么把这套系统串起来5.1 页面规划和组件结构系统最终由7个主要视图组成登录注册页、首页文章流、阅读详情页、生词本、个人中心、等级测评页、文章管理页管理员。每个视图都由若干Vue组件拼装而成。最核心的复用组件是ArticleCard.vue首页和推荐列表都用它展示文章摘要。组件内部通过props接收文章对象通过$emit向父组件抛事件。这种组件通信方式让文章的展示和交互复用成本降到最低。阅读详情页是交互最复杂的页面。它需要在长文阅读中记录用户的滚动位置、阅读时长并在随时能弹出查词面板。我用了Vue的keep-alive缓存阅读状态用户在文章列表和阅读页之间切换时不会丢失阅读进度。5.2 路由守卫与登录态控制前后端分离架构下前端的页面级权限控制要靠Vue Router的导航守卫实现。router.beforeEach((to, from, next) { const token localStorage.getItem(access_token) if (to.meta.requiresAuth !token) { next(/login) } else { next() } })后端每个接口也会校验JWT前端守卫只是提升用户体验不给未登录用户看空白数据页真正的安全边界在后端。我在项目的README中也特别注明前端路由守卫不等于权限校验它只是一个交互层的便捷机制。5.3 axios封装与拦截器axios请求最麻烦的一处是要在每个请求的Header里带上Token以及收到401响应时统一跳转登录页。如果不做封装每个API方法都要重复写这些逻辑。我把请求统一封装为request.js模块示例如下import axios from axios import router from /router const request axios.create({ baseURL: process.env.VUE_APP_BASE_URL, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(access_token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { localStorage.removeItem(access_token) router.push(/login) } return Promise.reject(error) } ) export default requestbaseURL从环境变量读取开发环境指到代理地址/api生产环境指到Nginx的域名。这样前端代码本身不需要关心后端地址在哪里。5.4 阅读进度与生词交互的防抖处理阅读过程中需要持续上报进度比如每10秒上报一次。如果用户快速滚动或者连点生词按钮高频请求会把后端打得不轻。我在Vue侧用了防抖函数滚动暂停8秒才上报一次位置生词添加后30秒内重复标记同一个单词只发一次请求。这个小优化做完后接口QPS峰值直接降了60%。6. 完整部署教程从IDEA到浏览器全流程6.1 环境准备清单部署前先确认机器上装齐了以下软件版本建议是我实测稳定的组合软件建议版本用途JDK1.8或11编译运行SpringBootMaven3.6后端依赖管理和打包Node.js14前端构建MySQL5.7或8.0数据存储Navicat或MySQL Workbench任意数据库可视化操作特别提醒不要用MariaDB替代MySQL跑这个项目。MyBatis的一些语法和MySQL特定函数在MariaDB上可能有兼容差异我之前在Linux服务器上遇到过。6.2 MySQL初始化字符集与导入步骤首次部署时先创建数据库并设置字符集避免后面中文乱码CREATE DATABASE reading_platform DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE reading_platform; SOURCE /path/to/schema.sql; SOURCE /path/to/data.sql;这里有一个很多教程不会明说的坑如果建库的时候用了utf8而不是utf8mb4生词表存Emoji或者特殊符号时会报字符串不正确的错误。直接用utf8mb4是最省心的选择。6.3 修改后端配置并启动后端配置文件application.yml中需要修改的是数据库连接server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/reading_platform?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.reading.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case这个配置务必设为trueMySQL字段是create_time而Java实体类是createTime开启驼峰映射后不用写一堆ResultMap。启动命令mvn clean package -DskipTests java -jar target/reading-platform-0.0.1-SNAPSHOT.jar看到Started ReadingPlatformApplication字样就表示启动成功。此时浏览器直接访问http://localhost:8080是不会出现页面的因为后端只提供API接口。6.4 前端本地开发与API代理本地开发时前端和后端不在同一个端口Vue的dev server跑在8081后端API在8080。跨域问题的标准解法是在vue.config.js里配代理module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: } } } } }当/api开头的请求被代理转发到后端时会把/api前缀去掉所以后端Controller的RequestMapping(/article/list)在浏览器里对应的前端请求地址是/api/article/list。启动前端npm install npm run serve浏览器访问http://localhost:8081所有/api请求都会自动代理到8080端口跨域问题消失。这个方法比在后端加CrossOrigin注解更推荐因为生产环境Nginx也是用同样的反代思路。6.5 生产环境打包部署前端构建静态文件npm run build构建产物在frontend/dist/目录是一堆纯静态的HTML、CSS、JS文件。把这些文件上传到服务器的Nginxhtml目录然后修改Nginx配置server { listen 80; server_name your-domain.com; root /var/www/reading-platform; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里的try_files配置很重要。Vue是单页应用路由由前端控制用户直接访问/reading/123时Nginx如果不把它重写到index.html会返回404。location /api/负责把接口请求反向代理给SpringBoot jar包。后端jar包在服务器上启动命令是nohup java -jar reading-platform-0.0.1-SNAPSHOT.jar app.log 21 用nohup让它退出终端后继续运行日志落盘到app.log方便排查。7. 部署后的坑与排查链路7.1 跨域配置失效的排查链路有朋友复现我的搭建步骤时发现配了Nginx代理但浏览器依然报跨域错误。排查顺序是这样的第一步打开浏览器开发者工具看Network面板里接口请求的直接状态。如果请求地址是http://localhost:8081/api/xxx且被代理状态码应该显示为200如果显示的是(blocked:mixed-content)说明是其他问题。第二步确认请求地址有没有真正走代理。把鼠标悬停在请求URL上如果它直接显示http://localhost:8080/xxx说明前端请求地址没有被统一封装baseURL写死了后端地址代理转发配置自然没生效。我后来把axios的baseURL全部改为相对路径/api后才解决。第三步检查Nginx的proxy_pass末尾有没有斜杠。proxy_pass http://127.0.0.1:8080/;和proxy_pass http://127.0.0.1:8080;行为完全不同前者会把/api/article/list改写为/article/list后者则原样转发导致后端404。7.2 MySQL 8.0的SSL连接报错部署这台服务器时我把MySQL装成了8.0连接报错javax.net.ssl.SSLHandshakeException: No appropriate protocol。原因很简单MySQL 8.0默认启用SSL而本地JDK和数据库之间证书握手失败。解决方法是连接URL上显式加useSSLfalse表明本地开发环境不需要加密传输。如果是生产环境则建议配置合法证书后保持SSL开启不要因为本地图方便把安全策略关了一路带到公网。7.3 中文乱码的两个处理点乱码问题出现过两次。第一次是数据库建表默认字符集错误建库时没带DEFAULT CHARACTER SET utf8mb4全表都是latin1存中文变问号。第二次是前端页面显示上乱码原因是后端接口响应头里没声明字符集加上produces application/json;charsetUTF-8后解决。这里最容易被忽视的是代码从文本编辑器复制到Linux服务器上时文件编码被改成了GBK也会导致乱码。建议所有代码文件统一用UTF-8编码保存。7.4 MyBatis日志不打印SQL调试阶段我想看MyBatis生成的SQL语句发现控制台一片安静。后来查资料确认MyBatis的SQL日志是独立于SpringBoot根日志的需要指定logger级别才能输出到控制台logging: level: com.reading.mapper: debug这种方式会打印完整的Preparing和Parameters日志。配置里我之前放的是mybatis.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl效果类似但会把SQL日志打到标准输出不利于按mapper分包控制。7.5 端口号冲突的常用判断法启动SpringBoot时提示Port already in use: 8080不要急着换端口先确认是谁占了端口。Windows和Linux都适用一条命令netstat -ano | grep 8080Windows下通过最后一列的PID去任务管理器定位进程Linux下用ps -ef | grep 对应的PID。很多时候是老旧的Tomcat或另一个Java进程在占用直接杀掉即可。如果确实是其他服务占用且无法关闭再考虑在application.yml里换一个端口。写在最后的一点心得这套系统从最初的原型到现在我最大的体会是分级平台的核心不在前端页面多好看而在难度标尺是否稳定可信。如果一个用户读完一篇标为基础的文章困难到根本读不下去那他不会再信任这个平台。所以分级算法值得反复用真实语料去校准我在测试阶段人工读了几十篇文章来对比系统给的难度值是否合理这个过程虽然枯燥但它是整个平台口碑的基石。最后分享一个部署上的小技巧如果你只有一台云服务器又不想单独装Nginx可以让SpringBoot的jar包直接监听443端口并把静态资源放进src/main/resources/static/目录后端启动后前端页面和API都在同一个端口对外服务。这种方式牺牲了前后端完全分离的独立部署能力但应急演示时最方便。它不改变开发时的前后端分离体验生产部署多了一个备选项。根据自己的运维能力二选一就好。