☰
在线英语阅读分级平台:SpringBoot+Vue+MyBatis全栈实践
2026/10/3 9:52:26 网站建设 项目流程

前后端分离的在线英语阅读分级平台,这个项目我前后改了四版才稳定下来。最早期版本是纯JSP的单体应用,页面和接口混在一起,后期加功能时改一个查询就要重启一次服务,前端想调样式还得等后端编译完成。后来彻底重构为SpringBoot+Vue+MyBatis+MySQL的前后端分离架构,赎罪了至少一半的联调时间。这篇文章我会把这套系统的设计思路、核心分级算法、数据库表结构、部署步骤和踩过的坑完整写清楚,不光是给毕业设计选型的同学参考,如果你正打算做一个内容推荐类的全栈项目,这套拆解也能直接复用。

标题里的"html+css"很容易让人误以为前端只是静态页面,实际是Vue组件化的单页应用,所有页面模板仍然是HTML+CSS的写法,只是被组件系统组织起来。整个平台解决的痛点很朴素:市面上的英语阅读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. 技术选型:SpringBoot+Vue+MyBatis+MySQL为什么能搭起来不打架

2.1 前后端分离的收益不是"代码好看",是协作方式变了

很多初学者对前后端分离有一个误解,以为就是把HTML文件从后端目录挪到前端目录。不是这个意思。

前后端分离的本质是:前端只关心页面渲染和交互,后端只关心数据处理和业务逻辑,两者通过JSON格式的HTTP接口通信。在这个架构下,前端不用管"页面数据从哪来",后端也不用管"数据在页面上长什么样"。

这个项目我体会最深的收益是并行开发效率。重构后的第二周,我和一个前端同事配合,我负责定义API接口的出入参格式和Mock数据,他直接照着Mock数据开写页面,等后端真实接口好了再切换baseURL。整个过程没有因为等待依赖而阻塞过一天。

当然也有代价,最大的代价是出现了跨域问题。浏览器安全策略默认禁止前端页面访问非同源的接口,这就引出了开发环境的代理配置和生产环境的Nginx反向代理。这些细节我会在部署章节详写。

2.2 SpringBoot、Vue、MyBatis、MySQL各自解决哪一层的问题

选这套组合不是因为它"热门",而是每一层都有不可替代的实用理由。

SpringBoot:负责后端接口和业务逻辑。我最早用SSH(SpringMVC+Spring+Hibernate)做过项目,光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_user)

id 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_article)

id 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 DATETIME

content字段用LONGTEXT是稳妥选择。VARCHAR最多能存65535字节,英文文章倒是勉强够,但有些长篇阅读理解超过这个长度就会插入失败。

level字段是冗余存储,因为蓝思值是一个连续值,实际展示和筛选用离散的等级更直观。计算完成后同时写两个字段。

阅读记录表(t_reading_record)

id 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_vocabulary)

id 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 id="selectArticleList" resultType="com.reading.entity.Article"> SELECT id, title, level, lexile_score, word_count, keyword FROM t_article <where> <if test="level != null"> AND level = #{level} </if> <if test="keyword != null and keyword != ''"> AND ( title LIKE CONCAT('%', #{keyword}, '%') OR keyword LIKE CONCAT('%', #{keyword}, '%') ) </if> AND status = 1 </where> <choose> <when test="sortBy == 'latest'"> ORDER BY create_time DESC </when> <when test="sortBy == 'difficulty'"> ORDER BY lexile_score ASC </when> <otherwise> ORDER BY id DESC </otherwise> </choose> </select>

<where>标签会自动去掉第一个条件前面的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查询"和"level+status同时查询"两个场景,但如果单独用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); }

公式里的两个系数是我拿一批已标注难度的文章做回归测试反推出来的,它不追求对标官方的蓝思值精确度,但在区分"这篇比那篇难"这件事上做到了稳定一致。

这里有一个很容易踩的坑:英文文本里常见"don't"、"it's"这类带撇号的单词,正则如果处理不好会把词切碎。我在实现里先转成小写,再用[^a-z ']保护撇号,最后按空白符分割,这样"don't"会被整体当作一个单词处理。

4.2 用户水平动态评估机制

用户等级不能一成不变。初始等级靠测评试卷,之后要持续调整。

我的调整策略是在用户完成一篇阅读后触发计算:

  • 如果完成率达到80%以上,且生词数小于文章总词数的5%,视为"读得太轻松",尝试上调等级;
  • 如果完成率低于40%,或生词率超过15%,视为"读得太吃力",下调等级;
  • 中间区间的,等级保持不变,但蓝思值做小幅微调。

这里有个设计细节值得说一下:我没有在用户每读完一篇文章时立即重算并覆盖等级,而是把计算结果写入一张t_user_level_record记录表,再通过定时任务每小时汇总一次。这样既保留了用户水平变化的轨迹,也避免高频写入导致行锁竞争。

4.3 推荐匹配的SQL实现:±1 等级的边界处理

从"用户当前等级±1"区间筛选文章,听起来简单,但有一个边界问题:用户如果是1级,"减一"就是0级,系统里不存在0级文章;用户如果是5级,"加一"就是6级,也没有。

我的Repository层是这样处理的:

<select id="selectRecommendArticles" resultType="com.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 request

baseURL从环境变量读取,开发环境指到代理地址/api,生产环境指到Nginx的域名。这样前端代码本身不需要关心后端地址在哪里。

5.4 阅读进度与生词交互的防抖处理

阅读过程中需要持续上报进度,比如每10秒上报一次。如果用户快速滚动,或者连点生词按钮,高频请求会把后端打得不轻。我在Vue侧用了防抖函数:滚动暂停8秒才上报一次位置,生词添加后30秒内重复标记同一个单词只发一次请求。这个小优化做完后,接口QPS峰值直接降了60%。

6. 完整部署教程:从IDEA到浏览器全流程

6.1 环境准备清单

部署前先确认机器上装齐了以下软件,版本建议是我实测稳定的组合:

软件建议版本用途
JDK1.8或11编译运行SpringBoot
Maven3.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?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/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.StdOutImpl

map-underscore-to-camel-case这个配置务必设为true,MySQL字段是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 2>&1 &

用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上显式加useSSL=false,表明本地开发环境不需要加密传输。如果是生产环境,则建议配置合法证书后保持SSL开启,不要因为本地图方便把安全策略关了一路带到公网。

7.3 中文乱码的两个处理点

乱码问题出现过两次。第一次是数据库建表默认字符集错误,建库时没带DEFAULT CHARACTER SET utf8mb4,全表都是latin1,存中文变问号。第二次是前端页面显示上乱码,原因是后端接口响应头里没声明字符集,加上produces = "application/json;charset=UTF-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 8080

Windows下通过最后一列的PID去任务管理器定位进程,Linux下用ps -ef | grep 对应的PID。很多时候是老旧的Tomcat或另一个Java进程在占用,直接杀掉即可。如果确实是其他服务占用且无法关闭,再考虑在application.yml里换一个端口。

写在最后的一点心得

这套系统从最初的原型到现在,我最大的体会是:分级平台的核心不在前端页面多好看,而在难度标尺是否稳定可信。如果一个用户读完一篇标为"基础"的文章,困难到根本读不下去,那他不会再信任这个平台。所以分级算法值得反复用真实语料去校准,我在测试阶段人工读了几十篇文章来对比系统给的难度值是否合理,这个过程虽然枯燥,但它是整个平台口碑的基石。

最后分享一个部署上的小技巧:如果你只有一台云服务器,又不想单独装Nginx,可以让SpringBoot的jar包直接监听443端口并把静态资源放进src/main/resources/static/目录,后端启动后前端页面和API都在同一个端口对外服务。这种方式牺牲了前后端完全分离的独立部署能力,但应急演示时最方便。它不改变开发时的前后端分离体验,生产部署多了一个备选项。根据自己的运维能力二选一就好。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询