☰
Spring Boot全栈实战:历史故事展播系统设计与部署
2026/10/4 20:59:39 网站建设 项目流程

“中华历史故事展播系统”这名字一眼看上去就是个带文化属性的内容展示项目,但真正落地上手,你会发现它其实是一个相当完整的 Spring Boot 全栈工程。它的核心任务是解决这样一件事:把大量历史故事内容结构化地组织起来,让用户能按朝代、人物、主题去浏览和检索,同时支持注册登录、收藏、评论,管理员则要能方便地维护整个内容池。从技术链路看,它牵涉到 Spring Boot 后端接口设计、MyBatis 持久层封装、MySQL 数据库建模,外加 Vue 前端工程化,以及很多人在最后一步最容易卡住的“Vue 打包产物如何放进 Spring Boot 一起发布”。这篇文章我就把这个系统的设计与实现从选题拆解到落地部署完整梳理一遍,核心代码、配置、表结构、踩坑记录都会给到,适合正在准备 Spring Boot 毕设的同学,也适合想快速掌握一个内容展播类系统完整闭环的初级开发者。

1. 项目概述与需求拆解

1.1 为什么选“历史故事展播”这个方向

很多 Spring Boot 毕设题目的通病是“为了 CRUD 而 CRUD”,比如简单的用户管理、商品管理,做完之后除了增删改查,几乎拿不出什么有说服力的功能亮点。历史故事展播这个方向不一样,它天然带着三层业务纵深:

第一层是内容组织。历史故事不是一堆散落的文本,它有朝代、人物、主题、来源典籍等多个维度。要做“展播”就得有分类体系,有列表页、详情页,有推荐位和热门排行。这些需求落到数据库里就是分类表、故事表、标签关系表的设计,比单纯一张业务表要复杂得多。

第二层是用户行为。展播系统不是给管理员自己看的,它面向普通访客。访客可以浏览、搜索、收藏、评论,这就牵扯出注册登录、鉴权、用户行为记录。做完这一层,整个系统就不再是“管理后台”,而是一个真正的用户端产品。

第三层是内容运营。管理员需要维护故事内容,包括图片上传、富文本编辑、上下架、分类调整,甚至可以根据收藏量、浏览量做内容排序。这层需求让后台管理模块有了实际工作可做,而不是摆设。

三层需求叠在一起,系统的复杂度刚好合适:既有常规 CRUD,又有搜索、分页、鉴权、文件上传、前后端分离、打包部署这些经典技术点。这也是我建议选题往这个方向靠的原因——答辩时你能讲的东西远多于“我写了个增删改查”。

1.2 核心角色与功能边界

我把整个系统划分为三类角色,功能边界在开发前必须划分清楚,否则后期会经常改表改接口。

  • 游客:浏览故事列表、查看故事详情、按分类/关键词检索故事。不需要登录,接口要控制好数据权限,只读不写。
  • 注册用户:在游客基础上增加收藏故事、发表评论、查看个人收藏列表。这里需要 JWT 或 Session 鉴权,所有写操作必须校验用户身份。
  • 管理员:维护分类、维护故事内容(新增、编辑、删除)、上传配图、管理评论、查看基础统计。管理员入口和用户端完全分离,走独立后台页面。

功能边界上我建议做减法,不要把系统无限放大。比如“用户关注作者”“故事音频播放”“积分系统”这类花活能不做就不做,先把核心链路做扎实。一个覆盖面合理、每个模块都能流畅跑通的系统,比一个功能列表华丽但到处是半成品的系统有价值得多。

2. 技术选型与架构设计

2.1 后端技术栈的选择逻辑

这个项目的技术选型要围绕“稳定、熟悉、性价比高”三个原则来定。

后端框架用 Spring Boot,这是毫无疑问的。Spring Boot 最大的价值是自动装配,它通过 starter 机制把 Spring MVC、内嵌 Tomcat、数据源配置、JSON 序列化这些东西全部整合好,开发者只需要引入依赖、写业务代码。很多人在面试或答辩时会被问“Spring Boot 为什么用起来这么方便”,其实就是spring-boot-autoconfigure这个模块在起作用,它根据 classpath 下的依赖和配置文件里的属性,自动创建对应的 Bean。理解了这个机制,后面遇到“引入某个依赖后项目启动报错”“配置项不生效”这类问题,排查思路会清晰很多。

持久层我选 MyBatis 而不是 JPA,原因很直接:历史故事展播涉及大量多表关联查询,比如“按分类查故事并统计收藏数”“查某用户收藏的故事列表并关联分类名”,MyBatis 的 SQL 是手写的,复杂查询更好控制、更好优化,也更容易向面试官解释查询逻辑。配合 MyBatis 的分页插件 PageHelper,分页就一个PageHelper.startPage()的事。

数据库选 MySQL,理由不多说了。这里特别提醒一点:数据库版本尽量选 5.7 或 8.0 中一个熟悉的版本,不要用新不旧的。不同小版本在时区、编码、驱动类名上都有细微差异,能少一事少一事。

前端选 Vue 3 + Vite + Element Plus。Vite 比 Webpack 配置简单、启动快,打包产物也更规范。Element Plus 提供现成的表格、表单、分页、弹窗组件,后台管理页面开发效率能翻倍。注意 Vue 3 和 Element Plus 是配套的,别稀里糊涂装了 Vue 2 版本的 Element UI,那会有一堆兼容报错。

2.2 数据库设计:核心表结构详解

我直接把这套系统的核心表设计思路拆开讲,这是整个项目的地基,设计得不合理后面全是补丁。

分类表 category

CREATE TABLE `category` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '分类ID', `name` VARCHAR(50) NOT NULL COMMENT '分类名称', `code` VARCHAR(50) NOT NULL COMMENT '分类标识,用于前端路由', `sort` INT DEFAULT 0 COMMENT '排序值,越小越靠前', `status` TINYINT DEFAULT 1 COMMENT '状态:1启用 0禁用', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='历史故事分类表';

这里我加了一个code字段,比如“xianqin”“tangchao”,用于前端路由或接口查询时的分类标识,比直接用数字 ID 更容易读,也方便以后做数据迁移。

故事表 story

CREATE TABLE `story` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '故事ID', `category_id` BIGINT NOT NULL COMMENT '分类ID', `title` VARCHAR(100) NOT NULL COMMENT '故事标题', `author` VARCHAR(50) DEFAULT NULL COMMENT '出处人物,如司马迁', `dynasty` VARCHAR(50) DEFAULT NULL COMMENT '故事所属朝代', `cover_image` VARCHAR(255) DEFAULT NULL COMMENT '封面图URL', `summary` VARCHAR(500) DEFAULT NULL COMMENT '故事摘要', `content` LONGTEXT COMMENT '故事正文,富文本内容', `view_count` INT DEFAULT 0 COMMENT '浏览量', `favorite_count` INT DEFAULT 0 COMMENT '收藏数', `status` TINYINT DEFAULT 1 COMMENT '状态:1已发布 0下架', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`), KEY `idx_title` (`title`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='历史故事表';

把view_count和favorite_count直接冗余在故事表里是一个很实用的设计。虽然理论上收藏数可以从收藏表 count 出来,但列表页展示收藏排行时,如果每次都去COUNT(*),数据量一大就很吃力。冗余字段的代价是写操作时需要同步维护,但对于这个项目来说,收益远大于成本。

content字段用LONGTEXT是因为富文本正文往往几万字,TEXT类型最多只能存 64KB,不够用。

用户表 user

CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(100) NOT NULL COMMENT '加密后的密码', `nickname` VARCHAR(50) DEFAULT NULL, `avatar` VARCHAR(255) DEFAULT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

密码字段长度给到 100 是必须的,因为 BCrypt 加密后的字符串长度超过 60,varchar(50) 直接存不进去。注册时用BCryptPasswordEncoder做哈希,永远不要明文存密码。

收藏表 favorite 和评论表 comment

CREATE TABLE `favorite` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `story_id` BIGINT NOT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_story` (`user_id`, `story_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收藏表'; CREATE TABLE `comment` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `story_id` BIGINT NOT NULL, `user_id` BIGINT NOT NULL, `content` VARCHAR(500) NOT NULL, `parent_id` BIGINT DEFAULT NULL COMMENT '上级评论ID,支持回复', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评论表';

收藏表必须加唯一索引(user_id, story_id),防止用户重复收藏,这一条索引比你在 Service 里写 if 判断要可靠得多。评论表保留parent_id是为了以后做“回复楼中楼”功能时有扩展余地,不是必须,但很值得加。

最后强调一个铁律:所有表都要用 utf8mb4 字符集,不要用 utf8。utf8mb4 才是完整的 UTF-8 支持,能存 emoji 和生僻字。历史故事正文里生僻字非常多,用了 utf8 纯属给自己挖坑。

2.3 前后端分离与开发部署模式

这个项目采用的是前后端分离架构,但在部署发布上做了一个“物理合并”的处理。开发阶段前端跑在 Vite 默认的 5173 端口,后端跑在 8080 端口,前端通过代理把/api请求转发到后端,完全隔离、互不干扰。生产阶段把 Vue 打包出的静态文件放进 Spring Boot 的static目录或classpath:/META-INF/resources/下,这样整个系统只启动一个 8080 端口,既能访问接口又能访问页面。

这种模式的好处特别明显:部署成本极低,不需要单独装 Nginx。对毕设演示和中小型内容系统来说,一台服务器、一个 jar 包就搞定了。坏处也有,比如前端页面和接口混在一个服务里,后期如果要做独立的静态资源 CDN 加速会比较麻烦。但对这个项目规模来说,利远大于弊。

3. 核心功能模块实现与实操细节

3.1 分类浏览与故事列表

用户端第一个核心页面是首页,展示分类导航、推荐故事列表和热门排行。接口设计上我推荐一个聚合接口来解决,而不是让前端调四五个接口自己拼。

@GetMapping("/api/home") public Result home() { List<CategoryVO> categories = categoryService.listAll(); List<StoryVO> recommendStories = storyService.listRecommend(8); List<StoryVO> hotStories = storyService.listHot(10); // 返回一个Map或专门的VO对象 }

分类点进去之后是列表页,这里需要做分页查询。分页参数统一用pageNum和pageSize,返回结构里带上total总数。用 PageHelper 实现如下:

public PageResult<StoryVO> pageByCategory(Long categoryId, int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); List<Story> stories = storyMapper.selectByCategory(categoryId); PageInfo<Story> pageInfo = new PageInfo<>(stories); // 转换为VO并返回 }

用 PageHelper 时有个非常重要的注意点:PageHelper.startPage()只对紧接着的第一条 SQL 查询生效。如果你的 Service 方法里在 startPage 之后还执行了其他查询,分页就会被污染。所以我习惯把 startPage 和查询写在紧挨着的两行,中间不插入任何逻辑。

3.2 搜索功能的实现与隐患

历史故事系统最常见的需求是“搜一下关于某个人物的故事”。最直接的做法是模糊查询:

SELECT * FROM story WHERE title LIKE CONCAT('%', #{keyword}, '%') OR summary LIKE CONCAT('%', #{keyword}, '%') OR content LIKE CONCAT('%', #{keyword}, '%')

这段 SQL 用 MyBatis 写没问题,只要使用#{}预编译,就不会有 SQL 注入风险。但要注意一点:LIKE '%关键字%'和LIKE '关键字%'性能差别很大,前缀模糊查询能走索引,但前后都带%的模糊查询基本要全表扫描。故事的content字段是几万字的大文本,数据量超过几百条后这种搜索会明显变慢。

对这项目规模来说,全表扫描能接受,但我在实现时做了两个兜底:第一,搜索范围限制在 title 和 summary 两个字段,不做全正文检索,响应速度会快很多,普通用户也基本够用;第二,在title字段上建了普通索引,LIKE '关键词%'的查询可以走索引。如果你确实需要全文检索,可以引入 Elasticsearch 或者 MySQL 的 FULLTEXT 索引,还可以用 HanLP 做中文分词,按索引分词后匹配,但那已经是扩展需求了,不建议在没有明确性能瓶颈时强行上。

3.3 注册登录与 JWT 鉴权

用户注册登录这块,我用的方案是Spring Security + JWT,但这个项目的安全需求不会太重,所以我其实更推荐直接用拦截器 + JWT自己实现,写起来更可控,答辩时也能讲得更清楚。Spring Security 配置繁琐,一不留神就把所有接口拦了,排查起来很痛苦。

我的实现思路是这样:

  • 注册接口接收用户名和密码,密码用BCryptPasswordEncoder加密后入库。
  • 登录接口校验用户名密码,成功后生成 JWT 令牌返回给前端。JWT 里只放userId和username两个必要信息,设置 24 小时的过期时间。
  • 定义一个JwtInterceptor拦截器,注册到 Spring MVC 中,只拦截需要登录的接口路径,比如收藏、评论相关接口。
  • 前端请求时在 Header 里带上Authorization: Bearer token,拦截器解析 token 后把 userId 放入 ThreadLocal,后续 Controller 直接获取当前用户。

在线生成 JWT 之前还有一个挺有意思的小配置,就是自定义 Spring Boot 启动 Banner。默认的 Spring Boot 启动图案看腻了,可以用在线 Banner 生成器(比如呢称 “springboot banner 在线” 搜到的那些网站)把项目名或你喜欢的图形转成 ASCII art,贴到src/main/resources/banner.txt里,启动时就会显示个性图案。这个东西不提升任何功能,但演示项目启动时观感确实不一样。

JWT 的密钥不要写死在代码里,放到application.yml的配置项里,例如:

jwt: secret: your-secret-key-please-change-me expire: 86400

用@Value注入即可。这里提醒一下,如果用了高版本 JDK(17 以上),注意java.secret相关的一些底层类做了强封装,JWT 库版本太老可能在使用 HS256 算法时报InaccessibleObjectException,解决办法是升级jjwt到 0.11.5 以上版本。

3.4 收藏与评论的业务闭环

收藏功能逻辑不复杂,核心点在于“避免重复”和“统计同步”。

收藏接口:

@PostMapping("/api/favorite/{storyId}") public Result addFavorite(@PathVariable Long storyId) { Long userId = CurrentUser.get(); boolean exists = favoriteMapper.exists(userId, storyId); if (exists) { return Result.error("你已经收藏过了"); } favoriteMapper.insert(userId, storyId); favoriteMapper.incrementCount(storyId); // update story set favorite_count = favorite_count + 1 return Result.success(); }

取消收藏就是反向操作。我在收藏/取消收藏的业务方法上加了@Transactional注解,保证收藏记录和统计字段要么同时成功要么同时失败。虽然这种场景下单独两条 SQL 的原子性不高,但对于这个项目而言,事务注解是性价比最高的保护手段。

评论功能的重点是数据库表结构。评论内容我用VARCHAR(500),因为评论区不是写长文的地方,限制长度一方面减少数据库压力,另一方面减少垃圾内容。评论列表查询时需要关联查出评论人的昵称和头像:

SELECT c.*, u.nickname, u.avatar FROM comment c LEFT JOIN user u ON c.user_id = u.id WHERE c.story_id = #{storyId} ORDER BY c.create_time DESC

注意这里用LEFT JOIN而不是INNER JOIN,万一用户被删了,评论还在,页面也不至于直接报错。我用的是逻辑删除用户或评论,物理删除会破坏历史数据,不建议做。

3.5 后台管理模块

后台管理模块开发的第一个重点是上传图片。管理员需要给故事传封面图,我做了本地文件存储的方案:配置一个上传目录(比如D:/upload/或/var/www/upload/),把文件写到磁盘,同时返回一个访问 URL。

Spring Boot 里要做静态资源映射,否则传上去的图片无法通过浏览器访问。我推荐在配置类里手动注册 ResourceHandler:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String uploadPath = fileConfig.getUploadPath(); // 比如 file:D:/upload/ registry.addResourceHandler("/upload/**") .addResourceLocations(uploadPath) .setCachePeriod(3600); } }

这里注意一个坑:Linux 下的路径要写成file:/var/www/upload/,Windows 下要写file:D:/upload/,目录末尾必须有斜杠。很多人图片传上去 404,八成就是这里少了斜杠或者路径前缀没对上。

第二个重点是富文本编辑器。我在后台故事编辑页面用了wangEditor,它是一款国产的轻量富文本编辑器,中文文档友好,集成到 Vue 里不费劲。它默认会生成 base64 格式的图片插入正文,如果图片很大,整段 base64 会让数据库字段爆炸,所以我在实操中把编辑器的图片上传事件重写了一下,让它把图片传到后端保存后,拿返回的 URL 插入正文。

后台的权限控制必须单独做。管理员表不用和用户表混在一起,直接建一张admin_user表,管理员登录后也发 JWT,但在拦截器里要区分角色。最简单的做法是在 JWT 的 claims 里加一个role字段,管理员接口拦截器校验角色是否为 admin。用户端接口则只校验身份,不校验角色。

4. 前端展示设计与打包部署

4.1 Vue 页面结构与 API 封装

前端我用 Vue 3 + Vite + Element Plus,主要页面有首页、分类列表页、故事详情页、登录注册页、个人收藏页、后台管理页。路由划分为/、/story/:id、/login、/user/favorites以及/admin/*。

API 请求封装是前端工程化的重要一步。我在src/utils/request.js里基于 axios 创建了一个实例,统一设置 baseURL 和超时时间,并在请求拦截器里自动加上 Authorization 头:

import axios from 'axios' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(error.response?.data?.message || '服务器异常') return Promise.reject(error) } )

这样写的好处是:前端每个页面调接口时都不用关心 token 和错误处理,只专注业务数据。401 状态码统一跳登录页,这在实际开发里非常省事。

4.2 开发环境跨域代理配置

开发环境下,前端跑在 5173,后端跑在 8080,两者不是同源。Vite 的解决方案是配置代理,在vite.config.js里写:

export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })

配置之后,前端请求/api/home时,Vite 开发服务器自动把请求转发给 8080 端口的后端。这个过程在前端浏览器里看起来是同源的,不会触发跨域问题,比后端加 CORS 配置更适合开发阶段。

后端那边我也预留了 CORS 配置作为兜底,防止出现某些特殊场景下前端直连后端接口的情况。如果哪次前后端联调时浏览器报跨域错,先确认是不是代理没生效,然后再排查后端 CORS 配置。不要一上来就打开全局 CORS,那会把接口暴露给任何域名的恶意调用。

4.3 生产打包:Vue 产物合并进 Spring Boot

这是很多人最后一步被卡死的地方。实际操作分三步。

第一步,构建前端产物。在前端项目根目录执行:

npm run build

构建完成后,Vite 会在项目根目录生成dist文件夹,里面有index.html、assets等静态文件。

第二步,把dist里的内容复制到 Spring Boot 的src/main/resources/static/目录下。注意是复制 dist 内部的文件,不是把 dist 整个文件夹丢进去。也就是说static/index.html必须是直接存在的。

第三步,重新打后端包:

mvn clean package

启动target目录下的 jar,访问http://localhost:8080/,理论上就能看到前端页面了。Spring Boot 会自动把static目录下的index.html作为欢迎页。

这里有一个很多人绕不开的坑:前端路由用 history 模式时,页面刷新会出现 404。因为 Vue Router 的 history 模式下路由是通过 URL 路径控制的,比如/story/3,刷新时浏览器会请求后端/story/3,但后端并没有这个 Controller,直接返回 404。解决办法有两个:

方案一:后端写一个转发规则,把非/api开头的所有路径都转发到index.html。

@Controller public class PageForwardController { @RequestMapping(value = {"/", "/story/**", "/user/**", "/admin/**"}) public String forward() { return "forward:/index.html"; } }

这种方式我实际用过,原理是把前端路由的刷新请求都导回前端入口,由 Vue Router 自己根据 URL 渲染对应页面。缺点是路由路径要维护一个列表,以后新增前端页面层级时可能忘记加。

方案二:直接改用 Vue Router 的hash 模式,URL 是http://localhost:8080/#/story/3这种格式,刷新时不会产生新的后端请求路径,就不存在 404 问题。代价是 URL 没那么好看。

我的建议是:毕设或演示项目直接用 hash 模式,省心;想要更优雅的 URL 就用 history 模式 + 后端转发规则。两种方案都要在选型阶段定下来,不要业务都写完了再切换路由模式。

4.4 IDEA 中配置启动参数与端口

开发时经常需要调整 Spring Boot 启动端口。如果你在 IDEA 里启动项目,启动参数可以改两个地方:application.yml中的server.port,或者 IDEA 的 Run Configuration 里配置 Program arguments 为--server.port=9090。命令行参数优先级高于配置文件,这个特性在临时切换端口时比改 yml 再重启更方便。

启动时如果发现端口被占用,可以在 IDEA 启动日志里看到明确报错。不要暴力换一个随机端口,先找出占用进程。Windows 下用:

netstat -ano | findstr :8080 taskkill /PID 进程号 /F

Linux 下用:

lsof -i:8080 kill -9 进程号

4.5 版本选型的避坑建议

热词里有“springboot版本太高”这个说法,确实是很多初学者的真实痛点。Spring Boot 3.x 相比 2.x 有几个关键变化:javax 命名空间改成了 jakarta,很多老教程里的javax.servlet.*包在 Spring Boot 3 下直接编译不过;MyBatis 相关的 starter 需要升级版本才能兼容;如果用了 Spring Security 5 的相关配置,3.x 下规则也变了。

我的建议是:毕设项目直接用Spring Boot 2.7.x 或者 2.5.x,这是网上资料最丰富、踩坑最少的一个大版本。资料多就是最大的优势,遇到问题搜一下基本都能解决。等新版普及、资料积累够了再切不迟。JDK 版本配套用 8 或 11,Spring Boot 2.x 在这两个版本下都跑得稳稳的。除非有硬性要求在简历里写 Spring Boot 3,否则没必要平白增加不确定性。

5. 常见问题与排查技巧实录

5.1 启动失败:依赖冲突和版本不兼容

最常见的问题是启动时报ClassNotFoundException或NoSuchMethodError。这类问题的根源基本都是 maven 依赖版本不一致。我排查习惯是三步走:

首先看完整错误栈的第一行,定位是哪个类找不到。然后去项目依赖树里查一下这个类的来源版本:

mvn dependency:tree

最后确认引用的 starter 和核心版本是否匹配。比如引入了spring-boot-starter-web3.x 的依赖,但自己又手动加了spring-boot-starter-parent2.x 的父工程,这百分之百要出问题。我见过太多这种“手动钦定版本”导致的项目启动失败,正确做法是把版本号交给 Spring Boot 的 parent BOM 统一管理,自己只声明需要用的 starter。

还有一个小坑是 Lombok 版本和 JDK 版本不兼容。JDK 17 配 1.18.20 以前的 Lombok 会直接启动失败。解决办法是升级 Lombok 到 1.18.26 以上。

5.2 中文乱码:从请求到响应全链路排查

中文乱码这个事在历史故事项目里特别常见,因为内容本身全是中文。乱码出现的位置无非三个:请求乱码、存储乱码、响应乱码。

请求乱码的根源通常是前端 POST 请求时未声明Content-Type,或者后端读取时编码不对。Spring Boot 2.x 里的CharacterEncodingFilter默认开启,大多数情况能解决,如果你在 yml 里显式改了编码,注意别改成 UTF-8 之外的值。

存储乱码最典型的场景:数据库表是 utf8,前端传的是 emoji 或生僻字,MySQL 在写入时报Incorrect string value。这个我在前面已经强调过,必须用 utf8mb4。如果你遇到了,不要只改连接参数,还要执行:

ALTER TABLE story CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

响应乱码最常见的原因是后端返回 JSON 时,未设置响应编码。Spring Boot 默认已经处理得很好了,只有在手动使用 response.getWriter() 输出字符串时才会踩到。建议所有接口返回统一用@RestController+ 统一响应体,不会出现响应乱码这类低级问题。

5.3 跨域问题:分清楚“真跨域”和“代理没生效”

前端联调时浏览器控制台报跨域错,不要第一时间怀疑后端。先确认一下前端请求的 URL。如果开的是 Vite 开发服务,请求路径写成了http://localhost:8080/api/xxx,这是真跨域,Vite 的 proxy 根本不会介入,因为浏览器发出的请求目标是 8080,没有经过 5173 的代理。正确写法是请求/api/xxx,让请求打到 Vite 的同源地址上,再靠代理转发到后端。

如果确认代理配置没问题但依然报跨域,那就去看后端有没有在响应头里加Access-Control-Allow-Origin。排查方法很简单,打开浏览器开发者工具,看接口响应头和预检请求 OPTIONS 的状态。后端加全局 CORS 的最简配置:

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true); } }

注意生产合并部署时,前后端同源,这个 CORS 配置就已经没有任何作用了,保留着只是为了开发环境方便。

5.4 图片上传 404 或无法访问

图片传上去了,浏览器访问 URL 却 404,原因基本就三类:

  • 静态资源映射路径没配置对。addResourceHandler("/upload/**")和实际访问的 URL 必须以相同前缀开头。
  • 目录绝对路径最后缺少斜杠。比如file:D:/upload是错的,必须是file:D:/upload/。
  • 上传时文件名重复导致覆盖。我上传实现里用 UUID 重命名,避免中文名和重复名带来的问题。

文件名处理还有个细节:不要直接拿原始文件名存库,中文文件名在 URL 访问时会经过转义,很容易出问题。我习惯用UUID.randomUUID()拼接原始文件扩展名,比如a8f3c0d1-xxxx.png,既唯一又无中文。

5.5 定时任务:让数据统计更自然

热词里提到“springboot定时任务”,在这个项目里有一个很合适的应用场景:定时把“收藏数”“浏览量”做一次落库持久化。因为view_count如果每次请求都直接UPDATE数据库,高并发下会产生很多行锁冲突。我在详情页的浏览量逻辑里用了内存计数,再通过定时任务每五分钟批量写一次数据库。

Spring Boot 开启定时任务非常简单,启动类加@EnableScheduling,方法上加@Scheduled(cron = "0 */5 * * * ?")即可。但要注意:定时任务的线程是串行的,如果一个任务执行时间超过了执行周期,下一次任务会排队等上一次结束。如果定时任务里要做批量更新,最好加上@Async或者配置线程池。我们这个场景数据量小,串行执行完全没问题。

5.6 自定义启动 Banner 与项目质感

最后聊个提升项目质感的小操作:自定义 Spring Boot 启动 Banner。操作方法很简单,用在线 Banner 生成器把“历史故事展播系统”或者其他你想展示的英文标识转成 ASCII Art,复制到src/main/resources/banner.txt。再设置关闭默认 Banner:

spring: main: banner-mode: console

启动时控制台会显示你专属的图案。这个细节不算什么技术亮点,但在演示项目时,能看到自己的项目标识打出来,观感上的确不一样。

6. 我的实操经验总结

整套系统从零到一做完,我个人最深的体会是:这个项目的难点从来不在某一个具体技术上,而在把内容管理、用户交互、前后端构建发布串联起来的整个过程里。每一步单拎出来可能都有教程,但把它们缝在一起时,处处都是版本坑和配置坑。

比如 Spring Boot 版本和 MyBatis 版本的匹配、Vite 代理和 CORS 的双保险、富文本编辑器的图片上传重写、打包后前端路由 404、JWT 密钥配置、数据库字符集选择,每一个点都是实际写到代码里才会发现的。我见过太多人项目写了一半卡在“为什么我按教程配了还是不对”,多半就是把别人的代码片段直接抄过来,却没有理解前后上下文。

如果让我给正在做类似系统的同学一个实用建议,我会说:先把数据库表结构设计到满意为止,再开始写代码。表设计对了,业务逻辑自然顺;表设计错了,后面就是无穷无尽的补丁。另外,不管你计划做的功能有多少,务必先把“Vue 打包放进 Spring Boot”这条发布链路在项目早期跑通一遍,确认最简页面能通过 jar 包正常访问,再往后铺页面。别把部署环节留到最后,那是所有整合工作里变数最大的一个点。

这个系统后续如果有扩展的想法,可以往古文原文对照、历史时间轴可视化、角色图谱关系这几个方向走。选一个方向做深,项目厚度会明显不一样。但对现阶段来说,把上面这些基础功能打磨稳定,已经是一份很完整的答卷了。

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

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

立即咨询