从去年开始我就在倒腾一个果蔬到家项目,前端用uniapp,后端用springboot,最后同时产出微信小程序和Android APP。这个组合现在很成熟,做生鲜电商类的小程序可以说是经典方案了。这套系统做下来,从商品展示、购物车、下单支付到订单配送、售后管理,基本覆盖了果蔬商城的完整链路。如果你正打算做类似的小程序商城,或者想了解uniapp加springboot前后端分离项目到底怎么落地,这篇内容应该能帮你少踩不少坑。
1. 项目整体设计与思路拆解
1.1 为什么选uniapp+springboot这对组合
先说说技术选型的逻辑。果蔬到家这种业务,目标用户基本都走微信小程序,但运营方往往会要求同时有一个APP兜底,另外可能还要做H5活动页。如果原生开发三端各写一遍,成本直接翻三倍。uniapp的核心价值就是一套代码编译到小程序、APP、H5,Vue语法对前端开发者几乎没有上手门槛,这是当时选它的主要原因。
后端选择springboot,理由更直接——生态成熟、招人容易、出问题能快速找到解决方案。配合mybatis-plus操作数据库,jwt做登录鉴权,redis扛热点数据,微信支付v3订单支付,这套组合拳在中小型电商项目里被验证过无数次,稳定性完全够用。果蔬生鲜类目还有一个特殊点:商品价格波动频繁、库存变化快、配送区域要精细化管理,这些都需要后端接口足够灵活,springboot的模块化结构很适合做这种快速迭代的业务。
1.2 商城核心业务模块划分
在动手写代码之前,一定要把业务边界画清楚。果蔬到家商城我拆成了三个端:用户端、运营端、后端服务。
用户端负责:首页商品瀑布流、分类筛选、商品详情、购物车、下单结算、订单列表、售后申请、个人中心、收货地址管理。运营端做在后台管理系统里,功能包括:商品上下架、库存修改、价格调整、订单状态跟进、配送人员分配。后端服务层则统一提供:用户鉴权、商品查询、购物车操作、订单创建、支付回调、优惠券核销这些接口。
这里面最容易被忽略的是“配送范围”这个概念。果蔬生鲜和普通电商最大区别在于配送半径。我第一版就踩了坑,用户下单后才发现配送区域覆盖不到,最后只能人工退款。后来在商品表和店铺表都加上了配送区域编码字段,下单时先校验配送覆盖范围,不在范围内直接拦截,这个逻辑在需求阶段就要想清楚。
1.3 技术栈与版本选型
版本选型方面,我给出一份可以直接照抄的清单:
| 模块 | 推荐技术 | 版本建议 |
|---|---|---|
| 前端框架 | uniapp | 使用Vue3版本,编译效率更高 |
| 后端框架 | springboot | 2.7.x(稳定且兼容性最佳) |
| ORM | mybatis-plus | 3.5.x |
| 数据库 | MySQL | 8.0+ |
| 缓存 | Redis | 6.x以上 |
| 鉴权 | JWT | 0.9.1或更新版本 |
| 支付 | 微信支付V3 | 官方SDK |
| 接口文档 | knife4j | 4.x |
有一个实际经验要说清楚:springboot版本不要盲目追新。3.x版本虽然性能有提升,但包名从javax变成了jakarta,很多老版本的mybatis-plus、pagehelper插件直接报错。如果是新项目并且团队没人踩过SpringBoot 3的坑,我反而建议先用2.7.x把业务跑通,后续再平滑升级。
2. 核心细节解析与实操要点
2.1 登录鉴权与用户体系
果蔬商城的用户绝大多数来自微信小程序,鉴权流程我推荐用微信官方登录态加jwt组合的方式。
用户点击微信登录后,前端调用uni.login拿到code码,传给后端接口。后端拿着code向微信服务器请求openid和session_key,openid是用户在某个小程序下的唯一标识,用它作为用户表的主键逻辑。拿到openid后查询数据库,如果用户不存在就自动注册,如果存在就正常签发jwt token,这里顺带可以把数据库查出来的用户主键id也存进token里,后续接口只需要从token拿用户id,不用频繁查数据库。
jwt有效期我习惯设成7天,同时维护一个refresh_token机制,但这是后话。对于果蔬商城这种低频工具类应用,7天有效期用户体验和安全性都够的。
关键代码示例,后端签发token的核心逻辑:
String token = Jwts.builder() .setSubject(userId.toString()) .claim("openid", openid) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 3600 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();注意,这里的secretKey一定要放到配置文件中,不要硬编码在代码里。同一个key在多环境(开发、测试、生产)之间切换时,记得区分开,不然测试环境签发的token在正式环境也能解析,这是存在安全风险的。我见过不少项目alpha环境不设置这个导致线上出现越权漏洞,这个细节一定要重视起来。
2.2 商品SKU与库存设计
生鲜果蔬的SKU设计有个独特的地方——同一个商品经常有多个规格,比如苹果可以分为“山东烟台红富士5斤装”“陕西洛川苹果10斤装”,价格和库存都不同。数据库层面我推荐用SPU加SKU两级结构。
SPU表存商品公共信息(标题、主图、详情描述、配送属性),SKU表存具体规格信息(规格名、价格、库存、规格图片、sku编码)。举个例子,一个“苹果”SPU对应多个“苹果-5斤装”“苹果-10斤装”的SKU记录。
在选商品规格时,用户端逻辑是:选中SPU进入详情页,默认展示第一个SKU的价格和图片,切换规格时刷新价格和库存。这部分uniapp端实现非常简单,用v-for渲染sku列表,点击时更新currentSku变量就行。
库存扣减是生鲜类目最需要谨慎的地方。高并发场景下直接用“先查库存—判断—再更新”的方式会出现超卖,我之前在一个促销活动中就遇到过库存10件卖出25件的闹剧。推荐用数据库层面的原子更新解决:
UPDATE sku SET stock = stock - #{count} WHERE id = #{skuId} AND stock >= #{count}如果受影响行数为0,说明库存不足,直接返回“库存不足”提示。这种方式不需要引入分布式锁,性能足够好。秒杀场景下可以考虑redis加lua脚本,但果蔬商城日常下单场景完全不需要。
2.3 果蔬订单状态机与配送流程
订单状态设计直接决定后续开发是否顺畅。生鲜订单比普通电商多几个状态,比如备货中、配送中。我总结出来的状态机如下:
待支付 - 用户取消/超时关闭 - 已支付 - 商家备货 - 配送中 - 已完成
待支付 - 支付成功 - 退款申请中 - 退款成功
这个状态机里有一个非常容易出问题的分支:用户支付成功后,商家还没来得及备货,用户就想退款。这时候不能简单把订单置为“已关闭”,否则支付系统的退款单对不上。我的经验是,订单中心里增加一个“售后状态”字段,和主状态分开管理。用户申请退款时,更新售后状态为“退款中”,商家审核通过后调用微信退款接口,退款成功再回写订单主状态为“已关闭”。两套状态各管各的,逻辑不会纠缠在一起。
2.4 支付模块与对账
微信支付v3是目前的主流方案。和v2相比,v3使用证书序列号、商户私钥和APIv3密钥三个关键信息。后端在接收到微信支付回调时需要做两件事:验签和解密。
第一,验签。微信支付回调请求头里有Wechatpay-Signature等字段,需要用微信支付平台证书验签,确保请求确实来自微信支付服务器。这一点很多初学者会忽略,直接解析请求体里的内容,这是不安全的,容易被伪造回调。你必须使用官方SDK提供的回调解析方法。
第二,解密。由于v3要求回调内容使用APIv3密钥进行AES-256-GCM加密,直接拿请求体解析会得到一堆密文,必须用APIv3密钥解密后才能拿到订单号、支付金额等明文信息。
我在实际项目中写了这样一个回调处理类:入口方法接收request,先通过sdk的CertificatesVerifier进行验签,验签通过后用AesUtil解密支付结果,再查询本地订单核对金额,一致则更新订单状态。这里的金额核对至关重要,必须比对“回调通知金额”和“本地订单应付金额”是否完全一致,防止支付金额被篡改的风险。
另外要提一个合规性问题:小程序的支付类目和资质要求非常严格。如果你的小程序因为类目不符或者涉嫌虚拟支付被限制了支付功能,那就需要先解决资质问题再进行开发对接,否则支付模块开发完也无法上线使用。支付权限冻结的问题,并不完全是技术能解决的,要重点检查小程序的运营类目是否与营业执照经营范围一致。
3. 实操过程与核心环节实现
3.1 uniapp项目初始化与目录结构
创建uniapp项目我习惯用HBuilderX可视化创建,选择Vue3版本模板。创建之后首先要做两个配置:一是manifest.json里配置小程序的appid,二是注册一个全局请求封装。
推荐目录结构如下:
src ├── pages │ ├── index // 首页 │ ├── category // 分类 │ ├── cart // 购物车 │ ├── order // 订单 │ └── mine // 我的 ├── components // 公共组件 ├── api // 接口请求封装 ├── utils // 工具函数 ├── static // 静态资源 └── App.vue接口请求封装我一般放在utils/request.js里,基于uni.request封装promise,统一带上token和contentType。如果后端返回401,全局跳转登录页。这里有一个注意点:uniapp的uni.request不支持请求拦截器和响应拦截器,需要自己在封装函数里处理。
一个常见需求是在页面中获取路由参数,比如从商品列表页跳转到详情页,需要携带商品id:
// 商品列表页跳转 uni.navigateTo({ url: '/pages/goods/detail?id=' + goodsId }) // 商品详情页接收 onLoad(options) { this.goodsId = options.id this.getGoodsDetail() }我当时在这个地方犯过一个错,一直以为onLoad里的options是个全局对象,结果发现多参数传递时参数被编码了,需要decodeURIComponent处理一下,不然参数里带中文和特殊符号会乱码。
3.2 springboot后端工程搭建
后端我推荐直接用Spring Initializr生成基础工程,勾选spring-web、spring-boot-starter-data-redis、mybatis-plus等依赖。这里说两个核心配置文件:
application.yml里必须有的配置项:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/fruit_mall?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 mybatis-plus: mapper-locations: classpath:mapper/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl jwt: secret: your-secret-key expire: 604800有一点要特别提醒:mybatis-plus默认的id生成策略是雪花算法,生成的id是19位雪花id。如果你对接的小程序端用到number类型去承载,会出现精度丢失问题。解决办法有两个:一是后端返回给前端时把id转成字符串,二是在实体类id字段上使用@JsonSerialize(using = ToStringSerializer.class)注解。我在做商城订单号时就遇到过这个坑,微信支付回调里对比订单号时怎么都不相等,排查了半天发现是精度丢失。字符串化之后问题彻底解决。
关于自动建表的问题,如果表结构变化频繁,可以用mybatis-plus配套的代码生成器,不要依赖框架自动建表。生产环境表结构变更应该走数据库迁移工具,比如Flyway,这样可以对线上库的变更留痕,团队协作时也能避免你改表我不知情的情况。
3.3 核心接口实现示例
商品列表是商城访问量最大的接口,必须做分页。前端滚动到底部就加载下一页,通过page和pageSize两个参数控制。
后端分页接口的标准写法:
@GetMapping("/goods/list") public Result<IPage<GoodsVO>> list(@RequestParam Integer page, @RequestParam Integer size, @RequestParam(required = false) Integer categoryId) { Page<Goods> pageParam = new Page<>(page, size); LambdaQueryWrapper<Goods> wrapper = new LambdaQueryWrapper<>(); if (categoryId != null) { wrapper.eq(Goods::getCategoryId, categoryId); } wrapper.eq(Goods::getStatus, 1) .orderByDesc(Goods::getSort); IPage<Goods> goodsPage = goodsService.page(pageParam, wrapper); // 转换为VO返回,隐藏内部字段 return Result.ok(goodsPage.convert(this::convertToVO)); }购物车加购逻辑其实很简单,先查询用户购物车中是否已经存在该SKU,存在则数量加一,不存在则插入一条新记录。但这里需要注意接口幂等性,用户快速点击多次加购时不能生成多条记录。借一个和Java后端很相关的设计思路,对于插入操作用唯一键约束,比如在购物车表设置userId和skuId为唯一索引,数据库层面就从根上规避了重复。
订单创建是整个系统最重的一个接口。要在一个事务里完成前面说的多步操作:校验收货地址、查询商品当前价格、锁定库存、创建订单主表、创建订单明细表、清空对应购物车记录。只要有任何一步失败,整个事务回滚。这个场景一定要加@Transactional注解,否则会出现订单生成了但库存没扣减的严重事故。
3.4 小程序/APP双端打包与发布
uniapp写完之后,发布小程序端比较直接:HBuilderX菜单中选择“发行—小程序-微信”,会自动生成微信开发者工具可识别的目录。打开微信开发者工具导入即可上传代码,提交审核。
但如果你想上架安卓应用市场,流程要复杂不少。首先是要有软著、隐私政策、安全评估报告等资质材料;其次不同应用市场(华为、小米、OPPO、vivo、应用宝)都要求应用认领和审核。
这里必须提到隐私政策弹窗的实现。APP上架时如果用户不同意隐私政策,app应该直接退出。uniapp的处理方式是:应用启动页open后,弹出隐私政策弹窗,用户点击“同意”时正常进入首页;点击“不同意”时调用plus.runtime.quit()退出应用。关键代码:
// 隐私政策弹窗-不同意退出 handleDisagree() { // 退出当前应用 plus.runtime.quit(); }我在实际项目中测试时发现,直接调用plus.runtime.quit()在部分安卓机型上会退出异常,应用退到后台而不是完全杀死。后来改进为先调用uni.exitMiniProgram(仅小程序)或plus.runtime.restart(),确保应用彻底退出。这块必须要在真机上反复测试,模拟器无法完全复现。
4. 常见问题与排查技巧实录
4.1 小程序软键盘遮挡查询内容
小程序里的搜索页面如果底部有输入框,弹出手机软键盘时会遮挡下方内容,这是真实用户反馈最多的体验问题之一。
解决办法有两个方向:一个是在input组件上设置adjust-position属性为false,然后自己监听键盘高度来调整页面位置,代码量稍大;更简单的是在页面配置里设置disableScroll为false,同时给底部查询按钮加一层padding-bottom,键盘弹起来时按钮默认会被自动顶上去。
我的经验是,小程序端最好直接使用input的confirm-type="search"属性配合bindkeyboardheightchange事件,实时计算键盘高度,然后通过css的transform属性把查询区域上移对应像素。这种做法在iOS和安卓上表现都比较稳定,也是目前比较通用的方案。
4.2 下拉刷新与页面滚动冲突
商城的首页页面既有商品滚动列表,又有顶部下拉刷新。如果使用的是page自身的滚动,下拉刷新和onReachBottom是有天然支持的;但如果你在页面里使用了scroll-view实现局部滚动,并且开启了enablePullDownRefresh,就会遇到一个经典问题:手指在scroll-view区域向上拉时,触发的是scroll-view的滚动,而不是页面级的下拉刷新。
最佳实践是:商城首页这种整页滚动的场景,不要用scroll-view,直接用page原生滚动。将刷新逻辑写在onPullDownRefresh生命周期里,将触底加载写在onReachBottom里,小程序原生会处理好一切,完全不存在冲突。
如果一定要用scroll-view,那需要手动监听scrolltoupper事件实现刷新触发逻辑。我一般只在分类页这种局部滚动的场景使用scroll-view,首页和列表页都坚持用page滚动。
4.3 自定义分享onShareAppMessage被全局覆盖
在做果蔬商城的时候,运营希望每个商品的分享卡片都带不同的图片和标题。我第一次是在全局App.vue里写了onShareAppMessage方法,结果发现所有页面的分享内容都是同一个,页面里定义的分享方法都不生效。
后来查了官方文档才发现,小程序对于onShareAppMessage的处理规则是:页面中定义的onShareAppMessage优先于全局App.vue中的定义,但如果页面中没有定义,就会调用全局的。
那问题就变成“页面中明明定义了,为什么还是走全局”?排查后找到原因:只有使用Vue3组合式API的definePageConfig或者在选项式API中的onShareAppMessage选项才会被页面识别。如果你是在script setup语法里直接写export default,是不会生效的。uniapp中正确写法是使用@dcloudio/uni-app提供的onShareAppMessage生命周期函数:
import { onShareAppMessage } from '@dcloudio/uni-app' onShareAppMessage(() => { return { title: this.goodsName, imageUrl: this.goodsImage, path: '/pages/goods/detail?id=' + this.goodsId } })4.4 springboot版本太高引入的“坑”
我另外一个项目里用过springboot 3.0,当时就是被网上“全新版本性能优化”的文章吸引,结果差点毁掉一个电商项目的交付周期。最大的问题就出在javax到jakarta的改名上。
SpringBoot 3.x要求所有依赖包中的javax.servlet、javax.annotation等包名改成jakarta.*。这意味着大量第三方库如果还是老版本,直接启动报ClassNotFoundException。mybatis-plus一直到3.5.3.1版本才做了完整适配,期间还出现了和springboot3不兼容的修复版本。如果你不是必须使用springboot 3,做商城项目老老实实用2.7.x,省下的调试时间足够你做很多业务功能。
还有一个小坑:springboot内置的tomcat版本在2.7.x和3.x之间差别很大,如果你的部署环境里使用了自定义的javax.validation校验注解,升级后很多注解会失效。为这种问题熬夜排查真是得不偿失,团队里如果有人提出升级版本,让他先把兼容性测试做完再说。
4.5 微信支付v3对接经验速查
最后整理一份我踩过坑之后的支付对接清单:
| 排查项 | 说明 |
|---|---|
| 商户号/证书序列号 | 三者必须完全匹配,不能用测试号的配置打正式环境 |
| APIv3密钥 | 必须28位以上,作为AES解密密钥 |
| 回调地址 | 必须是HTTPS域名,不能带路径参数 |
| 金额单位 | 所有金额都是分,不是元,差100倍 |
| 回调幂等性 | 重复回调可能触发多次,状态更新前判断当前状态 |
| 平台证书 | 需要定期检查是否过期,过期后验签会失败 |
另外,支付结果不能只依赖回调通知。我建议在后端提供一个主动查询支付状态的接口,前端在用户返回支付页面时拉取一次最新状态,防止回调延迟导致页面显示“未支付”但钱已经扣了的情况。这个主动查询在生鲜配送场景中很实用,因为用户下单后马上会追问“我付了钱为什么还没生成订单”。
做好这类项目的一点心得
果蔬到家这个项目从立项到上线,前后大概花了两个月,中间反复改了不少需求。我最深的体会是:生鲜电商的核心不是前端页面有多漂亮,而是后端要把订单、库存、支付、配送这四件事玩明白。任何一个环节出问题,影响的都是真金白银和用户信任。
如果只让我分享一条经验给后来者,那就是拿到需求后先设计数据库表和订单状态机,再谈页面和接口。数据库设计好了,后面所有功能开发都顺;数据库设计乱了,前端后端都会跟着返工。我现在新做一个商城项目,第一周只做一件事——把ER图和数据字典写到能让别人不看你代码也能建库的程度。
另外一个小技巧:商品图片一定要用图床或CDN,不要把图片直接塞进服务器本地目录。果蔬类商品图片多且更新频繁,本地存储既拖慢接口响应又占磁盘空间。我用阿里云OSS配合上传接口,前端拿到URL直接展示,后端只做权限校验,性能和可维护性都提升了一个档次。
这套uniapp+springboot的架构,后续还可以继续扩展社区团购、秒杀活动、积分商城等模块,整体框架完全撑得住。希望这篇内容能给你一些实际帮助,少走几步弯路。