
我大概花了三个周末把整个平台从零写到能跑通全流程期间改了两版数据库设计踩了不下十个坑。这篇文章把整个项目的设计思路、核心实现、以及那些让人挠头的细节都摊开来讲希望能让正在做类似前后端分离项目的朋友少走点弯路。1. 项目整体设计与选型思路先说这个项目到底是干什么的。在线英语阅读分级平台核心逻辑其实就一条让不同英语水平的人读到不同难度的文章。现实中类似的产品有蓝思阅读、RAZ分级阅读等付费平台我这套是自研的简化版本——管理员录入文章时给文章标注难度级别比如L1到L5用户注册时做一次简单测试或者自己选择级别系统就按这个级别推荐文章。阅读过程中记录用户的阅读进度、生词收藏后台还能查看文章的阅读统计数据。技术栈方面标题里写得很明白SpringBoot Vue3 MyBatis MySQL前端还有你自己写的 HTML CSS 定制样式。这套组合属于目前国内中小型项目最主流的一套没有一个是多余的SpringBoot负责提供 REST API内嵌 Tomcat不用单独部署容器打包成 jar 就能跑。我用的是 2.7.x 版本没用 3.x原因后面讲是踩过坑的。Vue3 Vite做单页面应用配合 Vue Router 管理页面跳转Pinia 管理用户状态。Vite 开发时的热更新速度比老一代 Webpack 方案快得多改完代码浏览器几乎秒刷。MyBatis做持久层SQL 自己写可控性强。尤其是涉及多表联查、分页查询的时候比 JPA 那种自动生成 SQL 的方式更容易理解执行逻辑。MySQL 8.0存数据InnoDB 引擎utf8mb4 字符集——这个字符集必须强调一下用 utf8 会导致 emoji 和部分生僻字比如某些英语音标符号存不进去。我后面专门写了下 utf8mb4 的原因。整体架构上我选的是前后端完全分离即前端只通过 Axios 调后端接口拿 JSON 数据所有的模板渲染、路由跳转都在浏览器端完成。开发时后端跑 8080 端口前端跑 5173 端口通过 Vite 的代理配置把 /api 开头的请求转发到后端完美避开跨域问题。生产环境则把前端打包出来的静态文件直接扔到 Nginx 里再由 Nginx 反向代理到 SpringBoot 服务。这个架构选型的核心考量是维护边界清晰。前端的人只管页面交互后端的人只管接口和数据谁也不用等谁。而且如果未来要加个小程序端后端接口完全可以复用只需要另做一个客户端。2. 数据库设计与后端核心实现2.1 数据表设计思路数据库设计是整个项目的根基一旦表结构设计不合理后面写Mapper的时候简直是给自己上刑。我第一版就把用户自主选级别这个逻辑直接做成 users 表里的一个 level 字段后来发现用户会换级别、管理员要调整文章推荐规则数据根本不好统计又重新拆表了。这是我的核心表设计九张表表名作用关键字段users用户信息id, username, password, current_level, role(0用户/1管理员), created_atarticles文章元数据id, title, level, category, cover_url, summary, word_count, read_countarticle_content文章正文id, article_id, content(按段落数组存入)user_levels用户级别历史id, user_id, level, reason, created_atreading_records阅读记录id, user_id, article_id, progress, status(0未读完/1读完), last_timevocabulary生词本id, user_id, word, definition, gloss, created_atfavorites收藏表id, user_id, article_id, created_atcategories分类表id, name(如科技/人文/故事)一个容易忽略的问题是文章正文为什么要单独拆一张表直接放到 articles 表里不香吗原因很实际列表页只需要标题、封面、摘要、难度和字数这些字段加起来不超过 200 字节而正文动不动就是几万字符如果放在同一张表列表查询的 IO 成本会高很多尤其数据量上来以后会非常明显。拆表是最简单有效的垂直分割方案。2.2 Maven 工程结构与依赖配置后端我用的标准 Maven 工程结构这一点真心建议初学者直接模仿就行别自己发明一套包名规则。包结构如下com.example.readingplatform ├── controller # 接口层 ├── service # 业务层 ├── mapper # MyBatis DAO层 ├── entity # 实体类 ├── dto # 前端交互用的数据传输对象 ├── config # 配置类跨域、拦截器等 ├── common # 通用返回类Result、异常处理 └── utils # JWT工具、加密工具等依赖配置方面最核心的几个依赖如下我用的 SpringBoot 2.7.18Java 8 语法兼容性最好parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency /dependencies注意SpringBoot 2.7.x 对应的 mybatis-spring-boot-starter 版本是 2.3.x这俩版本是官方验证过配套的。如果你用 SpringBoot 3.x就必须换成 mybatis-spring-boot-starter 3.x 版本而且 JDK 要升到 17。我当时贪新鲜试过 SpringBoot 3.2结果发现很多第三方 starter 还没适配完报错报得你怀疑人生生产项目保守一点不是坏事。2.3 MyBatis 配置与 SQL 实践MyBatis 这一块信息量比较大我挑几个最干活儿的部分展开说。第一驼峰映射与配置。以前写 SSM 项目时最烦的就是实体类属性camelCase和数据库字段snake_case的手动映射写多了手疼。MyBatis 提供了全局配置开关mybatis: configuration: map-underscore-to-camel-case: true mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.readingplatform.entity打开这个开关之后create_time字段就能自动映射到实体类的createTime属性了。但也别太依赖它遇到多表联查里出现两个表的同名ID字段时还是需要手动指定resultMap或者给列起别名这是 MyBatis 的经典坑。第二动态 SQL 的合理使用。写文章列表接口时要支持按难度、分类、关键词组合筛选。如果每个条件都写个独立接口那接口数量会爆炸。正确做法是使用 MyBatis 的动态 SQLselect idselectArticlesByCondition resultTypecom.example.readingplatform.entity.Article SELECT id, title, level, category, summary, word_count, read_count FROM articles where if testlevel ! null and level ! AND level #{level} /if if testcategory ! null and category ! AND category #{category} /if if testkeyword ! null and keyword ! AND (title LIKE CONCAT(%, #{keyword}, %) OR summary LIKE CONCAT(%, #{keyword}, %)) /if /where ORDER BY id DESC /select用where标签有个好处它会智能去掉多余的AND或OR前缀不需要你自己写那些丑陋的WHERE 11。LIKE 查询里的CONCAT(%, #{keyword}, %)也可以避免 SQL 注入风险不要用${keyword}直接拼接字符串。第三关于 MyBatis 的二级缓存。项目里默认只开了一级缓存SqlSession 级别二级缓存没开。原因很简单——阅读平台的文章列表接口每次返回的数据量都比较大如果开二级缓存缓存存放的是对象引用一旦某个线程修改了对象缓存同步就是个大麻烦。而且文章阅读数、热度排序这些字段是实时变化的缓存过期时间不好定。这类业务场景老老实实查数据库反而是最稳妥的。如果是字典表、配置表这类低频变更数据则可以考虑用CacheNamespace开启二级缓存。2.4 用户认证JWT 登录方案前后端分离项目的用户认证我选了 JWT 而不是传统的 Session。核心原因Session 依赖服务端存储前端是单页面应用的话每次请求都得带着 Cookie跨域场景下 Cookie 的处理极其麻烦。JWT 则从设计上解决了这个问题——用户登录成功后后端签发一个带过期时间的 token前端存到 localStorage 里每次请求放到Authorization请求头带回后端通过拦截器验证 token 合法性。生成和校验 JWT 的关键代码如下// 生成 token有效期设置为 24 小时 public String generateToken(Integer userId, String username, String role) { Date now new Date(); Date expireDate new Date(now.getTime() 24 * 60 * 60 * 1000); return Jwts.builder() .setHeaderParam(typ, JWT) .setSubject(String.valueOf(userId)) .claim(username, username) .claim(role, role) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }需要注意的是secretKey一定不能写在代码里硬编码我这边是放到application.yml的配置项里不同环境用不同配置。登录接口比较特殊它必须在拦截器白名单里。我用了一个简单的WebMvcConfigurer子类来配置拦截器放行/api/auth/**开头的路径其余一律校验。前端在 Axios 拦截器里统一塞 token 和处理 401 过期跳转的逻辑也要写好否则每个接口都要单独判断登录状态代码重复度会非常高。3. 前端 Vue3 项目实现与页面流程3.1 项目初始化与目录结构前端这里我用的是 Vue3 Vite Vue Router Pinia Element PlusUI 组件库的组合。Vite 创建项目的命令很简单npm create vitelatest reading-frontend -- --template vue cd reading-frontend npm install npm install vue-router4 pinia axios element-plus项目目录结构基本如下这个组织方式很常规但确实清晰src/ ├── api/ # Axios 接口封装 ├── assets/ # 静态资源 ├── components/ # 通用组件 ├── router/ # 路由配置 ├── stores/ # Pinia 状态管理 ├── views/ # 页面级组件 ├── utils/ # 工具函数 └── App.vue关于 Vue3 和 Vue2 的最大区别我自己的体会是Composition API 带来的思维转变。Vue2 的 Options API 把代码按data/methods/computed/watch分块逻辑稍微复杂一点就会发生同一个功能的代码被拆到三个地方的问题。而 Vue3 的script setup语法让我能按功能而不是按选项来组织代码特别是组合式函数Composables类似 React 的自定义 Hook可以很大的提升复用性。3.2 阅读器页面实现详解阅读器页面是整个系统最核心的界面。功能要求展示文章正文、记录阅读进度、允许用户收藏生词、调整字号、切换章节。实现的时候有几个关键点。第一正文渲染与进度记录结合。正文是按段落数组存放的前端拿到后用v-for渲染成多个p标签。要记录阅读进度我采用了滚动位置记录的方案。一个朴素的思路是在uniqueactive滚动事件中实时保存scrollTop但这有性能问题——滚动事件每秒钟能触发二三十次每次都往后端发请求显然不合理。我的做法是节流 离开页面时保存。滚动过程中只在本地记录滚动位置用户的滚动结束才通过节流函数800ms把位置临时保存到全局状态等到用户点击退出阅读或关闭页面beforeRouteLeave时一次性调接口把这个进度提交到后端。这样既保证了进度不丢又不会给后端造成压力。第二生词收藏的交互逻辑。用户阅读过程中遇到不认识的单词双击选中后弹出一个气泡卡片显示单词的释义以及加入生词本按钮。这里用到了window.getSelection()来获取选中文本然后调用后端接口查词义。词义数据我是提前录入到 vocabulary 词库表中的查不到时调用一个免费词典 API 兜底。这块交互看起来简单但实际上选中区域的定位和气泡卡片的位置计算很费头发我花了一晚上才搞定边缘情况。第三字号与主题的响应式设计。阅读器支持三档字号切换小/中/大和夜间模式这些状态直接用 Pinia 存起来刷新页面时从 localStorage 恢复。实现字号动态变化时有个小技巧不要给每个p单独绑定:style{ fontSize: size px }那样会产生大量样式更新。正确做法是给阅读器容器设一个font-size下方的p全部用em相对单位字号一变全局联动。3.3 前端与后端联调及跨域处理前后端分离开发中最烦躁的一个环节就是跨域。开发阶段因为前端是 Vite 的 5173 端口后端是 8080 端口属于跨域请求。我的解决方案是在 Vite 的配置文件里设置代理// vite.config.js export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这样前端代码里只需要写/api/articles/list这种相对路径请求到了 Vite 后自动转发到后端 8080 端口浏览器端完全感知不到跨域的存在。在生产环境Nginx 配置一个类似的 location 代理规则即可。同时后端我也做了一个 CORS 配置兜底防止有直接调用后端接口的场景比如第三方客户端Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowCredentials(true) .maxAge(3600); } }说句实在话习惯用 Vite 代理之后后端的 CORS 配置更多是一种保险实际开发流程里几乎不会触发。4. 核心功能模块的完整实现4.1 分级推荐逻辑的算法设计分级阅读平台最核心的价值在于分级推荐准不准。我做的工作分了三个层面第一文章的初始分级。管理员在录入文章的时候就要选择级别L1-L5同时系统也会给一个辅助参考正文录入时后端自动分析文章的词频计算文本的平均词长和句长对照经验值给出一个建议级别。这里的判断逻辑很简单平均句长小于 10 个词的偏 L1-L2平均句长超过 20 个词且出现大量低频词的偏 L4-L5。这个算法当然比不上专业的蓝思值评估但对于一个小型平台来说已经足够合理。第二用户级别的动态调整。初始注册时用户自选级别但用户读了几篇文章之后系统会根据阅读完成度和平均停留时长来判断文章难度是否合适。如果用户在 L3 的文章里平均进度超过 85%且每次阅读时长都超过 5 分钟系统就提示可以尝试挑战 L4 了。这个判断放在一个定时任务里跑每晚统计前一天的阅读数据更新user_levels表。第三推荐列表的生成逻辑。推荐列表不是简单按级别过滤我还会根据用户收藏的词汇、历史阅读分类来打一个偏好分将同级别中偏好分类的文章排在前面这就是一个最基本的推荐排序。SQL 实现上先按用户当前级别过滤再按分类偏好排序。4.2 阅读计时的统计方案后台管理页面需要展示一个文章的平均阅读时长这个数据用来辅助判断文章难度。前端阅读器页面会在进入文章时记录一个开始时间戳离开时把实际停留秒数传给后端。容易踩坑的是用户可能一直挂着页面不关。所以我设计了一个有效阅读时长算法只有页面处于可见且非空闲状态时才累计时间核心就是监听浏览器的visibilitychange事件document.addEventListener(visibilitychange, () { if (document.hidden) { // 页面被切走暂停计时 pauseTimer() } else { // 用户回来了恢复计时 resumeTimer() } })后端收到这些数据后按文章分组存到reading_stats表配合每天定时把统计数据预聚合到文章表的一个字段里避免后台管理首页每次打开都重算一遍大数据。4.3 管理后台实现要点管理后台是给管理员用的功能包括文章录入与管理、用户管理、阅读数据统计、词汇表管理。这里做了一个权限控制普通用户的 token 里 role 是user管理员登录后 role 是admin。前端路由守卫中判断登录用户角色如果访问未授权页面就跳转到 403 页面。后端也在拦截器里校验了/api/admin/**路径的 token 角色。文章录入这块是我做得比较顺滑但内容量最大的功能。管理员提交标题、分类、难度分级、封面图链接、摘要和正文后端需要做两件事生成articles表记录和article_content表记录计算文章字数并将正文按段落数组存储。封面图我目前用的是外部图床链接如果后续要升级可以接入 MinIO 做自托管的对象存储把文件上传改成从前端直接上传到 MinIO后端只负责生成签名 URL。这块思路和之前搜索热词里提到minio 加入到 springboot是吻合的属于项目的自然进化方向。管理员还可以在后台把文章数据导出成 Excel/Word 报表我用的是 Apache POI 库。之前看到有网友问java poi word 能生成图表吗答案是能但那段代码真的很折磨人文档对象模型非常繁琐。我这边的做法是 Excel 报表用 POI 生成服务端直接输出对应格式的下载流前端就是一个普通的下载请求用户体验很好。4.4 生词本与收藏模块用户阅读过程中双击查词并收藏到生词本前端弹出气泡卡片在生词本页面用户可以复习所有收藏的词可以标记已掌握删除。词汇表中需要包含单词本身、音标、中文释义、英文释义、例句。这个模块的表结构非常简单就是 id-user_id-word-definition-example-created_at。查询时根据自己的库做模糊匹配大写的单词统一转小写后存储避免同一单词大小写不一致导致的重复收藏。这里我想多说一句词形还原的问题用户在文章中碰到的是running最后收藏的应该还原成run吗我这个版本偷懒了直接存原文。但实际体验中你会发现如果系统给用户一个动词原型学习效果反而更好。要做得完善的话可以用一个简单的词库匹配或者调用分词库比如 HanLP热词里也有它的名字做词形还原这样生词本的质量会有质的提升。这块可以作为后续迭代方向。5. 环境部署过程中踩过的坑与解决方案5.1 MySQL 8 连接报错排查开发环境我用的是 MySQL 8.0SpringBoot 2.7.x 自带的数据库驱动是mysql-connector-java8.0.x本来应该没有大问题但我在首次启动时报了SSL connection error。这个报错的原因和解决方案在搜索引擎里一搜一大堆但很多人照抄了链接参数还是不行。问题多半出在 JDBC URL 上正确写法如下spring: datasource: url: jdbc:mysql://localhost:3306/reading_platform?useUnicodetruecharacterEncodingutf8mb4useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueserverTimezone和allowPublicKeyRetrieval这两个参数最容易忘。忘了serverTimezone会报时区差异异常忘了allowPublicKeyRetrieval在 MySQL 8 使用 caching_sha2_password 认证时可能直接连不上。useSSLfalse代表本地开发不用加密连接生产环境则应该正确配置 SSL 证书别照搬。数据库本身我建议直接下载 MySQL 官方安装包从 mysql 官网下载页下载新手优先选 MSI 安装包一路 Next 就行记得设置 root 密码时选 MySQL 8 推荐的认证方式或者干脆用mysql_native_password兼容性更强一点省得和驱动较劲。5.2 MyBatis Mapper 文件扫描不到这是 MyBatis 项目中最经典的报错启动时提示Invalid bound statement (not found)但你的 Mapper 接口确实存在方法也写了。原因是 XML 映射文件没有被扫描到。有两种常见情况第一application.yml里的mapper-locations路径写错了。检查你的 XML 文件是不是放在classpath:mapper/目录下注意这里的 classpath 对应的是编译后的 classes 目录不是源码目录。用 IDEA 时注意 maven 默认不会把 src/main/java 下的 XML 拷贝到 target 目录里如果你把 XML 放在源码目录下就要在 pom.xml 中额外配置资源目录build resources resource directorysrc/main/java/directory includes include**/*.xml/include /includes /resource /resources /build第二启动类忘记加MapperScan。我自己习惯是在启动类上加MapperScan(com.example.readingplatform.mapper)这样就不用给每个 Mapper 单独写Mapper注解了。如果你两个都没问题再检查 Mapper XML 的 namespace 是否和接口全限定名一致、方法名是否和 statement id 一致这两个错也是同款报错。5.3 前端启动常见故障前端部分我用 Vite 开发了一段时间遇到的第一个坑是 Node 版本太低。Vite 4 要求 Node 18我原来机器上还跑着 Node 14直接启动报错。解决方法很简单下载 Node 18 LTS 或直接装 Node 20。如果你机器上有多个项目要用不同的 Node 版本建议装一个 nvm-windows 来切换。第二个坑是npm install装依赖失败报各种编译错误。很多情况下是项目依赖版本冲突我的建议是一律锁定版本号不要动不动用最新版本。比如以下这组搭配我实测是稳定的{ vue: ^3.4.0, vue-router: ^4.2.0, pinia: ^2.1.0, axios: ^1.6.0, element-plus: ^2.5.0, vite: ^5.0.0 }第三个坑是前端页面空白、控制台报错Uncaught TypeError: Cannot read properties of undefined十有八九是你引用了某个不存在的window.xxx全局变量比如第三方 SDK 还没加载完成或者请求返回的数据结构跟你v-if里用的字段对不上。这种问题要养成一个习惯——前端接接口数据不要直接res.data.articles先打印日志或打断点看清楚返回结构再写模板能省很多排查时间。5.4 打包部署与 Nginx 配置打包部署阶段有几个经典的坑。SpringBoot 后端打完 jar 包后如果部署的服务器 JDK 版本低于编译版本启动时直接报UnsupportedClassVersionError这个好理解。真正坑的是jar包中如果带了前端静态资源路径的配置启动时要把端口、数据库连接等外部化配置放到启动命令中而不是打包进 jar。前端部署就用 Nginx 承接静态资源和 API 代理。通常 Nginx 配置模板如下server { listen 80; server_name your-domain.com; # 前端静态文件 location / { root /opt/reading-platform/dist; index index.html; try_files $uri $uri/ /index.html; # 关键解决 Vue Router history 模式刷新404 } # API 反向代理 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 $uri $uri/ /index.html;这行是 Vue Router 使用 history 模式时的灵魂。你没加这一行的话用户在文章详情页刷新浏览器Nginx 会尝试找真实的/article/123路径然后返回 404加了之后所有路由都会回退到 index.html前端路由接管跳转。这个坑我敢说绝大多数刚部署 SPA 的人都遇到过。6. 常见问题速查与性能优化建议6.1 问题速查表把我在开发中高频遇到的问题做一个整理方便你快速定位现象可能原因解决方案后端启动报端口占用8080端口被其他程序占用改端口或lsof -i:8080杀进程前端请求接口 502Vite 代理目标地址不对检查proxy.target是否指向后端实际端口查询结果中文乱码JDBC URL 未加 encoding 参数加characterEncodingutf8mb4确认表字符集JWT 每次重启后失效secretKey是随机生成的配置固定 key不要让每次启动重新生成MyBatis 查询出来对象为 null字段映射失败或表名写错检查 resultMap 或驼峰映射开关Element Plus 弹窗位置异常组件版本与 Vue 版本不一致统一升级到兼容版本上传图片后访问 403静态资源路径未配置放行后端配置addResourceHandlers暴露本地目录阅读进度每次为零前端保存时用了错误时间戳打印参数核对是否传入 articleId打包后前端接口 404Nginx 代理路径没匹配检查 location /api 的配置和路径前缀6.2 数据库层面的性能优化平台的数据规模短期不大但一些查询场景的写法从一开始就要注意。比如文章列表页要支持只显示未读过的文章最初的 SQL 写法可能是SELECT * FROM articles WHERE id NOT IN (SELECT article_id FROM reading_records WHERE user_id ?)这种写法在小数据量没问题当 reading_records 表膨胀到几十万行之后NOT IN就会明显变慢。更优的方案是用LEFT JOINIS NULLSELECT a.* FROM articles a LEFT JOIN reading_records r ON a.id r.article_id AND r.user_id ? WHERE r.id IS NULL两者的执行计划和性能差异在索引条件齐全时非常明显。另外给所有外键字段加上普通索引MySQL 中 InnoDB 不会自动给外键建索引除非你在建表语句里显式加了阅读记录的查询一定要走user_id article_id的联合索引。6.3 对 MyBatis 框架的进一步理解这两天看到热词里不少人在搜mybatis 中 typehandler 的工作流程图、“mybatis 自定义 configuration。这两个点确实值得掌握。TypeHandler 是 MyBatis 中处理 Java 类型与 JDBC 类型转换的接口。项目里我用到了一个场景articles 表级别的标签字段比如科技短文真题在数据库里以逗号分隔字符串存储Java 实体类里想要的是ListString。默认的处理器不会帮你做这个转换我就自定义了一个MappedJdbcTypes(JdbcType.VARCHAR) MappedTypes(List.class) public class StringListTypeHandler extends BaseTypeHandlerListString { Override public void setNonNullParameter(PreparedStatement ps, int i, ListString parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, String.join(,, parameter)); } Override public ListString getNullableResult(ResultSet rs, String columnName) throws SQLException { String value rs.getString(columnName); return value null ? new ArrayList() : Arrays.asList(value.split(,)); } // 还有两个基于列索引的重载方法 }在 Mapper XML 中这样声明使用resultMap idArticleResultMap typeArticle id propertyid columnid/ result propertytags columntags typeHandlercom.example.readingplatform.handler.StringListTypeHandler/ /resultMap至于自定义 ConfigurationMyBatis 允许你通过实现ConfigurationCustomizerSpringBoot 场景来定制一些全局行为比如自定义对象工厂、自定义拦截器Interceptor做 SQL 日志打印、SQL 执行时间统计。我做了一个简单的慢 SQL 拦截器SQL 执行超过 500ms 就打印警告日志开发阶段对定位哪条查询需要优化特别有帮助。7. 从零搭建完整项目的进度安排与展望如果从空文件夹开始做这么一套系统参考我踩坑调整后的效率合理的时间规划大致如下第1天初始化数据库表结构 SpringBoot 工程搭建配置好 MyBatis能跑通用户注册登录第2-3天完成文章 CRUD 接口 阅读记录接口 生词本接口第4天Vue3 工程初始化封装 Axios完成登录注册页面和路由守卫第5-6天实现文章列表页、筛选条件、文章详情页 阅读器第7天实现生词本页面、用户个人中心第8-9天管理后台的文章管理和数据统计页面第10天前后端联调 部署测试这个进度是单人开发、白天还有工作的情况如果你全天上手五六天能跑通核心链路。我在实际开发过程中感受最深的一件事是不要把前后端分离想得太神秘它的本质就是把渲染页面和处理数据拆成两个项目各自独立开发用 HTTP 接口沟通。难点不在技术本身而在于你能否在设计阶段就把数据模型和接口约定清楚。我的教训是开发前先跟未来的自己或者队友把接口文档对齐字段名、类型、状态码统一规范后面整个流程会顺畅非常多。这版平台做下来我自己最大的收获是把从前零散掌握的知识点真正串成了一条线SpringBoot 的自动装配、MyBatis 的动态 SQL、JWT 无状态鉴权、Vue3 的组合式组件开发、Nginx 反向代理、MySQL 的索引设计。如果你也在做类似的全栈项目我建议不要只停留在能跑就满足多想想每个环节为什么这么配置、性能瓶颈可能出现在哪里这样项目做完了才是真正的技术积累而不是又一个跟着教程敲了一遍的 demo。