前后端分离的项目做到登录功能联调这一步,十有八九会遇到同一个场景:前端明明点了登录按钮,控制台报了一堆网络请求错误,后端这边却干干净净一条日志都没有;或者后端日志明明打印了请求进来,前端却收到了 403、跨域报错、参数格式不对。这篇是《Vue2+SpringBoot在线商城》系列的第二篇,上一篇文章把前后端的骨架搭好了,这一篇就把登录功能这条链路完整串起来。
这篇文章不是纯讲概念,而是把我在实际商城项目里连接 Vue2 登录页面和 SpringBoot 后端接口时踩过的坑、验证过的方案、最终落到代码里的写法全部拆开讲。内容围绕几个核心点展开:登录方案选型、SpringBoot 端接口实现、Vue2 端 Axios 封装与表单对接、跨域处理、Token 存储与路由守卫、以及联调过程中的问题排查。适合正在做前后端分离项目、卡在登录联调这一步的开发者,也适合拿 SpringBoot + Vue2 做毕设的同学参考,只要你把业务逻辑替换成自己的,这套链路可以直接抄作业。
1. 联调之前,先把登录方案和技术选型定下来
1.1 前后端分离下登录为什么要用 Token 方案
很多同学第一次接触前后端分离时,会习惯性地沿用传统单体项目的登录思路:用户输入账号密码,后端验证成功后把用户信息存进 Session,再给浏览器种一个 JSCESSIONID 的 Cookie,后续请求都靠这个 Session 来识别用户身份。
这套方案在前后端分离的架构下会碰壁。原因很直接:前端和后端大概率不在同一个域名下,开发阶段前端跑在 8080,后端跑在 9090,这时候每次请求都会产生跨域。Session 依赖 Cookie 的自动携带,跨域情况下 Cookie 的携带策略变得非常麻烦,需要配置复杂的 CORS 跨域许可、还要处理 SameSite 属性。更现实的问题是,现在很多商城项目的后端不止一个实例,分布式部署之后 Session 同步又是新的麻烦。
所以在前后端分离的商城项目里,登录方案基本统一转向 Token 机制。核心逻辑是:用户提交账号密码,后端验证通过后生成一个签名字符串返回给前端,前端保存起来;之后的请求在请求头里带上这个 Token,后端用过滤器或拦截器统一校验。这个过程不依赖 Cookie,天然规避了跨域场景下 Cookie 携带的问题,也方便后续做多端适配——Web、小程序、App 全都可以用同样的接口和 Token 体系。
我做的这个在线商城项目最终定了 JWT 方案的变体:登录成功后后端生成 Token 返回给前端,前端存放在本地,每次请求通过请求头Authorization字段携带。后端写了一个拦截器统一校验,不需要在每一个 Controller 方法里重复判断登录状态。
1.2 接口约定先行,避免前后端各写各的
联调中大量的问题排查时间,其实不是在修 Bug,而是在对接口。前端按照自己的理解传参数,后端按照自己的习惯接收参数,结果就是前端传了formData,后端接口用@RequestBody接收 JSON,两边都不改,联调就卡死了。
在写登录接口的代码之前,我建议先定一份最小化的接口文档,不用搞得很正式,两个人能看懂就行。登录接口至少要确认四件事:请求路径、请求方式、请求参数格式、响应数据结构。
我在这个项目里定的登录接口是:
POST /api/user/login请求体使用application/json格式,参数是:
{ "username": "testuser", "password": "123456" }响应统一走封装结构:
{ "code": 200, "message": "登录成功", "data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "username": "testuser", "avatar": "http://..." } }这里有个落实细节值得先说清楚:password字段传输时前端用明文,后端接口接收的也是明文,但是在后端业务逻辑里对明文做加密处理再和数据库里存的密文比对。很多初学者喜欢在前端用 MD5 加密再传后端,这个做法在 HTTP 明文传输的场景下意义不大,因为前端加密的算法是公开的,别人拿到密文照样可以重放请求。真正要防的是传输过程本身被截获,这个靠 HTTPS 解决,而不是靠前端加密。
1.3 为什么给前端返回 Token 而不是用户对象
另一个容易纠结的点是登录成功之后,返回的数据里要不要带完整的用户信息。我见过一些项目直接把用户的手机号、邮箱、收货地址全部返回到前端,说是为了后续页面展示方便。
这个做法有隐患。登录接口返回的数据会被前端保存在本地,存得越多,泄露面就越大。商城项目里前端需要展示的通常就是用户名、头像、昵称这类基础资料,完整用户信息应该提供一个独立的查询接口,前端需要的时候再调,而不是一股脑塞进登录响应里。
我这个商城项目登录接口只返回了 Token、用户名和头像,其他信息都是前端通过/api/user/info接口按需获取的。这个设计在后面处理用户资料修改、权限变化时也更灵活——用户信息改了,前端拿到的还是旧的,必须重新拉取,但如果登录时就一次性给了全量数据,前端很容易忘记刷新。
2. SpringBoot 端登录接口:从 Controller 到数据库
2.1 Controller 层写什么、不写什么
SpringBoot 后端写登录接口,最忌讳的就是把业务全堆在 Controller 里,一个大方法几十行,查数据库、比对密码、生成 Token 全在一块儿。代码能跑,但后面加需求、做权限、写单元测试的时候会非常痛苦。
我在项目里把登录接口分成了 Controller、Service、Mapper 三层。Controller 只干三件事:接收参数、调用业务、返回统一响应。
@RestController @RequestMapping("/api/user") public class UserController { @Resource private UserService userService; @PostMapping("/login") public Result login(@RequestBody @Validated LoginRequest request) { LoginResponse response = userService.login(request); return Result.success(response); } }LoginRequest和LoginResponse是两个简单的 DTO 对象。LoginRequest里加了@NotBlank注解做参数校验,用户名和密码都为空时,SpringBoot 会直接抛出参数校验异常,被全局异常处理器捕获后返回友好的错误提示,不会进入业务代码。
这里有一个新手容易忽略的点:请求参数一定不要直接用Map<String, Object>接收。虽然代码写起来快,但到了联调阶段问题就来了——前端传的参数名对不上,后端拿到的是一个 Map 里的 null 值,排查半天也定位不到是哪个字段有问题。用 DTO 接收,字段名清晰,编译期就能发现拼写错误,配合参数校验注解还能自动拦截非法请求。
2.2 Service 层:真正干活的逻辑
Service 层的登录逻辑是整个接口的核心,我拆成了三步。
第一步是查询用户。根据用户名去数据库里查用户记录,查不到直接抛出业务异常,提示“用户不存在”。这里有个安全细节:提示信息故意写得模糊一点,不要明确告诉对方是“用户不存在”还是“密码错误”,防止被用来探测系统里有哪些账号是真实存在的。
第二步是密码校验。数据库里存的是经过 BCrypt 加密的密文,前端传过来的是明文。用BCryptPasswordEncoder.matches()方法比对,返回 false 就抛出“用户名或密码错误”的异常。
第三步是生成 Token。校验通过后,用 JWT 生成一个带用户 ID、用户名、过期时间的字符串返回给前端。我用的方案是jjwt库,代码就几行:
String token = Jwts.builder() .setSubject(user.getId().toString()) .claim("username", user.getUsername()) .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();Token 的有效期我设置的是 24 小时。商城项目里大家更关注的是登录之后一段时间内不要再重复输入密码,这个有效期时长比较合理。设置更长比如 7 天也不是不行,但会带来安全隐患——Token 一旦泄露,别人可以连续 7 天以你的身份操作。
2.3 统一的响应结构省掉多少事
很多项目前后端联调不顺畅,响应格式不统一是重要原因。有的接口成功时返回{ "ok": true },失败时返回{ "success": false, "msg": "xxx" },前端拿到响应后还要判断好几种格式,非常容易出错。
我一开始就把响应格式统一了。后端所有接口都返回同一个Result结构,包含code、message、data三个字段。code为 200 表示成功,401 表示未登录或 Token 失效,500 表示服务端异常,业务参数错误用 400。前端只需要判断code === 200就能决定走成功逻辑还是失败逻辑。
这个看似简单的统一,给前端省了大量的分支判断代码。Vue2 项目里我在 Axios 响应拦截器里统一处理code,等于所有接口的异常处理逻辑都收口到了一处,不需要每个页面单独写错误提示。
3. 前端登录功能落地:从按钮到接口调用
3.1 创建一个可复用的 Axios 实例
Vue2 项目里发 HTTP 请求,绕不开 Axios。很多初学者页面里直接import axios from 'axios'然后一把梭,代码短平快,但到了登录联调这一步就发现不顺手——每个请求都要写完整的 URL、每个请求都要手动处理错误、Token 要自己手动加。
我建议在项目里创建一个统一的 Axios 实例,所有请求都走这个实例。配置集中在src/utils/request.js里,核心配置包括baseURL、timeout、请求拦截器、响应拦截器。
import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_URL, timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }, error => { return Promise.reject(error) }) service.interceptors.response.use(response => { const res = response.data if (res.code !== 200) { Message.error(res.message || '请求失败') if (res.code === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(new Error(res.message)) } return res }, error => { Message.error(error.message || '网络异常,请稍后重试') return Promise.reject(error) }) export default service这里baseURL用的是环境变量process.env.VUE_APP_BASE_URL,开发环境指向http://localhost:9090,生产环境指向服务器地址。用环境变量而不是写死 URL,好处是切换环境不用改代码,只要在项目根目录的.env.development和.env.production两个文件里配置即可。
3.2 登录页从“按钮”到“请求”的完整链路
登录页的功能看起来就一个按钮、两个输入框,但真正落地时细节不少。以 Element UI 为例,完整链路是:点击登录按钮 → 表单校验 → 组装参数 → 调用登录接口 → 处理响应 → 跳转首页。
核心的登录方法大概是这样的:
handleLogin() { this.$refs.loginForm.validate(valid => { if (!valid) { return } this.loading = true login({ username: this.loginForm.username.trim(), password: this.loginForm.password }).then(res => { const { token, username, avatar } = res.data localStorage.setItem('token', token) localStorage.setItem('username', username) if (avatar) { localStorage.setItem('avatar', avatar) } this.$message.success('登录成功') this.$router.push('/') }).finally(() => { this.loading = false }) }) }几个细节值得展开。表单校验放在请求发送之前,用户名为空、密码长度不足这类问题在前端直接拦截,不用浪费一次网络请求。登录按钮加loading状态,防止用户重复点击造成重复提交。接口返回后再跳转路由,避免 Token 还没存好就进入需要鉴权的页面。
3.3 联调阶段前端最容易踩的三个坑
第一个坑是参数格式。后端接口用@RequestBody接收 JSON,前端却用了application/x-www-form-urlencoded格式提交;或者反过来,前端传了 JSON,后端的接口却用@RequestParam接收。我见过最典型的报错是后端返回 415 Unsupported Media Type,几乎都是这个原因。解决办法就是前端在发送请求前确认Content-Type,Axios 默认发送 JSON 格式,用POST传对象时不需要特意指定;但如果用了qs库序列化,就会变成 form 格式,这时候后端就要用@RequestParam接收。
第二个坑是拦截器里面的逻辑写错。有些项目在响应拦截器里判断response.data.code !== 200就弹错误提示,但登录失败时后端返回的 HTTP 状态码可能是 200,code字段是 400;也可能后端直接返回了 HTTP 401 状态码。前端拦截器只处理了其中一种,另一种情况就直接抛到页面里的catch里去了,页面代码里没写错误处理,就表现为点击登录按钮之后毫无反应。排查这个问题的方法是看浏览器 Network 面板里的响应体内容,确认后端到底返回了什么结构,再回头改拦截器。
第三个坑是接口地址写错。项目里有process.env.VUE_APP_BASE_URL是/api的情况,也有直接写完整 IP 端口的情况。如果后端接口是POST /api/user/login,而baseURL已经带了/api,请求路径又写了/api/user/login,实际请求就会变成/api/api/user/login。这种错误在 Network 面板里非常显眼,看请求 URL 一眼就能定位。
4. 跨域问题:前后端联调的第一道坎
4.1 同源策略和跨域产生的原理
前端跑在http://localhost:8080,后端跑在http://localhost:9090,两者端口不同,浏览器就认为这是跨域请求。浏览器为了安全,默认不允许跨域请求读取响应内容,这就是为什么很多前端同学在 Network 面板里能看到请求发出去了、后端也返回了数据,但前端代码里response却是 undefined,控制台报了一堆 CORS 错误。
用一个生活化的类比:你让小区门卫帮你给隔壁小区的朋友送一封信,信确实送到了,朋友也写了回信,但门卫出于安全考虑把回信扣下了,不交给你。所以前端看到的现象就是“请求没成功”,但实际情况是请求早就到达后端了。
4.2 三种解决方案的对比和选型
跨域问题的解决方案不外乎三种:后端配置 CORS、前端开发代理、部署时用 Nginx 反向代理。
后端配置 CORS 是最直观的——在后端加一个配置类,允许指定的前端地址跨域访问。
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOrigin("http://localhost:8080"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }前端开发代理是另一种常见方案,开发环境里通过vue.config.js配置 devServer 的 proxy,把前端的/api请求转发到后端的localhost:9090,浏览器看请求是同源的,就不存在跨域了。这个方案的好处是前端代码里不用区分环境,baseURL直接写/api,生产环境部署时由 Nginx 统一转发。
我在这个商城项目里用的是后端 CORS 配置 + 前端环境变量双保险。开发时前端直接请求后端的完整地址,后端放开跨域限制;部署时用 Nginx 做反向代理,前端所有/api请求由 Nginx 转发到后端服务,后端不再暴露到公网。
4.3 跨域配置里最容易被忽略的几个细节
配置 CORS 时,allowedOrigin一定不要写成*。我们项目登录接口已经用了 Token 方案,请求头里带Authorization,同时响应可能需要携带自定义的响应头,这就必须设置allowedHeaders包含Authorization,同时设置allowCredentials为 true。一旦使用*作为允许的来源,allowCredentials就不能设置为 true,浏览器会直接拒绝响应。
还有一个小细节是,如果后端配置了全局拦截器或过滤器,处理逻辑里直接返回了响应,而没有走到 Spring 的 CORS 过滤器链,那么跨域配置可能不生效。这种情况在带 Token 校验的拦截器里很常见——拦截器里先判断请求有没有带 Token,没带就直接返回 401,但跨域预检请求OPTIONS是不会带 Token 的,于是预检请求被拦截器拦截,前端就会报跨域错误。
解决办法是在拦截器里放行OPTIONS请求,让预检请求能正常通过。
5. Token 处理与路由守卫:登录成功之后的事
5.1 Token 存在哪:localStorage 还是 sessionStorage
登录成功后前端的第一个问题就是 Token 存哪里。常见的选项有localStorage、sessionStorage和 Cookie。
localStorage的特点是浏览器关闭后数据依然存在,用户下次打开页面还是登录状态。sessionStorage的特点是标签页关闭后数据就没了,适合对安全性要求更高的场景。Cookie 在这个场景下不太适合,因为前端手动操作 Cookie 比较繁琐,而且 Token 放 Cookie 里还要应对 CSRF 攻击。
商城项目一般建议用localStorage。用户体验优先,用户今天登录了,明天打开电脑不希望再输入一遍账号密码。如果担心 XSS 攻击把 Token 偷走,更合理的策略是加强输入过滤和内容安全,而不是牺牲用户体验去换一个伪安全。
我实际用的是localStorage,并且在登录、退出登录、Token 失效三个节点集中处理存储操作。封装了一个简单的 auth 工具模块,所有页面都从这里面读写 Token,而不是散落在各个组件里。
5.2 请求拦截器自动携带 Token,避免每个请求手动添加
前端每个请求都要在请求头里带 Token,如果每个接口都手动写一遍,代码量翻倍且容易漏。我在前面写的request.js里已经做了这件事:请求拦截器统一从localStorage里读取 Token,自动添加到Authorization头。
这里有一个容易犯的错误是 Token 的格式。后端约定的是Authorization: Bearer <token>,还是直接Authorization: <token>,前后端必须统一。如果后端是Bearer形式,前端只传了 Token,后端解析时就拿不到正确的凭证,返回 401。我在项目里统一用'Bearer ' + token的格式,后端拦截器里也是先去掉Bearer前缀再解析,两边一致,就不会出问题。
5.3 401 处理与退出登录
Token 会过期,用户身份被顶掉,后端校验不过来了,这时接口会返回 401。前端要做的事情很明确:清除本地的登录状态,跳转到登录页。
这个逻辑写在响应拦截器里最关键的一点是要避免循环跳转。如果用户当前就在登录页,后端返回 401,拦截器又把用户重新router.push('/login'),页面会反复刷新。我的处理方式是先判断当前路由是否已经在/login,是就不跳转,只清除 Token。
退出登录的按钮事件就更简单了:调用后端登出接口(可写可不写,因为 Token 无状态)、清除本地存储、跳转登录页。
handleLogout() { localStorage.removeItem('token') localStorage.removeItem('username') localStorage.removeItem('avatar') this.$router.push('/login') }这里后端登出接口的作用其实有限,因为无状态 Token 在服务端没有被吊销,Token 本身在有效期内仍然是可用的。真正的安全性靠 Token 过期时间保证,所以登出接口更多是业务上的“前端登出”,清掉本地凭证就足够了。
6. 常见问题与排查技巧实录
6.1 登录联调高频问题速查表
把我在商城项目登录联调阶段遇到过的典型问题整理成了一张速查表,你可以直接对照排查。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 前端请求报 CORS 错误 | 后端未配置跨域、拦截器拦截了 OPTIONS 预检请求 | 看 Network 面板里的请求类型,确认有没有 OPTIONS 请求,后端日志有没有打印 |
| 请求 404 | 前端路径写错、后端@RequestMapping写错 | 看请求 URL 是否真的是后端映射的路径,注意/api前缀是否重复 |
| 请求 400 | 参数格式不匹配、缺少必填字段 | 看请求体是否符合后端 DTO 定义,确认 JSON 字段名和后端字段名一致 |
| 请求 401 | Token 缺失、Token 格式错误、Token 过期 | 检查请求头有没有Authorization,格式是否为Bearer xxx,后端解密是否报错 |
| 请求 405 | 请求方式不对 | 确认前端是 GET 还是 POST,后端接口声明的是哪个 |
| 请求 415 | Content-Type 不对 | 确认前端发送的是 JSON 格式,qs序列化后变 form 格式会导致这种错误 |
| 后端返回 500 | 后端代码异常、数据库查不到表、SQL 错误 | 看后端控制台日志,一般异常堆栈里已经写清楚了 |
| 响应拦截器不生效 | 拦截器逻辑有误、code判断条件不对 | 单独打印response.data确认结构,再检查拦截器里code的取值 |
6.2 一个高效的排查顺序,少走弯路
登录联调出了问题,我建议的排查顺序是:先看 Network 面板确认请求发出去了没有、URL 对不对、请求方式对不对;再看请求头、请求体是否符合后端预期;然后看响应状态码和响应体内容;最后看后端日志。
很多同学一上来就翻代码,改一行重新启动,再改一行再启动,效率极低。比如前端报 404,先在浏览器地址栏直接用 POST 工具请求一下这个接口,确认后端接口本身是否能通,如果直接用工具能通,问题一定在前端代码;如果工具也不通,问题就在后端。这样一分钟就能把问题范围缩小一半。
6.3 用 Postman 独立验证后端接口,少一次扯皮
后端开发完登录接口,强烈建议先用 Postman 完整测一遍:正常的用户名密码、错误的密码、不存在的用户、参数缺失,这四种情况把接口行为摸清楚。
这一步做完,联调效率会高很多。前端说“接口通不了”,你可以先反问一句“我用 Postman 测是通的,你那边请求参数我看一下”。多数情况下,前端的问题很快就暴露了。
我在项目里还做了一个小动作:把 Postman 的请求示例导出成接口文档发给前端同事,请求 URL、请求头、请求体、响应体全部写完,前端照着对接,联调阶段几乎没扯过皮。
6.4 开发期间一个实用的调试技巧
Vue2 项目里修改完代码,页面会热更新,但request.js里的baseURL如果用的是环境变量,改了.env.development文件后需要重启开发服务器才生效,热更新不会重新加载环境变量。
另一个和热更新相关的坑是:Vue2 的data里如果放了响应结果相关的字段,调试时修改了request.js的逻辑,页面数据还是旧的,这时候手动刷新一下页面,而不是依赖热更新。很多奇怪的数据问题刷新之后就消失了,不是代码问题,是热更新的缓存问题。
写在最后
登录功能联调是整个前后端分离项目里最值得用心打磨的一个环节,它牵扯到接口设计、跨域、拦截器、路由守卫、状态管理,把这一个功能调通了,后面所有业务模块的对接基本上都是复制这套流程。
我在实际项目里做过对比,有的项目登录联调用了两三天,大部分时间浪费在前后端参数对不上、跨域配置没生效、Token 格式不一致这些低效问题上。而按我这篇文章里讲的思路,先把接口约定定好,后端用 Postman 独立验证,前端统一封装 Axios 实例,跨域问题提前配置好,登录联调通常几个小时就能完成。
最后分享一个实际用着很舒服的小习惯:项目里所有接口的路径都写在src/api目录下,按业务模块拆文件,登录相关的接口单独放一个user.js。页面里不直接写请求路径,而是引入user.js里导出的方法。这样做的好处是后端接口路径变了只需要改一个文件,全局受影响,另外代码的可读性也更好,别哪天页面上一堆字符串路径,想全局搜索都搜不干净。商城项目后面要接商品、购物车、订单这些模块,接入前先把登录这条基础设施搞扎实,后面会省心很多。