1. 项目概述
1.1 核心需求解析
做全栈开发这几年,我见过太多半路出家的开发者在 Spring Boot 和 Vue 的边界线上反复横跳——后端能写接口,但前端拿着 JSON 不知道往哪儿塞;前端能把页面画出来,但遇到跨域、鉴权、打包部署就抓瞎。这个实战指南解决的就是这个问题:把一条完整的前后端分离开发链路串起来,从零开始搭建一个可直接运行的全栈项目。
注意这里的关键词是“全栈”,不是“前后端各写一半”。项目会包含一个完整的业务闭环:前端 Vue 负责页面交互和数据展示,后端 Spring Boot 负责接口服务和数据持久化,中间通过 RESTful API 通信,最终打包部署到同一台服务器上跑通整个流程。这个过程中涉及的技术点非常密集——Vue 的生命周期、Axios 的请求拦截、Spring Boot 的分层架构、MyBatis 的数据映射、JWT 的登录态管理、Nginx 的反向代理——任何一个环节掉链子,整个项目就跑不起来。
这个项目的受众其实很广。刚入行的初级开发者可以通过它建立全栈思维,知道前端发起的请求在后端到底经历了什么;写了好几年接口的后端开发可以补上前端技能,独立负责完整模块;做毕设或小团队项目的同学可以直接拿这套骨架改业务逻辑就能上线。我的建议是,无论你偏向前端还是后端,都完整地把项目过一遍,收获最大的恰恰是你知识短板那部分的内容。
1.2 技术栈全景与选型理由
Spring Boot 3.x 搭配 Vue 3.x 是当前主流选择,我用的版本组合是 Spring Boot 3.2.5 + Vue 3.4.21 + Vite 5.2.0。这套组合的好处是生态成熟,遇到问题搜一下基本都是现成的答案。Spring Boot 3.x 相比 2.x 最大的变化是底层基于 Spring Framework 6,支持 Jakarta EE 规范,默认使用 Java 17 及以上版本,开发体验和性能都有明显提升。Vue 3 的组合式 API 写起来比 Options API 更符合直觉,配合 Vite 的开发服务器,热更新速度能缩短到一两秒。
数据库我用 MySQL 8.0,持久层框架选了 MyBatis Plus 而不是纯 MyBatis,原因很简单——单表操作完全不需要手写 SQL,自带的方法够用,复杂查询再看情况写 XML。鉴权方案采用 JWT + Spring Security,但做了简化处理,只用了 Spring Security 的加密工具类,没有引入完整的安全过滤链。很多全栈项目的通病是过度设计,明明是个小项目,却把微服务、分布式那套全上了,结果光搭建环境就花了大半时间。这次的实战特意保持克制,让你能专注于理解核心链路。
提示:如果你的环境是 JDK 8,暂时不想升级,建议使用 Spring Boot 2.7.x 配合 Vue 2,但同样建议尽早升级。JDK 17 的垃圾回收性能和容器支持都比 8 好很多,IntelliJ IDEA 2023 版本对 JDK 17 的支持也更顺手。
2. 整体设计与架构拆解
2.1 前后端分离架构的边界划分
全栈项目最容易犯的错误是职责混乱。前端代码里塞后端逻辑,后端代码里混页面模板,时间一长代码就成浆糊了。这次项目采用严格的前后端分离模式,前端是纯静态资源,通过 Axios 异步调用后端接口;后端只负责提供 JSON 数据,不做页面渲染。
简单来说,你可以把整套系统想象成一家餐厅:前端是服务员,负责记录顾客需求、展示菜品(渲染数据);后端是后厨,负责准备菜品(处理业务);数据库就是储藏室。服务员不需要知道菜怎么做,后厨也不需要直接和顾客沟通,两者之间的点菜单就是 API 接口。
拿一个具体的业务场景来说:用户在前端页面上登录,填完表单点击提交按钮,Vue 组件把表单数据通过 POST 请求发送到后端的/api/login接口;后端 Controller 接收请求,调 Service 层校验用户名密码,再调 Mapper 层查数据库;校验通过后生成一个 JWT token 返回给前端。前端收到 token 后存在 localStorage 里,之后每次请求都在请求头带上这个 token,后端用拦截器验证 token 是否有效。整个链路中,前端和后端各自独立开发、独立测试、独立部署,契约就是接口文档。
2.2 后端分层设计的职责划分
后端项目的包结构按照经典的三层架构来组织:Controller 层负责接收请求和参数校验,Service 层负责业务逻辑,Mapper 层负责数据库操作。在这个之上再加一个 Entity 包放实体类,一个 DTO 包放数据传输对象,一个 Config 包放配置类,一个 Common 包放统一返回结果和异常处理。
每个层之间通过接口和类来协作。Controller 不直接操作数据库,Service 不写 SQL,Mapper 只做数据映射。刚入门的开发者容易把业务代码全堆在 Controller 里,一个方法写上百行,看起来很“高效”,实际上后面需求一变更就得重写。我曾经接手过一个项目,登录接口里直接拼 SQL 查数据库,后来要加验证码功能,那叫一个费劲。
打个比方:三层架构就像公司的三级审批——Controller 是前台接待,负责接需求和判断诉求是否合理;Service 是部门主管,负责制定处理方案并协调资源;Mapper 是具体执行人,负责落实细节。每一层只做自己该做的事,出问题时能快速定位是哪个环节的锅。
2.3 前端组件化设计的模块划分
前端项目按功能模块划分目录结构,api目录放接口请求封装,router目录放路由配置,stores目录放 Pinia 状态管理,views目录放页面组件,components目录放公共组件。
组件化设计的关键是找到合适的拆分粒度。举个具体的例子:后台管理系统里常见的“用户管理”页面,可以拆成搜索栏、表格、弹窗表单三个部分。搜索栏接收用户输入的查询条件,通过事件把条件抛给父组件;父组件拿到条件后更新列表数据;表格展示数据并监听行点击;弹窗表单负责新增和编辑。
组件拆分有个度,阿里推出的前端规范里提到一个原则:如果一个组件太长,超过合理范围还没法拆分出可复用的子组件,可能就是职能不单一。反过来,把组件拆得太碎也有问题——有一次我把一个简单的列表页拆成了十多个组件,每个组件只有几十行代码,结果传参传得头晕,调试一个 bug 得在文件间跳来跳去。一般的原则是:只在组件被两个以上地方复用,或者单个组件的复杂度已经明显影响可读性时才需要拆分。
3. 环境准备与项目初始化
3.1 开发环境的版本选型与安装清单
先过一遍环境清单。后端需要 JDK 17、Maven 3.9+、MySQL 8.0;前端需要 Node.js 18+(我用的 20.12.0)、npm(或 pnpm/yarn,这里用 npm)、Vite 作为构建工具。IDE 方面我推荐后端用 IntelliJ IDEA,前端用 VS Code,两个都装也没问题。
几个需要注意的坑先提前说:
- JDK 版本必须和环境变量匹配。装了多个 JDK 的机器上,IDEA 里配置的 Project SDK 和系统环境变量最好统一,否则 Maven 编译时可能用错版本。
- Node.js 尽量用偶数版本。Node 官方对奇数次版本的支持时间较短,有些依赖可能不兼容。当年 Node 15 时代装 Vite 就是各种问题,后来学聪明了,直接固定用最新 LTS 版本。
- MySQL 8.0 的认证插件。默认的
caching_sha2_password在旧版连接驱动下会报错,Spring Boot 3.x 自带的 MySQL Connector/J 8.x 没问题,但如果你的其他老项目还在用 5.x 驱动,连接 MySQL 8 会失败。
安装顺序没有硬性要求,但建议先装后端环境,再装前端环境。因为后端跑起来之后,你可以先用 Postman 测试接口,确认没问题再写前端页面,这样排查问题时范围更清晰。
3.2 后端项目初始化三步走
先用 IntelliJ IDEA 创建 Spring Boot 项目。如果你用的是 IDEA 2023+ 版本,可以直接在 New Project 里选 Spring Initializr,Group 填com.example,Artifact 填fullstack,Java 版本选 17,依赖勾选 Spring Web、Spring Security(可选,也可以后面手动加)、MySQL Driver、Lombok、Validation。
需要补充说明的是,Spring Initializr 生成的项目默认带一个mvnw(Maven Wrapper),它允许你在没有全局安装 Maven 的机器上直接执行./mvnw命令来构建项目,这对保证构建环境一致性很有帮助。如果你的机器没有 Maven,可以用它来代替mvn命令。
项目生成后先别急着写代码,把目录补齐。基础结构大概是:
src/main/java/com/example/fullstack ├── controller ├── service │ ├── impl ├── mapper ├── entity ├── dto ├── config └── common然后在pom.xml里加入 MyBatis Plus 的依赖。有个地方容易踩坑:MyBatis Plus 在 Spring Boot 3.x 下需要引入mybatis-plus-spring-boot3-starter,而不是mybatis-plus-boot-starter(那是给 Spring Boot 2.x 用的)。如果你用错版本,启动时大概率会报Invalid value type for attribute 'factoryBeanObjectType'之类的错误。
配置文件application.yml按以下内容配置:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/fullstack_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: your_password jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto这里有个细节:Jackson 的日期格式设置很重要。如果前端传来的日期字符串是"2024-05-20 10:30:00"格式,后端 LocalDateTime 字段不加@JsonFormat注解或全局配置,默认是 ISO 格式,反序列化时就会报错。全局配好省得每个字段都写注解。
3.3 前端项目初始化与依赖配置
前端我用 Vite 创建项目,命令如下:
npm create vite@latest frontend -- --template vue cd frontend npm install然后安装项目需要的依赖:
npm install vue-router@4 pinia axios element-plus选 Element Plus 做 UI 组件库是国内全栈项目的常见选择,它对中文场景支持好,表格、表单、分页这些后台管理常用的组件都有现成的。同时安装它的图标库@element-plus/icons-vue,可以在全局注册,方便在菜单和按钮里直接用图标。
依赖装完之后,先配置 Vite 的代理和构建选项。开发阶段前端和后端分离运行,需要把请求代理到后端服务;生产阶段前端构建后的静态文件由 Nginx 托管,也需要配置转发规则。
在开发环境,vite.config.js里加代理:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })这样前端代码里请求/api/login时,Vite 会把请求转发到http://localhost:8080/api/login,绕过了开发环境下的跨域限制。生产环境则交给 Nginx 配置 location 块做反向代理。
提示:配置代理解决的是开发阶段的跨域问题。如果你的前后端部署在同一台服务器、同一个域名下,生产环境一般不需要跨域处理;但如果部署在不同域名或端口下,则要在后端加 CORS 配置。这个细节等到部署章节详细讲。
4. 核心功能开发实操
4.1 用户认证模块的完整实现方案
用户认证是几乎所有全栈系统的刚性需求。我用 JWT 实现了这套认证流程,核心思路是:用户登录成功后,后端签发一个有效期为一定时间的 token,前端存储这个 token,在每次请求的 Authorization 请求头里带上来,后端通过拦截器统一校验。
先说数据表结构,用户表设计为:
CREATE TABLE `sys_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT '加密后的密码', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `email` varchar(100) DEFAULT NULL COMMENT '邮箱', `status` tinyint(4) DEFAULT 1 COMMENT '状态:1启用 0禁用', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;密码存储用的是 BCrypt 加密算法,这是 Spring Security 里自带的工具类,不需要额外引依赖:
// 加密 String encodedPassword = BCryptPasswordEncoder().encode(rawPassword); // 校验 boolean matches = encoder.matches(rawPassword, encodedPassword);这里有一个很容易被忽略的安全细节:matches方法第一个参数是明文密码,第二个是数据库里取出来的密文。我第一次写的时候参数顺序搞反了,调试了半小时才发现是参数顺序问题。另外注意 BCrypt 的加密结果是随机的,同一个明文每次加密出来的密文都不一样,所以不能用常规字符串比较,必须用matches方法校验。
登录接口的逻辑不复杂,伪代码是这样的:Controller 接收LoginRequest(用户名+密码),Service 层根据用户名查用户,如果用户不存在直接抛异常提示“用户名或密码错误”(注意这里的提示不能太具体,否则会暴露用户是否存在);存在则用 BCrypt 校验密码,失败同样提示“用户名或密码错误”;成功后用用户 ID、用户名和当前时间戳生成 JWT,返回 token + 用户信息。
4.2 JWT 拦截器的实现与请求链路
JWT 拦截器是一个关键的“横切关注点”。我用 Spring 的HandlerInterceptor实现,配合 WebMvcConfigurer 注册,让所有/api/**下的接口都经过校验(登录接口本身要放行)。
核心逻辑如下:
@Component public class JwtInterceptor implements HandlerInterceptor { @Autowired private JwtUtils jwtUtils; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if (request.getMethod().equals("OPTIONS")) { return true; } // 前端约定 token 放在请求头的 Authorization 字段中 String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { return this.unauthorized(response, "未登录或登录已过期"); } // 校验 token 合法性 try { String userId = jwtUtils.getUserIdFromToken(token); request.setAttribute("userId", userId); return true; } catch (Exception e) { return this.unauthorized(response, "token 无效或已过期"); } } private boolean unauthorized(HttpServletResponse response, String msg) throws IOException { response.setStatus(401); response.setContentType("application/json;charset=utf-8"); response.getWriter().write(JSON.toJSONString(Result.error(401, msg))); return false; } }注意一个细节:预检请求(OPTIONS)一定要放行。浏览器在跨域 POST 请求前会自动发送一个 OPTIONS 预检请求,如果你的拦截器把它拦下来了,前端会报 CORS 错误,而且这个错误在浏览器控制台看起来像是后端 CORS 配置有问题,实际上却是拦截器没放行 OPTIONS。
拦截器只是一个粗粒度的路由守卫,对于不同接口的细粒度权限(比如只有管理员能调用的接口),可以在 Controller 方法上加自定义注解,再在拦截器里读取注解做二次判断,这个属于权限系统的进阶内容,本次项目不做展开。
4.3 用户管理模块的 CRUD 与分页查询
用户管理模块虽然简单,但把前后端交互的典型模式都覆盖了:查询列表、新增、编辑、删除。我用 MyBatis Plus 的IService和BaseMapper框架,代码量比传统 MyBatis 少很多。
Controller 层代码示例:
@RestController @RequestMapping("/api/user") public class UserController { @Autowired private UserService userService; @GetMapping("/list") public Result<IPage<UserVO>> list(@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, String keyword) { Page<User> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); if (StringUtils.hasText(keyword)) { wrapper.like(User::getUsername, keyword).or().like(User::getNickname, keyword); } wrapper.orderByDesc(User::getCreateTime); IPage<User> userPage = userService.page(page, wrapper); // 转 VO 返回,不直接暴露实体 return Result.success(PageUtil.convert(userPage, UserVO.class)); } // 新增、编辑、删除逻辑省略,遵循同样的模式 }细节问题来了:新增用户密码需要加密存储,编辑用户如果密码为空说明不修改密码。这些分情况讨论的业务逻辑都放在 Service 层。Controller 里写太多 if-else,后面维护起来就想骂人。
前端对应这个接口的调用也是固定套路,封装在api/user.js模块里:
import request from '../utils/request' export function getUserList(data) { return request({ url: '/user/list', method: 'get', params: data }) } export function addUser(data) { return request({ url: '/user/add', method: 'post', data }) } // ...4.4 Axios 请求封装的几个关键决策点
Axios 封装的好坏直接决定整个前端项目请求代码的整洁程度。我的做法是单独建一个utils/request.js文件,生成一个配置了基础 URL、超时时间和拦截器的 Axios 实例。
核心代码:
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '../router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截器:自动附加 token request.interceptors.request.use( config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = token } return config }, error => Promise.reject(error) ) // 响应拦截器:统一处理错误码 request.interceptors.response.use( response => { const res = response.data // 如果后端返回了业务错误码 if (res.code !== 200) { ElMessage.error(res.message || '请求失败') if (res.code === 401) { // token 过期,跳转登录页 localStorage.removeItem('token') router.push('/login') } return Promise.reject(new Error(res.message)) } return res.data }, error => { ElMessage.error(error.message || '网络错误') return Promise.reject(error) } ) export default request几个决策点说一下。baseURL 设为/api,配合 Vite 代理或 Nginx 反向代理,前端代码里就不用写死后端地址,环境切换时只需要改代理配置就行。响应拦截器里把后端的code/message/data统一解开,前端调用接口时直接拿到业务数据,不需要每个调用处都写.then(res => res.data)。
还有一个细节上的选择:token 存 localStorage 还是 sessionStorage。有人建议存 sessionStorage,因为关闭浏览器就自动清掉,安全一些;但用户希望刷新页面后保持登录状态,所以我用 localStorage。另外,逻辑上要区分业务错误码(比如 500)和 HTTP 状态码(比如 404),上述封装里对这两类错误都做了处理。
5. 前端关键页面与交互实现
5.1 登录页的实现与表单校验
登录页是系统的“门面”,虽然逻辑简单,但涉及的交互细节并不少。我用 Element Plus 的el-form组件实现,配合它的表单校验规则:
- 用户名必填,格式校验长度 3-20 个字符。
- 密码必填,长度 6-32 个字符。
- 点击登录时先校验表单,通过后才发请求。
- 登录成功后跳转到首页,并提示“欢迎回来,xxx”。
登录页有一个容易被忽视的用户体验问题:请求失败时表单要保留用户输入,不能整个页面刷新重置。Vue 的单页应用优势在这里体现得比较明显。另外,登录按钮在请求期间要处于 loading 状态,防止用户重复提交。这些都是小细节,但拿捏好才是专业的表现。
关于“记住密码”功能,严谨的开发不会把密码存在前端,通常只记住用户名。这个项目我没有做这个功能,因为涉及敏感信息,不做比做更安全。
5.2 布局与路由守卫
后台管理系统的典型布局是:左侧菜单栏 + 右侧内容区。我用 Element Plus 的el-container组件实现,菜单数据直接写死在路由配置里,通过v-for循环渲染。
路由配置的要点是嵌套路由:
const routes = [ { path: '/login', component: Login }, { path: '/', component: Layout, redirect: '/dashboard', children: [ { path: 'dashboard', name: 'Dashboard', component: Dashboard, meta: { title: '首页', icon: 'HomeFilled' } }, { path: 'user', name: 'User', component: UserManagement, meta: { title: '用户管理', icon: 'User' } } ] } ]路由守卫是前端鉴权的另一道防线。在router.beforeEach里检查是否登录,未登录则重定向到登录页:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login') { next() } else { if (!token) { next({ path: '/login' }) } else { next() } } })提醒一句:前端路由守卫只是提升用户体验的手段,不是安全措施。真正校验 token 的有效性还得靠后端的拦截器,因为用户可以直接改前端代码,绕过路由守卫。一切前端校验都是“防君子不防小人”。
5.3 表格、分页与表单的联动模式
用户管理页面的核心交互是:搜索栏输入条件 → 点击查询 → 表格展示数据 → 点击编辑弹出表单 → 保存后刷新列表。这是后台管理系统最高频的交互模式。
页面数据结构大概是:
searchForm:搜索条件的响应式数据tableData:当前页的数据列表pageInfo:页码、每页条数、总条数dialogVisible:弹窗显隐formModel:表单数据formRules:表单校验规则
交互逻辑是:点击搜索时把页码重置为 1,然后调用列表接口,把返回的数据和总条数赋值给响应式变量。前端代码里分页组件和表格的状态同步是需要注意的地方:
<el-pagination v-model:current-page="pageInfo.pageNum" v-model:page-size="pageInfo.pageSize" :total="pageInfo.total" layout="total, prev, pager, next, sizes" @change="loadData" />需要注意el-pagination在 Element Plus 3.x 里事件名已经改成了@change,旧版是@current-change和@size-change两个事件分别处理。如果团队里有老手用新版时还写旧事件,表格点击分页没反应排查老半天,最后发现是事件名不对。
弹出表单的编辑功能有一个常见坑:编辑用户时,打开弹窗要把当前行的数据深拷贝到表单模型里,避免直接修改表格数据源。如果不做深拷贝,弹窗里改字段时表格数据也会跟着变,因为引用的是同一个对象。用JSON.parse(JSON.stringify(...))做深拷贝最简单,或者用展开运算符新建对象。
5.4 状态管理与跨组件通信的方案选择
Vue 3 中状态管理的选择比 Vue 2 时代丰富多了。Pinia 是官方推荐方案,用它替代 Vuex 写起来舒服得多:没有 mutations、actions 那套冗余的流程,store 里定义的 state 直接用,actions 就是一个普通函数。
这个项目用到 Pinia 的场景:用户登录后存基本信息和 token。在登录成功时调用userStore.setLoginInfo(...),然后在多个组件里通过userStore.username展示当前登录用户。
示例代码:
import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('token') || '', userId: null, username: '', nickname: '' }), actions: { setLoginInfo(data) { this.token = data.token this.userId = data.userId this.username = data.username this.nickname = data.nickname localStorage.setItem('token', data.token) }, logout() { this.token = '' this.userId = null this.username = '' this.nickname = '' localStorage.removeItem('token') } } })在这个系统里,用户信息需要全局共享,但很多业务组件之间的通信其实用 props 和 emit 就够。过度引入全局状态管理会让代码难追踪——某个字段不知道被谁修改了,找起来很费劲。我的原则是:跨页面、跨层级深组件才考虑 Pinia,父子组件之间能用 props/emit 就用 props/emit。
6. 前后端联调与常见问题排查
6.1 联调前的接口约定与准备
前后端分离开发模式下,联调阶段往往是问题集中爆发的时期。前端认为接口该这么返回,后端认为前端该那么传参,经常对不上。经验是先定接口文档再动代码,哪怕是简单的表格约定、字段类型、命名风格、错误码,都提前对齐。
接口文档我用 YApi 或者 Apifox 维护,简单项目也可以在项目根目录放一个 Markdown 格式的接口说明文档。文档要约定如下内容:
- 请求方式(GET/POST/PUT/DELETE)
- 请求路径和参数(路径参数、查询参数、请求体参数)
- 参数类型(string/number/boolean/object/array)
- 返回格式(code/message/data 的结构)
- 鉴权方式(token 放在哪里,怎么传)
- 错误码语义(200 成功、401 未授权、500 服务器错误、业务错误从 400 开始)
联调前先用 Postman 把后端接口全部测一遍。实测下来,后端写接口时引入一个全局性的统一返回结构Result<T>,联调效率能提升不少。前端看到code/message/data就知道怎么处理,省去了解析各种怪异结构的时间。我在项目里定义了一个通用返回体:
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } }任何接口都返回Result包装后的对象,异常处理用全局@RestControllerAdvice捕获并统一转成这个格式,前端就永远只需要处理一种结构,代码逻辑非常简单。
6.2 跨域问题的三种表现与对应解法
跨域是全栈联调最常遇到的问题,但它有不同层次的原因,症状相同解法不同。
第一种:开发环境跨域。前端在http://localhost:3000,后端在http://localhost:8080。浏览器拦截了跨域请求。通用的解法是后端加 CORS 配置,让后端允许前端来源访问。
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:3000") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }但更推荐用 Vite 代理解决,前端配置代理后,前端请求的地址看起来是同源的。代理方式更好是因为它把跨域问题的处理留在了构建层,后端不需要关心前端运行在哪个端口,而且开发环境和生产环境的代理配置方式保持一致(生产环境用 Nginx)。
第二种:后端接口报 CORS 错误但后端没有 CORS 配置。这种情况多半是浏览器请求直接打到了后端地址,而请求方式是非简单请求(比如 PUT/DELETE),或者带了自定义请求头(比如 Authorization)。这时候浏览器会先发 OPTIONS 预检请求,如果后端拦截器没放行 OPTIONS,就会报 CORS 错。解决办法是拦截器放行 OPTIONS。
第三种:生产环境跨域。前端和后端部署在不同域名。这种情况下最稳妥的做法是用 Nginx 做反向代理,让前后端在同一个域名下,前端请求/api路径时 Nginx 转发给后端服务。
6.3 开发过程中遇到的高频报错速查表
下面整理一份联调阶段的常见报错和解决建议,都是实操中反复出现的问题:
| 报错现象 | 可能原因 | 解决方法 |
|---|---|---|
| 前端请求挂起很久后报 timeout | 后端服务未启动或接口路径不对 | 检查后端日志,确认服务启动,检查请求的路径和 Controller 映射是否一致 |
| 404 Not Found | URL 路径写错或 Controller 方法没有映射 | 核对前端 api 模块中的 URL 和后端@RequestMapping是否有出入 |
| 401 Unauthorized | token 缺失或无效 | 确认登录是否成功、token 是否在请求头中被正确携带 |
| 500 后端报 NullPointerException | 后端代码对 null 值没有处理 | 看异常堆栈,定位到具体行号,检查参数是否为空 |
| 数据库中文乱码 | 数据库连接串没有指定字符集或者表默认字符集错误 | 在 JDBC URL 后加characterEncoding=utf8,建表时指定DEFAULT CHARSET=utf8mb4 |
| MySQL 连接失败 Public Key Retrieval is not allowed | 连接串缺少 allowPublicKeyRetrieval 参数 | JDBC URL 中加入allowPublicKeyRetrieval=true |
| 打包前端后访问页面空白 | 路由模式或静态资源路径配置问题 | 如果使用 history 模式,Nginx 要配置 try_files 重写规则 |
| 跨域报 Access-Control-Allow-Origin | CORS 配置不当 | 按照上文跨域问题的三种表现依次排查 |
6.4 前后端联调中的时间同步与数据格式问题
还有一种不报错但结果不对的坑,最典型的是时间字段。后端 LocalDateTime 序列化后得到的是"2024-05-20T10:30:00",而前端希望显示的是"2024-05-20 10:30:00"。我见过不少项目因为时间格式对不上,前端硬生生写了个字符串替换函数来转格式,相当丑陋。正确做法是在后端做好全局配置,让所有时间字段统一输出为固定格式,前端直接拿到标准字符串后展示或传给后端。
JSON 序列化这里再提一次:在application.yml里加 Jackson 的时间格式配置,代码里不需要再给每个LocalDateTime字段加@JsonFormat注解。不过要注意的是,如果分页返回的数据里包含时间排序,序列化后的字符串格式影响不大,只要保证比较的逻辑放在数据库层面做就行。
另一个常见问题是前端传日期字符串给后端时,后端用@DateTimeFormat注解或全局配置 Jackson 的LocalDateTime反序列化格式来接收。我遇到过前端传"2024-05-20 10:30:00",后端接收后解析失败,报 400 错误。这是因为 Fastjson 和 Jackson 对 LocalDateTime 支持有差异,解决方式是统一用 Jackson,并在全局配置里声明日期格式。
7. 项目打包与生产环境部署
7.1 前端构建与单页应用路由配置
生产部署的第一步是前端构建。执行npm run build,Vite 会生成dist目录,里面是压缩后的静态资源文件。这里要特别关注两个点。
第一,构建产物的路径问题。如果你把前端部署在域名根路径/下,那baseURL保持/api没问题;但如果部署在子路径(比如https://example.com/admin)下,Vite 构建时要在vite.config.js里配置base: '/admin/',否则静态资源的引用路径全部 404。
第二,前端路由的 history 模式需要服务端配合。Vue Router 的 history 模式(URL 中不带#),刷新在子路由页面(比如/user)时,如果不配置服务端重写规则,Nginx 只会看文件系统里有没有对应的user目录,结果就是 404。Nginx 配置如下:
location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }try_files的$uri先看请求路径对应的文件是否存在,如果不存在就回退到/index.html,由前端路由接管。
7.2 后端打包与启动脚本
后端用 Maven 打包:
mvn clean package -DskipTests命令执行完会在target目录下生成fullstack-0.0.1-SNAPSHOT.jar。如果你用的是 Java 17 和 Spring Boot 3.x,还有一个可执行 jar 的特性:Spring Boot 的 Maven 插件会自动配置 Main-Class,你不需要额外配置,直接java -jar就能运行。
启动命令:
java -jar fullstack-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod生产环境的配置建议单独建一个application-prod.yml,里面配置生产环境用的数据库地址、端口、日志级别等,和开发环境区分开。日志输出也建议配置到文件中,不要只输出到控制台,服务重启后日志就丢了。一个典型的日志配置:
logging: level: com.example.fullstack: info file: name: logs/fullstack.log线上环境有个小建议:用nohup启动服务,避免终端断开时进程被杀掉。或者更规范的写法是注册为 systemd 服务,让操作系统管理进程生命周期,开机自启、崩溃重启都能自动处理。
7.3 Nginx 反向代理与服务部署细节
Nginx 在这套系统中的角色是:托管前端静态文件 + 反向代理后端 API。完整配置参考如下:
server { listen 80; server_name your-domain.com; # 前端静态资源 location / { root /var/www/frontend/dist; index index.html; try_files $uri $uri/ /index.html; } # 后端 API 反向代理 location /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; proxy_set_header X-Forwarded-Proto $scheme; } }这里有个细节:location /api/加尾斜杠proxy_pass http://127.0.0.1:8080;不带尾斜杠,那 请求/api/user/list会转发到http://127.0.0.1:8080/api/user/list,路径和原来保持一致。如果把proxy_pass写成http://127.0.0.1:8080/,请求会被转发到http://127.0.0.1:8080/user/list,/api前缀会被吞掉。这个细节容易踩坑,务必确认后端 Controller 的映射上有/api前缀,否则会报 404。
HTTPS 配置线上环境一般用 Certbot 自动申请证书并配置到 Nginx,这里先不展开,但强调一句:生产环境不要裸奔 HTTP,涉及登录和 token 的接口不上 HTTPS 相当于把门锁挂在门外面。
7.4 数据库初始化与迁移策略
生产环境数据库初始化不能靠手动一条条执行 SQL。我用 Flyway 做数据库版本管理,每次 SQL 变更写一个带版本号的文件放到resources/db/migration目录,应用启动时会自动执行未执行过的脚本。
打比方说明一下:Flyway 就像数据库的 Git,每次变更都是一个 commit(版本号递增),它会在数据库中建一张flyway_schema_history表来记录已经执行过的版本,下次启动时自动跳过已执行的,只执行新增的迁移脚本。
示例迁移文件:
-- V1__create_user_table.sql CREATE TABLE `sys_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT '加密后的密码', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `email` varchar(100) DEFAULT NULL COMMENT '邮箱', `status` tinyint(4) DEFAULT 1 COMMENT '状态:1启用 0禁用', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;使用 Flyway 有两点要非常谨慎:已经提交给团队的迁移脚本不要修改,哪怕里面 SQL 写错了,也要靠新的迁移脚本来修正;否则团队的数据库版本就对不上了。另外,生产库不要轻易执行flyway clean,它会重建整个数据库,操作前就算写满警告也不为过。
8. 常用工具与效率提升清单
8.1 接口调试工具的选择
联调阶段接口测试工具的作用很大。Postman 是最经典的选择,优势是功能齐全、支持 Collection 管理、环境变量设置。Apifox 是国内团队常用的方案,优势是集成了接口文档管理、Mock 数据、自动化测试。如果项目中后端还没有完全开发完,Apifox 可以直接在接口文档上生成 Mock 数据给前端先行开发,这种前后端并行开发的模式能省不少时间。
实际使用建议:如果你是一个人做全栈,直接用 IDEA 的 HTTP Client 就行,不需要额外装软件。IDEA 里新建.http文件写请求,可以配合{{var}}使用环境变量,还可以在后端代码左侧直接 Run 生成对应的测试请求,效率很高。
8.2 数据库连接与查询工具
数据库客户端方面,Navicat 和 DataGrip 都常用。Navicat 功能全面、界面友好,DataGrip 更适合写复杂 SQL,它提供的智能提示和格式化功能非常好用。个人项目简单查询用 VS Code 的 MySQL 插件也可以凑合。
我的建议是:对于持久层查询,用 MyBatis Plus 的LambdaQueryWrapper写条件查询已经非常直观,大多数场景不需要打开数据库客户端。只有在排查数据问题时才需要连数据库。遇到数据库连接不上的情况,先检查服务是否启动、端口是否被占用、认证插件版本是否匹配这三个点,大多数问题都能解决。
8.3 联调排查常用的浏览器开发者工具
前端调试离不开浏览器开发者工具。Network 面板能看到每个请求的状态码、耗时、请求头和响应体;Console 面板直接报错信息;Vue 专门有 Vue DevTools 插件,可以查看组件树、Pinia 状态和路由信息。
排查问题建议按照这个顺序来看:先在 Network 里确认请求发出去了没有、状态码是多少;再看请求头里的 Content-Type 和 Authorization 对不对;然后看响应体里的具体数据;最后结合 Console 的报错信息定位问题。有的开发者一上来就在代码里打断点,其实大多数联调问题在网络请求这一层就能定位。
8.4 代码生成器的合理使用
到了项目后期,增删改查的代码写多了会产生明显的机械重复感。手动复制粘贴再改字段名容易出错,我保留了一条规则:多表关联、复杂业务逻辑的代码必须手写,但单表 CRUD 的代码交给生成器生成,生成完再人工审查和微调。
MyBatis Plus 官方提供了一个代码生成器(mybatis-plus-generator),可以根据数据库表自动生成 Entity、Mapper、Service、Controller 代码。用它的时机设置在项目初始化阶段:把表结构设计好,一键生成基础代码,后面再在 Service 中补充业务逻辑。这能省去大量重复劳动,但你一定要会改生成的代码,不然会陷入“低级错误看不出来”的局面。
9. 项目扩展方向与进阶建议
9.1 从单体应用到模块化演进
当前项目的结构是单体应用,这个结构适合大多数中小型项目。随着业务量增长,你可以按模块拆分,比如用户模块、订单模块、商品模块,每个模块独立成一个 Maven 模块,甚至独立成一个 Spring Boot 服务。拆分的关键依据是团队规模和部署复杂度,不要因为趋势而盲目拆分。
如果项目超过 10 个接口、3 个以上开发人员,合理的模块化拆分能明显减少合并冲突和协作成本。反过来,如果就是一个人的项目,拆成微服务纯属自找麻烦。
9.2 引入 Redis 缓存与接口性能优化
项目中如果有热点数据(比如用户信息、系统配置),可以引入 Redis 做缓存层。实现方式是在原有的 Service 逻辑包一层:先查缓存,缓存未命中再查数据库,同时把结果写入缓存并设置过期时间。用 Redis 的StringRedisTemplate就够,不需要引入复杂的缓存框架。
这个实践我建议放到项目稳定之后再做第二个版本迭代。先保证功能正确,再考虑性能。接口响应时间慢,先看是不是数据库查询的问题(有没有索引、有没有 N+1 查询),再决定要不要加缓存。过早引入缓存会让 bug 排查多一层维度,代价不小。
9.3 安全加固与权限模型的进阶方向
做全栈项目时,很多人忽略了权限设计。目前项目只有“登录/未登录”两层。如果你的系统需要不同角色(管理员、普通用户、访客),可以引入 RBAC 权限模型:用户-角色-权限三层结构。表设计上新建sys_role、sys_menu、sys_user_role、sys_role_menu四张表,后端在拦截器或注解层面校验是否有指定权限。
前端对应地可以做动态路由:用户登录后根据后端返回的权限列表,动态生成可访问的路由配置。这个是很多全栈项目的进阶难点,会接触到更深的前后端正交互。建议把当前项目跑通后,再往这个方向升级,每一步都是新的技术内容。
9.4 前端工程化的进一步打磨
前端部分可以继续打磨的点很多:代码规范(ESLint + Prettier)、提交规范(Commitlint + husky)、单元测试(Vitest)、自动化部署(GitHub Actions)。这些属于前端工程化范畴。
举一个很实用的例子:在项目里加 ESLint 和 Prettier 配置,团队协作时代码风格统一,少了无谓的争吵。不建议一开始就上全量工程化配置,先把项目跑通,再逐步加上这些工具,学习曲线更平滑。
10. 完整的实操流程对照表
下表整理了这个项目从零到上线的完整操作清单,按顺序执行,每完成一步打一个勾:
| 阶段 | 关键操作 | 产出物 | 耗时预估 |
|---|---|---|---|
| 环境准备 | JDK 17、Maven、Node.js 20、MySQL 8 | 开发环境跑通 | 0.5 天 |
| 数据库设计 | 创建数据库和表、初始化测试数据 | SQL 脚本 | 0.5 天 |
| 后端骨架 | 创建 Spring Boot 项目、MyBatis Plus 配置 | 可启动空项目 | 0.5 天 |
| 后端核心接口 | 登录、CRUD、拦截器、全局异常处理 | 可测试的 REST API | 2-3 天 |
| 前端骨架 | Vite 创建项目、配置路由、Pinia、Axios | 可运行前端基础工程 | 1 天 |
| 前端页面开发 | 登录页、布局页、用户管理页 | 可交互的完整页面 | 2-3 天 |
| 前后端联调 | 测试登录流程、CRUD 流程、异常流程 | 完整的业务闭环 | 1 天 |
| 打包部署 | 前端 build、后端 jar、Nginx 配置 | 生产环境可访问系统 | 1 天 |
| 上线检查 | 安全、日志、备份、监控 | 稳定运行的系统 | 0.5 天 |
这是一个典型的个人全栈项目的工期预估。如果你是第一次做,时间会翻倍,正常现象。如果遇到卡壳超过半天的问题,建议跳过去,先把主流程跑通,再回头看卡住的部分。
我个人的经验是:全栈开发能力的提升不是线性的,而是靠一个个完整的项目串起来的。这个项目虽然从技术角度看,每个环节都是“常用技术”,但当它们组合在一起形成完整闭环时,你对前端和后端协作模式的理解会上一个台阶。当你遇到任何业务需求时,脑子里能浮现出完整的请求链路和数据流转图,而不是只盯着自己熟悉的那一层,这就算真正入门了。