恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Spring Boot全栈实战:历史故事展播系统设计与部署
首页
资讯中心
/
Spring Boot全栈实战:历史故事展播系统设计与部署
Spring Boot全栈实战:历史故事展播系统设计与部署
发布时间:2026/10/4 20:59:48
“中华历史故事展播系统”这名字一眼看上去就是个带文化属性的内容展示项目但真正落地上手你会发现它其实是一个相当完整的 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 数据库设计核心表结构详解我直接把这套系统的核心表设计思路拆开讲这是整个项目的地基设计得不合理后面全是补丁。分类表 categoryCREATE 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT历史故事分类表;这里我加了一个code字段比如“xianqin”“tangchao”用于前端路由或接口查询时的分类标识比直接用数字 ID 更容易读也方便以后做数据迁移。故事表 storyCREATE 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT历史故事表;把view_count和favorite_count直接冗余在故事表里是一个很实用的设计。虽然理论上收藏数可以从收藏表 count 出来但列表页展示收藏排行时如果每次都去COUNT(*)数据量一大就很吃力。冗余字段的代价是写操作时需要同步维护但对于这个项目来说收益远大于成本。content字段用LONGTEXT是因为富文本正文往往几万字TEXT类型最多只能存 64KB不够用。用户表 userCREATE 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;密码字段长度给到 100 是必须的因为 BCrypt 加密后的字符串长度超过 60varchar(50) 直接存不进去。注册时用BCryptPasswordEncoder做哈希永远不要明文存密码。收藏表 favorite 和评论表 commentCREATE 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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() { ListCategoryVO categories categoryService.listAll(); ListStoryVO recommendStories storyService.listRecommend(8); ListStoryVO hotStories storyService.listHot(10); // 返回一个Map或专门的VO对象 }分类点进去之后是列表页这里需要做分页查询。分页参数统一用pageNum和pageSize返回结构里带上total总数。用 PageHelper 实现如下public PageResultStoryVO pageByCategory(Long categoryId, int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); ListStory stories storyMapper.selectByCategory(categoryId); PageInfoStory 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注入即可。这里提醒一下如果用了高版本 JDK17 以上注意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 里要做静态资源映射否则传上去的图片无法通过浏览器访问。我推荐在配置类里手动注册 ResourceHandlerConfiguration 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.port9090。命令行参数优先级高于配置文件这个特性在临时切换端口时比改 yml 再重启更方便。启动时如果发现端口被占用可以在 IDEA 启动日志里看到明确报错。不要暴力换一个随机端口先找出占用进程。Windows 下用netstat -ano | findstr :8080 taskkill /PID 进程号 /FLinux 下用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 或 11Spring 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。再设置关闭默认 Bannerspring: main: banner-mode: console启动时控制台会显示你专属的图案。这个细节不算什么技术亮点但在演示项目时能看到自己的项目标识打出来观感上的确不一样。6. 我的实操经验总结整套系统从零到一做完我个人最深的体会是这个项目的难点从来不在某一个具体技术上而在把内容管理、用户交互、前后端构建发布串联起来的整个过程里。每一步单拎出来可能都有教程但把它们缝在一起时处处都是版本坑和配置坑。比如 Spring Boot 版本和 MyBatis 版本的匹配、Vite 代理和 CORS 的双保险、富文本编辑器的图片上传重写、打包后前端路由 404、JWT 密钥配置、数据库字符集选择每一个点都是实际写到代码里才会发现的。我见过太多人项目写了一半卡在“为什么我按教程配了还是不对”多半就是把别人的代码片段直接抄过来却没有理解前后上下文。如果让我给正在做类似系统的同学一个实用建议我会说先把数据库表结构设计到满意为止再开始写代码。表设计对了业务逻辑自然顺表设计错了后面就是无穷无尽的补丁。另外不管你计划做的功能有多少务必先把“Vue 打包放进 Spring Boot”这条发布链路在项目早期跑通一遍确认最简页面能通过 jar 包正常访问再往后铺页面。别把部署环节留到最后那是所有整合工作里变数最大的一个点。这个系统后续如果有扩展的想法可以往古文原文对照、历史时间轴可视化、角色图谱关系这几个方向走。选一个方向做深项目厚度会明显不一样。但对现阶段来说把上面这些基础功能打磨稳定已经是一份很完整的答卷了。