简介:基于SSM(Spring+SpringMVC+MyBatis)框架开发的微信小程序农产品自主供销系统,是一套覆盖商品发布、在线下单、支付配送的完整前后端工程,适合微信小程序开发者、Java后端程序员以及正在准备毕业设计的高校学生。压缩包共含1400个文件,以Java代码、Vue组件、JavaScript脚本、WXML/WXSS页面文件为主,其中Java和Vue承担服务端逻辑及后台管理界面,WXML/WXSS结合JS构成小程序前端页面,另附有SQL数据库脚本、XML配置和Maven相关文件,整体仅13.47MB,目录结构清晰,便于本地导入和二次开发。当前已有184人学习下载,具备较强的工程参考价值。读者可从中获取完整的小程序设计思路、服务端接口实现、后台管理页面布局、数据库表设计方式,以及农产品信息发布、订单跟踪、促销活动等核心模块的落地方法;从内容预览看,资源还包含了安装运行脚本和备份文件,可辅助快速搭建环境,适合在毕业设计或课程项目中直接复用。
1. 农产品自主供销小程序,为什么选 SSM + 微信小程序
拿到类似“ssm框架基于微信小程序的农产品自主供销小程序.rar”这种项目包,第一件事不是解压跑起来,而是先拆清楚标题里的三块:SSM 负责什么,微信小程序负责什么,“自主供销”到底要做成什么形态。很多人把农产品项目做成了静态展示页,商品摆上去却不能下单,不能扣库存,也不能处理订单,那只能叫农产品黄页。真正的自主供销,至少要覆盖商品上架、选购下单、库存扣减、支付或货到付款、订单履约这几条链路,缺一条都不完整。
SSM 是 Spring + SpringMVC + MyBatis 的经典组合,服务端承担接口、事务、SQL 操作;微信小程序承担买家端交互,通过 HTTPS 与后端交换 JSON,不直接碰数据库。这套分层在中小型项目里足够清晰,也正好是很多课程设计和毕业设计的选型。它适合两类人:一是要把农产品商城从 0 到 1 跑通的学生团队,二是在老 SSM 项目上做二次开发的工程师。下面按服务端、小程序端、供销难点和导出工具的路径,把一套可落地的做法讲清楚。
2. SSM 框架服务端:搭好农产品接口的第一层
2.1 为什么还在用 SSM,而不是直接换成 Spring Boot
Spring Boot 本质上仍然是 Spring,只是把自动配置、内嵌容器和起步依赖都封装好了。SSM 的区别在于,你要自己维护web.xml、spring-mvc.xml、mybatis-config.xml,这虽然繁琐,但能让你看清 DispatcherServlet 是怎么注册的、SqlSessionFactory 是怎么被 Spring 管理的。很多现存的管理后台、课程设计模板仍然跑在 SSM 上,因为课程大纲按 Spring、SpringMVC、MyBatis 三块来教,所以“SSM 框架”四个字才会出现在项目标题里。
拿到项目包后,先确认构建方式。如果是 Maven 工程,看pom.xml里依赖版本是否冲突;如果直接把 jar 包放在WEB-INF/lib下,就很容易出现同一个类被不同版本重复加载。我一般会先抽出一个最简单的接口,比如查商品列表,把链路跑通后再往里面加订单和库存逻辑。
2.2 核心依赖版本要对齐,否则启动就报类找不到
SSM 对版本很敏感。Spring 5.3.x 搭配 MyBatis 3.5.x 是常见组合,Spring 6 或 Spring Boot 3 已经把javax.*换成了jakarta.*,传统 SSM 项目不要直接升上去。下面这组依赖可以放在pom.xml中作为起点。
<dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.3.39</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.39</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.16</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.1.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>这里的版本号不必完全照抄,关键原则是 Spring 5.3.x 搭配 MyBatis 3.5.x 和 mybatis-spring 2.1.x,驱动用 MySQL 8 就选com.mysql.cj.jdbc.Driver,不要用已经移除的com.mysql.jdbc.Driver。如果项目中同时存在 Spring 4 和 Spring 5 的包,启动时会大量出现NoSuchMethodError,那不是代码写错,而是依赖没有收敛。
2.3 农产品商品表设计:把供销基础字段先定住
自主供销的前提是商品能上架、能下架、能控制库存。常见做法是设计两张基础表:product存商品,category存分类,order 相关放到后面订单章节。下面这组字段可以满足大部分农产品商城场景。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键,自增 |
| name | VARCHAR(100) | 农产品名称 |
| category_id | BIGINT | 分类 id |
| price | DECIMAL(10,2) | 销售价,以后端计算为准 |
| stock | INT | 剩余库存 |
| image_url | VARCHAR(255) | 图片地址(小程序端直接用) |
| weight | DECIMAL(10,3) | 单件重量,单位 kg,用于运费计算 |
| status | TINYINT | 1 上架,0 下架 |
| create_time | DATETIME | 创建时间 |
对应的建表 SQL 里要注意字符集用utf8mb4,否则农产品名称里的特殊字符可能存不进去。下面这段可以直接用于初始化。
CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT '农产品名称', category_id BIGINT NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, image_url VARCHAR(255) DEFAULT '', weight DECIMAL(10,3) DEFAULT 0, status TINYINT NOT NULL DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_category (category_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;status字段很关键,小程序端商品列表默认只查status = 1的商品,下架操作不要物理删除,这样订单关联的商品快照还能保证完整性。weight字段在计算运费时用,别放在备注里。
2.4 写一个商品列表接口:从 Controller 到 Mapper
商品列表是供销小程序最基础的接口,也是整条链路能否跑通的验证点。Controller 层暴露 REST 接口,Service 层处理分页逻辑,Mapper 层直接写 SQL。这里给一个最简实现。
@RestController @RequestMapping("/api/product") public class ProductController { @Autowired private ProductService productService; @GetMapping("/list") public Result list(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size) { return Result.ok(productService.pageOnSale(page, size)); } }Result是统一返回体,通常包含code、message、data三个字段,小程序端所有接口都解析同一个结构。page和size是分页参数,默认值写在注解里,防止前端漏传导致 SQL 异常。Mapper 层用 XML 写 SQL,方便以后加复杂查询。
<select id="listOnSale" resultType="com.example.pojo.Product"> SELECT id, name, price, stock, image_url AS imageUrl FROM product WHERE status = 1 ORDER BY id DESC LIMIT #{offset}, #{size} </select>image_url AS imageUrl是因为 Java 属性叫imageUrl,数据库字段是image_url。如果你在mybatis-config.xml里开启了mapUnderscoreToCamelCase=true,AS可以省略,系统会自动做驼峰映射。LIMIT的两个参数,第一个是偏移量,第二个是每页条数,Service 层计算时通常是(page - 1) * size。
2.5 联调前先在微信开发者工具里取消域名校验
小程序端请求后端接口,如果后端只用了局域网 IP,比如http://192.168.1.10:8080,在真机上必须打开“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”这个选项,否则一个请求都发不出去。这个选项不是上线配置,只是本地联调用的。我一般会让后端在电脑上的 Tomcat 里跑起来,小程序用同一台机器的浏览器能先访问到接口,再处理具体字段。
3. 微信小程序端:供销商城的四个页面骨架
3.1 页面划分和 tabBar 配置
农产品供销小程序最常见的结构是四个页签:首页商品列表、购物车、订单、个人中心。app.json里的tabBar会直接决定启动后能看到哪些页签,以下是一个参考值。
{ "pages": [ "pages/index/index", "pages/cart/cart", "pages/order/list/list", "pages/user/user" ], "tabBar": { "list": [ { "pagePath": "pages/index/index", "text": "首页" }, { "pagePath": "pages/cart/cart", "text": "购物车" }, { "pagePath": "pages/order/list/list", "text": "订单" }, { "pagePath": "pages/user/user", "text": "我的" } ] } }pagePath必须与pages数组里的路径完全一致,否则 tabBar 显示不出来。四个页面里,订单列表页不是简单展示,而是根据订单状态切换“待付款”“待发货”“已完成”等视图,这会在小程序端做状态过滤,不需要每次都请求后端。
3.2 request 封装和微信小程序登录
小程序端不能像浏览器一样直接跨域,但wx.request本身不受浏览器的同源策略限制,真正限制是必须使用 HTTPS 并配置合法域名。开发环境下可以暂时忽略。我习惯先封装一个request.js,把 baseURL 和 token 统一处理。
const BASE_URL = 'http://localhost:8080'; function request(path, method = 'GET', data = {}) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + path, method: method, data: data, header: { 'Content-Type': 'application/json', 'Authorization': wx.getStorageSync('token') || '' }, success: (res) => { if (res.data.code === 0) { resolve(res.data.data); } else { wx.showToast({ title: res.data.message, icon: 'none' }); reject(res.data); } }, fail: reject }); }); } module.exports = { request };这段封装把后端返回的code统一判断为 0 表示成功,这样每个页面只需要关心业务数据。Authorization头用来携带登录后的 token,如果用户还没登录,后端会返回 401,再触发wx.login。wx.login拿到的 code 只能使用一次,而且要尽快发给后端。
wx.login({ success: (res) => { request('/api/user/login', 'POST', { code: res.code }) .then((data) => { wx.setStorageSync('token', data.token); }); } });后端拿到 code 后,通过微信接口换 openid,再生成自定义 token 返回。小程序端不要直接拿 openid 做登录态,因为它是敏感信息,也不应该暴露给前端页面。
3.3 商品列表页:首页加载的最小实现
商品列表页的常见做法是在onLoad里请求接口,然后setData渲染列表。要注意的是setData有传输大小限制,列表数据量太大时需要分页。
Page({ data: { products: [], page: 1, size: 10, loading: false, hasMore: true }, onLoad() { this.loadProducts(); }, loadProducts() { if (this.data.loading || !this.data.hasMore) return; this.setData({ loading: true }); request(`/api/product/list?page=${this.data.page}&size=${this.data.size}`) .then((data) => { this.setData({ products: this.data.products.concat(data.list), page: this.data.page + 1, hasMore: data.list.length === this.data.size }); }) .finally(() => { this.setData({ loading: false }); }); } });hasMore用来判断是否还有下一页,避免滚动到底部后不断重复请求。.finally在开发者工具里需要库版本支持,不然可以用complete回调代替。商品列表页的view层只需要渲染name、price、imageUrl,加购物车按钮单独绑定>function addToCart(product) { const cart = wx.getStorageSync('cart') || {}; const key = product.id; if (cart[key]) { cart[key].count += 1; } else { cart[key] = { ...product, count: 1 }; } wx.setStorageSync('cart', cart); wx.showToast({ title: '已加入购物车', icon: 'success' }); }
key用商品 id 保证唯一。购物车数据里保存price和imageUrl只是为了展示,提交订单时不能直接用购物车里的价格,必须以服务端重新查询出来的价格为准。如果要做多端同步购物车,再考虑建cart_item表,否则本地存储更省事。
3.5 修改刚进入的加载页面:入口页和启动占位页
很多小程序启动时先进入pages/index/index,但如果你想做宣传图或者广告页,可以用entryPagePath指定入口页。以下配置会让用户先到pages/launch/launch。
{ "pages": [ "pages/launch/launch", "pages/index/index", "pages/cart/cart" ], "entryPagePath": "pages/launch/launch" }在launch页的onLoad里,用wx.reLaunch跳转到首页。reLaunch会关闭所有页面,避免用户按返回键回到启动页。注意entryPagePath指定的页面也要在pages列表里注册,否则编译直接报错。这是“修改刚进入的加载页面”最直接的做法,不需要改原生启动图。
4. 供销场景的难点:库存、配送与支付回调
4.1 库存扣减用条件更新,防止超卖
农产品有很强的季节性,库存数字如果和实际对不上,后面订单履约就会出问题。最常见的错误是先select stock,再在 Java 里判断是否够用,然后update,这在高并发下会超卖。正确做法是在一条 SQL 里完成扣减和条件校验。
UPDATE product SET stock = stock - #{quantity} WHERE id = #{productId} AND stock >= #{quantity}JDBC 的update方法会返回受影响行数,如果返回 0,说明库存不足,整个事务应该回滚。在 Service 方法上加上@Transactional,保证订单创建和库存扣减要么都成功,要么都失败。
@Transactional public void createOrder(Long productId, Integer quantity) { int rows = productMapper.deductStock(productId, quantity); if (rows == 0) { throw new BusinessException("库存不足"); } orderMapper.insert(order); }deductStock返回的rows是数据库更新的行数,不是查询结果。库存字段要设置成INT NOT NULL,不要允许为空,否则stock >= #{quantity}的判断会失效。注意@Transactional只对运行时异常生效,不要捕获异常后吞掉。
4.2 订单价格以后端计算为准,前端价格只能展示
小程序端购物车里的price是本地缓存,用户修改商品规格、后台改价格或者做促销,都会让前端价格失真。生成订单时,后端必须根据商品 id 重新从数据库读取价格,再和数量相乘。下面是一个订单项计算的简化逻辑。
public BigDecimal computeOrderAmount(List<Long> productIds, Map<Long, Integer> quantities) { BigDecimal total = BigDecimal.ZERO; for (Long productId : productIds) { Product product = productMapper.selectById(productId); if (product == null || product.getStatus() != 1) { throw new BusinessException("商品已下架"); } BigDecimal itemAmount = product.getPrice() .multiply(BigDecimal.valueOf(quantities.get(productId))); total = total.add(itemAmount); } return total; }BigDecimal不能用double计算,否则精度会出问题,尤其农产品经常出现“9.9 元 3 斤”这类小数。商品下架后,前端可能还能看到缓存图片,但下单请求必须被后端拦截。
4.3 配送参数:自提点选择和运费模板
农产品自主供销不一定走快递,很多场景是“线上下单、线下自提”或者“同城配送”。订单表里需要增加一个delivery_type字段,1 表示自提,2 表示配送。自提要存自提点 id,配送要存收货地址。运费按重量计算是农产品商城比较合理的方案,因为一箱苹果和三斤大米的运费差别很大。
| 参数名 | 类型 | 说明 |
|---|---|---|
| delivery_type | TINYINT | 1 自提,2 配送 |
| address_id | BIGINT | 配送地址 id,自提时为空 |
| pickup_point_id | BIGINT | 自提点 id,配送时为空 |
| freight | DECIMAL(10,2) | 运费金额 |
运费计算可以放在后端,起步价和续重价做成配置。简单做法是先算总重量,再判断是否超过首重,超过部分按续重单价累加。自提模式下运费直接置 0,不需要走快递逻辑。
4.4 支付回调:接入微信支付前先确认三件事
如果农产品供销要支持线上支付,微信支付 v3 是绕不开的。小程序的wx.requestPayment需要后端先调用微信支付统一下单接口,拿到prepay_id再生成支付参数。支付回调是异步的,后端必须验签,不能只靠前端返回的 success 就改订单状态。
第一,回调地址必须是 HTTPS,且不能带查询参数;第二,回调通知里的resource是 AES-256-GCM 加密数据,需要用 API v3 密钥解密;第三,同一个订单可能收到多次回调,需要先查订单状态,如果已经是已支付就直接返回成功,防止重复发货。
if (order.getStatus() == OrderStatus.PAID) { return "{\"code\":\"SUCCESS\"}"; } orderMapper.updateStatus(order.getId(), OrderStatus.PAID);这段代码放在回调处理逻辑的最前面,目的就是幂等。很多项目在这里没有判断,结果同一订单被回调两次,导致库存又扣了一遍。后续的库存扣减如果放在下单时做,退款要回补库存;如果放在支付后做,未支付订单不占库存,但需要定时清理过期订单。
4.5 联调排错:抓包和高频问题对照
小程序和后端联调时,Charles 是常用的抓包工具。抓电脑端微信小程序时,需要开启 SSL Proxying,并安装 Charles 根证书;抓手机端时,让手机 WiFi 指向电脑,再安装证书。注意微信小程序的wx.request并发数限制是 10 个,超过的请求会排队,商品列表图片太多时会表现为页面卡顿。
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
request报fail url not in domain list | 没有把接口地址加入小程序后台合法域名 | 开发环境勾选不校验域名,上线前加白名单 |
| 真机上图片不显示 | image_url是 HTTP 地址 | 改用 HTTPS,或者开启域名校验时加入图片域名 |
| 提交订单后库存不变 | 事务没生效或异常被吞 | 确认@Transactional在 Spring 容器管理的方法上 |
| iOS 上页面滚动卡顿 | 大量setData且数据量大 | 列表页用wx:for分批渲染,减少大对象传递 |
这里还有一个 iOS 微信小程序渲染机制的特殊坑:如果日期选择器组件放在scroll-view里,滚动事件容易被父容器抢走,导致选择器打开后立即关闭。常见做法是让日期选择器浮层脱离滚动容器,用position: fixed定位到屏幕中间,再绑定catchtouchmove阻止透传。
5. 订单导出 Excel:给农户和管理员的日常工具
5.1 后端生成 xlsx 文件并返回下载地址
农产品供销项目做完交易闭环后,最常用的功能就是订单导出。农户需要每天看卖了多少、管理员需要按日期对账,这时候生成 Excel 比页面里一页一页翻效率高很多。后端可以用 Apache POI 生成.xlsx文件,写入订单号、商品名、数量、金额、收货信息和订单状态。
@GetMapping("/api/order/export") public void export(@RequestParam String date, HttpServletResponse response) throws IOException { List<OrderVO> orders = orderService.listByDate(date); try (Workbook workbook = new XSSFWorkbook()) { Sheet sheet = workbook.createSheet("订单导出"); String[] headers = {"订单号", "商品", "数量", "金额", "状态", "下单时间"}; Row headerRow = sheet.createRow(0); for (int i = 0; i < headers.length; i++) { headerRow.createCell(i).setCellValue(headers[i]); } // 写入订单数据 for (int i = 0; i < orders.size(); i++) { Row row = sheet.createRow(i + 1); row.createCell(0).setCellValue(orders.get(i).getOrderNo()); // 省略具体赋值 } response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=orders_" + date + ".xlsx"); workbook.write(response.getOutputStream()); } }XSSFWorkbook对应.xlsx,HSSFWorkbook对应.xls,后者最大只支持 65536 行,订单量大的时候会直接报错,所以优先用 XSSF。响应头里的Content-Disposition会影响到小程序端下载后的文件名,建议把日期拼进去,避免同名文件被覆盖。
5.2 小程序端下载 Excel 的两个必填参数
小程序端不能用<a>直接下载,需要通过wx.downloadFile拿到临时文件路径,再用wx.openDocument打开。注意downloadFile的 URL 必须在微信公众平台配置downloadFile合法域名,开发工具里可以临时跳过,真机不行。
wx.downloadFile({ url: 'https://your-domain.com/api/order/export?date=2025-06-01', success: (res) => { if (res.statusCode === 200) { wx.openDocument({ filePath: res.tempFilePath, fileType: 'xlsx', showMenu: true, success: () => { console.log('打开成功'); } }); } } });fileType要显式传xlsx,否则部分真机无法识别文件格式。showMenu: true可以让用户长按右上角菜单,把文件转发或保存到其他应用。导出日期参数最好用YYYY-MM-DD格式,而不是传时间戳,这样后端 SQL 可以直接按日期范围查询。
5.3 导出前处理好 LocalDateTime 的序列化格式
订单导出最容易被忽略的坑是时间字段。如果后端实体里的createTime是LocalDateTime,在生成 Excel 或返回 JSON 时,没有配置ObjectMapper的JavaTimeModule,就会出现一串数字而不是2025-06-01 10:30:00。常见做法是在applicationContext.xml或spring-mvc.xml里给MappingJackson2HttpMessageConverter设置ObjectMapper,并注册JavaTimeModule,指定日期格式为yyyy-MM-dd HH:mm:ss。
ObjectMapper objectMapper = new ObjectMapper(); objectMapper.registerModule(new JavaTimeModule()); objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));这个配置不只影响导出,也会影响小程序端setData后显示的下单时间。如果所有接口返回的时间格式都有问题,检查点就在这里,而不是逐个页面去拼接字符串。
本文还有配套的精品资源,点击获取