ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SpringBoot+Vue+MyBatis实战:构建在线英语阅读分级平台

2026/9/28 12:21:19 拓冰建站 浏览量
SpringBoot+Vue+MyBatis实战:构建在线英语阅读分级平台 作为一个常年泡在前后端项目里的开发者我前后花了两周时间把一套在线英语阅读分级平台从零搭了起来。技术栈就是标题里那套黄金组合SpringBoot做后端接口Vue写前端页面MyBatis管数据库操作MySQL当存储底座。整个项目走的是前后端分离架构前端用HTMLCSS搭建页面骨架和样式通过axios和后端交互。这套系统解决的痛点很直接给英语学习者按难度分级推荐阅读内容同时记录阅读进度、收集生词、完成阅读测评让学习过程有数据可循。今天这篇文章不聊虚的把项目从需求设计、数据库建模、后端接口实现、前端页面开发到最终部署上线的完整过程都摊开讲附上我踩过的坑和排查思路给正在做类似项目或者想练手前后端分离实战的朋友一份可直接参考的实操手册。1. 需求梳理与技术选型这个平台到底在解决什么问题1.1 分级阅读平台的核心业务逻辑在线英语阅读分级平台说白了就是一套内容分级跟踪的组合系统。分级阅读这个概念在教育领域早就有了蓝思值、A-Z分级、CEFR欧洲共同语言参考标准都是给文本标定难度、给学习者标定水平的手段。平台要做的事情就是把这两者匹配起来读者进来先通过测评或者手动选择确定自己的级别然后系统推荐对应难度的英文文章读完记录进度、标记生词、做配套练习形成学习闭环。我见过不少失败的同类项目问题大多出在只有内容没有跟踪或者有跟踪但没有分级逻辑所以这个项目在需求阶段就确定了几个核心模块缺一不可用户模块注册、登录、个人信息管理登录状态用JWT来维护。文章模块文章按级别如A1到C2或入门到高级打标签支持分页筛选。阅读模块实时记录阅读位置、完成状态自动上报进度。生词模块阅读过程中一键收藏生词形成个人生词本。测评模块简单的阅读理解选择题根据得分计算用户当前级别并推荐对应难度的文章。这几个模块互相咬合业务上就是一个完整的学习闭环。技术上它正好覆盖了SpringBoot的接口开发、MyBatis的复杂查询、Vue的页面交互、MySQL的多表关联作为全栈练手项目再合适不过。1.2 前后端分离架构为什么这套组合是首选很多人问为什么非要用前后端分离把页面直接扔在SpringBoot的templates目录里不也跑得起来吗说实话在小规模项目里单体方案确实更快但一旦涉及多端复用Web端、管理后台、未来可能的移动端接口和后端逻辑必须抽出来独立维护。前后端分离的核心好处是职责清晰后端只提供JSON接口前端只负责页面渲染和交互两边各改各的互不干扰。具体到技术选型我对比过三套方案SpringBoot Thymeleaf/JSP适合快速出活但前后端耦合严重改个页面样式也要重启服务。SpringBoot Vue未分离把Vue文件塞进静态目录本质还是没脱离服务端。SpringBoot Vue完全前后端分离后端提供纯API前端独立运行在Nginx上部署灵活并发压力分散这才是主流企业做法。为什么MyBatis而不是JPA这个项目里文章列表、阅读进度、测评结果涉及不少多条件动态查询MyBatis的XML里写动态SQL非常直观尤其分页和条件组合很好控制。MyBatis的缓存机制、分页插件PageHelper在SpringBoot里配置也简单。JPA适合CRUD为主的场景但遇到复杂查询时个人感觉不如MyBatis顺手。MySQL就不用多说了开源免费、生态成熟、性能稳定配合Navicat这类可视化工具操作数据库非常方便十几张表的规模完全够用。1.3 功能模块全景梳理为了不让自己在开发过程中迷失方向我先把整个系统拆成两张业务图用户视角和管理员视角。用户视角注册登录后进入首页首页展示推荐的文章列表按难度分级标签筛选点进阅读器开始阅读阅读器左侧是正文右侧显示生词本区域文章底部是配套测评题提交后系统判定级别并反馈个人中心展示阅读历史、完成进度、生词统计。管理员视角文章管理包括新增文章、编辑内容、设置分级、上下架用户管理查看注册用户、封禁违规账号测评管理维护题目和级别映射数据概览看用户活跃度和文章阅读量。以这个为蓝图后端我拆出了ArticleController、UserController、ProgressController、VocabularyController、QuizController这几个核心接口组前端对应几个页面HomeView、ReaderView、ProfileView、AdminView。模块划分清楚之后开发效率会高非常多后面每一步代码只需要往对应的格子里填就行。2. 数据库设计与后端搭建SpringBootMyBatisMySQL2.1 数据表设计与关系梳理数据库设计是这个项目中我最重视的一环表结构一旦定下来后面改起来特别痛苦。我最终设计了6张核心表用户表、文章表、阅读进度表、生词表、测评表和测评结果表外加一张角色表做权限管理也可以合并进用户表我习惯拆开。-- 用户表 CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, nickname VARCHAR(50) DEFAULT , level VARCHAR(10) DEFAULT B1 COMMENT 当前阅读级别, role VARCHAR(10) DEFAULT USER COMMENT USER/ADMIN, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 文章表 CREATE TABLE article ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, content TEXT NOT NULL, level VARCHAR(10) NOT NULL COMMENT 分级A1-C2, category VARCHAR(50) DEFAULT general COMMENT 分类科技/文化/故事等, word_count INT DEFAULT 0, status TINYINT DEFAULT 1 COMMENT 1-上架 0-下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 阅读进度表 CREATE TABLE reading_progress ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, article_id BIGINT NOT NULL, current_position INT DEFAULT 0 COMMENT 阅读位置按字符计算, duration INT DEFAULT 0 COMMENT 阅读时长(秒), completed TINYINT DEFAULT 0, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_article (user_id, article_id) ); -- 生词表 CREATE TABLE vocabulary ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, word VARCHAR(100) NOT NULL, definition VARCHAR(500) DEFAULT , article_id BIGINT DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_word (user_id, word) ); -- 测评表 CREATE TABLE quiz ( id BIGINT PRIMARY KEY AUTO_INCREMENT, article_id BIGINT NOT NULL, question VARCHAR(500) NOT NULL, option_a VARCHAR(200), option_b VARCHAR(200), option_c VARCHAR(200), option_d VARCHAR(200), answer CHAR(1) NOT NULL ); -- 测评结果表 CREATE TABLE quiz_result ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, article_id BIGINT NOT NULL, score INT DEFAULT 0, recommend_level VARCHAR(10) DEFAULT , create_time DATETIME DEFAULT CURRENT_TIMESTAMP );这里有几个设计上的关键决策值得展开说。阅读进度表我加了UNIQUE KEY uk_user_article (user_id, article_id)的联合唯一索引目的是确保一个用户对一篇文章只会有一条进度记录后续更新进度就执行INSERT ... ON DUPLICATE KEY UPDATE避免每次阅读都插一条新记录把表撑爆。生词表也有类似的联合唯一约束。文章内容用TEXT类型存全文虽然这篇文档里写的SQL是简化版但实际项目里会根据文章长度考虑用MEDIUMTEXT。测评表单独拆出来而不是塞在文章表里是因为一篇文章可能有多道题一对多关系必须拆表。索引设计上除了唯一约束之外文章表我建了level status的联合索引因为首页最常见的查询就是按级别列出上架文章这个索引直接命中。测评结果表建了user_id create_time索引方便个人中心按时间倒序查最近测评记录。2.2 SpringBoot项目初始化与配置搭建SpringBoot工程我用的是Spring Initializr选Java 8或者11都行我用的Java 11依赖勾选Spring Web、MyBatis Framework、MySQL Driver、Validation如果用到JWT做鉴权再加一个jjwt的第三方依赖。项目骨架生成之后第一件事就是改配置文件。server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/reading_platform?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 你自己的密码 hikari: maximum-pool-size: 10 minimum-idle: 5 connection-timeout: 30000 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.reading.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true数据源我用的HikariCPSpringBoot 2.x默认就是它不需要额外引入性能在社区评测里一直是第一梯队。注意MySQL 8以上版本的驱动是com.mysql.cj.jdbc.Driver老项目里常见的com.mysql.jdbc.Driver在新版驱动里已经移除了。serverTimezoneAsia/Shanghai这个参数必须要加不然连接MySQL 8会报时区相关的异常。allowPublicKeyRetrievaltrue也是MySQL 8加密连接方式变更后需要开的开关不加上在部分环境会报Public Key Retrieval is not allowed。MyBatis配置里我开了map-underscore-to-camel-case这样数据库的下划线字段名能自动映射到Java的驼峰属性比如create_time直接映射到createTime省去一堆ResultMap手写映射的麻烦。开发阶段加一行log-impl: StdOutImpl控制台直接打印SQL语句排查问题的时候太重要了。PageHelper的分页插件配置在SpringBoot里只需要加依赖然后在启动类加一个Configuration类声明或者直接用Bean方式集成后面专门讲。2.3 基于MyBatis的持久层实现与分页查询MyBatis的开发模式很固定实体类、Mapper接口、XML文件三件套。以文章列表这个核心场景为例需求是支持按级别筛选、按关键词搜索、按分类过滤并且分页返回。这种多条件动态查询在MyBatis里就非常适合用where标签来组装。public interface ArticleMapper { ListArticle selectArticleList(Param(level) String level, Param(keyword) String keyword, Param(category) String category); }对应的XMLselect idselectArticleList resultTypecom.example.reading.entity.Article SELECT id, title, level, category, word_count, create_time FROM article where if testlevel ! null and level ! AND level #{level} /if if testkeyword ! null and keyword ! AND (title LIKE CONCAT(%, #{keyword}, %) OR content LIKE CONCAT(%, #{keyword}, %)) /if if testcategory ! null and category ! AND category #{category} /if AND status 1 /where ORDER BY create_time DESC /select这里有个经验模糊查询用LIKE CONCAT(%, #{keyword}, %)而不是字符串拼接%${keyword}%。前者走#{}预编译不会产生SQL注入后者用${}直接拼接用户输入里带个单引号就能把SQL句子改掉属于高危操作。凡是接受外部输入的查询条件一律用#{}。分页用的是PageHelper插件用法极其简单Service public class ArticleService { Autowired private ArticleMapper articleMapper; public PageInfoArticle getArticlePage(int pageNum, int pageSize, String level, String keyword) { PageHelper.startPage(pageNum, pageSize); ListArticle articles articleMapper.selectArticleList(level, keyword, null); return new PageInfo(articles); } }PageHelper.startPage(pageNum, pageSize)后面跟的第一条SQL查询会被自动拦截并生成带LIMIT的语句返回的PageInfo里就封装了总记录数、总页数、当前页数据等现成信息前端拿这个对象直接分页就行。PageHelper官方说reasonable: true这个配置的含义是如果页码超过最大页就自动回退到最后一页页码小于1就自动回到第一页我在实际项目里建议打开因为前端经常会传一个越界的页码过来不配置就容易返回空列表甚至报错。关于MyBatis的缓存这里有必要做一个提醒。MyBatis默认开启一级缓存作用域是同一个SqlSession也就是在一次会话内的多次相同查询会走缓存二级缓存默认是关闭的需要手动配置cache/标签才会启用。在SpringBoot整合环境下很多新手发现更新数据后再查询返回的还是旧值大概率就是开了一级缓存没刷新导致的。我自己做这个项目时没有开二级缓存因为阅读平台的文章内容其实不怎么变没必要把缓存复杂度引进来。如果未来要优化热点文章查询建议用独立的Redis缓存来做而不是依赖MyBatis二级缓存。2.4 阅读进度上报与JWT登录鉴权后端接口里最核心的一个是进度上报。前端在阅读器里每隔几秒或者用户翻页时把当前阅读位置传给后端。接口设计我给它定义了这样的规范POST /api/progress 参数格式{ articleId: 12, position: 3456, duration: 120 }这个接口对应的Service层逻辑是先根据userId和articleId查进度表里有没有记录有就更新没有就插入判断position是否超过文章总字符数的一定比例比如95%来自动标记完成状态。这个判断比例值不能拍脑袋写死得跟产品讨论。我做的是文章内容加载后前端会把总长度传给后端后端按position / totalLength算完成度达到阈值就把completed置为1。并发场景我要点名提醒一下。用户可能开着两个浏览器标签页同时读一篇文章两个请求同时到达时连查带改就可能在唯一索引上撞车。解决办法有两个一是利用INSERT ... ON DUPLICATE KEY UPDATE在一条SQL里完成插入或更新靠数据库层面的唯一索引兜底二是在更新时加版本号做乐观锁。我最终用的是第一种配合事务注解Transactional实际压测下来非常稳定。JWT登录鉴权这块我写了一个简单的拦截器加JwtUtil工具类。用户登录成功后签发一个有效期7天的Token前端把Token存到localStorage每次请求在axios拦截器里带上Authorization: Bearer token。后端拦截器排除掉/api/auth/**注册、登录接口其他接口都要校验Token合法性。这种做法的好处是无状态后端不用存Session横向扩展时不用考虑Session共享问题。public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { String token authHeader.substring(7); try { Claims claims JwtUtil.parseToken(token); request.setAttribute(userId, claims.get(userId)); return true; } catch (Exception e) { // 解析失败继续回到401逻辑 } } response.setStatus(401); return false; } }注意一个细节拦截器里解析出的userId要放到request.setAttribute里后续Controller直接从Request里拿不需要每个接口都重复解析一遍。Order配置上我把它加到了WebMvcConfigurer的拦截器注册里同时配置了排除路径。这里犯过低级错误以为拦截器返回false就结束请求了其实响应状态和JSON格式都需要自己处理所以我统一在一个异常处理器里把401错误包装成前端能识别的JSON结构。3. 前端实现VueHTMLCSS从页面到交互3.1 Vue工程创建与环境配置前端我用的是Vue 2 Vue CLI你也可以用Vite Vue 3语法大同小异但Vue 2的生态更稳定很多毕设项目还在用它。创建工程我用的是npm install -g vue/cli vue create reading-web cd reading-web vue add router npm install axios element-ui npm install sass -DElement UI是PC端管理后台常用的组件库文章管理表格、弹窗表单、分页这些直接用现成组件能省下一大堆样式时间。阅读器页面我则是完全自己写的HTML CSS因为阅读器的排版需求段落间距、字体大小、行高、生词悬浮用组件库反而绑手绑脚。目录结构上我习惯把页面、组件、API、路由拆开src/ api/ # 所有接口请求封装 assets/ # 静态资源 components/ # 公共组件Footer、Pagination等 router/ # 路由配置 views/ # 页面 HomeView.vue ReaderView.vue ProfileView.vue LoginView.vue Admin/ ArticleManage.vue UserManage.vue utils/ # 工具类token、axios实例关键配置是vue.config.js里的开发代理因为前后端分离开发时前端运行在8081端口后端在8080直接请求后端接口必然触发跨域。我在配置里加上module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }这样开发时前端把请求发到/api/xxxdevServer会自动转发到后端的8080端口浏览器里看不到跨域问题。这个配置属于开发环境的临时方案生产环境的跨域和转发交给Nginx处理第四章会讲。3.2 页面板块与组件划分整个前端页面我划分为五大板块这里把每个板块的视觉呈现和功能列出来首页HomeView顶部搜索框筛选标签级别、分类文章卡片列表卡片上有标题、级别徽章、字数、阅读按钮。阅读器ReaderView这是全站核心页面。左侧正文区支持字号调节右侧生词浮层选中正文中的单词自动调用词典接口并显示释义点击加入生词本收藏底部是进度条显示当前阅读百分比。测评页QuizView文章阅读完成后的配套习题每题四个选项提交后显示得分和建议级别。个人中心ProfileView展示头像用默认图、当前级别、阅读统计文章数量、总时长、生词数、阅读历史列表。管理后台Admin面板文章表格管理、用户列表、测评题编辑用Element UI的Table和Dialog组件拼装。组件拆分上文章卡片我抽成了ArticleCard.vue测评选项抽成了QuizOption.vue生词本抽成了VocabularyPanel.vue。组件化最大的好处是列表页和阅读器都能复用ArticleCard管理后台和首页共用同一个分页组件Pagination.vue。如果一个页面超过300行我基本就会考虑抽组件了。这里特别讲一下阅读器页面的HTMLCSS实现。阅读器不是普通的页面排版它要长时间阅读所以排版细节决定体验。正文区我用max-width: 720px居中宽度太宽眼睛扫起来累太窄每行字数少显得碎片化。font-size: 18pxline-height: 1.8这组参数是我实际找几个人试读后的结果对比度、间距、字重都比较舒服。生词上次的悬浮层用的定位是position: absolute选中文本时通过getSelection()获取选区坐标在选区上方弹出一个卡片这个交互在移动端和PC端行为不一致移动端建议改成点击弹窗的方式不然选区和浮层的坐标会错位。div classarticle-content :class{ font-small: fontSizesmall } p v-for(paragraph, index) in paragraphs :keyindex classparagraph mouseuphandleTextSelection($event, paragraph) {{ paragraph }} /p /divCSS部分用Vue的scoped样式隔离避免组件间的样式互相污染。深色阅读模式就加一个>import axios from axios; import { Message } from element-ui; import router from ../router; const service axios.create({ baseURL: /api, timeout: 10000 }); service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] Bearer token; } return config; }); service.interceptors.response.use( response { const res response.data; if (res.code 401) { localStorage.removeItem(token); router.push(/login); } return res; }, error { Message.error(error.response?.data?.message || 请求失败); return Promise.reject(error); } ); export default service;统一拦截的好处是Token注入、错误提示、登录过期跳转这些横切逻辑只写一遍。实际项目里我遇到过401后还要弹窗确认的复杂需求那种情况就得在拦截器里区分请求类型不能一股脑跳转登录。接口定义我也单独放一层比如api/article.js里导出getArticlePage(params)、getArticleDetail(id)页面组件里只关心调用函数不关心URL怎么拼。这一层封装看起来多写了一堆文件但后端的URL一变只需要在这个文件里改一个地方否则所有页面都要翻出来改那种痛苦写过的都懂。3.4 阅读交互与进度上报的节流处理阅读器前端有一个比较关键的交互问题进度上报频率怎么把握。如果用户每翻一页就上报一次频率尚可接受如果用户用滚轮高速滚动一次滚动可能触发几十次scroll事件每次都发请求后端根本扛不住。我的做法是在scroll事件里做节流2秒内最多上报一次同时记录用户停留的position等节流窗口到了再发送。let lastReportTime 0; const THROTTLE_INTERVAL 2000; function handleScroll() { const now Date.now(); if (now - lastReportTime THROTTLE_INTERVAL) { reportProgress(currentPosition); lastReportTime now; } }另外要处理一个边界用户读完文章后直接关闭浏览器或跳转页面最后一次上报可能来不及发送。我在beforeRouteLeave和window.addEventListener(beforeunload)里用navigator.sendBeacon把进度数据发送出去。sendBeacon是浏览器提供的专门用来在页面卸载时发送数据的接口它不受页面关闭影响比普通的fetch更靠谱但注意它只能发POST且数据格式是Blob或FormData后端接口要兼容这种Content-Type。这个细节我在调试时一度以为是进度丢失后来加日志才发现是请求根本没发出改用sendBeacon之后就稳定了。4. 部署上线Nginx SpringBoot完整流程4.1 部署架构总览开发完成后的部署思路我最终选择了业界最常见的组合前端打包成静态文件扔给Nginx托管后端打成一个可执行Jar包运行在服务器上Nginx再把/api路径的请求反向代理到后端的8080端口。前端页面和后端服务分开部署好处是静态文件的响应完全由Nginx负责性能好后端只需要专注处理接口将来如果要把静态资源放到CDN或者换成其他Web服务器都不用动后端代码。有人可能会问为什么不把前端打包后直接塞进SpringBoot的src/main/resources/static目录里一个Jar包全搞定部署不更简单吗这种单包方案确实存在适合个人项目或者极简部署但它的问题是前端每次更新都要重新打包后端而且Nginx层面能做的静态缓存、压缩、HTTPS终结全都享受不到了。我建议学这个项目的时候按标准的前后端分离部署走一遍单包方案当作备用选项了解即可。4.2 后端打包与运行后端打包比较简单因为用的是Maven在项目根目录执行mvn clean package -DskipTests构建产物在target/目录下名字是reading-platform-0.0.1-SNAPSHOT.jar。运行命令java -jar reading-platform-0.0.1-SNAPSHOT.jar --server.port8080如果你要把端口、数据库连接这些配置外置用SpringBoot的配置方式在Jar包同目录放一个application.yml启动时会自动读取优先级比Jar包内部的配置更高。生产环境我建议把数据库密码、JWT密钥这类敏感信息通过环境变量传进去别硬编码到配置文件里。部署到服务器上后直接用java -jar在前台跑的话SSH窗口一关服务就停了。我在Linux服务器上用的是systemd托管[Unit] DescriptionReading Platform Backend Afternetwork.target [Service] Useradmin WorkingDirectory/opt/reading-platform ExecStart/usr/bin/java -jar /opt/reading-platform/reading-platform.jar Restartalways RestartSec5 [Install] WantedBymulti-user.targetRestartalways意味着进程异常退出会自动拉起RestartSec5是退出5秒后重启。个人服务器上反正是够用。如果你用的云主机自带进程管理面板比如宝塔的进程守护也可以用面板来托管。Windows环境下的测试部署就没有这些花招开一个命令行窗口跑Java命令或者用WinSW工具注册成Windows服务都行。这里补充一个热词里的点Tomcat和SpringBoot的关系。SpringBoot内置了Tomcat容器所以我们上面的Jar包直接就能跑不需要单独装Tomcat。但如果你的项目是有独立Tomcat环境要求需要把打包方式改成war包部署到外部Tomcat的webapps目录下那就要在pom.xml里把packagingwar/packaging启动类继承SpringBootServletInitializer并重写configure方法。如果只是做前后端分离项目我要推荐走Jar方式省事、版本自包含、不依赖服务器环境上线部署时不用刻意买整套环境去适配Tomcat的版本。4.3 前端构建与Nginx配置前端构建在项目根目录npm run build构建产物在dist/目录里是一堆纯静态文件html、css、js、图片。把这整个目录上传到服务器的/opt/reading-web目录下然后在Nginx里做一个server配置server { listen 80; server_name your-domain.com; root /opt/reading-web; 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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location ~* \.(js|css|png|jpg|jpeg|gif|svg|woff2?)$ { expires 7d; add_header Cache-Control public, immutable; } }这里有一个几乎所有前端都会踩的坑try_files $uri $uri/ /index.html;这一行。Vue默认用的路由模式是history模式地址栏里是/reader/12这种干净的URL但浏览器刷新页面时Nginx会拿这个URL去找服务器上对应的静态文件肯定找不到直接报404。try_files的/index.html兜底逻辑就是找不到文件时把请求重写回首页HTML让Vue Router接管路由渲染出正确页面。如果你用的是hash模式地址里带#就不会有这个问题但history模式的URL更美观代价就是Nginx必须配合做这一步。关于proxy_pass地址注意结尾有没有斜杠的区别proxy_pass http://127.0.0.1:8080;不带斜杠会把原始的/api/xxx路径原样转发到后端如果写成http://127.0.0.1:8080/;带斜杠会把匹配到的/api/前缀剥掉再转发变成后端收到的路径是/xxx。到底哪种对取决于后端Controller的RequestMapping路径。我的后端Controller统一都带/api前缀所以Nginx这里用不带斜杠的写法后端的接口路径保持/api/...完整转发。如果两者不匹配接口请求到后端就是404这个排查点很隐蔽。Windows本地测试Nginx也很简单下载Nginx Windows版解压改好配置文件后在目录里执行nginx.exe -c conf/nginx.conf启动默认监听80端口。注意不要开两个Nginx实例改配置后使用nginx.exe -s reload重新加载配置不用重启进程。4.4 部署检查清单部署完成后我按下面这张清单逐项检查避免漏掉关键点检查项检查方法常见问题后端端口状态netstat -tlnp查看8080是否监听端口被占用换端口或杀进程前端静态文件浏览器访问首页按F12看Network404说明dist上传不完整或root路径不对接口转发访问/api/article/list看返回的JSON502是后端没起504是代理超时404是proxy_pass配错数据库连接看后端启动日志中有没有HikariPool启动成功的记录MySQL没启动或密码错误权限校验去掉Token访问受保护接口返回401JSON才正常刷新路由在/reader/12页面按F5刷新404说明try_files没生效静态缓存看JS文件响应头里的Cache-Control更新前端后浏览器还是旧版本考虑加版本号这套检查清单是我在一次部署时被坑出来的。那时候前端刷新404我第一反应是后端路由有问题排查了半小时发现是Nginx的try_files漏写了。后来学乖了部署完成后按清单从底层往上层逐项看哪里断了哪里一眼就清楚。5. 常见问题与排查实录5.1 MySQL连接类问题这个项目启动阶段最容易卡住的就是数据库连不上。最常见的报错是ERROR 2002 (HY000): Cant connect to local MySQL server through socket /tmp/mysql.sock。这个报错通常意味着MySQL服务根本没有启动Linux上可以通过systemctl status mysqld有的版本是mysql查服务状态systemctl start mysqld启动。如果是云服务器还要检查安全组和防火墙是否开放了3306端口。另一个典型的坑是MySQL 8的用户认证插件问题。MySQL 8默认的加密方式是caching_sha2_password而一些老版本的JDBC驱动或者把PASSWORD设为mysql_native_password的账号连不上报错信息是Unable to load authentication plugin caching_sha2_password。解决办法是升级驱动到8.x版本或者把用户认证改回旧方式ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 你的密码; FLUSH PRIVILEGES;还有时区问题。报The server time zone value Öйú±ê׼ʱ¼ä is unrecognized这种乱码时区报错就是连接串里没加serverTimezone参数在url里补上serverTimezoneAsia/Shanghai即可。遇到数据库报错别慌先把连接串的几个关键参数时区、SSL、公钥获取逐项检查一遍八成问题就出在这几个地方。5.2 MyBatis与分页插件排查Mapper接口的XML文件如果扫描不到启动会直接报Invalid bound statement (not found)。检查三个地方application.yml里mybatis.mapper-locations路径对不对XML文件的namespace是不是对应的Mapper接口全限定名接口方法名和XML里的id是否完全一致。尤其是第三个我曾经把方法名叫selectArticleListXML里写的是selectArticleLists少一个字母直接报错而且这个错误在编译期不会提示只有运行到调用时才会炸出来。PageHelper的坑主要体现在分页不生效或者分页错乱。最常见的原因是在startPage和查询之间插了别的查询操作比如我做文章列表前先查了个用户信息然后PageHelper会把LIMIT加到这个用户查询上导致权限查询只返回一页业务列表反而不分页。记住一个铁律PageHelper.startPage后面必须紧跟要分页的那条Mapper查询中间不要夹带任何其他数据库操作。另外如果自己写了LIMIT语句又用了PageHelper会SQL语法错误两者只需选其一。MyBatis缓存导致的问题前面提过再补充一个具体场景文章管理后台编辑文章内容保存成功后前台阅读器里刷新看到的还是旧内容。这种情况大概率是二级缓存或者一级缓存没有清理。如果确实开了二级缓存在文章的增删改Mapper的XML里加上flushCachetrue/或者干脆给文章表对应的Mapper不开缓存阅读内容这种数据对实时性要求比性能要高不值得冒缓存脏读的风险。5.3 前端与跨域问题生产环境的前后端分离部署几乎都会遇到跨域问题。如果用浏览器直接访问http://your-domain.com80端口页面里的ajax请求/api/...因为同源所以没有跨域问题这是Nginx反向代理方案的最大优势。但如果前端和后端端口不一致比如前端8081后端8080浏览器直接请求后端接口就会出现Access-Control-Allow-Origin报错。开发环境用代理解决生产环境还是把动静分离交给Nginx。如果实在要跨域直接请求后端那么后端需要配置跨域过滤器Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(http://your-frontend-domain.com) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意如果用了allowCredentials(true)allowedOrigins就不能写*必须明确指定具体的域名。这个坑来自浏览器的安全策略携带Cookie的跨域请求不能使用通配符Origin。前端还有个经常被忽视的问题出现页面白屏。排查思路是打开控制台看有没有JS报错最常见的是Element UI组件没按需引入导致某个组件找不到、路由懒加载的组件路径写错、或者接口返回的数据结构不对导致模板里访问了undefined的属性。我遇到过一次非常隐蔽的白屏后端接口正常返回但前端代码里用了article.category.name这种链式写法而后端返回的category是null模板渲染直接抛异常导致整个页面白掉。后来统一在接口层做数据兜底或者模板里用article.category?.name可选链写法这个问题才算根治。5.4 安全防护细节在线平台尤其是这类人人可注册的平台安全上的东西我不能不提两句。第一个是接口参数校验。拿文章内容上传来说管理员在前端富文本编辑器里写入的内容如果原样存库原样返回等于给站点埋了一个XSS大坑。读取文章正文时如果直接v-html渲染恶意脚本就会被执行。我的处理方式分两段入库前用后端过滤HTML标签和事件属性前端展示时尽量用文本插值而不是v-html确实需要富文本展示的地方就做一个白名单过滤只允许保留加粗、换行、链接这些安全标签。第二个是SQL注入。项目里凡是字符串拼接的地方都必须用MyBatis的#{}这个前面强调过了。第三个是文件上传。平台如果支持用户上传头像、PDF等文件要限制文件类型和大小SpringBoot里可以配置spring.servlet.multipart.max-file-size同时在后端做扩展名和服务端文件头双重校验。如果还要防XSS攻击我可以加一个全局的过滤器在请求进入Controller前统一过滤参数这是安全链路里最简单的一道防线单靠某个接口自行过滤是不可靠的。结语做完这个项目后我的一些体会整个项目从设计数据库到上线部署全部跑通后我最大的感受是前后端分离架构在项目中带来的收益是实打实的。后端六个模块、前端五个页面两端各改各的并行开发完全没冲突联调的时候对照API文档走一遍就通了。数据库设计上花的功夫值得唯一索引和联合索引在数据量上来之后会明显感觉查询稳定进度上报和生词本的重复插入都被数据库层面的约束挡得干干净净。如果继续往下扩展这个平台还可以加入一个基于词频统计的推荐系统对用户读过的文章做单词分析统计高频生词然后从文章库中推荐包含最多用户未掌握单词的新文章这会让分级推荐四个字从静态标签变成动态策略。再往上层走还可以结合NLP的分词和词形还原工具对英文文本做预处理自动计算文本的词汇难度级别减少人工标注工作量。这些方向说到底都属于同一套数据链路基础底座搭好了扩展就是加接口和加页面的问题。希望这篇文章能帮到正在纠结类似架构或者正在做同类平台的你。