
从去年开始我就在倒腾一个果蔬到家项目前端用uniapp后端用springboot最后同时产出微信小程序和Android APP。这个组合现在很成熟做生鲜电商类的小程序可以说是经典方案了。这套系统做下来从商品展示、购物车、下单支付到订单配送、售后管理基本覆盖了果蔬商城的完整链路。如果你正打算做类似的小程序商城或者想了解uniapp加springboot前后端分离项目到底怎么落地这篇内容应该能帮你少踩不少坑。1. 项目整体设计与思路拆解1.1 为什么选uniappspringboot这对组合先说说技术选型的逻辑。果蔬到家这种业务目标用户基本都走微信小程序但运营方往往会要求同时有一个APP兜底另外可能还要做H5活动页。如果原生开发三端各写一遍成本直接翻三倍。uniapp的核心价值就是一套代码编译到小程序、APP、H5Vue语法对前端开发者几乎没有上手门槛这是当时选它的主要原因。后端选择springboot理由更直接——生态成熟、招人容易、出问题能快速找到解决方案。配合mybatis-plus操作数据库jwt做登录鉴权redis扛热点数据微信支付v3订单支付这套组合拳在中小型电商项目里被验证过无数次稳定性完全够用。果蔬生鲜类目还有一个特殊点商品价格波动频繁、库存变化快、配送区域要精细化管理这些都需要后端接口足够灵活springboot的模块化结构很适合做这种快速迭代的业务。1.2 商城核心业务模块划分在动手写代码之前一定要把业务边界画清楚。果蔬到家商城我拆成了三个端用户端、运营端、后端服务。用户端负责首页商品瀑布流、分类筛选、商品详情、购物车、下单结算、订单列表、售后申请、个人中心、收货地址管理。运营端做在后台管理系统里功能包括商品上下架、库存修改、价格调整、订单状态跟进、配送人员分配。后端服务层则统一提供用户鉴权、商品查询、购物车操作、订单创建、支付回调、优惠券核销这些接口。这里面最容易被忽略的是“配送范围”这个概念。果蔬生鲜和普通电商最大区别在于配送半径。我第一版就踩了坑用户下单后才发现配送区域覆盖不到最后只能人工退款。后来在商品表和店铺表都加上了配送区域编码字段下单时先校验配送覆盖范围不在范围内直接拦截这个逻辑在需求阶段就要想清楚。1.3 技术栈与版本选型版本选型方面我给出一份可以直接照抄的清单模块推荐技术版本建议前端框架uniapp使用Vue3版本编译效率更高后端框架springboot2.7.x稳定且兼容性最佳ORMmybatis-plus3.5.x数据库MySQL8.0缓存Redis6.x以上鉴权JWT0.9.1或更新版本支付微信支付V3官方SDK接口文档knife4j4.x有一个实际经验要说清楚springboot版本不要盲目追新。3.x版本虽然性能有提升但包名从javax变成了jakarta很多老版本的mybatis-plus、pagehelper插件直接报错。如果是新项目并且团队没人踩过SpringBoot 3的坑我反而建议先用2.7.x把业务跑通后续再平滑升级。2. 核心细节解析与实操要点2.1 登录鉴权与用户体系果蔬商城的用户绝大多数来自微信小程序鉴权流程我推荐用微信官方登录态加jwt组合的方式。用户点击微信登录后前端调用uni.login拿到code码传给后端接口。后端拿着code向微信服务器请求openid和session_keyopenid是用户在某个小程序下的唯一标识用它作为用户表的主键逻辑。拿到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?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/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 ResultIPageGoodsVO list(RequestParam Integer page, RequestParam Integer size, RequestParam(required false) Integer categoryId) { PageGoods pageParam new Page(page, size); LambdaQueryWrapperGoods wrapper new LambdaQueryWrapper(); if (categoryId ! null) { wrapper.eq(Goods::getCategoryId, categoryId); } wrapper.eq(Goods::getStatus, 1) .orderByDesc(Goods::getSort); IPageGoods 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-typesearch属性配合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直接展示后端只做权限校验性能和可维护性都提升了一个档次。这套uniappspringboot的架构后续还可以继续扩展社区团购、秒杀活动、积分商城等模块整体框架完全撑得住。希望这篇内容能给你一些实际帮助少走几步弯路。