微信小程序与SSM后端联调指南:从接口配置到避坑实践

发布时间:2026/10/30 15:21:13
微信小程序与SSM后端联调指南:从接口配置到避坑实践

简介:这是一套微信小程序与SSM后端整合开发的完整案例资源,定位为家庭大厨场景的食谱分享与家常菜教学应用。项目采用前后端分离架构:小程序负责界面展示与用户交互,Spring、SpringMVC、MyBatis组成的后端服务负责业务逻辑与数据持久化。资源共856个文件,大小约36.84MB,Java、XML、properties等构成SSM后端核心,WXML、WXSS、JS、JSON等支撑小程序前端,Vue、SCSS用于后台管理,另有SQL脚本、说明文档和批处理脚本,目录结构清晰。已有112人学习下载。对正在做毕业设计或期末大作业的学生而言,可从源码中理解RESTful API设计、前后端数据交互与数据库表搭建等关键实践,适合有一定Java和前端基础、希望快速上手全栈项目的学习者。

1. 拿到手先别急着跑:家庭大厨源码包到底装了什么

家庭大厨微信小程序加 SSM 后端这套源码,是我拆过的毕设里比较典型的一种:前端是原生微信小程序,后端是 Spring + SpringMVC + MyBatis,数据库用 MySQL。说白了,它把一条完整的全栈链路——小程序发请求、后端接请求、数据库读写、数据回填页面——压缩在了一个项目里。对做毕业设计或者期末大作业的人来说,最值钱的不是那几千行代码,而是它能让你在两天内把一个能演示的系统跑起来;对刚入门的后端同学,它又是一份活生生的前后端对接教材。本文会按“拆结构 → 跑后端 → 联调前端 → 踩坑 → 升级”的顺序展开,重点讲清楚参数配置和翻车点。

2. 系统拆解:先看数据表和接口,再谈怎么跑

打开源码包,别急着启动。我习惯先把后端 controller 包下的类扫一遍,然后打开 sql 目录里的建表脚本,对照画一张接口和表的对应关系图。这十几分钟做的事,能省掉后面联调时的大半排查时间。

2.1 功能模块与前端的页面对应关系

家庭大厨这个项目,按常见毕设模板演进的思路,实际包含五个核心模块:菜品分类、菜品列表、菜品详情、购物车和用户中心。小程序端一般是五个页面,底部 tab 对应首页、分类、购物车、我的;菜品详情页从列表页进入。

小程序页面核心功能后端接口(示例)
首页轮播图、推荐菜品GET /dish/recommend
分类页分类切换、菜品列表GET /category/list、GET /dish/list?categoryId=
菜品详情菜品信息、加购GET /dish/detail/{id}、POST /cart/add
购物车购物车列表、勾选结算GET /cart/list、POST /cart/update
我的登录状态、订单入口POST /user/login、GET /user/info

为什么把接口放在这里强调?因为很多同学拿到源码之后,习惯只盯着 controller 里的方法看,却忽略了一个事实:小程序端 wx.request 的 url、后端 @RequestMapping 的路径、数据库表字段,这三者必须完全对齐。任何一个环节不一致,就是前端的 404 或者后端的 null。所以,我建议你把上表作为走查清单,在启动项目前逐一核对。

数据流转也不复杂:小程序页面里触发一个事件,调用封装好的 request 函数,把请求发到后端 Controller;Controller 拿到参数后调用 Service;Service 调 Mapper;Mapper 执行 SQL 把结果返回,再一层层封装成 JSON。这个链路是 SSM 项目的基本功,后面第三章会展开讲。

2.2 MySQL 表结构:从用户表到菜品表一次说清

建表脚本在整个源码包里是最值得先读的文件,没有之一。它决定了你的实体类、Mapper 和页面字段的数据来源。在家庭大厨这套设计里,至少会有这么几张表:用户表、菜品分类表、菜品表和购物车表。我这里把最核心的三张表抽出来做示例。

CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT, `openid` VARCHAR(64) NOT NULL COMMENT '微信openid', `nickname` VARCHAR(32) DEFAULT '' COMMENT '昵称', `avatar` VARCHAR(255) DEFAULT '' COMMENT '头像', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `category` ( `id` INT NOT NULL AUTO_INCREMENT, `name` VARCHAR(32) NOT NULL, `sort` INT DEFAULT 0 COMMENT '排序值', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `dish` ( `id` INT NOT NULL AUTO_INCREMENT, `category_id` INT NOT NULL, `name` VARCHAR(64) NOT NULL, `price` DECIMAL(10,2) NOT NULL, `image` VARCHAR(255) DEFAULT '' COMMENT '图片路径', `description` VARCHAR(500) DEFAULT '', `stock` INT DEFAULT 0, `status` TINYINT DEFAULT 1 COMMENT '1上架 0下架', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里有几个设计上的细节:user 表的 openid 字段加了唯一索引,这是微信登录场景的标准做法,因为同一个用户不能重复绑 openid;dish 表里的 price 用 DECIMAL 而不是 float,因为金额用浮点类型在计算合计时会出现 0.1+0.2 不等于 0.3 的精度问题;status 字段给菜品做上下架,列表接口默认只查 status=1 的,这样前端首页就不会把已下架的菜品展示出来。

如果你拿到的源码包里有订单相关功能,通常还会补两张表:order 和 order_item。order 存订单主信息,比如总价、状态、下单时间;order_item 存订单里每一项菜品的快照,包括当时的单价和数量。为什么要存快照?因为菜品价格可能随时修改,订单历史里的价格必须保持用户下单那一刻的数值。这个设计在面试时也经常被问到。

2.3 接口约定:小程序发什么、后端回什么

在开始联调之前,建议先约定一套统一返回格式。家庭大厨这套源码一般会定义一个 Result 类,返回给前端的数据结构固定成:

{ "code": 200, "msg": "success", "data": { "list": [], "total": 0 } }

code 表示业务状态码,200 是正常,4xx、5xx 表示异常;msg 是给前端提示用的;data 是真正的数据。这个约定非常重要,它让前端在处理请求时不需要每页去猜返回结构,直接在公共请求层判断 code 即可。

分页接口也有一个约定俗成的参数格式:小程序端传 pageNum 和 pageSize,后端返回 total 和 list。比如:

{ "code": 200, "data": { "total": 45, "pageNum": 1, "pageSize": 10, "list": [] } }

这里的 pageNum 从 1 开始,pageSize 默认 10。后端 SQL 的 limit 偏移量要用 (pageNum - 1) * pageSize。很多同学翻车就翻在这里,我后面第四章和第五章都会提到。搞清楚了这些约定,下一步就可以动手把后端跑起来了。

3. 把 SSM 后端跑起来:从 web.xml 到 Mapper 的一条线

SSM 项目不像 Spring Boot 那样一个 main 方法就能启动,它需要打 war 包丢进 Tomcat,或者配 IDEA 的 Tomcat 运行环境。这也是做毕设时最容易卡住的地方。我先把关键配置过一遍,再带你看 Controller 和 Mapper 的写法。

3.1 工程结构与核心配置文件

标准 SSM 工程长这个样:

src/main/java └─ com.jtdc ├─ controller ├─ service ├─ mapper ├─ entity └─ common src/main/resources ├─ jdbc.properties ├─ mybatis-config.xml ├─ spring-mvc.xml └─ applicationContext.xml src/main/webapp └─ WEB-INF/web.xml

web.xml 是整个应用的入口,它负责加载 Spring 容器和 SpringMVC 的前端控制器。关键配置是这样:

<context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:applicationContext.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <servlet> <servlet-name>springMVC</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>springMVC</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping>

web.xml 里有两个容易忽略的点:一个是 contextConfigLocation 指定 applicationContext.xml,另一个是 DispatcherServlet 的 url-pattern 配成了/,这意味着所有请求都交给 SpringMVC 处理,静态资源还需要另外放行,不然后端接口是通的,图片资源却会 404。

再来看 spring-mvc.xml 的片段:

<mvc:annotation-driven /> <context:component-scan base-package="com.jtdc.controller" /> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/" /> <property name="suffix" value=".jsp" /> </bean> <mvc:default-servlet-handler />

这套配置里,annotation-driven 是开启 SpringMVC 注解支持的关键,component-scan 只扫描 controller 包,避免把 service 和 mapper 也扫进 Web 容器造成事务失效。default-servlet-handler 的作用就是放行静态资源,配合 url-pattern=/ 使用。

3.2 Controller 层:常用注解与请求映射

家庭大厨的 Controller 层是前后端对接的直接入口。一般会按模块建类,比如 DishController、CartController、UserController。先看一个最典型的菜品列表接口:

@RestController @RequestMapping("/dish") public class DishController { @Autowired private DishService dishService; @GetMapping("/list") public Result list( @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, @RequestParam(required = false) Integer categoryId) { PageResult<Dish> page = dishService.pageQuery(pageNum, pageSize, categoryId); return Result.success(page); } @GetMapping("/detail/{id}") public Result detail(@PathVariable("id") Integer id) { return Result.success(dishService.getById(id)); } }

这里是 SSM 常用注解的一个浓缩:@RestController 表示这个类里所有方法返回值直接写进响应体,不再走视图解析器,对小程序前端来说特别合适,省去了每个方法上挂 @ResponseBody;@RequestMapping 定义类级别的路径前缀;@GetMapping 是 @RequestMapping 的 method=GET 的简写;@RequestParam 绑定 query 参数,并且给了默认值 pageNum=1、pageSize=10,这样前端偶尔漏传参数后端也不会报错;@PathVariable 用来绑定 URL 路径里的 id,对应小程序端 wx.request 的 url/dish/detail/1这种风格。

有个容易混淆的点:@RequestParam 和 @RequestBody 什么时候用。简单记:GET 请求的参数在 URL 后面,用 @RequestParam;POST 请求如果前端把参数放在请求体里,用 @RequestBody 接 JSON 字符串再转对象。如果混了,后端会直接报参数缺失或者类型转换错误,这在联调时太常见了。

3.3 Service 与 Mapper:把 SQL 写对,接口就成功一半

Service 层一般做事务和业务编排。以分页查询为例,Service 里会调用 Mapper,再把结果包装成返回给前端的分页对象:

@Service public class DishServiceImpl implements DishService { @Autowired private DishMapper dishMapper; @Transactional(readOnly = true) @Override public PageResult<Dish> pageQuery(Integer pageNum, Integer pageSize, Integer categoryId) { int offset = (pageNum - 1) * pageSize; List<Dish> list = dishMapper.selectPage(offset, pageSize, categoryId); int total = dishMapper.count(categoryId); return PageResult.build(pageNum, pageSize, total, list); } }

注意这里 offset 的计算,实际开发的坑九成出在这一行。SQL 的 limit 语法是limit 偏移量, 每页条数,第一页的偏移量是 0,第二页是 10,所以必须有(pageNum - 1)这套换算。

对应的 Mapper 接口和 XML 是这样:

public interface DishMapper { List<Dish> selectPage(@Param("offset") int offset, @Param("size") int size, @Param("categoryId") Integer categoryId); int count(@Param("categoryId") Integer categoryId); }
<select id="selectPage" resultType="com.jtdc.entity.Dish"> SELECT id, name, price, image, description, stock FROM dish WHERE status = 1 <if test="categoryId != null"> AND category_id = #{categoryId} </if> ORDER BY id DESC LIMIT #{offset}, #{size} </select> <select id="count" resultType="int"> SELECT COUNT(*) FROM dish WHERE status = 1 <if test="categoryId != null"> AND category_id = #{categoryId} </if> </select>

这个 XML 里面有两个细节值得说:动态 SQL 的<if test="categoryId != null">,作用是不传分类时查出全部菜品,传了就在后面拼一个分类条件,这样同一个方法能吃两种请求;LIMIT #{offset}, #{size}里的参数占位符用的是#{},MyBatis 会把它 precompile 成?,有效防止 SQL 注入。如果你的分类条件用了category_id = ${categoryId},那这就是个活生生的注入点,我见过不少毕设源码这么写,务必改掉。

另外,实体类 Dish 里的驼峰字段,比如 categoryId,和数据库下划线 category_id,默认可能映射不上。最省事的方案是在 mybatis-config.xml 里开驼峰映射:

<settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings>

开了之后,select 结果会自动把category_id映射到categoryId。如果你拿到源码发现查出来的对象 categoryId 是 null,先查这一项,这问题我几乎每个项目都会遇到。

4. 小程序端联调:请求封装、分页加载与导航适配

后端能跑之后,真正花时间的在小程序这边。原生小程序没有 axios,没有 fetch,所有网络访问都走 wx.request。如果不做封装,每个页面写一遍 header、错误处理,代码会冗余到后边根本不想维护。

4.1 请求封装:wx.request 的公共层怎么写

我一般会在 utils 目录下建一个 request.js,把所有接口请求统一收口。代码不长,但对联调效率影响很大。

const BASE_URL = 'http://localhost:8080' // 改成你实际的后端地址 function request(url, method = 'GET', data = {}) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + url, method: method, data: data, header: { 'Content-Type': 'application/json;charset=UTF-8', 'token': wx.getStorageSync('token') // 用 token 做登录态 }, success(res) { if (res.data.code === 200) { resolve(res.data.data) } else { wx.showToast({ title: res.data.msg || '请求失败', icon: 'none' }) reject(res.data) } }, fail(err) { wx.showToast({ title: '网络异常', icon: 'none' }) reject(err) } }) }) } module.exports = { request, BASE_URL }

这个封装做了三件事:把 BASE_URL 统一放一个地方,后面换后端地址只改一行;把 header 里的 token 自动带上,后续用 token 鉴权时不用每个页面手写;统一判断返回体的 code,非 200 直接在公共层弹 toast,页面里只需要关心业务数据。

有几个细节要注意:BASE_URL 里不要以斜杠结尾,和后面传入的/dish/list拼接才不会出现双斜杠;method 不传时默认 GET,但实际调接口时务必显式写 method,不然 POST 参数会被放在 query 上,后端用 @RequestBody 就接不到;header 里的 token 用的是本地存储的,如果小程序还没有登录逻辑,这里可以先不取,等第六章升级时再补。另外,小程序 wx.request 不受浏览器同源策略限制,所以不需要后端配跨域;如果以后改成 H5 用 axios 调这个接口,就得考虑后端跨域过滤器了。

4.2 列表加载更多:onReachBottom 与分页参数怎么配合

“微信小程序页面列表加载更多”是列表页几乎必写的功能。家庭大厨的菜品列表页就是一个典型案例。我写代码前会先理清三个变量:pageNum、pageSize、hasMore。pageNum 控制当前请求第几页,pageSize 固定,hasMore 表示后面还有没有数据。

页面里的核心代码是这样的:

Page({ data: { dishList: [], pageNum: 1, pageSize: 10, hasMore: true, loading: false }, onLoad() { this.loadDishList(true) }, onReachBottom() { if (!this.data.hasMore || this.data.loading) return this.loadDishList(false) }, loadDishList(isRefresh) { if (this.data.loading) return this.setData({ loading: true }) const pageNum = isRefresh ? 1 : this.data.pageNum + 1 request(`/dish/list?pageNum=${pageNum}&pageSize=${this.data.pageSize}`) .then(data => { const list = isRefresh ? data.list : this.data.dishList.concat(data.list) this.setData({ dishList: list, pageNum: pageNum, hasMore: pageNum < Math.ceil(data.total / this.data.pageSize), loading: false }) }) .catch(() => this.setData({ loading: false })) } })

这里的关键逻辑在 loadDishList 里:isRefresh 是下拉刷新时传 true,把 pageNum 重置回 1,直接用 data.list 覆盖旧列表;触底加载时传 false,pageNum 自增,然后用 concat 把新数据追加到旧数据后面。hasMore 的判断用 total 除以 pageSize 向上取整,当前页数小于总页数才继续加载。

最常见的翻车是:触底加载后第一页数据重复了。基本可以断定是 pageNum 没有递增,或者后端 offset 算错,返回了相同的数据。另一个翻车点是不写 loading 判断,触底事件被连续触发,发出去一堆重复请求。我在代码里加了if (this.data.loading) return,虽然简单,但真的能救你一命。

4.3 顶部导航栏高度:拿胶囊按钮算状态栏偏移

“微信小程序顶部导航栏高度”也是高频问题。如果你想在自定义导航栏的页面上做顶部定位,不能写死一个高度,因为不同手机的状态栏高度和胶囊位置都不一样。常见做法是用 wx.getMenuButtonBoundingClientRect 拿到胶囊按钮的位置,再结合系统信息计算。

function getNavBarHeight() { const systemInfo = wx.getSystemInfoSync() const menuButton = wx.getMenuButtonBoundingClientRect() // 状态栏高度,单位 px const statusBarHeight = systemInfo.statusBarHeight || 44 // 导航栏高度 = (胶囊顶部 - 状态栏高度) * 2 + 胶囊高度 const navBarHeight = (menuButton.top - statusBarHeight) * 2 + menuButton.height return { statusBarHeight: statusBarHeight, navBarHeight: navBarHeight } }

这个计算公式可能有点绕,我拆开解释:menuButton.top 是胶囊按钮离屏幕顶部的距离,减去状态栏高度,得到的是胶囊按钮与状态栏底部的间隙;在微信的设计里,这个间隙上下是对称的,所以导航栏总高度等于状态栏高度加上间隙的两倍,再加上胶囊本身的高度。计算出这两个值后,把页面内容往下偏移到 navBarHeight 的位置,标题就能和胶囊按钮保持在同一条水平线上。

这套计算通常放在 app.js 的 onLaunch 里做一次,然后存进全局变量,页面 onLoad 时读取。注意拿到的值默认单位是 px,在小程序里布局建议单位是 rpx,如果要用于 style 里的定位,需要做 px 和 rpx 的换算:rpx = px * (750 / windowWidth)。这也是个小坑,很多人直接拿 rpx 乘以倍率结果差出一大截。同时,页面的 onShow、onHide 这些生命周期也可以配合做数据刷新,比如从详情页返回列表页时,判断购物车角标是否需要更新。

5. 避坑指南:SSM 与小程序联调的五个翻车现场

这一章是血泪经验集。我拆过不少类似的项目,以下五个问题基本把联调期的大部分 bug 覆盖了。每条我都按现象、原因、解决三个层面写,前面的代码和参数你也可以直接拿去对照排查。

5.1 坑一:接口通但字段全是 null——MyBatis 映射没配对

现象:用 Postman 请求后端 /dish/list,返回的 JSON 里 id、name 有值,但 categoryId、createTime 这类驼峰命名的字段全是 null,页面渲染出来就是空。

原因:数据库列名是 category_id,实体类是 categoryId,MyBatis 默认不会自动把下划线转驼峰。如果 XML 里没写 resultMap,又没有开启 mapUnderscoreToCamelCase 配置,映射就断掉了。

解决:最快速的办法是在 mybatis-config.xml 里加上:

<settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings>

加完重启 Tomcat,一般就恢复了。如果加完还没生效,再看两种可能:一是这个配置写在了 spring-mvc.xml 里而不是 mybatis-config.xml,Spring 加载不到;二是 SQL 语句用了 SELECT * 且返回的 resultType 是 Map,Map 不会触发驼峰映射,需要改成实体类。检查顺序先配置后 SQL。

5.2 坑二:开发者工具能通,真机上一片空白——域名校验没关

现象:电脑上打开微信开发者工具,请求后端接口正常;点击“真机预览”,手机上的小程序请求全部失败,网络报错。

原因:微信小程序有安全限制,请求的 URL 必须是已备案的 HTTPS 合法域名。本地开发时你在开发者工具里勾选了“不校验合法域名”,所以能跑;真机预览时这个勾选不生效,HTTP 和 localhost 都会被拦截。

解决:开发阶段,在开发者工具右上角“详情 → 本地设置”里勾选“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”。这只能让自己测试用,如果要给老师或同学演示,最简单的方式是让后端也跑在同一台电脑上,手机和电脑连同一 WiFi,后端启动时把监听地址改成 0.0.0.0,前端 BASE_URL 填电脑的局域网 IP。注意:“不校验”只对工具打开的项目生效,换一台电脑需要重新勾选。

5.3 坑三:POST 请求后端收到 null——Content-Type 与参数位置不匹配

现象:小程序端 POST /cart/add,后端 Controller 参数对象里的字段全是 null,但 GET 请求正常。

原因:大概率是 wx.request 的 data 里传的是一个对象,但 header 的 Content-Type 不是 application/json。后端如果是 @RequestBody,它需要从请求体里解析 JSON;如果前端把参数拼在 url 上,请求体是空的,自然接不到。还有一种情况是小程序发请求时 header 里写了Content-Type: application/x-www-form-urlencoded,@RequestBody 也解析不了。

解决:在公共 request 封装里强制把 header 的 Content-Type 设为application/json;charset=UTF-8,并且 wx.request 的 data 参数直接传对象,不要自己拼成字符串。后端方法签名保持:

@PostMapping("/add") public Result add(@RequestBody Cart cart) { ... }

如果后端不是用 @RequestBody,而是用 @RequestParam 接受表单格式,那前端就要改成将对象转成 query 字符串后放进 data。判断方法很简单:后端方法参数前是 @RequestBody 就传 JSON,是 @RequestParam 就传表单格式,两者不能混。

5.4 坑四:分页越加载越乱,重复数据大量出现——偏移量没换算

现象:列表页第一次加载 10 条正常,触底后请求第二页,返回的数据是从第 11 条开始没错,但后续第三页出现了第一页的数据,或者总数对不上。

原因:多半是后端 SQL 的 limit 写成了LIMIT #{pageNum}, #{pageSize},而前端传的 pageNum 从 1 开始。MySQL 的 limit 第一个参数是偏移量,不是页码。第一页 offset 应该是 0,第二页是 10,如果直接把 pageNum=2 当偏移量,第二页就会从第 2 条开始,越往后错得越离谱。

解决:后端统一把偏移量计算放在 Service 层:

int offset = (pageNum - 1) * pageSize;

然后在 XML 里用:

LIMIT #{offset}, #{pageSize}

另外注意前端 pageNum 的传递和累加要和返回值有一致约定。我最推荐的做法是后端返回total,前端根据Math.ceil(total / pageSize)判断是否还有下一页,不要用“当前页返回条数是否小于 pageSize”这种笨办法——当最后一页恰好满 10 条时,这种判断会误判为还有下一页,导致多一次空请求。

5.5 坑五:登录态经常丢失——别指望 Session,老老实实用 token

现象:小程序登录后第一次请求正常,过一会再操作提示“未登录”,或者每次重启小程序都要重新登录。

原因:微信小程序的 wx.request 基于浏览器的 XMLHttpRequest,但默认不带 Cookie;即使手动设置了 Cookie,iOS 和 Android 对 Session Cookie 的处理也不太一样,后端 Session 很可能存不下来。所以用传统的 HttpSession 保存登录态,在微信小程序里就是玄学。

解决:改成 token 方案。后端在小程序 wx.login 拿到 code 后,向微信接口换取 openid,生成一个唯一的 token 存到数据库或者 Redis,返回给前端;前端存到 wx.setStorageSync,后续每次请求在 header 里统一携带 token;后端加一个拦截器,从 header 取 token 并解析用户。这个过程看起来复杂,实际上换到业务代码里就是两步:登录接口不再写 session.setAttribute,而是返回值里带 token;请求进来时从 header 读 token。下一章我会写一个最简实现,你可以直接抄。

6. 进阶技巧:给家庭大厨加上统一返回体与 Token 拦截器

前面提到家庭大厨这类 SSM 项目,很多源码是直接返回对象的,登录态也用 Session。如果想把它升级成能写进简历的“前后端分离项目实战”,第一步就是加统一返回体和 Token 拦截器。这里我给出最小可用版本。

先定义一个 Result 类:

public class Result { private int code; private String msg; private Object data; public static Result success(Object data) { Result r = new Result(); r.code = 200; r.msg = "success"; r.data = data; return r; } }

然后写一个 HandlerInterceptor:

public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("token"); if (token == null || token.isEmpty()) { response.setStatus(401); return false; } // 这里根据 token 查用户,解析后放入 request attribute request.setAttribute("userId", 1); return true; } }

这个拦截器只是骨架,真正的项目里你还要在 preHandle 里根据 token 查一下用户是否有效,比如从 Redis 取,或者查表。查不到就返回 401,前端收到 401 后跳转登录页。token 也可以用 JWT 生成,这样可以不用存库,解码就能拿到 userId,但注意 JWT 有个没法强制失效的问题,毕设阶段用随机字符串存表会更直观。

再在 spring-mvc.xml 里注册拦截器,把需要鉴权的路径拦起来,把 /user/login 这类接口放行:

<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/user/login"/> <mvc:exclude-mapping path="/dish/**"/> <bean class="com.jtdc.interceptor.AuthInterceptor"/> </mvc:interceptor> </mvc:interceptors>

注意:这里的 exclude-mapping 要把不鉴权的接口全部列出来,否则首页还没加载出来就被拦了,前端会一脸懵。我遇到过把 /dish/list 也拦了的,结果列表页一直白屏,找了半天才发现是拦截器拦截顺序问题。

从那以后,我每次拿到一套 SSM 后端源码,都会先看一眼它有没有统一返回体和拦截器。没有的话,我会在动手改业务前先花一小时把这些基础设施补上,因为一旦业务逻辑写完,再回头改造的代价会翻倍。这套思路在很多大作业和面试题里都会被反复问到,值得你花时间自己敲一遍。希望帮到你。

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