☰
前后端分离的饿了么后端项目实战:Servlet+Vue+Token登录校验
2026/10/12 4:12:02 网站建设 项目流程

简介:基于饿了么场景的前后端分离Web应用示例工程,采用Vue.js构建前端界面,后端由Java Servlet处理业务逻辑,并通过AJAX完成异步数据交互。适合正在学习Java Web开发、前后端分离架构或准备课程设计的初学者与进阶者。压缩包内有131个文件,含45个png图片、14个less与14个scss样式源文件、12个css样式、10个html页面、7个java源文件,以及class、jar、配置文件和字体图标等,整体约4.15MB,目录层次清晰,覆盖前端展示、后端接口和数据库访问等模块。资源中已实现用户登录验证、商品查询、订单处理等典型业务,体现MVC/MVVM分层思想与RESTful API风格,并配有项目配置和环境文件,可直接导入IDE调试和二次开发,从登录校验到商品异步加载,串联起前端组件化开发与后端服务编写的完整流程。已有1435人浏览学习,适合作为实战参考和课设模板。

1. 先搞清楚这个"饿了么后端项目"到底要做什么:前后端分离到底分在哪

很多同学拿到这个题目时,第一反应是"先建个 web 工程,写一堆 JSP"。实际上,这个"饿了么后端项目"是课程设计里流传的外卖平台教学复刻项目,要的是前后端分离:Vue 只负责页面和交互,Servlet 只负责把数据加工成 JSON,AJAX 负责把两者之间的数据搬来搬去。

前后端分离不是把文件拆成两个文件夹,而是两个独立工程各自跑在不同端口,通过 HTTP 接口约定通信。后端不再返回 HTML,前端也不再直接把 Java 对象拿来用,所有数据都经过 JSON 这一层中转。这种模式能解决课程设计里最常见的"前端改一处、后端跟着乱"的问题,也贴合真实企业开发的分工方式。

这篇笔记会从需求拆分、接口设计、Servlet 实现、Vue 联调到问题排查,把整个项目完整落地。适合没写过完整前后端项目、想用课设练手的人,也适合想搞清前后端交互链路、跨域和 Token 校验细节的开发者。你不需要有框架基础,有 Java 和 JavaScript 基础就能跟完。

2. 把外卖系统拆成接口和数据流:起点不是代码,是需求表和 URL 约定

做这种前后端分离项目,最大的误区是一上来就建工程、写 Servlet。真正消耗时间的从来不是敲代码,而是前后端 URL 对不上、字段名不一致、返回结构不统一,改了前面前面又坏。我一般会先把需求翻译成一张接口表,把边界画死,后面联调至少能省一半时间。

2.1 先从场景拆模块:用户、商户、订单各需要什么接口

外卖系统无论怎么简化,至少要覆盖四类角色行为:用户登录、浏览商家、下单、查看订单;商家维护菜品、更新订单状态。把场景翻译成"谁、对什么资源、做什么操作",接口就自然出来了。对应关系可以这样列:

模块功能方法URL说明
用户账号密码登录POST/api/auth/login入参 username、password
用户注册POST/api/auth/register入参 username、password、nickname
商家浏览商家列表GET/api/shops支持分页参数 page、size
商家查看某商家菜单GET/api/shops/{shopId}/dishes返回菜品列表
购物车加购POST/api/cart入参 shopId、dishId、count
订单提交订单POST/api/orders入参 shopId、items、address
订单查询订单详情GET/api/orders/{orderId}返回订单及明细
订单商家更新状态PUT/api/orders/{orderId}/status入参 status

这张表不需要一次设计得完美,但必须在写代码之前定好。因为前端组件的数据绑定依赖固定字段名,后端实体类也依赖同一份字段名,改一个字段要动三层。表格里出现 /api 前缀,是为了让所有接口有一个统一入口,后面做权限过滤和反向代理都靠这个前缀来识别。

2.2 用接口表把前后端边界画死:URL、Method、请求和响应

前后端分离以后,前端和后端各自维护一份代码,联调时最怕的就是"我以为你返回了 list,你返回了 records"这类字段歧义。所以接口表里不仅要写 URL 和 Method,还要明确请求 JSON 的字段名、响应包里 data 的结构。常见做法是约定一个统一响应结构,所有接口无论成功失败都长一个样:

{ "code": 200, "msg": "ok", "data": { } }

code 用 200 表示业务成功,非 200 表示业务失败;msg 给前端弹提示;data 只放业务数据。这个结构的好处是前端可以在 axios 响应拦截器里统一判断 code,不用每个接口单独写 if else。你也可以把 code 改成 0 或 1,只要前后端在一个项目里保持一致就行。

数据流是固定的:Vue 页面触发事件,调用封装好的 AJAX 请求函数,axios 把请求发到 Servlet;Servlet 调用 Service 处理业务,DAO 访问 MySQL,结果逐层返回;Servlet 把结果包装成 Result 对象序列化为 JSON,写回响应流;axios 收到响应后,在拦截器里剥壳,只把 data 交给页面使用。这条链路里任何一环字段名不一致,都会在最后渲染页面时暴露出来。

2.3 打通目录骨架:前端一个工程,后端一个工程,互不掺和

这类项目我建议建两个平级目录,后端一个 Maven Web 工程,前端一个 Vue 工程,不要把前端文件塞进后端的 webapp 里。目录结构可以这样规划:

takeout-project/ ├── takeout-server/ # 后端,Java Servlet 工程 │ ├── pom.xml │ └── src/main/ │ ├── java/com/example/takeout/ │ │ ├── servlet/ # HttpServlet 子类,只做请求接收和响应 │ │ ├── service/ # 业务逻辑 │ │ ├── dao/ # JDBC 或 MyBatis 数据访问 │ │ ├── entity/ # 实体类 │ │ ├── filter/ # 编码、CORS、登录校验过滤器 │ │ └── util/ # JSON 工具、Token 工具 │ └── webapp/ └── takeout-web/ # 前端,Vue3 工程 ├── src/ │ ├── api/ # 按模块拆分的接口函数 │ ├── views/ # 页面组件 │ ├── router/ # 路由配置 │ └── utils/ # axios 封装、工具函数 └── package.json

后端用 Maven 管理依赖,前端用 npm 管理依赖,两者通过 HTTP 通信,唯一契约是接口表。这样做的好处是:前端开发时可以用 mock 数据,不用等后端写完;后端测试时可以用 Postman 直接调用接口,不用打开浏览器页面。目录命名不必跟我完全一致,但"servlet、service、dao、filter"这几层尽量保留,因为评审老师提问时常常从分层开始问。

2.4 统一返回结构:让前端不用每次猜字段名

在真正写 Servlet 之前,先把 Result 结构定为代码层面的约定,前后端都按这个来。后端每个 Servlet 返回的都是 Result 的 JSON 序列化结果,前端每个接口从响应拦截器拿到的也是 Result 结构。这样处理之后,前端页面组件里永远只关心 data 部分,不会出现"这次返回数组、下次返回对象"的分叉。

如果你下载过一些现成课设代码,会发现有些项目每个接口返回格式都不一样,有的直接返回 List,有的返回 Map,有的把错误信息放在 data 里。这种代码在本地跑通没问题,一旦前端认真做,就会到处出 TypeError。所以我要单独用一张表把返回码的语义定清楚:

code含义前端处理
200业务成功正常渲染 data
401未登录或 Token 过期跳转登录页
500业务失败使用 msg 弹错误提示
404接口不存在检查 URL 与后端注解

这张表会和后面的登录校验过滤器配合使用。现在先把约定定下来,第 3 章就按照这个约定写代码。

3. 用 Servlet 写 JSON 接口:从工程骨架到登录接口的完整落地

后端实现的重点是四个字:接收 JSON、返回 JSON。Servlet 本身不帮我们完成 JSON 反序列化,也不像 Spring Boot 那样有自动配置,所以每一步都要自己写清楚。这一章从选型和依赖开始,一步步走到一个能用的登录接口。

3.1 为什么选 Servlet 而不直接上 Spring Boot:课程设计里 Servlet 仍是主流

可能有人会问:既然前后端分离,为什么后端不直接用 Spring Boot?原因很现实:很多课程设计的大纲要求就是 Servlet,考察的是对 HTTP 请求生命周期的掌握,而不是会调用框架。Spring Boot 把 Tomcat、DispatcherServlet、参数绑定全包了,你很难讲清楚"请求从浏览器到达 Servlet 中间发生了什么"。

Servlet 方案里,用户请求先到 Web 容器,容器根据 @WebServlet 注解映射的路径找到对应类,调用 doGet 或 doPost 方法。开发者要亲手动 setContentType、手动读请求体、手动序列化结果,虽然繁琐,但理解链路更清楚。如果你的课程允许 Spring Boot,自然可以选择;但如果题目指定 Servlet,就用 Servlet 方案,技术栈不要擅自替换。

后端依赖建议用最简组合:javax.servlet-api 提供 Servlet 接口,Gson 做 JSON 序列化和反序列化,Druid 做数据库连接池,MySQL Connector/J 做驱动版本对应。用 Maven 管理时,pom.xml 里建议锁定 Servlet 依赖 scope 为 provided,避免和 Tomcat 自带的 Servlet 容器冲突。

3.2 先写响应外壳:Result 类和它的三个约定

后端所有接口都返回同一个外壳类,前端才好在拦截器里统一处理。这个类必须包含 code、msg、data 三个字段,并提供 success 和 error 两个静态工厂方法。代码并不复杂,但作用很大,先把这个类写出来:

package com.example.takeout.entity; public class Result<T> { private int code; private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 200; result.msg = "ok"; result.data = data; return result; } public static <T> Result<T> error(String msg) { Result<T> result = new Result<>(); result.code = 500; result.msg = msg; result.data = null; return result; } public int getCode() { return code; } public void setCode(int code) { this.code = code; } public String getMsg() { return msg; } public void setMsg(String msg) { this.msg = msg; } public T getData() { return data; } public void setData(T data) { this.data = data; } }

这个类的构造方法我特意写成了私有之外可以 new 的普通方式,方便在不同模块里灵活使用。success 方法里定义 code=200,error 方法里定义 code=500,这和后端异常提示语义一致。data 用泛型,让每个接口可以返回不同业务数据类型,而外层结构保持一致。

使用 Gson 序列化时,默认会调用 getter 方法,所以 getCode、getMsg、getData 三个 getter 一个都不能少。如果你把类字段命名为 status 而 getter 写成了 getCode,序列化结果里就会出现 status 和 code 两个字段,前端取值就会错位。这是非常容易忽略的坑,建议字段名和 getter 命名严格一致。

3.3 完整写一个登录 Servlet:读 JSON、回 JSON、参数逐个说明

登录接口是业务里最典型的场景:接收 JSON、校验账号密码、返回用户信息。这个 Servlet 写透了,后面注册、加购物车、下订单都按同一套模式复制。注意这里我不写数据库真实查询,用 UserService 的代表性方法代替,重点是把 Servlet 层面的请求处理模式讲清楚:

package com.example.takeout.servlet; import com.example.takeout.entity.LoginParam; import com.example.takeout.entity.Result; import com.example.takeout.entity.User; import com.example.takeout.service.UserService; import com.google.gson.Gson; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.BufferedReader; import java.io.IOException; import java.io.PrintWriter; import java.util.stream.Collectors; @WebServlet("/api/auth/login") public class LoginServlet extends HttpServlet { private final UserService userService = new UserService(); private final Gson gson = new Gson(); @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { // 1. 必须放在获取流之前,否则编码设置不生效 response.setContentType("application/json;charset=UTF-8"); // 2. 从请求体读取 JSON 字符串 BufferedReader reader = request.getReader(); String body = reader.lines().collect(Collectors.joining(System.lineSeparator())); if (body == null || body.isEmpty()) { response.getWriter().write(gson.toJson(Result.error("请求体不能为空"))); return; } // 3. Gson 把 JSON 字符串反序列化为参数对象 LoginParam param = gson.fromJson(body, LoginParam.class); if (param.getUsername() == null || param.getPassword() == null) { response.getWriter().write(gson.toJson(Result.error("用户名和密码不能为空"))); return; } // 4. 调用 Service 查询用户并校验密码 User user = userService.login(param.getUsername(), param.getPassword()); PrintWriter out = response.getWriter(); if (user == null) { out.write(gson.toJson(Result.error("用户名或密码错误"))); return; } // 5. 不把 password 字段反射到前端,只返回需要的信息 UserVO vo = new UserVO(user.getId(), user.getUsername(), user.getNickname()); out.write(gson.toJson(Result.success(vo))); } }

这段代码有四个关键点。response.setContentType 必须先于 getWriter 调用,否则浏览器拿到的 Content-Type 是默认 text/html,Vue 端虽然能解析,但跨域时可能被浏览器拦截。请求体用 getReader 读取,这个流只能读一次,所以不要在 Service 里再次尝试读取请求体。

Gson 反序列化时,JSON 里字段名要和 LoginParam 的字段名一致,否则 param.getUsername() 拿到 null。如果前端传的是 userId 而不是 username,那这里就要么报错要么判空失败。因此接口表里把字段名写清楚,比写一堆注释都管用。

UserVO 是我推荐的做法:从 User 实体里挑出 id、username、nickname 三个字段组成新的值对象,避免把 password、createTime 这些字段直接暴露给前端。如果你直接把整个 User 对象序列化返回,密码就会明文出现在浏览器 Network 面板里,这是评审老师最容易挑的问题。

3.4 编码过滤器:中文不乱码的两个前置动作

登录接口已经跑通,但中文乱码问题很快会找上门。乱码出现的位置不止一处,常见的是两种情况:请求体里包含中文用户名,Servlet 读出来是乱码;数据库里存进去是乱码,查出来显示还是乱码。这两种问题的修复点不一样,我会用一个过滤器处理请求和响应编码,再配合 JDBC URL 参数保证数据库链路编码正确。

package com.example.takeout.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; HttpServletResponse httpResponse = (HttpServletResponse) response; // 必须在 Servlet 读取请求体之前设置,否则不生效 httpRequest.setCharacterEncoding("UTF-8"); httpResponse.setCharacterEncoding("UTF-8"); chain.doFilter(request, response); } }

注意一个问题:request.setCharacterEncoding 一定要在 Servlet 里 getReader 或 getParameter 之前执行,而过滤器天然满足这个条件,所以编码过滤器是必须放在所有请求最前面的。如果你已经在 LoginServlet 内部调用 getReader 之后再设置编码,那段设置就是无效代码。

另一个乱码源头是 JDBC 连接串没有指定字符集。数据库连接 URL 上要明确加 characterEncoding=utf8,否则即使程序内部编码不乱,传给 MySQL 时也会因为客户端连接默认 latin1 而变成问号。可以这样写:

jdbc:mysql://localhost:3306/takeout?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai

为了兼顾不同地区的时间存储,serverTimezone 也建议加上。表结构字符集对应使用 utf8mb4,否则 emoji 或者部分生僻字会插入失败。这条链路里任何一环断掉,最终表现都是中文乱码,排查顺序按"请求编码 -> 响应编码 -> JDBC 编码 -> 表字符集"来查。

4. 用 Vue 和 AJAX 把前端接起来:axios 封装、请求拦截器和页面渲染

后端接口就绪后,开始做前端。Vue 本身并不关心数据从哪来,它只负责把 data 渲染到模板里;AJAX 层是唯一的 IO 通道。这一章要把 axios 封装成团队级的请求工具,而不是在页面里东写一个 axios.post、西写一个 axios.get。

4.1 初始化工程:Vite 创建 Vue3 应用并安装 axios

前端工程建议使用 Vite 创建 Vue3 项目,相比 Vue CLI 创建更快,配置也更直观。创建命令和执行依赖安装可以用下面这段:

npm create vue@latest takeout-web cd takeout-web npm install npm install axios element-plus npm run dev

创建过程中会询问是否安装 vue-router、Pinia、ESLint 等选项,按需选择。路由建议安装,因为登录页和首页需要跳转;状态管理可以先不装,登录信息用 localStorage 保存即可,减少学习成本。Element Plus 作为 UI 组件库,主要用来提供弹窗、表单和消息提示。

axios 是当前前后端分离项目里最主流的 AJAX 库,它基于 Promise,支持请求拦截器、响应拦截器、取消请求、超时设置。Vue 项目里不要直接在组件内 import axios 并使用默认实例,那样每个接口都要重复写 baseURL 和超时配置,也不方便统一处理错误提示,下一步就把它封装掉。

4.2 二次封装 axios:baseURL、超时、响应剥壳一次配好

封装 axios 的核心目的是把"接口请求"和"业务处理"解耦。页面组件只要调用封装好的 request 实例,不用关心 Token 放哪里、错误怎么弹、超时给什么提示。基础封装如下:

import axios from 'axios' import { ElMessage } from 'element-plus' const request = axios.create({ // 这里写后端接口根地址,项目部署时改用环境变量 baseURL: 'http://localhost:8080/takeout', timeout: 10000 }) // 响应拦截器:统一处理业务码和网络错误 request.interceptors.response.use( (response) => { const res = response.data // 后端统一返回 Result 结构,code 不为 200 表示业务失败 if (res.code !== 200) { ElMessage.error(res.msg || '请求失败') return Promise.reject(new Error(res.msg)) } // 直接返回 res,页面里用 res.data 拿到业务数据 return res }, (error) => { if (error.response && error.response.status === 401) { ElMessage.error('登录状态已过期,请重新登录') localStorage.removeItem('token') window.location.href = '/login' } else { ElMessage.error('网络异常,请检查后端服务是否启动') } return Promise.reject(error) } ) export default request

这段代码有几个设计要点。超时时间设为 10000 毫秒,配合后端慢查询场景,避免请求挂住不提示。响应拦截器里 res.code 来自后端 Result 结构的 code 字段,约定为 200 是成功,其他都走错误分支。

我特意返回 res 而不是 res.data,是为了让页面代码语义更清晰:接口函数返回的是整个响应外壳,页面用 res.data 取数据。如果你在拦截器里直接返回 res.data,页面就要用 result.data.data 这种嵌套结构,很容易写错。选择哪种方式没有对错,关键要全项目统一,最好在封装文件的注释里写清楚。

4.3 请求拦截器:让每个请求自动带上登录态

登录成功后,后端会返回一个 token,前端把它存进 localStorage。后续每个接口都需要携带这个 token,后端才能识别"你是谁"。做一个请求拦截器,在请求发出前统一从 localStorage 读取 token 并加到请求头里,是最省事且最不容易漏的做法:

request.interceptors.request.use( (config) => { const token = localStorage.getItem('takeout_token') if (token) { config.headers['Authorization'] = `Bearer ${token}` } return config }, (error) => Promise.reject(error) )

这段代码要和上面 response 拦截器合在同一份 request.js 文件里,请求拦截器先执行,响应拦截器后执行。Authorization 头的格式我写了 Bearer 前缀,这是较为通用的做法,后端过滤器解析时可以按前缀切分。

为什么不用 Cookie 带 Session?在前后端分离场景下,前端工程运行在 5173 端口,后端运行在 8080 端口,Cookie 的 SameSite 和跨域策略会让 Session 管理变得很麻烦。Token 方案则是前端主动把凭证放在请求头里,只要后端验证签名或查表即可,与端口、域名解耦。这个设计第 6 章还会继续深化。

4.4 页面组件调接口:从登录到渲染商家列表

以登录页面为例,组件内部只做三件事:收集表单、调用接口函数、根据结果跳转。使用 Vue3 的 script setup 写法,登录逻辑可以这样组织:

<script setup> import { ref } from 'vue' import { useRouter } from 'vue-router' import request from '@/utils/request' const router = useRouter() const username = ref('') const password = ref('') const loading = ref(false) async function handleLogin() { if (!username.value || !password.value) { return } loading.value = true try { const res = await request.post('/api/auth/login', { username: username.value, password: password.value }) // 拦截器已经剥壳,res.data 就是后端返回的用户信息和 token localStorage.setItem('takeout_token', res.data.token) router.push('/home') } catch (e) { // 错误提示已在拦截器弹过,这里不再重复处理 } finally { loading.value = false } } </script>

这里要特别注意的是 res.data 的含义。因为响应拦截器返回的是 res(Result 外壳),所以 res.data 是后端 Result 里的 data 字段,也就是用户信息和 token。如果你在拦截器里改变返回层级,这一行也要跟着变。

商家列表接口的调用方式类似:页面加载时调用 request.get('/api/shops'),把返回的 res.data.list 赋值给响应式变量,再通过 v-for 渲染到 template 里。每个模块的接口函数建议收拢到 src/api/ 目录下,比如 shop.js 里导出 getShopList、getDishes,这样页面组件只依赖 api 模块,不直接感知 axios 细节。

5. 联调常见问题排查:跨域、404、JSON 格式不匹配和编码坑

前后端各写各的,第一次连起来的时候几乎是必出问题的。这一章列出四个高频典型,每一条从现象、原因到解决完整展开,建议遇到同类问题时直接按这个流程查,能省下很多跟玄学斗争的时间。

5.1 跨域报错:Access-Control-Allow-Origin 不见了

现象非常典型:前端 npm run dev 启动在 http://localhost:5173,后端 Tomcat 启动在 http://localhost:8080,页面发起 axios 请求后,控制台报错:Access to XMLHttpRequest at 'http://localhost:8080/...' from origin 'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource。

原因是浏览器同源策略。前端地址和接口地址的协议相同、域名相同但端口不同,属于跨域。浏览器拦截的不是请求发出,而是响应被前端拿到,所以后端日志里可能已经能看到请求进来,但前端依然报错。解决思路有两条:让后端在响应里加上 CORS 头,或者让前端 dev server 做代理转发。

后端加 CORS 头用过滤器最方便,设置 Allow-Origin 为前端地址、Allow-Methods 为 GET/POST/PUT/DELETE/OPTIONS,并放行 Authorization 和 Content-Type 请求头,同时处理浏览器的 OPTIONS 预检请求。代码写法如下:

package com.example.takeout.filter; import javax.servlet.annotation.WebFilter; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @WebFilter("/*") public class CorsFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletResponse httpResponse = (HttpServletResponse) response; HttpServletRequest httpRequest = (HttpServletRequest) request; httpResponse.setHeader("Access-Control-Allow-Origin", "http://localhost:5173"); httpResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS"); httpResponse.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization"); httpResponse.setHeader("Access-Control-Allow-Credentials", "true"); // 预检请求直接返回,不需要进入业务 Servlet if ("OPTIONS".equals(httpRequest.getMethod())) { httpResponse.setStatus(HttpServletResponse.SC_OK); return; } chain.doFilter(request, response); } }

注意 Access-Control-Allow-Origin 不能随便写 *,如果后面带上了 Allow-Credentials 为 true,浏览器会拒绝 * 通配。建议把前端地址写具体,生产环境换成实际域名。第二种方案是前端 vite.config.js 里配置 server.proxy,把 /api 开头的请求转发到后端 Tomcat,这样浏览器看到的请求是同源的,不会触发跨域。

// vite.config.js export default { server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '/takeout/api') } } } }

rewrite 这行要注意,它会去掉开头的 /api 再加入 /takeout,因为后端工程的 context-path 是 /takeout。如果 baseURL 直接写 http://localhost:8080/takeout,就不用配 rewrite;如果 baseURL 写 /api,就必须配。代理方案更贴近生产环境,生产环境通常由后端网关或 Nginx 转发,不需要后端考虑 CORS,所以新手联调我更推荐代理方案。

5.2 接口 404 或 404 POST:Tomcat context-path 没对上

现象是:前端代码里请求地址写的是 http://localhost:8080/api/auth/login,F12 网络面板显示 404 Not Found,后端控制台没有任何报错。排查思路是先确认接口是否真的存在,再看 URL 是不是被 context-path 干扰。

Tomcat 部署 Web 应用时,默认会有一个上下文路径,也就是访问前缀。如果你用 IDEA 部署工程,context-path 通常是工程名 takeout,完整接口 URL 是 http://localhost:8080/takeout/api/auth/login。前端如果漏写 /takeout,请求会落到 Tomcat 根路径,自然找不到 Servlet。

请求 URL结果
http://localhost:8080/takeout/api/auth/login正常
http://localhost:8080/api/auth/login404
http://localhost:8080/api/auth/logon404

处理这类问题不要靠肉眼猜,直接在浏览器 F12 网络面板里看请求的完整 URL,再用 Postman 直接访问那个 URL,验证后端接口是否存在。如果 Postman 能通而前端不通,问题大概率在前端 baseURL;如果 Postman 也不通,检查 @WebServlet 注解的路径是不是写错,比如把 /api/auth/login 写成了 /api/auth/Login。

另外一个隐藏点是 Servlet 的 @WebServlet 路径区分大小写,Tomcat 默认大小写敏感。前端的 axios 请求使用小写路径,后端注解却写了 Login,就会出现 404。这类问题校验方法是打开后端接口类的源码,直接比对注解字符串和请求 URL,不要凭记忆。

5.3 页面拿到 undefined:JSON 层级和类型对不上

现象是:接口请求成功,Network 面板能看到返回 JSON 数据,但页面上渲染的位置是 undefined,或者控制台报 Cannot read properties of undefined (reading 'name')。这类问题最迷惑人,因为后端明明返回了数据,前端却在取数时出错。

常见原因有三个。第一是响应拦截器返回层级不一致,比如后端 Result 结构是 code、msg、data,页面上却用 res.data.list,但接口返回的 data 不是对象而是数组,于是 list 不存在。第二是字段大小写不匹配,Java 后端字段名一般是驼峰 nickname,前端 JS 里写成了 nickName。第三是 data 本身为 null,比如某个用户没有填写昵称,后端返回 null,前端却直接访问 data.nickname 的长度。

解决这一刻突然发现,还是要靠接口表和打印日志。接口表里对每个接口的 data 结构写清楚,前端页面开发前先读一遍;页面调试时在拿到数据后先执行一次 console.log(res),把实际结构打印出来,再写渲染逻辑。不要依赖记忆中某次成功经验,前后端任意一方改字段,结构就变了。

5.4 数据库中文字符全变问号:URL 和过滤器两条线都要查

现象是:插入数据库的中文显示为问号,比如用户名"张三"入库后变成"??? ",但是前端发送时看起来正常。这个问题不是前端 AJAX 引起的,而是后端 JDBC 连接数据库时字符集不对,或者数据库表字符集不支持中文。

排查顺序是:先在 Servlet 入口把收到的 body 打印出来,确认 Servlet 拿到的是不是正常中文。如果这里已经乱码,回到编码过滤器,看 request.setCharacterEncoding 是否在 getReader 之前执行。如果 Servlet 拿到的中文正常,那问题在 JDBC URL,检查是否漏了 characterEncoding=utf8。

建表语句也要确认字符集,推荐使用 utf8mb4 而不是 utf8:

CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT, `username` VARCHAR(32) NOT NULL, `nickname` VARCHAR(32), PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

如果表已经建好但字符集不对,可以执行 ALTER TABLEuserCONVERT TO CHARACTER SET utf8mb4;但历史数据如果是乱码,转换后也救不回来,只能删掉重新插入。所以新手阶段最好在建表时就统一编码,别等到数据写坏再找后悔药。

6. 进阶技巧:用 Token 把登录态串进下单链路,接口别裸奔

前后端分离的第一个质变,是把登录凭证从 Session 换成 Token。Session 依赖 Cookie 和服务器内存,跨端口、跨域场景下体验很差;Token 则是一串携带身份信息的凭证,由后端签发,前端在请求头里主动携带,服务器可以无状态校验。

6.1 为什么把 Session 换成 Token

在前后端分离架构里,前端 5173、后端 8080 是两个源,Cookie 默认不会跨端口发送,即使通过 CORS 配置放行,SameSite 策略也会影响浏览器行为。项目里用 Session 实现登录会面临一个尴尬问题:接口能通,但后端每次拿不到 Session,用户永远被认为是未登录。

Token 方案逻辑更简单:登录成功时后端生成一段随机字符串,存到一个 TokenStore 里,映射到用户 ID,并把 token 返回给前端。前端把 token 保存到 localStorage 或 sessionStorage,每次请求在 axios 请求拦截器里加到 Authorization 头。后端过滤器每次校验 token,再决定放行或拒绝。

6.2 从登录到下单:后端发 Token、前端携带、过滤器校验

后端登录成功时生成 token,并把它和用户信息一起放进 Result:

// LoginServlet 内部,登录成功后 String token = UUID.randomUUID().toString().replace("-", ""); TokenStore.put(token, user.getId()); Map<String, Object> dataMap = new HashMap<>(); dataMap.put("token", token); dataMap.put("user", userVO); out.write(gson.toJson(Result.success(dataMap)));

前端拿到 token 后存入 localStorage,后续请求由 axios 请求拦截器统一注入。后端加一个 AuthFilter 拦截 /api/* 下的接口,登录和注册两个地址放行,其余接口先验证 Authorization 头里的 token:

@WebFilter("/api/*") public class AuthFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; HttpServletResponse httpResponse = (HttpServletResponse) response; if ("OPTIONS".equals(httpRequest.getMethod())) { chain.doFilter(request, response); return; } String uri = httpRequest.getRequestURI(); if (uri.endsWith("/auth/login") || uri.endsWith("/auth/register")) { chain.doFilter(request, response); return; } String auth = httpRequest.getHeader("Authorization"); String token = auth == null ? "" : auth.replace("Bearer ", ""); Integer userId = TokenStore.getUserId(token); if (userId == null) { httpResponse.setContentType("application/json;charset=UTF-8"); httpResponse.getWriter().write(gson.toJson(Result.error("未登录或登录已过期"))); return; } chain.doFilter(request, response); } }

过滤器里把 OPTIONS 预检请求直接放行,是因为跨域场景下浏览器会先发预检请求,此时还没有业务逻辑。TokenStore 的实现可以是 ConcurrentHashMap,也可以用一个简单的静态类代替,后续若引入 Redis,只需要把 TokenStore 的接口替换成 Redis 实现,不需要改动过滤器逻辑。

下单链路完整跑通时的顺序是:登录接口返回 token,前端存入 localStorage;用户选择菜品提交订单,请求拦截器自动给 /api/orders 加上 Authorization 头;AuthFilter 验证通过后,OrderServlet 从 TokenStore 查出用户 ID,写入订单表时把 userId 作为外键。整个链路里没有任何一个页面组件手动拼接 token,也不会出现在未登录状态下直接下单越权的漏洞。

我自己的习惯是做任何课设项目都把权限校验当成主线功能来做,后端所有接口默认都过过滤器,只有明确公开的接口放行。这样做的成本很低,却能让项目在答辩时有一个值得讲的亮点。希望这篇笔记能帮你把前后端分离的外卖项目真正串起来,少走几天弯路。

本文还有配套的精品资源,点击获取

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

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

立即咨询