Spring Boot+Vue前后端分离实战:校园生活信息平台开发全解析 带学生做毕业设计这些年前后端分离这个话题被反复问了几十遍。很多人把后端 Spring Boot 一启动、前端 npm run dev 一跑就宣布我完成了前后端分离结果真到部署上线那天跨域报错、接口 404、刷新白屏的问题排队等着。今天我不讲空泛的概念直接用一套完整的校园生活信息平台来拆解技术栈就是标题里那套标配Spring Boot Vue MyBatis MySQL。这篇文章适合两类人看一类是要交课程设计或毕业论文的学生另一类是刚入行想练一个完整全栈项目的开发者。我会把模块设计、核心代码、部署流程、踩坑记录全部摊开讲保证每个步骤都能照着做。1. 先想清楚校园生活信息平台到底要解决什么1.1 校园信息流通的三大痛点做系统最忌讳一上来就建表写代码。我接手这个项目时先花了一天去调研真实需求。一个高校校园里的信息流通通常卡在三个地方第一二手交易信息极度分散。毕业季的教材、考研资料、小家电基本靠朋友圈和群聊转发发出去了没有沉淀想要的人搜不到。第二失物招领全靠运气。校园卡、耳机、雨伞丢了以后要么去线下失物招领点翻要么在各个群里刷屏效率低且没人统一管理。第三通知公告被淹没。学校的讲座、比赛、社团活动信息往往发在官网某个角落或者班级群里想看的人不知道去哪看想发布的人不知道发到哪。这三个痛点决定了一个信息平台而不是管理系统的产品定位。系统要解决的不是审批流程而是信息发布、检索、互动的效率问题。所以我建议的核心模块定为通知公告、二手集市、失物招领、校园活动、个人中心外加一个后台管理端。1.2 模块拆解与角色权限设计模块拆好之后接下来要确定角色和权限这一步直接决定后面的接口设计、菜单权限、表单字段。我把系统角色分成三档普通学生用户、管理员、超级管理员也就是开发调试时用的最高权限。学生用户能发帖、留言、报名活动、查看公告管理员负责内容审核、用户管理、分类管理超级管理员则能看到全部数据和系统日志。实际开发时我没有过度设计——没整 RBAC 那套复杂的权限模型。因为校园平台的用户量级和后台使用频率决定了两个角色加一个最高权限就足够撑起全部业务场景。页面流转也要提前画清楚。游客进入平台能看到公告和部分列表但点进详情或发帖时会被拦截到登录页登录后进入首页能看到信息流和快捷入口个人中心里能看到我发布的宝贝、我报名的活动、我收到的评论。后台则单独走一套管理页面的路由入口在首页底部或者通过指定路径进入用管理员账号才能访问。把这个画成一张页面-接口对照表挂在项目文档里后面写代码会轻松很多。这里给大家看一张我在项目初期整理的表格虽然简单但非常管用页面对应模块主要接口可见角色首页公告/内容聚合GET /api/notice/list所有人二手集市商品发布/分类检索GET/POST /api/goods学生/管理员失物招领失物/拾物发布GET/POST /api/lost学生/管理员活动广场活动报名/收藏GET /api/activity学生后台管理用户/内容管理GET/POST /api/admin/*管理员2. 技术选型这些组合为什么是标准答案2.1 前后端分离的边界在哪里你可能觉得前后端分离就是 Vue 一个工程、Spring Boot 一个工程各写各的联调用接口。这话对了一半。前后端分离的本质是展现逻辑与业务逻辑的隔离。在前端你要管理的是一套视图状态用户点了什么按钮、弹窗开没开、列表加载到哪一页、搜索框填了什么词。在后端你管理的是数据状态MySQL 里有多少条记录、某条记录的字段是什么、权限校验是否通过。两边的状态类型完全不同放在一起写会互相干扰。前端通过调用统一封装过的 HTTP 接口与后端通信这样有个好处只要接口约定不变前端可以整套替换比如从 Vue 换到 React后端也可以独立升级。我在项目里对接口做了统一约定返回格式固定为{ code: 200, message: ok, data: ... }前后端各留一份接口文档联调时几乎没有扯皮。2.2 后端 Spring Boot MyBatis 的取舍逻辑后端采用 Spring Boot理由很充分内嵌 Tomcat一个 main 方法就能跑起来自动配置帮你省掉大半 XML 配置Spring Security 和 JWT 生态也成熟。真正值得讨论的是持久层选型。MyBatis 和 Spring Data JPA 我都用过校园平台这种场景我更推荐 MyBatis。原因很简单SQL 可控。比如二手商品按分类、价格区间、发布时间做联合筛选JPA 的 Specification 能写但写出来可读性很差MyBatis 直接用动态 SQL 拼条件查什么、怎么查一眼看明白。而且国内多数团队的协作习惯就是 mapper.xml 集中管理 SQL遇到问题排查也方便。不过要注意MyBatis 的一个小缺点是单表 CRUD 的样板代码多。解决方案是用通用 Mapper 或者 MyBatis-Plus。我这次项目选的是 MyBatis-Plus既有 MyBatis 的 SQL 可控性又自带单表 CRUD 封装省了实体对应的基础方法复杂查询仍然手写 XML两边的好处都占了。2.3 前端 Vue Element UI 的组织方式前端框架我选了 Vue。如果现在新起项目建议直接 Vue 3 组合式 API Vite Element Plus。但考虑到很多学校的课件和同学的已有基础还是 Vue 2我这次用的是 Vue 2 Element UI原理完全一样你按自己的熟悉度来选即可。Vue 要解决的三个核心问题数据响应式更新、组件复用、路由管理。数据响应式是 Vue 的看家本领不用多说组件复用方面我会把搜索栏 表格 分页这种典型后台组合封装成一个通用列表组件不同页面传不同的列配置和接口就能快速生成一个管理页面路由方面用 vue-router 的懒加载把页面按模块拆分首屏只加载当前需要的组件避免一次性加载全部页面导致首屏白屏时间过长。UI 库选 Element UI 的理由也很实际——组件全、文档清晰、社区案例多。学生做毕设时遇到这个表格怎么加个操作列弹窗表单怎么回显这类问题一搜就是 Element UI 的现成答案学习成本非常低。3. 数据库设计与后端核心功能实现3.1 六张核心表的拆分与关系我常说数据库设计决定了这个项目是越写越顺还是越写越乱。校园生活信息平台我最终拆成了八张表用户表、公告表、二手商品表、失物招领表、活动表、活动报名表、评论表、分类表。关键字段设计上有几个细节值得展开说。发布时间字段我统一用create_time命名用 datetime 类型后端 Java 实体用 LocalDateTime 映射避免用 java.util.Date 的时区问题。用户状态字段用 tinyint 而不是 varchar 存正常/禁用因为状态是个有限集合用数字枚举在查询和索引上性能更好。逻辑删除字段deleted我坚持要加虽然让每个查询都得多带一个条件但换来的是数据可追溯万一误删还能救回来。以二手商品表为例核心 SQL 如下CREATE TABLE goods ( id bigint(20) NOT NULL AUTO_INCREMENT, title varchar(100) NOT NULL COMMENT 标题, description text COMMENT 描述, price decimal(10,2) DEFAULT NULL COMMENT 价格, category_id bigint(20) DEFAULT NULL COMMENT 分类ID, seller_id bigint(20) NOT NULL COMMENT 发布者ID, images varchar(1000) DEFAULT NULL COMMENT 图片路径逗号分隔, status tinyint(4) DEFAULT 0 COMMENT 0上架 1已售 2下架, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted tinyint(1) DEFAULT 0, PRIMARY KEY (id), KEY idx_seller_id (seller_id), KEY idx_category_status (category_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;两个索引是重点idx_seller_id用于个人中心我发布的商品查询idx_category_status用于商城列表的分类筛选和状态过滤。校园平台的数据量不大但这两条索引能保证在列表页频繁查询时不拖慢响应。3.2 JWT 登录鉴权替换 Session 的正确姿势校园平台虽然不算高并发系统但我依然推荐用 JWT 而不是传统 Session。原因很简单前后端分离以后后端可能不止一个实例如果 Session 存在内存里多实例部署时登录请求落在实例 A、业务请求落在实例 B用户就得重新登录。JWT 把用户信息编码在 token 里后端无状态天然适配多实例部署。JWT 在 Spring Boot 中的接法是登录接口验证账号密码成功后生成 token 返回前端前端把 token 存在 localStorage之后每个请求在请求头带Authorization: Bearer token后端通过一个过滤器解析 token把用户 ID 放进请求上下文。核心拦截器代码给大家看一下Component public class JwtInterceptor implements HandlerInterceptor { Autowired private StringRedisTemplate redisTemplate; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equals(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); String userId JwtUtil.parseToken(token); // 解析失败返回null if (userId ! null) { request.setAttribute(userId, userId); return true; } } response.setStatus(401); return false; } }这里有个容易被忽略的点我同时引入了 Redis。为什么 JWT 是无状态了还要 Redis因为要做单端登录和退出失效。校园项目里学生的账号在自己手里一般不会到处登录但管理员账号在值班室多台电脑上有登录需求如果只有 JWT 没法踢人下线。我把生成后的 token 存了一份在 Redis拦截器里把 JWT 解析出来的 token 值再和 Redis 里的做比对不一致就拒绝。这个设计不大但面试和答辩时讲出来非常加分。3.3 MyBatis Mapper 封装与复杂查询写法用 MyBatis-Plus 之后单表 CRUD 大部分靠继承BaseMapperT就搞定了public interface GoodsMapper extends BaseMapperGoods { // 复杂查询多条件动态SQL IPageGoodsVO selectGoodsPage(PageGoods page, Param(categoryId) Long categoryId, Param(keyword) String keyword, Param(status) Integer status); }对应的 XML 里是动态 SQLselect idselectGoodsPage resultTypecom.demo.vo.GoodsVO SELECT g.*, u.nickname AS sellerName FROM goods g LEFT JOIN user u ON g.seller_id u.id WHERE g.deleted 0 if testcategoryId ! null AND g.category_id #{categoryId} /if if testkeyword ! null and keyword ! AND (g.title LIKE CONCAT(%, #{keyword}, %) OR g.description LIKE CONCAT(%, #{keyword}, %)) /if if teststatus ! null AND g.status #{status} /if ORDER BY g.create_time DESC /select这里有两个实操细节提醒大家。第一LIKE模糊查询用CONCAT(%, #{keyword}, %)拼接千万不能直接在 Java 里拼好再传进去否则容易出 SQL 注入风险。第二多表关联字段我建议在 VO 里加字段而不是在前端把两个对象嵌套传比如上面的sellerName直接在 SQL 里 JOIN 出来前端少处理一层数据也降低联调时的字段名争议。3.4 文件上传与图片回显一个最容易出的坑校园平台必然涉及图片上传商品照片、失物照片、活动封面。我见过太多项目在上传图片上翻车特此单独写一节。Spring Boot 接收 MultipartFile 本身很简单坑主要在存储路径。开发时你随手写一个相对路径比如upload/images在 IDEA 里跑一切正常部署到服务器后jar 包解压到临时目录图片写到了临时目录里项目重启图片全没了前端经常访问不到。我的建议在全局配置文件里显式声明一个绝对路径作为上传根目录file: upload-dir: /www/uploads access-path: /upload/**然后写一个 WebMvcConfigurer把本地上传目录映射成静态资源 URLOverride public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadDir /); }这样前端拿到/upload/xxxx.jpg这样的 URL 就能直接访问不需要 Nginx 额外配静态目录部署时也更方便。路径分隔符在 Linux 和 Windows 上不同代码里统一用 File.separator 或者直接用 URI 拼接别手写反斜杠。4. Vue 前端从登录到业务闭环4.1 工程初始化与目录划分前端工程我用 Vue CLI 创建然后调整目录结构。这是我自己沉淀比较久的一套目录约定分享出来供大家参考src/ ├── api/ // 所有接口调用按模块拆文件 │ ├── user.js │ ├── goods.js │ └── activity.js ├── utils/ │ ├── request.js // axios 封装 │ └── auth.js // token 存取 ├── store/ // vuex 状态管理 ├── router/ // 路由配置 ├── views/ // 页面组件 └── components/ // 公共组件约定只有一个核心思想api 层禁止散落在页面里。很多人写页面直接在.vue文件里this.$http.post(...)一旦接口地址变动要满个项目搜。把所有接口抽到api目录集中管理页面里只引用方法改接口只用改一处。4.2 请求封装与登录态拦截axios 封装是前端工程里最重要的一个文件。我在 request.js 里做了三件事请求前拦截器从 localStorage 取出 token设置到Authorization请求头响应后拦截器统一处理业务码code ! 200时弹出错误提示401 统一处理跳转登录页并清空本地登录信息代码结构如下service.interceptors.request.use(config { const token getToken() if (token) { config.headers[Authorization] Bearer token } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { Message.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response error.response.status 401) { clearToken() router.push(/login) } Message.error(网络异常请稍后重试) return Promise.reject(error) } )看着很简单但很多毕设项目就死在没有统一封装上。有了这层封装后续每个页面请求都只需要关注业务数据不需要重复写错误处理和状态判断。有基础的同学还可以再加一个请求防抖或者加载状态开关这个属于锦上添花。4.3 列表页、搜索与分页的组件化写法校园平台里二手集市、失物招领、活动列表、公告列表本质上都是列表 筛选 分页的页面。如果每个页面都从头写代码量会翻好几倍。我把它们抽象成了一个通用模块页面负责传配置组件负责渲染。template div search-form :fieldssearchFields searchloadData / data-table :columnscolumns :datatableData :loadingloading operationhandleOperation / pagination :totaltotal v-model:pagequeryParams.pageNum v-model:limitqueryParams.pageSize paginationloadData / /div /template页面里只需要声明searchFields几个筛选框怎么渲染、columns表格显示哪些列、以及loadData调用哪个 api。设计模式上的收益是往后再加一个新品模块、一个社团模块都是几分钟的事把组件拖过来配置一下就行。4.4 用户状态管理用 Vuex 还是直接 localStorage前端要不要上 Vuex这是新手最容易纠结的问题。我的判断标准很简单如果你的页面之间需要共享一份会变化的数据那就用 Vuex如果只是取出来存着用 localStorage 就行。校园平台里用户的基本信息昵称、头像、角色在多个页面都要用而且登录后会变我放在 Vuex 里同时持久化到 localStorage刷新后从本地恢复。另一部分数据比如 token只用于请求鉴权不参与页面渲染本地存储就足够了。Vuex 我采用的是模块化写法用户信息单独一个小 store避免所有状态堆在一个文件里export default { state: () ({ name: , avatar: , role: }), mutations: { setUserInfo(state, userInfo) { Object.assign(state, userInfo) }, clearUserInfo(state) { state.name state.avatar state.role } } }等页面里真正需要角色判断比如管理员才显示某个菜单直接用this.$store.state.user.role就行。5. 部署上线完整可复现的流程5.1 后端打包成 jar 并运行后端部署没有太多花哨环节但步骤顺序有讲究。我推荐在本地做完一切验证后再上服务器操作。第一步检查配置文件。把application-dev.yml里的数据库地址改成线上地址上传文件的绝对路径改为服务器路径。注意spring.datasource.url后面要带参数最佳实践是jdbc:mysql://localhost:3306/campus?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueserverTimezoneAsia/Shanghai一定要加否则连接 MySQL 8 极大概率报时区错误。allowPublicKeyRetrievaltrue是 MySQL 8 配合 caching_sha2_password 认证插件时常见的问题也建议一并加上。第二步打包。在项目根目录执行mvn clean package -DskipTests如果本地有测试类不想打包时执行-DskipTests最省事。打包完会得到target/campus-0.0.1-SNAPSHOT.jar。第三步上传到服务器。我的习惯是建一个专门的部署目录比如/www/campusjar 放在里面。启动命令nohup java -jar /www/campus/campus.jar --spring.profiles.activeprod /www/campus/logs/app.log 21 日志重定向很重要。不重定向的话nohup 会把日志写进当前目录的 nohup.out时间久了文件巨大。你也可以用 systemd 管理进程但校内展示场景 nohup 加手动 kill 完全够用。5.2 前端构建产物与 Nginx 托管前端部署相对简单核心就两步构建、托管。npm run build执行完会生成dist目录里面是纯静态文件。把这整个目录传到服务器比如/www/campus/dist。然后配置 Nginx 站点。这里有一个我踩过很多次的坑Vue 路由如果用了 history 模式刷新页面会 404。因为对于/goods/1这样的路径Nginx 找不到实际的物理文件默认返回 404。解决方案是配置try_files让所有请求都回退到 index.htmlserver { listen 80; server_name your-domain.com; root /www/campus/dist; index index.html; location / { try_files $uri $uri/ /index.html; } }try_files $uri $uri/ /index.html这一行是 SPA 部署的生命线必须知道它每段含义先找实际文件再找目录都找不到就返回首页。Vue Router 接手后根据 URL 渲染对应页面。5.3 把前后端接起来的反向代理配置前后端分离部署的最后一个关键步骤是接口代理。前端部署在 80 端口后端运行在 8080 端口如果前端的 axios 请求直接写http://服务器IP:8080/api/xxx会碰到两个问题一是跨域二是把后端端口暴露给用户不太体面。标准做法是在 Nginx 里做反向代理把所有/api开头的请求转发到本地 8080location /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; }这样前端所有请求都只跟同源的/api打交道不存在跨域问题。后端也只需要把接口路径统一前缀为/api前端 axios 的 baseURL 配置成/api即可。生产环境不建议前端和 Spring Boot 开 CORS能少开一个口子就少开一个口子。6. 实测中踩过的坑与规避方案6.1 跨域从困惑到彻底解决整个项目调试过程中跨域是我被问最多的问题。前后端分离开发时前端跑在localhost:8080后端跑在localhost:9090前端访问后端接口浏览器会拦截这就是经典的 CORS 跨域。解决思路有两条路。开发环境用 Vite 或 Vue CLI 的代理功能最舒服。Vue CLI 在vue.config.js里配置module.exports { devServer: { proxy: { /api: { target: http://localhost:9090, changeOrigin: true } } } }生产环境就是上一节说的 Nginx 反向代理。两条路本质上都是绕开跨域——让浏览器看到的是同源请求。如果以后端方式硬解Spring Boot 里可以加一个全局 CORS 配置但我不推荐把它作为生产环境方案只在联调时用用就好。核心认知是理解跨域的本质是浏览器的同源策略而不是服务的拒绝请求。6.2 数据库时区与 Tomcat 上传临时目录时区问题尤其是 MySQL 8 Spring Boot 2.x 的组合几乎每个项目都会碰一次。报错信息大致是 The server time zone value ... is unrecognized。解决方式是在数据库连接串后面加上serverTimezoneAsia/Shanghai这个我前面提过再强调一次是因为实在太常见。Tomcat 上传临时目录的坑则是另一种画风。本地 IDEA 跑项目上传文件正常打包部署到服务器后上传文件报错java.io.IOException: The temporary upload location is not valid。根因是 Spring Boot 内嵌 Tomcat 的临时目录指向/tmp/tomcat.*某些系统会定时清理该目录。解决办法是显式配置上传临时目录在配置文件中加上spring: servlet: multipart: location: /tmp/campus-upload-tmp提前创建好这个目录并确认权限可写就能彻底避开这个隐性问题。6.3 刷新页面 404 与相对路径问题前面讲的try_files解决的是刷新 404。还有一个相关问题是打包后静态资源路径不对导致页面白屏或者样式丢失。检查两个地方vue.config.js里publicPath是否改成相对路径./以及路由 mode 是history还是hash。我的建议是如果部署场景不需要美观的 URL直接用hash模式更省心不会出现刷新 404如果想要history模式一定记得 Nginx 配try_files两者配套使用。publicPath方面构建产物放在子目录部署时必须处理否则资源加载会指向域名根路径。这个坑虽然小排查时却能耗掉半天时间。另外后端如果有跨域访问图片资源的场景注意在前端用相对路径拼 URL 时别多拼一层/api我们代理规则里/api是后端接口前缀图片静态资源走的是 Spring Boot 的静态资源映射路径要区分清楚。这是我在联调阶段翻了三次车才彻底记住的教训。最后分享一个个人习惯项目做到部署阶段时每踩一个坑、每解决一个问题都在项目根目录的 README 里记一行问题原因解决方式。这个文档平时没什么用写毕业设计说明书的部署章节和问题分析章节时它就是现成的素材来源而且全部是自己的真实经历比任何模板都经得起追问。