简介:资源为基于Spring Boot与微信小程序实现的水果销售系统完整项目包,面向计算机相关专业毕业设计、课程实训或小程序开发学习者,覆盖从后端接口到前端展示、从管理员到用户端的完整功能闭环。包内含832个文件,压缩包约18.23MB,以Java后端源码、Vue管理端页面、微信小程序wxml/wxss/js/json界面逻辑为主,同时包含SQL数据库脚本、SVG/PNG图标、配置文件与部署脚本,便于直接导入运行和二次开发。系统围绕用户信息、水果信息、水果类型三个核心模块展开,提供增删改查与模糊查询、条件筛选等操作,体现典型企业管理后台的开发思路。目前已有118人学习下载,适合需要参考完整项目结构、数据库设计及前后端联调细节的开发者。
1. 微信小程序水果销售系统:从业务模型到Spring Boot落地
水果销售系统是典型的电商垂直场景,相比标品电商,它的业务痛点集中在生鲜非标、库存波动大、配送时效强。把微信小程序作为前端入口,Spring Boot作为后端服务,恰好覆盖了“高频访问、轻量下单、后台管理”的核心需求。这个标题里的“设计与实现”不是简单写几个CRUD接口,而是要把商品展示、购物车、订单流转、库存扣减、用户登录态串成一条完整链路。适合做毕业设计、课程项目,也适合小团队快速搭建一个可演示的MVP。本文会按“小程序端→后端→联调→部署”的顺序,把每个环节的关键代码、参数和踩坑点展开,让你照着能跑通,跑通后也知道怎么改。
2. 小程序端结构、页面加载与购物车同步
2.1 页面目录与导航栏设计
微信小程序原生开发,目录结构按业务模块划分,我一般保留纯页面文件,把业务逻辑抽到utils/api.js和store/里。水果销售系统的页面至少要包含:
pages/ index/ // 首页:banner、热销水果、分类入口 category/ // 分类列表 + 商品列表 detail/ // 商品详情 cart/ // 购物车 order/ // 订单列表 order-confirm/ // 确认订单 user/ // 个人中心底部导航使用app.json里的tabBar,配置五个入口时会遇到一个容易被忽略的问题:tabBar的selectedColor和背景色如果与页面主色不一致,真机观感会差很多。另外,如果你在pages里自定义了导航栏,比如想突出水果促销标题,那就要留意胶囊按钮的位置和高度。常见做法是使用wx.getWindowInfo()获取状态栏高度,再在onLoad里动态设置padding-top。
顶部导航栏高度不是固定值,iPhone X系列和普通机型的差异可以超过20px。不要硬编码,要用系统API动态计算:
const windowInfo = wx.getWindowInfo(); const navBarHeight = (windowInfo.statusBarHeight || 20) + 44;44是多数机型的导航栏默认内容高度,部分安卓机型会略有偏差。如果使用了navigationStyle: custom,你需要自己绘制标题和返回按钮,这时候高度计算更要准确。不自定义导航栏,可以省掉这个麻烦,但首页的搜索框和分类Tab的视觉自由度会受限。
2.2 商品列表分页加载与下拉刷新
水果商品的SKU一般不会很多,几百条记录属于正常范围。但为了真实系统体验,分页仍是必要设计。小程序端我习惯用“加载更多 + 空态提示”的模式,而不是一次性请求全部数据。这里的核心不只是接口参数,还要处理并发:用户快速上拉时,onReachBottom可能被多次触发,需要用一个isLoading标志位防抖。
// pages/index/index.js data: { productList: [], page: 1, pageSize: 10, hasMore: true, isLoading: false }, async loadProducts() { if (this.data.isLoading || !this.data.hasMore) return; this.setData({ isLoading: true }); try { const res = await request({ url: '/api/fruit/products', data: { page: this.data.page, size: this.data.pageSize, categoryId: this.data.activeCategoryId } }); const list = res.data.list; this.setData({ productList: this.data.productList.concat(list), page: this.data.page + 1, hasMore: res.data.hasMore, isLoading: false }); } catch (e) { this.setData({ isLoading: false }); wx.showToast({ title: '加载失败', icon: 'none' }); } }, onReachBottom() { this.loadProducts(); }pageSize一般设10~20,水果列表页图片较多,设置过大会导致页面渲染卡顿。hasMore由后端根据“当前页返回条数是否等于pageSize”判断。注意concat与setData的配合:如果列表项带有图片地址,在setData时数据量过大会导致视图层与逻辑层通信压力大,所以尽量让列表项只包含展示所需字段,不要整个实体都塞进去。
2.3 购物车数量管理与本地缓存
购物车模块常见实现方案是“后端持久化 + 本地缓存备份”。水果用户对加购速度非常敏感,如果每次点击加减号都请求后端,弱网环境下体验会很差。我通常采用“先改本地,再异步同步后端”的方式:在wx.setStorageSync中维护一份购物车数据,同时把productId和quantity发送到后端覆盖。
function updateCartItem(product, delta) { const cart = wx.getStorageSync('cart') || {}; const existing = cart[product.id] ? cart[product.id].quantity : 0; cart[product.id] = { productId: product.id, name: product.name, price: product.price, image: product.image, quantity: existing + delta }; if (cart[product.id].quantity <= 0) { delete cart[product.id]; } wx.setStorageSync('cart', cart); // 异步更新后端 request({ url: '/api/cart/sync', method: 'POST', data: { items: Object.values(cart) } }); }这里的product对象只存了展示所需字段,避免本地存储占用过大。后端同步接口接收到全量购物车,直接覆盖该用户的购物车记录,天然解决多端同步问题。购物车角标数字则通过订阅getApp().globalData.cartCount的变化来更新,或者在showTabBarRedDot时重新计算。还有一个细节:水果是有“份量”概念的,比如“约500g/份”,下单时不要只传数量,还要把规格id带上,否则后台没法计算价格。
2.4 请求封装与登录态处理
微信小程序的网络请求必须走wx.request,且域名必须配置为HTTPS。我会在utils/api.js里做一层Promise封装,统一设置baseUrl、超时时间、token注入,以及401处理。
// utils/api.js const BASE_URL = 'https://yourdomain.com/api'; function request(options) { return new Promise((resolve, reject) => { wx.request({ url: `${BASE_URL}${options.url}`, method: options.method || 'GET', data: options.data || {}, header: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${wx.getStorageSync('token') || ''}` }, timeout: 10000, success(res) { if (res.data.code === 401) { // token过期,触发重新登录 handleLoginExpired(); reject(new Error('登录过期')); } else if (res.data.code === 0) { resolve(res.data); } else { wx.showToast({ title: res.data.msg, icon: 'none' }); reject(res.data); } }, fail(err) { reject(err); } }); }); }handleLoginExpired需要避免重复跳转,做法是维护一个全局的isLoggingIn标志位。真正的登录逻辑放在用户第一次进入小程序或调用需要用户身份接口前。有别于其他系统,微信登录是“静默”的:先wx.login()获取code,再把code发给后端换openid和token。注意不要在业务请求中频繁调用wx.login,因为wx.login有并发限制。最佳实践是设置一个loginTask单例:
let loginTask = null; function ensureLogin() { if (loginTask) return loginTask; loginTask = new Promise((resolve, reject) => { wx.login({ success(res) { request({ url: '/auth/login', method: 'POST', data: { code: res.code } }).then(r => { wx.setStorageSync('token', r.data.token); resolve(); }).catch(reject); } }); }).finally(() => { loginTask = null; }); return loginTask; }这样即使多个接口同时返回401,也只会触发一次wx.login,避免code被重复使用。后端的jscode2session接口要求code一次性的,重复使用会直接报错。
3. Spring Boot后端:数据建模、鉴权与订单状态
3.1 数据库表设计与索引
水果销售系统的核心表不复杂,但字段要贴合业务。我习惯用MyBatis Plus操作数据库,表结构如下:
| 表名 | 关键字段 | 说明 |
|---|---|---|
| user | id, openid, nickname, avatar, created_at | 微信用户 |
| fruit_category | id, name, sort, status | 分类 |
| fruit_product | id, category_id, name, main_image, detail_images, price, stock, unit, sales, status | 商品 |
| cart_item | id, user_id, product_id, quantity, spec | 购物车 |
| order_info | id, order_no, user_id, pay_amount, status, address, created_at, paid_at | 订单主表 |
| order_item | id, order_id, product_id, product_name, price, quantity | 订单明细 |
订单表是高频查询表,order_no和user_id必须建索引。水果销售有季节性活动,商品表的category_id和sales做联合索引,可以加速分类页面按销量排序。真实项目中不要使用物理外键,逻辑外键即可,因为订单明细需要保留商品名称和价格的快照,物理外键会限制历史数据追溯。
3.2 微信登录与JWT签发
后端登录接口接收小程序传来的code,调用微信接口换取openid,然后生成JWT返回给前端。
@RestController @RequestMapping("/api/auth") public class AuthController { @Autowired private UserService userService; @POSTMapping("/login") public Result login(@RequestBody WxLoginRequest request) { String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + appid + "&secret=" + secret + "&js_code=" + request.getCode() + "&grant_type=authorization_code"; // 使用RestTemplate或OkHttp发起请求 WxSessionResponse resp = restTemplate.getForObject(url, WxSessionResponse.class); if (resp.getOpenid() == null) { return Result.error("登录失败"); } User user = userService.findOrCreateByOpenid(resp.getOpenid()); String token = JwtUtil.createToken(user.getId(), user.getOpenid()); return Result.success(token); } }jscode2session这个接口有频率限制,如果大量用户集中登录,后端缓存openid和session_key是必要的。JWT的secret不要写在代码里,放到application.yml,并通过环境变量注入。session_key可用于解密手机号,但这里我们先不展开。token有效期一般设7天,小程序端活跃用户刷新频繁,7天足够。如果用户超过7天未打开,重新走wx.login即可,体验无感知。
3.3 商品列表接口与分页响应格式
接口返回格式要统一,这样才能让前端复用请求封装。我常用下面的结构:
{ "code": 0, "msg": "success", "data": { "list": [], "total": 100, "page": 1, "hasMore": true } }后端代码使用MyBatis Plus的分页插件:
@GetMapping("/products") public Result list(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(required = false) Long categoryId) { LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); if (categoryId != null) { wrapper.eq(Product::getCategoryId, categoryId); } wrapper.eq(Product::getStatus, 1); Page<Product> result = productMapper.selectPage(new Page<>(page, size), wrapper); Map<String, Object> data = new HashMap<>(); data.put("list", result.getRecords()); data.put("total", result.getTotal()); data.put("hasMore", result.getCurrent() * result.getSize() < result.getTotal()); return Result.success(data); }注意page和size需要校验上限,否则有人直接传size=10000把全库打出来。分页插件需要配置PaginationInnerInterceptor,不然selectPage不走分页SQL。分类参数categoryId为空时返回所有分类,但前端页面上通常会固定一个“全部”分类tab,所以这里做可空处理。
3.4 下单与库存扣减的状态机
水果库存变动频繁,下单时要防止超卖。最简单的方案是在UPDATE语句中加入库存判断:
UPDATE fruit_product SET stock = stock - #{quantity}, sales = sales + #{quantity} WHERE id = #{productId} AND stock >= #{quantity}用MyBatis Plus写这个操作,要使用自定义SQL或UpdateWrapper,核心是保证原子性。先扣库存再创建订单,如果订单创建失败必须回滚事务。订单状态我用OrderStatus枚举:
| 状态值 | 含义 | 触发动作 |
|---|---|---|
| 0 | 待支付 | 下单后生成,30分钟未支付自动关闭 |
| 1 | 已支付 | 模拟支付或微信支付回调 |
| 2 | 已发货 | 后台发货 |
| 3 | 已完成 | 用户确认收货或超时自动完成 |
| 4 | 已取消 | 用户取消或超时关闭 |
订单超时关单使用Spring@Scheduled定时任务,每分钟扫描一次created_at超过30分钟且状态为0的订单,将它们更新为“已取消”,并恢复库存。注意恢复库存时要判断商品是否还有效,避免把已下架商品的数量加回去。
3.5 拦截器校验token与用户上下文
后端接口除了登录和商品列表,其他接口都需要携带token。我用HandlerInterceptor实现,校验通过后把用户信息放入ThreadLocal,业务层通过工具类获取当前用户ID。
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization").replace("Bearer ", ""); Integer userId = JwtUtil.verify(token); if (userId == null) { response.setStatus(401); return false; } UserContext.set(userId); return true; } }拦截器生效后,购物车、订单、地址相关接口就不需要每次传userId了。注意放行路径:/api/auth/**、/api/products(如果允许未登录浏览)、静态资源等。压测时若出现401,先检查是不是token带了额外空格。
4. 前后端联调的关键参数:域名、登录态与支付模拟
4.1 request合法域名配置
小程序真机调试时,如果你在开发者工具里关闭了“不校验合法域名”,请求会直接报blocked:url not in domain list。这几乎是联调阶段第一道坎。解决办法是在微信公众平台后台配置“服务器域名”中的request合法域名,要求是:
- HTTPS协议
- 已备案的域名
- 不能带端口,除非是443默认端口
- 需要上传校验文件或通过域名归属认证
在本地开发阶段,我经常使用内网穿透工具把本机8205端口映射到一个临时域名,但要注意穿透工具的域名可能已经被微信标记,导致配置不生效。最稳妥的是使用已经备案的独立域名,在云服务器上用Nginx把/api路径反向代理到本地的Spring Boot端口。
4.2 登录态过期与自动重登
联调时最容易发现的坑是:在开发者工具里,wx.login正常,真机上偶尔出现“登录过期”。原因是开发者工具的网络环境和微信App不同,wx.login返回的code有效期只有5分钟。如果前端在某个页面写了“同步调用登录”,后一个请求使用的code已经失效。
前端封装中,如果request返回401,先清除本地token,然后调用ensureLogin()重新获取token,再重放原请求。这个“重放”需要原始请求数据,不能让用户手动再点一次。我在封装里保留resolve和reject,在handleLoginExpired中返回Promise,让调用方继续等待。
4.3 微信支付模拟与订单关单
实际微信支付需要商户号、API证书、支付回调等,毕设或演示项目往往没有这些。常见做法是做一个“模拟支付”按钮,直接调用后端POST /api/order/{orderNo}/mockPay,后端把订单状态从0改成1,同时记录支付时间。这个接口只允许在application-dev.yml环境开启,线上环境必须移除。
@PostMapping("/{orderNo}/mockPay") public Result mockPay(@PathVariable String orderNo) { Order order = orderService.getByOrderNo(orderNo); if (order.getStatus() != OrderStatus.PENDING) { return Result.error("当前状态不可支付"); } order.setStatus(OrderStatus.PAID); order.setPaidAt(LocalDateTime.now()); orderService.updateById(order); return Result.success(); }如果你想模拟微信支付回调,也可以用单元测试直接调用payCallback方法,传入预先构造的支付结果对象。关键点是:回调接口需要考虑幂等,同一个订单多次回调不能把已支付状态改成退款。水果销售系统里,用户取消订单后,如果支付已经成功,需要走退款流程,退款金额从原支付渠道返回,这又是一个状态分支。
4.4 图片上传与静态资源映射
商品图片不能存数据库,要存文件系统或OSS。本地部署时,我习惯把图片存到服务器/data/fruit/images/,然后通过Spring Boot配置静态资源映射:
spring: web: resources: static-locations: file:/data/fruit/images/上传接口:
@PostMapping("/upload") public Result upload(@RequestParam("file") MultipartFile file) { String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String filename = UUID.randomUUID().toString() + ext; file.transferTo(new File("/data/fruit/images/" + filename)); return Result.success("/images/" + filename); }注意MultipartFile.transferTo在Windows和Linux上路径分隔符不同,最好用Paths.get()拼接。上传大小默认1MB,需要配置spring.servlet.multipart.max-file-size才能支持水果详情页的大图。小程序端使用wx.uploadFile上传时,name要和后端@RequestParam("file")对应,否则报“Required request part 'file' is not present”。
5. 部署上线与排错:从加载页优化到真机验证
5.1 使用Nginx反向代理Spring Boot
线上部署我常用Nginx承接HTTPS请求,再反代到内网Spring Boot端口。这样可以利用Nginx处理静态资源、压缩、限流,Spring Boot专注业务。
server { listen 443 ssl; server_name fruit.example.com; ssl_certificate /etc/nginx/ssl/fruit.pem; ssl_certificate_key /etc/nginx/ssl/fruit.key; location /api/ { proxy_pass http://127.0.0.1:8205; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /images/ { root /data/fruit; expires 7d; } }proxy_pass地址末尾是否带/有讲究。不带时,请求/api/auth/login会完整透传到后端;带/时,/api前缀会被剥离,后端接口就必须写成/auth/login。建议保持前后端接口路径都以/api开头,Nginx直接透传。另外,Spring Boot的server.servlet.context-path不要设置,避免二次路径拼接。
5.2 真机调试的加载页与导航栏问题
“修改刚进入的加载页面”是很多开发者关心的。小程序启动时,默认会展示一个小程序logo的白屏页,称为“启动加载页”。如果希望自定义加载内容,可以在app.json里配置launch页面,或者在首页onLoad中展示骨架屏图片。但要注意,微信限制启动页的展示时长,无法完全替换为一个自定义动画页。更务实的做法是:在小程序端做一个首屏骨架屏组件,用CSS模拟商品列表的灰色占位块,数据加载完成后替换。
真机上的另一个坑是顶部导航栏的下拉背景色。如果你在app.json里设置了"navigationBarBackgroundColor": "#ff6b00",而下拉露出的是窗口背景色,两者不一致会让人感觉突兀。可以把window的backgroundColor也设置成相近颜色。小程序顶部导航栏高度在不同机型上表现不同,尤其是刘海屏。通过wx.getMenuButtonBoundingClientRect()获取胶囊按钮位置来计算自定义导航栏高度,比固定44px可靠得多。
5.3 性能优化:图片压缩与分包
水果商品图片往往大且多,首页一次加载10个商品,每个图片2MB,用户流量和渲染时间都会超标。我在上传图片时做了两级处理:后端用Thumbnailator生成一个最大宽度400px的缩略图,命名如xxx_thumb.jpg,列表页用缩略图,详情页用原图。缩略图后,单个图片可控制在50KB以内。
String thumbPath = filename.replace(".", "_thumb."); Thumbnails.of(new File(originalPath)) .size(400, 400) .keepAspectRatio(true) .toFile(thumbPath);小程序端也需要在wxml里为图片设置lazy-load和合理的widthFix模式,避免图片加载撑动页面布局。如果页面太多,可以把用户中心、订单详情这种低频页面放入分包“subpackages”,减少主包体积。分包时注意tabBar页面必须在主包内,否则报错。
5.4 系统验证清单与故障定位
部署完成后不能只测主流程,我列一份验证清单,覆盖主要风险:
| 功能模块 | 测试操作 | 预期结果 |
|---|---|---|
| 登录 | 首次打开小程序 | 自动静默登录,业务请求带token |
| 商品列表 | 分类切换、上拉加载 | 无重复数据,加载提示正确 |
| 购物车 | 增删改数量、清空 | 本地缓存与后端同步 |
| 下单 | 正常提交、库存不足 | 扣库存成功,库存不足给出提示 |
| 模拟支付 | 对待支付订单支付 | 状态变为已支付 |
| 订单超时 | 修改数据库时间到30分钟前 | 定时任务自动关闭并回滚库存 |
| 图片上传 | 后台新增商品上传图片 | 缩略图与原图均可访问 |
遇到接口报错,先看Spring Boot日志中的异常栈。如果Dreamland中出现502,多半是Nginx转发地址错误或后端进程崩溃;504则是后端处理超时,可以排查数据库连接池是否耗尽。在小程序开发者工具的Network面板里,能看到请求的域名、状态码和耗时,结合后端日志定位。
水果销售系统还有一个容易忽略的边界:订单完成后,用户再次进入商品详情页,要显示“已售罄”而不是允许再次加购。这部分逻辑可以在后端查询商品时,把stock <= 0的状态置为下架,前端根据商品状态禁用按钮。把边界case想清楚,系统才算真正可用。
本文还有配套的精品资源,点击获取