SpringBoot3+微信小程序:校园食堂点餐配送系统全栈实战
2026/9/11 14:27:42 网站建设 项目流程

校园食堂点餐配送,听起来是个老掉牙的选题,但真正动手做过的朋友都知道,这里面坑一点都不少。尤其是用上SpringBoot3打底、微信小程序做前端这套组合,既要处理校园场景特有的高并发午高峰,又要对接微信支付v3那一堆证书和回调,还要让商家端、骑手端、用户端三方的订单状态保持一致。这篇文章,我就以自己实际落地的经历,把整个系统的设计与实现过程拆开来讲,从数据库设计到小程序界面适配,从支付对接踩坑到配送状态流转,把那些文档里不会明说的细节一并交代清楚。如果你正在做类似的毕业设计,或者想给学校食堂做一套可用的点餐配送系统,这篇内容可以直接帮你少走一半弯路。

1. 整体设计与思路拆解

1.1 校园场景的特殊性,决定了系统的核心诉求

先聊需求。校园食堂点餐配送,表面上看和普通外卖系统差不多,用户选菜、下单、支付、配送,但实际上校园场景有三个典型特征,直接决定了系统的设计方向。

第一是午高峰流量集中。中午十一点半到十二点半这一个小时内的订单量,可能比全天其他时间的总和还高。第二是配送范围高度固定,基本限定在宿舍区和教学区,不需要复杂的LBS匹配和路径规划。第三是用户群体极其统一,都是在校学生和老师,使用习惯和账号体系相对单一,甚至可以不用做复杂的会员体系,直接绑定校园身份就行。

这三个特征带来了什么影响?首先,后端在秒杀式流量进来的时候要能扛住,数据库表设计、缓存策略、支付超时兜底这些都要提前考虑到;其次,配送模型不需要做成美团那样的大盘调度,只需要简单的接单—取餐—送达三段式流转即可,这大大降低了实现复杂度;最后,登录鉴权可以做得非常轻,微信小程序本身就是天然的登录入口,配合后端JWT签发令牌,整个安全体系几分钟就能搭完。

1.2 技术选型:为什么是SpringBoot3加微信小程序

后端我选的是SpringBoot3,不是没纠结过。早期很多人还在用SpringBoot2,3.0推出之后最大变化是强制基于Jakarta EE 9规范,很多老依赖的坐标从javax.*换成了jakarta.*,这意味着网上大量的旧教程代码直接报红。但SpringBoot3带来的好处也很明显:原生支持GraalVM,启动速度更快,内置的Tomcat和Undertow性能有提升,而且Spring Security 6的配置方式更清晰,对于新项目来说没有历史包袱,直接上手反而更顺畅。

前端选择微信小程序更是顺理成章的事情。校园场景下用户不太可能为了打个饭特意下载App,小程序即扫即用,而且微信支付在小程序内有天然的使用路径。整个开发过程中,小程序端我用的是原生框架而非uniapp,原因很简单:原生框架调试更直接,遇到问题搜索解决方案时命中率也更高。当然如果以后要打包成支付宝小程序或者抖音小程序,那转uniapp是另一回事了,但就校园食堂这个单平台场景,原生开发完全够用,不用为了框架而框架。

1.3 功能模块全景:四个端口的职责划分

系统拆成四个角色:用户端小程序、商家端小程序/Web、骑手端小程序、管理后台Web。核心功能模块包括:

  • 用户端:菜品浏览、分类检索、在线点餐、购物车管理、订单创建、在线支付、订单状态跟踪、配送地址维护、历史订单回看
  • 商家端:菜品上下架管理、库存设置、订单接单/拒单、出餐状态更新、营业额统计
  • 骑手端:待接订单大厅、抢单或系统派单、取餐确认、送达确认、配送统计
  • 管理后台:用户管理、商家入驻审核、评论管理、数据大屏、系统配置

整个系统最核心的就是订单状态机。这一块如果设计不清楚,后面联调阶段会不停出bug。我把订单状态定义成如下链路:待支付 → 待接单 → 待取餐/制作中 → 配送中 → 已完成,中间穿插着已取消、已退款、异常单等分支状态。这个状态机的定义是所有业务逻辑的心脏,后面章节我会专门展开讲。

2. 后端核心架构与关键实现

2.1 SpringBoot3项目骨架搭建与依赖版本锁定

先说项目骨架。我使用IDEA的Spring Initializr生成项目,Java版本选的17,核心依赖包括Spring Web、Spring Validation、MyBatis-Plus、MySQL Driver、Lombok、Spring Data Redis(用于缓存和Session管理)、Spring AOP(用于切面记录日志和权限验证)。SpringBoot3版本的坑从一开始就存在,比如传统连接池Druid的老版本在SpringBoot3下会报兼容错误,需要指定druid-spring-boot-3-starter这个专门适配的版本,很多新手在这一步就卡住了。

最终的pom.xml里我用了这些关键坐标:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.0</version> <relativePath/> </parent> <dependencies> <!-- SpringBoot3 Web场景 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis-Plus,注意SpringBoot3需要3.5.3+版本 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.5</version> </dependency> <!-- MySQL驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- Redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- knife4j 接口文档,就是这个热词里提到的springboot3使用knife4j --> <dependency> <groupId>com.github.xiaoymin</groupId> <artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId> <version>4.4.0</version> </dependency> </dependencies>

这里每一个人都要注意:SpringBoot3下Knife4j、MyBatis-Plus、Druid这类组件一定要选带jakarta标识或者明确声明支持SpringBoot3的版本,否则就会出现启动直接报ClassNotFoundException: javax.servlet.Filter之类的问题。

接口文档用的是Knife4j 4.x版本。说实话,以前用SpringFox那一套配SpringBoot3特别痛苦,因为SpringFox已经停止维护了。Knife4j基于OpenAPI3规范,在SpringBoot3上是开箱即用的,只需在配置类里加@OpenAPIDefinition@SecurityScheme注解就能让所有接口自动生成文档页面,访问/doc.html能看到所有Controller的接口描述。这对我前后端联调的效率提升很大,后面第4章会说具体用法。

2.2 数据库设计:八张核心表的关系与字段把控

数据库是整个系统的地基。我强烈建议在写第一行业务代码前,把表结构定清楚,不然后面改表结构的成本极高。这个系统我设计了八张核心表:

表名核心字段说明
userid, openid, nickname, avatar, phone, role, campus_address用户表,role区分用户/商家/骑手
merchantid, name, logo, description, business_status, rating商家信息表
categoryid, merchant_id, name, sort菜品分类表
dishid, merchant_id, category_id, name, price, image, stock, status菜品表
cartid, user_id, dish_id, quantity, selected购物车表
ordersid, order_no, user_id, merchant_id, rider_id, total_amount, status, remark订单主表
order_itemid, order_id, dish_id, dish_name, dish_image, price, quantity订单明细表
delivery_addressid, user_id, dorm_building, dorm_room, contact_name, contact_phone配送地址表

订单主表是整个系统的核心,关于状态字段,我建议用Integer类型的枚举值存储而不要用字符串,比如0待支付、1待接单、2待取餐、3配送中、4已完成、-1已取消。这样做的理由是数据库层面可以做索引,比如统计某个商家的历史订单量时直接WHERE status = 4的效率会好很多。

还有一点经验之谈:订单号一定要自己生成,不要用数据库自增ID暴露给前端。我用的生成规则是yyyyMMddHHmmss + 商家ID后三位 + 用户ID后四位 + 随机四位数字,比如20250121123001001200012345,这样既保证了唯一性,也能从订单号直接反推出下单时间和商家,排查问题非常方便。随机后四位我用的是RedisTemplateincrement操作生成自增序号再接一段随机数,直接把相同时间、相同用户、相同商家的订单区分开。

2.3 登录鉴权设计:openid交换JWT令牌

微信小程序登录流程,很多刚接触的开发者容易误解,以为需要用户输入账号密码。实际上小程序的登录逻辑非常固定:小程序端调用wx.login()拿到临时code,把code传给后端,后端用code去微信官方接口换取openid和session_key,然后后端生成自己的JWT令牌返回给小程序端,后续所有请求都带上这个令牌就行。

后端对应的核心代码大概是这样的:

@PostMapping("/login") public Result login(@RequestBody LoginRequest request) { // 1. 获取临时登录凭证 code String code = request.getCode(); // 2. 请求微信接口获取 openid String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + appId + "&secret=" + appSecret + "&js_code=" + code + "&grant_type=authorization_code"; String response = restTemplate.getForObject(url, String.class); JSONObject json = JSON.parseObject(response); String openid = json.getString("openid"); // 3. 判断用户是否已存在,不存在则自动注册 User user = userMapper.selectByOpenid(openid); if (user == null) { user = new User(); user.setOpenid(openid); user.setRole(1); // 普通用户默认角色 user.setCreateTime(LocalDateTime.now()); userMapper.insert(user); } // 4. 生成JWT令牌 String token = Jwts.builder() .setSubject(user.getId().toString()) .claim("role", user.getRole()) .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 3600 * 1000)) .signWith(secretKey) .compact(); return Result.success(token); }

JWT令牌建议的有效期是7天,因为学生手机端的微信每天都会打开,过期后重新走一次静默登录完全没感知。注意当你后续要开发管理后台时,不要复用这套JWT签名密钥,或者至少要在token里加入roleaudience字段区分来源,防止普通用户的token直接调管理接口。

2.4 订单状态机:让每个状态流转都有迹可循

订单状态是系统的主动脉。我之前见过很多人做这套系统时,一个Controller里十几个if-else散落各处,一旦订单处于某个中间状态,代码根本猜不到下一步可以做什么。所以我自己专门写了一个OrderStatusHandler类,把状态之间的合法迁移集中管理起来。

我定义的合法迁移规则:

当前状态可执行操作目标状态
待支付(0)用户支付待接单(1)
待支付(0)用户取消/超时未付已取消(-1)
待接单(1)商家接单待取餐(2)
待接单(1)商家拒单已取消(-1)
待取餐(2)骑手取餐配送中(3)
配送中(3)骑手送达已完成(4)

每个状态流转的方法里都要加状态校验,防止前端重复点击导致状态错乱。比如用户支付成功后,前端收到支付成功的回调同时还会触发轮询接口,两次请求如果都到了后端,第二次操作时订单已经是待接单状态了,此时如果没检查就直接执行待支付→待接单是没问题的,因为状态一致。但如果用户是在待接单状态下点了取消,商家端同时也在点接单,那么有一个请求会因为状态不匹配被拦截并抛出异常,必须让前端捕获后刷新订单详情。

这个状态机类核心就是一个ConcurrentHashMap存映射关系,方法签名类似OrderStateTransitionResult tryTransition(oldStatus, targetStatus, operator)

public boolean canTransition(Integer currentStatus, Integer targetStatus) { Map<Integer, List<Integer>> map = new HashMap<>(); map.put(0, Arrays.asList(1, -1)); map.put(1, Arrays.asList(2, -1)); map.put(2, Arrays.asList(3)); map.put(3, Arrays.asList(4)); return map.getOrDefault(currentStatus, Collections.emptyList()) .contains(targetStatus); }

有了这个状态机之后,微信支付回调的处理就简单了:支付回调里验证完签名和金额后,直接调用tryTransition(0, 1, "system"),如果返回失败说明订单状态已经不是待支付,那就把回调标记为重复回调记录下来。不需要在每个业务方法里反复写状态判断。

3. 微信小程序端开发实操

3.1 项目初始化与目录结构设计

小程序端我用的原生框架,项目结构按页面维度划分。新建项目时首选使用微信开发者工具直接创建模板,AppID建议申请一个测试号,校园项目如果急着上线,正式AppID的申请需要认证主体,周期可能一周左右。

目录结构这样设计最清晰:

├── app.js // 全局逻辑,包含登录逻辑和全局用户信息 ├── app.json // 页面注册、窗口配置 ├── app.wxss // 全局样式 ├── utils/ │ ├── request.js // 封装 wx.request,统一携带 token │ ├── config.js // 环境配置,开发环境/生产环境地址切换 │ └── format.wxs // 时间格式化工具 ├── images/ ├── components/ │ ├── dish-card/ // 菜品卡片组件 │ ├── cart-bar/ // 购物车结算栏组件 │ └── order-card/ // 订单卡片组件 └── pages/ ├── index/ // 首页:菜品分类与列表 ├── cart/ // 购物车 ├── order/ // 订单列表与详情 ├── profile/ // 个人中心 ├── address/ // 配送地址管理 ├── merchant/ // 商家管理 └── rider/ // 骑手接单大厅

有一个非常容易被忽视的问题:小程序顶部导航栏的适配。用默认导航栏时,右上角胶囊按钮会挤压自定义导航栏的标题空间。我在做自定义导航栏时踩过坑,后来直接使用系统导航栏,只在首页做沉浸式轮播图时用了提升布局的navigationStyle: "custom"方案。自定义导航时需要用wx.getWindowInfo()获取状态栏高度和胶囊按钮位置,动态计算导航栏高度,这不仅仅是iOS和安卓的差异问题,不同机型的刘海屏高度都不同,建议封装一个navHeight的计算函数。

3.2 用户登录链路:从wx.login到token存储

小程序的登录逻辑,官网文档写得很简洁,但实际操作有几个细节。第一,wx.login()在每台设备每个会话的code只能用一次,有效期为5分钟。如果你在app.js里面调用了两次wx.login(),第二次获取的code再拿去后端换openid就会失败,返回40029错误码。所以最好把登录封装成一个返回Promise的全局方法,并且用一个isLogining的布尔变量防止并发触发重复请求。

我最终的做法是维护一个登录Promise的单例模式:

// utils/login.js let loginPromise = null; function login() { if (loginPromise) return loginPromise; loginPromise = new Promise((resolve, reject) => { wx.login({ success: (res) => { request({ url: '/api/user/login', method: 'POST', data: { code: res.code }, }).then((data) => { const token = data.token; wx.setStorageSync('token', token); resolve(token); }).catch(reject); }, fail: reject, }); }).finally(() => { loginPromise = null; }); return loginPromise; }

这样无论页面同时多少个请求进来,都只触发一次真实的登录请求。后续每次调用wx.request前,都从storage里面读取token并追加到Header的Authorization字段。

3.3 点餐与购物车交互:数据怎么在小程序端流转

点餐页是整个小程序端最核心的页面。左边是菜品分类栏,右边是菜品列表,底部悬浮购物车结算栏。这里有三个实现要点。

第一个是分类切换的交互反馈。分类本质上是一个数组,通过scroll-view来实现左右联动。右侧列表滚动时会触发onPageScrollscroll-viewbindscroll事件,根据当前滚动位置判断应该高亮哪个分类。这个联动逻辑写起来不难,但要注意节流,否则频繁触发setData会导致页面卡顿。

第二个是购物车数据的本地缓存。学生在选择菜品时并没有真正下单,购物车数据只存在于小程序端。我把购物车存在全局变量globalData.cartList里面,同时同步到wx.setStorageSync('cartList', ...)做持久化,防止用户手滑关闭小程序又打开后购物车丢失。购物车的数据结构是一个以dishId为key的Map,包含dishId、name、price、quantity、image、stock等字段。

第三个是购物车的价格计算。每次数量和选择状态变化时,都要重新计算总价。这里有个坑:浮点数精度问题。如果单价是3.5元,数量是3份,JavaScript里面3.5 * 3 = 10.499999999999998。所以我在工具函数里统一做了分转元的处理,前端展示时后端传给小程序的price字段是整数分(比如350表示3.5元),计算逻辑全部用整数相加,只在最终展示时除以100并保留两位小数。后端存储Price也用DECIMAL(10,2)或者直接用Integer单位分,彻底规避精度问题。

3.4 微信支付v3对接:证书、回调与签名避坑记

微信支付v3的对接,绝对是我在开发过程中掉坑最多的地方。因为小程序的wx.requestPayment需要后端先调用微信支付API生成预支付订单,拿到paySign等参数返回给前端,然后前端拉起收银台。很多热词里提到 “小程序微信支付v3对接” 和 “无可用的平台证书”,我就在这里卡住了很久。

整个支付流程是这样的:

  1. 后端创建订单后调用微信支付v3接口下单,参数包括appidmchiddescriptionout_trade_noamount.totalpayer.openid,最终返回prepay_id
  2. 后端用prepay_id生成二次签名参数timeStampnonceStrpackage=prepay_id=xxxsignType=RSA,用商户私钥做SHA256WithRSA签名后返回给小程序端
  3. 小程序端把这些参数传给wx.requestPayment,微信客户端弹出支付确认框
  4. 支付完成后微信服务器异步通知后端回调地址/api/pay/notify,后端必须正确解密通知内容,然后修改订单状态

坑点一:平台证书序列号。v3的报错信息里常出现 “请使用支付平台公钥验签” 或 “无可用的平台证书” 这类提示。这是因为微信支付v3的证书体系分为商户API证书(用于请求签名)和平台证书(用于验证微信的响应与回调)。老版本的sdk需要预先下载平台证书文件,而新版本接口已经改为通过WechatPayHttpClientBuilder自动获取平台证书并缓存在本地,只需要配置商户私钥、商户证书序列号和APIv3密钥即可。不要在商户平台手忙脚乱地“申请”什么,那是个老误区。

wechatpay-java官方sdk为例,配置类如下:

@Bean public CloseableHttpClient wechatPayClient() { // 加载商户私钥 PrivateKey merchantPrivateKey = PemUtil.loadPrivateKey( new FileInputStream("/path/apiclient_key.pem")); // 自动更新微信支付平台证书 AutoCertificateService.updateCertificate( wechatPayConfig.getMchId(), wechatPayConfig.getMerchantSerialNo(), merchantPrivateKey, wechatPayConfig.getApiV3Key(), wechatPayConfig.getPrivateKeyPath() ); return WechatPayHttpClientBuilder.create() .withMerchant(wechatPayConfig.getMerchantId(), wechatPayConfig.getMerchantSerialNo(), merchantPrivateKey) .withValidator(new AutoCertificateService.getValidator()) .build(); }

坑点二:回调验签。支付回调的请求体是加密的,不是明文JSON。需要用平台证书或者APIv3密钥解密成明文的JSON,然后取出out_trade_notransaction_id,再查库更新订单状态。我当时犯的错误是先验签再解密,顺序搞反导致一直报验签失败。

坑点三:金额校验。回调里的amount.total是用户实际支付的金额,单位为分。后端拿到金额后必须和订单表里的total_amount比对,如果不一致绝对不能更新订单状态。这个不是为了防什么重大黑客,而是防止支付和业务数据不一致导致的资损风险。哪怕只差一分钱,都要记录日志并标记为异常单。

4. 前后端联调与配送流转打通

4.1 本地开发环境配置:让小程序连上本机后端

开发环境下,小程序有一个很恼人的限制:真机预览请求本机后端地址是不通的,因为手机访问不了电脑的localhost。解决方案有两个。第一,开发时使用微信开发者工具的“不校验合法域名、web-view(业务域名)、TLS版本以及HTTPS证书”选项,然后让小程序请求后端地址时填入电脑在局域网内的IP,比如http://192.168.1.105:8080。注意同时要在IDEA中配置application.yml的server.address为0.0.0.0,或者不管,默认就是监听所有网卡。

第二个方案是使用内网穿透工具把本机端口映射到一个公网域名上,这样手机上直接用公网域名访问。但是注意这里必须加上安全组和权限控制,否则开发期的接口就直接暴露了。我后来是在内网穿透服务上加了一层Token校验,让只有携带小程序token的请求能通过。

联调阶段强烈推荐在项目里接入Knife4j的/doc.html接口文档页面。前端同学看着Swagger文档调接口,比直接翻后端代码高效得多。而且Knife4j支持在线调试,不需要再用Postman单独维护一套请求地址了。启动后端后,浏览器访问http://localhost:8080/doc.html,在右上角把全局参数里的Authorization填上登录返回的token,就能一键测试所有接口。

联调中还经常用到的工具是抓包工具,比如热词里提到的 Burp Suite 抓取 PC 端微信小程序。我说明一下:开发期如果想看小程序的网络请求,最直接的方法是用微信开发者工具自带的Network面板,能看到每个请求的Headers、Payload和Response。如果你想分析别人线上小程序,尤其是反编译这种操作,我劝你打消这个念头,这不仅涉及违规,而且无助于你现在这个项目的开发。调试自己的接口用开发者工具就够了。

4.2 配送模块实现:从商家出餐到骑手送达的完整链路

配送模块是这个系统里最容易做模糊的部分。我把它拆成两种模式:商家自行配送骑手抢单。校园场景下,小食堂没有专职骑手,更多的其实是众包式的,谁有空谁抢单。

数据库里orders表增加了rider_idtake_timedeliver_time字段。订单状态从待取餐流转到配送中这个动作,必须是骑手端的“确认取餐”按钮触发。而骑手端看到哪些订单可以抢?我的实现是:当商家把订单状态从待接单改为待取餐(即接单成功后立即出餐),系统把这个订单自动拉入骑手端的待抢订单池。骑手端通过轮询接口拉取status = 2 且 rider_id is null的订单列表,点击抢单时后端要加乐观锁,用SQL条件更新来防止两个骑手同时抢同一单:

public boolean grabOrder(Long orderId, Long riderId) { String sql = "UPDATE orders SET rider_id = ?, status = 3, take_time = NOW() " + "WHERE id = ? AND (rider_id IS NULL OR rider_id = 0) AND status = 2"; int rows = orderMapper.updateRider(sql, riderId, orderId); return rows == 1; }

这个地方如果我当初不是用条件更新,而是先查询再更新,并发场景下就会有两个人同时抢到同一单,那就尴尬了。

4.3 消息通知机制:订单状态变化怎么触达用户

订单支付成功后,用户希望第一时间知道商家已接单、骑手已出发。实现方案有几种:小程序订阅消息、WebSocket长连接、短信通知。校园项目里短信通知成本高不划算,我最终采用的是小程序订阅消息+页面轮询双保险。

小程序订阅消息的第一步是引导用户订阅。必须在用户点击“提交订单”按钮时调用wx.requestSubscribeMessage,将tmplIds数组传给微信,用户点允许后,后端才能在下单成功时通过订阅消息模板给用户推送“支付成功通知”。这里有个反直觉的设计:订阅是一次性的,用户每次允许订阅只对应下一次推送,所以小程序端要在需要通知的关键节点前引导用户再次订阅。

页面轮询相对简单,用户进入订单详情页后,每3秒调用一次查询订单状态接口,直到订单状态变为已完成或者用户离开页面就停止。这里注意不要写死在onLoad里忘记在onUnload清理定时器,否则会导致离开页面后还在频繁请求接口,浪费带宽和服务器资源。

WebSocket这套方案我这里没有用,但如果你订单量大、轮询压力大,可以考虑服务端做WebSocket通道,小程序端用wx.connectSocket。不过实际上校园订单量撑不住需要上WebSocket的量级,轮询完全可以应付,这也是很多人容易过度设计的地方。

5. 常见问题与排查技巧实录

开发这个系统的过程中,我整理了十个高频问题,很多都是网上搜不到明确答案但一旦踩到就很痛苦的,这里集中列出来。

问题现象原因解决方案
小程序登录返回40029错误code重复使用或过期登录逻辑单例化,确保一次会话内只发起一次code换openid
支付报错无可用平台证书平台证书未正确加载或缓存使用官方sdk的AutoCertificateService,配置APIv3密钥自动更新证书
SpringBoot3启动报ClassNotFoundException: javax.servlet.*依赖版本不兼容SpringBoot3改用jakarta坐标版本,Druid用druid-spring-boot-3-starter
自定义导航栏在全面屏手机上顶部留白未适配状态栏高度使用wx.getWindowInfo().statusBarHeight动态计算导航栏高度
购物车金额计算出现小数误差JavaScript浮点数精度问题金额统一用整数分存储和计算,展示时再转元
两个骑手同时抢到同一单先查询再更新导致并发冲突使用数据库条件更新(UPDATE ... WHERE rider_id IS NULL)
商品库存超卖扣库存操作未加锁使用UPDATE语句带stock>0条件扣减,或用乐观锁
支付回调收到多次微信网络重试机制回调幂等处理,处理过的订单直接返回成功
蓝牙搜索不到设备(涉及多端联动调试时)未申请蓝牙权限或未授权在app.json配置permission字段,检查Android 12+的附近设备权限

5.1 微信环境兼容性:从模拟器到真机的不一致问题

开发过程中最让人头疼的问题之一,就是模拟器一切正常,真机上一跑各种幺蛾子。我用真机测试时遇到最典型的几个:

第一个是wx.request的域名问题。开发者工具勾选了“不校验合法域名”后模拟器能请求,但真机上这个选项不生效,必须把后端接口域名配置到小程序后台的 request 合法域名里,并且要求域名有HTTPS证书。开发模式下很多人用内网穿透工具生成的临时域名,但那个域名没有备案或证书,真机往往无法请求。后来我的做法是:后端绑定一个泛域名的HTTPS证书,并让request的合法域名填主域名加端口,然后在utils/config.js里用环境变量区分开发环境和生产环境请求前缀。

第二个是安卓手机上wx.requestPayment拉起收银台时报403。排查下来是后台支付目录配置问题,微信支付商户平台需要配置支付授权目录,格式类似https://你的域名/pay/。目录配置错一点都不行,必须精确到目录级别。这个坑网上讨论的人不多,但遇到就会卡很久。

第三个是iPhone的Safari下小程序的Date对象解析yyyy-MM-dd HH:mm:ss格式的时间字符串时返回Invalid Date。iOS不支持这种带横杠的时间格式,必须替换成yyyy/MM/dd HH:mm:ss才能被正确解析。我一开始在订单详情页显示时间时,iOS真机上一片空白,排查了很久才发现是这个原因。后来统一封装了一个formatTime工具函数,所有时间展示前都先转换。

5.2 SpringBoot3实战经验:热部署、日志与异常处理

后端在SpringBoot3上的开发体验,有几个细节值得单独聊一下。

第一个是热部署。SpringBoot3搭配spring-boot-devtools实现代码热更新非常爽,改完Java文件自动重启,不用手动按Ctrl+F5。但注意devtools和IDEA的自动编译有联动,需要开启Build project automatically选项。而且热部署时候Redis、MyBatis-Plus的缓存数据会保留,如果改了实体类字段,容易在运行时报字段映射错误,这种情况直接手动重启一次解决就行。

第二个是全局异常处理。我整个项目用一个@RestControllerAdvice@ExceptionHandler统一处理异常。对于业务异常,定义了一个BizException类,包含错误码和错误信息,前端根据错误码做不同提示。比如库存不足错误码是20001,前端收到后自动清理购物车中对应的菜品,并弹Toast提示“菜品已经卖完了”。

第三个是接口日志切面。我写了一个AOP切面,对所有Controller的请求进行日志记录,包括请求路径、请求参数、响应结果、耗时。这个对排查线上问题特别有用,比如某一单支付回调丢了,翻日志可以看到回调到底有没有到达后端。日志格式尽量结构化,用JSON格式输出,后续如果接ELK或者Loki,解析起来会方便很多。

5.3 上线前必做的五件事

最后聊上线。很多人的项目在本地跑得飞起,一部署到服务器就崩了,原因往往集中在环境差异、资源不足、配置硬编码这三类问题上。我列出上线前必做的五件事:

  1. 切换生产环境配置application.yml里的数据库连接、Redis地址、微信支付商户号和密钥、AppSecret,全部从环境变量或配置中心读取,禁止硬编码写在代码里。
  2. 配置HTTPS证书。小程序的request、uploadFile接口对域名有HTTPS强制要求,用Nginx做反向代理,在Nginx层配置SSL证书,SSL证书可以申请免费的DV证书,只是过期时间短,记得设置自动续期或到期提醒。
  3. 数据库自动备份。MySQL每天凌晨自动全量备份,保留最近7天。同时开启binlog,以备误操作导致的数据丢失可以按时间点恢复。这个校园系统虽然数据量不大,但万一哪天数据库挂了,用户点不了餐比自己吃不上饭还着急。
  4. 页面加载性能优化。小程序首屏加载时不要一次性请求全部菜品,按分类懒加载,图片要做压缩。后端给前端返回的菜品列表接口加上Redis缓存,缓存的失效时间设置为菜品上下架时手动刷新。实测下来首屏白屏时间能减少30%以上。
  5. 准备降级预案。如果支付接口挂了,用户在收银台无法完成支付,系统要有提示并允许用户取消订单。如果订单量突然爆了,后端接口响应变慢,前端要有超时重试机制,避免请求堆积。

关于这套系统,我最后再啰嗦几句

我自己做完这套校园食堂点餐配送系统最大的感受是:技术栈本身并没有多难,真正的难点在于把校园场景下的业务流程理清楚,把订单状态的流转、并发的库存扣减、支付回调的幂等性这些细节想明白。SpringBoot3加微信小程序这个组合非常成熟,社区资料也多,遇到问题基本都能搜到答案。如果你也在做类似的系统,我建议先花一整周时间把数据库设计和状态机定义敲定,不要急着写代码,这个地基打好了,后面开发会流畅得多。

还有一个小技巧:开发过程中任何一次修改数据库表结构,都顺手更新一份字段说明文档放在项目根目录,哪怕是个简单的Excel表格。因为两周后你回头看自己写的SQL,可能已经记不清某个字段到底是取值为1表示正常还是0表示正常了。团队协作时这份文档更加重要,它能避免无数个“这个字段是什么意思”的来回询问。

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

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

立即咨询