简介这是一套微信小程序与SSM后端整合开发的完整案例资源定位为家庭大厨场景的食谱分享与家常菜教学应用。项目采用前后端分离架构小程序负责界面展示与用户交互Spring、SpringMVC、MyBatis组成的后端服务负责业务逻辑与数据持久化。资源共856个文件大小约36.84MBJava、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 函数把请求发到后端 ControllerController 拿到参数后调用 ServiceService 调 MapperMapper 执行 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE category ( id INT NOT NULL AUTO_INCREMENT, name VARCHAR(32) NOT NULL, sort INT DEFAULT 0 COMMENT 排序值, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有几个设计上的细节user 表的 openid 字段加了唯一索引这是微信登录场景的标准做法因为同一个用户不能重复绑 openiddish 表里的 price 用 DECIMAL 而不是 float因为金额用浮点类型在计算合计时会出现 0.10.2 不等于 0.3 的精度问题status 字段给菜品做上下架列表接口默认只查 status1 的这样前端首页就不会把已下架的菜品展示出来。如果你拿到的源码包里有订单相关功能通常还会补两张表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.xmlweb.xml 是整个应用的入口它负责加载 Spring 容器和 SpringMVC 的前端控制器。关键配置是这样context-param param-namecontextConfigLocation/param-name param-valueclasspath:applicationContext.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener servlet servlet-namespringMVC/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namespringMVC/servlet-name url-pattern//url-pattern /servlet-mappingweb.xml 里有两个容易忽略的点一个是 contextConfigLocation 指定 applicationContext.xml另一个是 DispatcherServlet 的 url-pattern 配成了/这意味着所有请求都交给 SpringMVC 处理静态资源还需要另外放行不然后端接口是通的图片资源却会 404。再来看 spring-mvc.xml 的片段mvc:annotation-driven / context:component-scan base-packagecom.jtdc.controller / bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views/ / property namesuffix 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) { PageResultDish 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 表示这个类里所有方法返回值直接写进响应体不再走视图解析器对小程序前端来说特别合适省去了每个方法上挂 ResponseBodyRequestMapping 定义类级别的路径前缀GetMapping 是 RequestMapping 的 methodGET 的简写RequestParam 绑定 query 参数并且给了默认值 pageNum1、pageSize10这样前端偶尔漏传参数后端也不会报错PathVariable 用来绑定 URL 路径里的 id对应小程序端 wx.request 的 url/dish/detail/1这种风格。有个容易混淆的点RequestParam 和 RequestBody 什么时候用。简单记GET 请求的参数在 URL 后面用 RequestParamPOST 请求如果前端把参数放在请求体里用 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 PageResultDish pageQuery(Integer pageNum, Integer pageSize, Integer categoryId) { int offset (pageNum - 1) * pageSize; ListDish 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 { ListDish selectPage(Param(offset) int offset, Param(size) int size, Param(categoryId) Integer categoryId); int count(Param(categoryId) Integer categoryId); }select idselectPage resultTypecom.jtdc.entity.Dish SELECT id, name, price, image, description, stock FROM dish WHERE status 1 if testcategoryId ! null AND category_id #{categoryId} /if ORDER BY id DESC LIMIT #{offset}, #{size} /select select idcount resultTypeint SELECT COUNT(*) FROM dish WHERE status 1 if testcategoryId ! null AND category_id #{categoryId} /if /select这个 XML 里面有两个细节值得说动态 SQL 的if testcategoryId ! null作用是不传分类时查出全部菜品传了就在后面拼一个分类条件这样同一个方法能吃两种请求LIMIT #{offset}, #{size}里的参数占位符用的是#{}MyBatis 会把它 precompile 成?有效防止 SQL 注入。如果你的分类条件用了category_id ${categoryId}那这就是个活生生的注入点我见过不少毕设源码这么写务必改掉。另外实体类 Dish 里的驼峰字段比如 categoryId和数据库下划线 category_id默认可能映射不上。最省事的方案是在 mybatis-config.xml 里开驼峰映射settings setting namemapUnderscoreToCamelCase valuetrue/ /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;charsetUTF-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 覆盖旧列表触底加载时传 falsepageNum 自增然后用 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实体类是 categoryIdMyBatis 默认不会自动把下划线转驼峰。如果 XML 里没写 resultMap又没有开启 mapUnderscoreToCamelCase 配置映射就断掉了。解决最快速的办法是在 mybatis-config.xml 里加上settings setting namemapUnderscoreToCamelCase valuetrue/ /settings加完重启 Tomcat一般就恢复了。如果加完还没生效再看两种可能一是这个配置写在了 spring-mvc.xml 里而不是 mybatis-config.xmlSpring 加载不到二是 SQL 语句用了 SELECT * 且返回的 resultType 是 MapMap 不会触发驼峰映射需要改成实体类。检查顺序先配置后 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-urlencodedRequestBody 也解析不了。解决在公共 request 封装里强制把 header 的 Content-Type 设为application/json;charsetUTF-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如果直接把 pageNum2 当偏移量第二页就会从第 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即使手动设置了 CookieiOS 和 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; } }然后写一个 HandlerInterceptorpublic 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 classcom.jtdc.interceptor.AuthInterceptor/ /mvc:interceptor /mvc:interceptors注意这里的 exclude-mapping 要把不鉴权的接口全部列出来否则首页还没加载出来就被拦了前端会一脸懵。我遇到过把 /dish/list 也拦了的结果列表页一直白屏找了半天才发现是拦截器拦截顺序问题。从那以后我每次拿到一套 SSM 后端源码都会先看一眼它有没有统一返回体和拦截器。没有的话我会在动手改业务前先花一小时把这些基础设施补上因为一旦业务逻辑写完再回头改造的代价会翻倍。这套思路在很多大作业和面试题里都会被反复问到值得你花时间自己敲一遍。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?