简介:微信小程序农产品自主供销系统,面向计算机相关专业毕业设计及课程设计学生,提供从小程序端到后端数据库的完整可运行工程,覆盖农产品展示、下单交易、供销管理、用户登录授权等典型业务模块,可直接用于项目答辩或二次开发。压缩包共1399个文件,大小14.69MB,主要包含微信小程序前端页面(wxml/wxss/png)、Vue后台管理界面(vue/js)、Java后端逻辑(java)以及SQL数据库脚本和JSON配置等,前端页面与后端接口层级清晰,便于按模块定位代码。目前已有123人学习下载,适合需要完整项目实战案例的初中级学习者。包内另附环境安装与启动批处理脚本、关键文件备份及项目说明文档,可辅助快速搭建本地环境,减少部署踩坑,助力理解农产品电商类小程序的整体架构与开发流程。
1. 农产品自主供销小程序:从“发布供给”到“撮合成交”,到底需要哪些源码和数据库
农产品自主供销小程序,听着像商城,实际落地时你会发现自己不是在做普通电商,而是在做一张“双向撮合网络”:农户要发布供给,采购商要发布需求,两边都能从数据库里找到彼此,然后走完订单。很多从业者一开始把所有精力放在页面 UI 上,结果第一轮验收就被“别人的订单我怎么接”这类问题卡住——因为供给、需求、订单三张表之间根本没关联。
这个项目真正值钱的部分是源码里的业务闭环:登录怎么识别用户身份,供给和需求分别用什么字段描述,订单状态由谁来改,数据库权限怎么配才不泄露电话。对准备接农业信息化项目的开发者来说,能把这一套理顺,就等于拿到了小程序开发里最通用的“信息撮合”模板;对想给自家合作社做线上渠道的运营者来说,也能一眼看出什么功能必须做、什么功能可以缓一缓。
所以这篇笔记不是教你怎么用模板套页面,而是照着大多数从业者真正在用的“微信云开发 + 原生小程序”方案,把数据库表、核心代码、状态流转和上线前必踩的坑一次讲完。你跟着改字段名、改集合名,就能跑出自己的版本。
2. 数据库是供销的骨架:6张表装下用户、供给、需求和订单
2.1 为什么选文档型数据库而不是一开始就上 MySQL
做农产品这类中小体量撮合平台,我第一反应都是微信云开发自带的数据库。原因不是它比 MySQL 更强,而是它可以让人拿出半天时间就把前端、存储、服务端全串起来,省掉一台服务器。云开发数据库是典型的文档型存储,每张表叫“集合”,每条记录是一个 JSON 文档,字段可以比 MySQL 更宽松,适合早期需求经常变的情况。
但如果你所在团队已经有后端服务器,或者需要做复杂报表,MySQL 依然稳妥。我一般建议:单人开发或小团队验证用云开发;已有 Java/Go 服务端或者必须出审计报表,就选 MySQL。下面给的字段设计两者通用,MySQL 里一张表对应一个集合。
这类数据库的核心选型原则是“冗余优先于关联”。比如订单表里直接存商品标题和主图,而不是去关联查询 item 表。因为农产品交易周期长,如果农户某天删掉了商品记录,订单历史里还能看到当时买的是什么、什么价格,这就是商品快照。文档型数据库里冗余是正常操作,不必担心违背范式。
2.2 用户集合:一次登录到底存哪些字段
用户表是所有业务的地基。这里最需要注意的是不要只存用户名和头像,还要存角色和地址。角色决定了你能发起供给还是需求,地址字段决定后续“按距离排序”能不能实现。
下面这段是 MySQL 的建表语句,云开发集合的字段和它一一对应:
CREATE TABLE `user` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID', `openid` VARCHAR(64) NOT NULL COMMENT '微信openid,登录凭证', `nickname` VARCHAR(32) DEFAULT '' COMMENT '微信昵称', `avatar_url` VARCHAR(255) DEFAULT '' COMMENT '头像链接', `phone` VARCHAR(20) DEFAULT '' COMMENT '联系电话,下单后展示', `role` TINYINT NOT NULL DEFAULT 1 COMMENT '1-农户/卖家 2-采购商/买家 3-管理员', `origin_province` VARCHAR(32) DEFAULT '' COMMENT '产地省', `origin_city` VARCHAR(32) DEFAULT '' COMMENT '产地市/县', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1-正常 0-禁用', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';逻辑说明:openid 是微信平台给每个用户生成的唯一标识,登录时用 wx.login 拿到临时 code,再通过服务端换回来。数据库里 openid 必须建唯一索引,因为登录接口可能被反复调用,没有唯一索引就会产生重复用户。role 字段建议固定数值,别用字符串,否则后面做统计、按角色改权限时要写一堆 if else。
参数说明:phone 不要放在 user 表上必填,很多新用户第一次进小程序只是浏览,不摘手机号;等到他要发布供给或创建订单时再补手机号,转化率会更高。origin_province 和 origin_city 是冗余字段,从定位接口或者用户手动选择写入,用来做“本地产地优先”筛选,这个功能在农产品场景里非常刚需。
2.3 供给、需求、订单三个核心集合的字段设计
供给是“卖家能提供什么”,需求是“买家想收什么”。这两个集合看起来相似,但侧重点完全不同:供给重视库存和产地,需求重视期望价格和有效期。
CREATE TABLE `item` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '供给ID', `seller_id` INT UNSIGNED NOT NULL COMMENT '发布农户用户ID', `title` VARCHAR(64) NOT NULL COMMENT '标题,例如 新鲜本地散养土鸡', `category` VARCHAR(32) NOT NULL COMMENT '分类,如 禽蛋肉/蔬菜水果/粮油干货', `images` TEXT COMMENT '图片地址,逗号分隔,最多9张', `unit` VARCHAR(8) NOT NULL DEFAULT '斤' COMMENT '计量单位:斤/箱/头/棵', `price` DECIMAL(10,2) NOT NULL COMMENT '单价,按单位计量', `stock` DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '库存数量', `min_order` DECIMAL(10,2) NOT NULL DEFAULT '1.00' COMMENT '最小起订量', `origin` VARCHAR(64) DEFAULT '' COMMENT '产地描述,如 某县河湾村', `description` TEXT COMMENT '描述,存放养殖/种植方式等', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1-上架 2-下架 3-删除', `shelf_time` DATETIME DEFAULT NULL COMMENT '上架时间', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category`), KEY `idx_seller_status` (`seller_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供给表';逻辑说明:库存字段我特意用了 DECIMAL,而不是 INT。因为农产品常出现“订 2.5 斤”这种带小数的情况,用整型再去换算成“500克”反而麻烦。unit 字段极其关键,很多平台不做单位规范化,导致出现 1 箱和 1 斤的对比混乱;建议在前端发布页用固定下拉框选择,不允许用户自由输入。
需求表的设计逻辑是“给买家一个广播台”:
CREATE TABLE `demand` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '需求ID', `buyer_id` INT UNSIGNED NOT NULL COMMENT '发布采购商用户ID', `title` VARCHAR(64) NOT NULL COMMENT '求购摘要,例如 求购当季土豆 2000 斤', `category` VARCHAR(32) NOT NULL COMMENT '分类', `unit` VARCHAR(8) NOT NULL DEFAULT '斤', `quantity` DECIMAL(10,2) NOT NULL COMMENT '期望采购量', `expected_price` DECIMAL(10,2) DEFAULT NULL COMMENT '期望单价,留空表示面议', `deadline` DATETIME DEFAULT NULL COMMENT '需求有效期,到期自动关闭', `description` TEXT COMMENT '附加要求,如 不要冷库存、需要检测报告', `location` VARCHAR(64) DEFAULT '' COMMENT '收货所在地', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1-发布中 2-已匹配 3-已关闭', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_buyer_status` (`buyer_id`, `status`), KEY `idx_category_deadline` (`category`, `deadline`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='需求表';订单表则要把买卖双方、商品快照、订单状态全部绑在一起:
CREATE TABLE `orders` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '订单ID', `order_no` VARCHAR(32) NOT NULL COMMENT '订单号,业务上展示给用户', `item_id` INT UNSIGNED DEFAULT NULL COMMENT '供给ID,可空表示用户用需求反向发起', `demand_id` INT UNSIGNED DEFAULT NULL COMMENT '需求ID,可选', `seller_id` INT UNSIGNED NOT NULL COMMENT '卖家用户ID', `buyer_id` INT UNSIGNED NOT NULL COMMENT '买家用户ID', `item_title` VARCHAR(64) NOT NULL COMMENT '商品快照标题', `item_image` VARCHAR(255) DEFAULT '' COMMENT '商品快照主图', `price` DECIMAL(10,2) NOT NULL COMMENT '成交单价', `quantity` DECIMAL(10,2) NOT NULL COMMENT '成交数量', `unit` VARCHAR(8) NOT NULL COMMENT '计量单位快照', `total_fee` DECIMAL(10,2) NOT NULL COMMENT '成交总额', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-待确认 1-待发货 2-待收货 3-已完成 4-已取消 5-退款中', `remark` VARCHAR(255) DEFAULT '' COMMENT '备注', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `pay_time` DATETIME DEFAULT NULL COMMENT '确认订单时间,不一定真付款', `ship_time` DATETIME DEFAULT NULL COMMENT '发货时间', `finish_time` DATETIME DEFAULT NULL COMMENT '完成时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_seller_status` (`seller_id`, `status`), KEY `idx_buyer_status` (`buyer_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';逻辑说明:订单里把 item 标题、主图、价格、单位都冗余了一份,这就是商品快照。等三个月后你再查订单,即使原来的 item 记录已经被卖家改成下架,订单详情页依然能展示完整信息。status 字段是这整套系统的灵魂,所有业务动作最后都是改它,关键是不允许前端直接改,必须经过云函数校验。
参数说明:订单金额建议只做展示,不直接接微信支付。农产品撮合平台里有大量定金、线下验货、对公转账的交易,双方更愿意在线下结算,所以订单状态里没有“已支付”,只有“待确认”和“待发货”。如果以后要接支付,可以在 status 前再加一层交易流水表。
2.4 初始化数据:分类、单位、公告的预置脚本
部署一套新环境最烦的就是打开小程序发现分类是空的,单位可以乱填。所以我会写一个初始化脚本,把最常用的数据直接塞进数据库:
INSERT INTO `category` (`name`, `parent_id`, `sort`) VALUES ('蔬菜水果', 0, 1), ('禽蛋肉', 0, 2), ('粮油干货', 0, 3), ('水产', 0, 4), ('茶叶菌菇', 0, 5); INSERT INTO `unit` (name, sort) VALUES ('斤', 1), ('公斤', 2), ('箱', 3), ('头', 4), ('只', 5), ('棵', 6), ('份', 7), ('吨', 8); INSERT INTO `notice` (title, content, status) VALUES ('平台使用说明', '发布供给请务必填写真实产地和库存;采购需求请写明期望单价。', 1);云开发集合也可以直接用同样的文档跑 import,但要注意每个集合需要在控制台的“权限设置”里把读权限打开为“所有用户可读,仅创建者可写”,否则前端列表加载一片空白。这步是新手最常漏的,后面避坑章节详细说。
3. 小程序端落地:登录、发布、列表和下单的完整代码路径
3.1 项目目录结构:页面和云函数如何分工
我用原生小程序结构,不用第三方框架,原因只有一个:农业场景的小程序用户大部分用低端安卓手机,原生语法体积最小、兼容性最稳。目录按功能拆:
pages/ index/ # 首页,供给列表和需求列表 publish/ # 发布页,供给和需求共用 detail/ # 供给详情页 demand/ # 需求详情页 order/ # 订单列表页 order-detail/ # 订单详情页 profile/ # 我的页面 cloudfunctions/ login/ # 登录,返回 openid publish/ # 发布供给/需求 createOrder/ # 创建订单 updateOrder/ # 更新订单状态云函数的作用是处理数据库写入权限和业务校验。前端直接db.collection('item').add()不是不行,但用户可以把库存改成负数,也可以把别人的供给直接下架。所有写操作都走云函数,前端只负责“把用户动作翻译成参数”。
3.2 登录与用户身份绑定:wx.login 别只用在 app.js 一次
很多从业者把登录写在 app.js 的 onLaunch 里,用户换头像数据后还留着旧值。我习惯在首页 onShow 时做一次“静默登录”:拿 code 传给云函数,返回 openid 后检查用户是否存在,不存在就自动建档。
// cloudfunctions/login/index.js const cloud = require('wx-server-sdk'); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }); const db = cloud.database(); exports.main = async (event) => { const { OPENID } = cloud.getWXContext(); if (!OPENID) return { code: 401, msg: '登录失败' }; const userColl = db.collection('user'); const exist = await userColl.where({ openid: OPENID }).get(); if (exist.data.length > 0) { return { code: 0, data: { isNew: false, user: exist.data[0] } }; } const now = Date.now(); const user = { openid: OPENID, nickname: '微信用户', avatar_url: '', phone: '', role: 0, // 0-未设置角色 status: 1, created_at: db.serverDate() }; const addRes = await userColl.add({ data: user }); return { code: 0, data: { isNew: true, user } }; };逻辑说明:每次进页面都会调用这个云函数,但并不会每次都新建用户,而是用 where 查询 openid。云函数端拿到的是 cloud.getWXContext().OPENID,这个值由微信平台注入,无法伪造,比前端把 openid 传过来安全得多。返回的 isNew 用来引导新用户补全角色信息。
参数说明:role 默认给 0,是让用户必须去“我的”页面选一次“我是农户还是采购商”,而不是自动猜测。很多开发在这里直接猜 role 导致发布页永远不对。前端调用方式很简单:
// utils/cloud.js 中统一定义请求封装 function callCloud(name, data = {}) { return wx.cloud.callFunction({ name, data }).then(res => res.result); } // pages/index/index.js 的 onShow 中调用 const res = await callCloud('login'); if (res.code === 0) { this.setData({ user: res.data.user, isNew: res.data.isNew }); }3.3 发布供给/需求:图片上传到云存储,数据落库
发布页最重的操作是图片上传。农产品必须靠图片打动买家,一张实拍图和一张网图在详情页的转化率完全不同。wx.chooseMedia 返回的临时文件路径不能在数据库中保存,要先上传到云存储拿到 fileID。
// pages/publish/publish.js 核心片段 async onUploadImage() { const res = await wx.chooseMedia({ count: 6, mediaType: ['image'], sizeType: ['compressed'] }); const uploadTasks = res.tempFiles.map(async (file, index) => { const cloudPath = `item/${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}.jpg`; const upload = await wx.cloud.uploadFile({ cloudPath, filePath: file.tempFilePath }); return upload.fileID; }); const imageList = await Promise.all(uploadTasks); this.setData({ images: this.data.images.concat(imageList) }); }, async submitPublish() { const { title, category, unit, price, stock, description } = this.data; if (!title || !price || !stock) { wx.showToast({ title: '请填写必填项', icon: 'none' }); return; } const res = await callCloud('publish', { type: 'item', data: { title, category, unit, price, stock, description, images: this.data.images } }); if (res.code === 0) { wx.showToast({ title: '发布成功' }); wx.switchTab({ url: '/pages/index/index' }); } }逻辑说明:把多个图片上传任务放在 Promise.all 里并发执行,上传速度更快,但要注意上传接口有并发限制,6 张图以内没问题。图片路径里带上时间戳和随机串,避免不同用户上传同名图片互相覆盖。发布供给和发布需求共用这一个页面,通过 type 字段区分,后端云函数里再决定写进 item 还是 demand 集合。
参数说明:price 在前端用字符串保存,提交时转成 Number 后传给云函数。云开发数据库不接受 undefined 字段,也没有 default 值自动填充,所以云函数里要主动给不存在字段补默认值,比如 stock 默认 0、unit 默认 斤。
对应的云函数 publish 里写入之前要判断角色:
// cloudfunctions/publish/index.js const type = event.type; const data = event.data; const isItem = type === 'item'; const collName = isItem ? 'item' : 'demand'; const userRes = await db.collection('user').where({ openid: OPENID }).get(); const user = userRes.data[0]; if (isItem && user.role !== 1) { return { code: 403, msg: '只有农户角色可发布供给' }; } if (!isItem && user.role !== 2) { return { code: 403, msg: '只有采购商角色可发布需求' }; } data.seller_id = user._id; data.buyer_id = user._id; data.status = 1; data.created_at = db.serverDate(); const addRes = await db.collection(collName).add({ data }); return { code: 0, id: addRes._id };3.4 供求列表与详情:筛选、排序和闲置数据清理
列表页看起来只是.where().get(),但有几个不得不处理的细节。第一是图片链接要转成可访问的临时链接:云数据库里存的是 fileID,直接放到 image 标签上不能显示,需要用wx.cloud.getTempFileURL换取临时 URL。第二是筛选状态必须判断 status=1,否则下架商品还会出现在首页。
// pages/index/index.js 首页加载 async loadList() { const db = wx.cloud.database(); const _ = db.command; const { category, keyword, type } = this.data; const collName = type === 'item' ? 'item' : 'demand'; let query = db.collection(collName).where({ status: 1 }); if (category) query = query.where({ category }); if (keyword) query = query.where({ title: db.RegExp({ regexp: keyword, options: 'i' }) }); const res = await query.orderBy('created_at', 'desc').limit(20).get(); const list = res.data; // 集中换取图片临时链接 const fileIDs = list.reduce((arr, item) => { return arr.concat(item.images || []); }, []); if (fileIDs.length > 0) { const urlRes = await wx.cloud.getTempFileURL({ fileList: fileIDs }); const map = {}; urlRes.fileList.forEach(item => { map[item.fileID] = item.tempFileURL; }); list.forEach(item => { item.images = (item.images || []).map(id => map[id] || ''); }); } this.setData({ list }); }逻辑说明:列表查询用链式 where,云开发数据库支持在一个查询上追加条件。正则查询在小数据量下可用,但如果数据过万,还是建议建搜索字段或者接入第三方搜索。getTempFileURL 一次最多传 50 个 fileID,这里列表 20 条、每条最多 6 张图,正好在安全范围内。
参数说明:orderBy 默认使用升序,如果要按时间倒序必须显式写 desc。分页用 .skip().limit(),但对深分页性能不好;我建议用滚动加载 + 每次 20 条,不要让人一直翻到几千条。真正做跑量时要在服务端做游标分页,这里的简易版足够支撑小规模试点。
4. 云函数与订单状态机:看懂“自主供销”为什么不是两张表就能跑通
4.1 创建订单云函数:库存、价格、角色校验一次性完成
订单创建看着最简单,实际最容易出事故。用户发起“我要买”,前端跳转确认页,然后调 createOrder。这一步如果校验漏掉库存或卖家状态,订单就会建到已经下架的商品上。
// cloudfunctions/createOrder/index.js const cloud = require('wx-server-sdk'); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }); const db = cloud.database(); const _ = db.command; exports.main = async (event) => { const { OPENID } = cloud.getWXContext(); const { itemId, quantity, remark } = event; const userRes = await db.collection('user').where({ openid: OPENID }).get(); if (userRes.data.length === 0) return { code: 401, msg: '用户不存在' }; const buyer = userRes.data[0]; const itemRes = await db.collection('item').doc(itemId).get(); const item = itemRes.data; if (!item || item.status !== 1) return { code: 404, msg: '商品不可购买' }; if (item.seller_id === buyer._id) return { code: 403, msg: '不能购买自己发布的商品' }; const qty = Number(quantity); if (!(qty > 0)) return { code: 400, msg: '数量不合法' }; const transaction = await db.startTransaction(); try { const freshItem = await transaction.collection('item').doc(itemId).get(); const remain = freshItem.data.stock - qty; if (remain < 0) { await transaction.rollback(); return { code: 400, msg: '库存不足' }; } await transaction.collection('item').doc(itemId).update({ data: { stock: remain } }); const orderNo = 'ORD' + Date.now() + Math.random().toString(36).slice(2, 6).toUpperCase(); const orderData = { order_no: orderNo, item_id: itemId, demand_id: event.demand_id || '', seller_id: item.seller_id, buyer_id: buyer._id, item_title: item.title, item_image: (item.images && item.images[0]) || '', price: item.price, quantity: qty, unit: item.unit, total_fee: Math.round(item.price * qty * 100) / 100, status: 0, remark: remark || '', create_time: db.serverDate(), pay_time: null, ship_time: null, finish_time: null }; const addRes = await transaction.collection('orders').add({ data: orderData }); await transaction.commit(); return { code: 0, orderId: addRes._id }; } catch (e) { await transaction.rollback(); return { code: 500, msg: e.message }; } };逻辑说明:这里没有直接用前端传过来的商品价格,而是从数据库再读一次,防止用户抓包把单价改成 0.01。事务里做了两个操作:扣库存、插入订单,要么都成功,要么都失败。云开发数据库事务要求所有读写都通过 transaction 对象发起,否则会在 commit 时报错。
参数说明:total_fee 先乘后除 100 做两位小数修正,避免浮点计算出现 5.999999 这种值。订单号用时间戳加随机串拼接,避免用户从订单号推出平台每日单量。如果后续要接微信支付,需要把支付单号、预支付 id 加进订单表,这不在当前范围。
4.2 订单状态流转:每一步都校验操作者身份
状态机的核心不是“记录状态”,而是“谁有权利把状态从 A 改成 B”。比如卖家履约后,买家确认收货,这个动作只能由买家发起;卖家申请取消未确认订单,必须允许。这里给出一个精简状态表:
| 当前状态 | 允许动作 | 操作者 | 目标状态 |
|---|---|---|---|
| 0 待确认 | 买家取消/卖家拒绝 | 任意一方 | 4 已取消 |
| 0 待确认 | 卖家确认接单 | 卖家 | 1 待发货 |
| 1 待发货 | 卖家发货 | 卖家 | 2 待收货 |
| 2 待收货 | 买家确认收货 | 买家 | 3 已完成 |
| 3 已完成 | 买家发起售后 | 买家 | 5 退款中 |
| 5 退款中 | 卖家同意退款 | 卖家 | 3 已完成 |
| 任意状态 | 管理员强制关闭 | 管理员 | 4 已取消 |
对应的云函数 updateOrder 里要先用 openid 查出当前用户,再读取订单,然后判断用户是不是订单参与者:
// cloudfunctions/updateOrder/index.js 状态校验片段 const orderRes = await db.collection('orders').doc(event.orderId).get(); const order = orderRes.data; if (!order) return { code: 404, msg: '订单不存在' }; const isSeller = order.seller_id === user._id; const isBuyer = order.buyer_id === user._id; const isAdmin = user.role === 3; const status = order.status; const action = event.action; let newStatus = -1; if (status === 0 && action === 'cancel' && (isBuyer || isSeller)) newStatus = 4; else if (status === 0 && action === 'confirm' && isSeller) newStatus = 1; else if (status === 1 && action === 'ship' && isSeller) newStatus = 2; else if (status === 2 && action === 'finish' && isBuyer) newStatus = 3; else if (status === 3 && action === 'refund' && isBuyer) newStatus = 5; else if (status === 5 && action === 'agree_refund' && isSeller) newStatus = 3; if (newStatus === -1) return { code: 403, msg: '非法状态流转' }; const updateData = { status: newStatus }; if (newStatus === 1) updateData.pay_time = db.serverDate(); if (newStatus === 2) updateData.ship_time = db.serverDate(); if (newStatus === 3) updateData.finish_time = db.serverDate(); await db.collection('orders').doc(event.orderId).update({ data: updateData }); return { code: 0, status: newStatus };逻辑说明:这种条件判断看起来朴素,但比任何状态机框架都直观,因为每条分支都对应业务规则。没有买卖双方权限的历史问题只会在后端拦截。注意最后把时间戳和状态一起更新,避免再发一个请求记录时间。
参数说明:如果平台以后要支持“买家直接对需求发起订单”,需要额外校验 demand 表的状态和买家身份。下单按钮在前端也要根据 order.status 显示不同文案,后端这里保持最简。
4.3 供需匹配:让需求方和供给方主动找上门
很多从业者问:系统到底怎么“自主”匹配?其实真正跑起来的产品极少做智能推荐,都是做“弱匹配”:把最新需求和最新供给用列表+分类展示,同时给买卖双方一个“联系对方”的按钮,发起沟通并生成一条消息记录。这样既符合农产品看货议价的习惯,也避开了推荐算法冷启动的坑。
具体做法是在列表页的详情卡片上显示两个动作,供给详情对买家显示“我要联系”,需求详情对卖家显示“我有货源”。点击之后调一个发消息云函数,把发送者、接收者、内容固化成一条会话,并把订单上下文留空。当双方聊到下单时,再走 createOrder 生成正式订单。
这种模式的价值在于:数据库只需要存消息表,不需要维护复杂的“匹配队列”。农产品有很强的地域性,真正能成交往往因为买家看到“产地某县”这几个字,而不是因为系统算了什么相似度。把搜索排序做好,把发布时间新、产地近的排在前面,科普阶段已经足够。
5. 避坑指南:上线前最常翻车的5个问题与排查方案
5.1 手机号授权规则调整,静默登录拿不到手机号
现象:开发版点“手机号快捷登录”能用,真机预览时偶尔提示“手机号获取失败”,后台根本没有这条用户记录。
原因:微信对手机号授权能力一直有收紧,没有完成认证的小程序无法调用getPhoneNumber,而且一次只能获取一个绑定手机号,不可用做新用户拉新。凭 code 换 nickname 的头像昵称也经常返回“微信用户”。
解决:登录只保证 openid,把手机号改成用户后续在“我的”页面手动补充,并做前端校验。发布供给和创建订单时如果 phone 为空,就弹窗引导用户去完善。认证问题要到小程序后台“开发管理”里检查当前是否已通过个人主体认证,个人主体基本拿不到合规的手机号接口,别在这个环节死磕。
5.2 图片在开发者工县显示,真机上全部空白
现象:模拟器上列表图片正常,一到安卓真机所有商品图都裂了,iOS 偶尔能出图。
原因:数据库里存的是云存储文件 ID,比如 cloud://xxx.jpg,<image>组件不支持这个协议。必须在展示前调用getTempFileURL换取 https 链接,并定期清理过期 URL。
解决:封装一个formatFileUrl的公共方法,在每次 setData 渲染前批量处理图片数组。临时链接有效期为两小时,二小时内用户多次进详情页可以直接用缓存,不用重复请求。云存储有 CDN 缓存,临时链接过一次就能稳定展示。
5.3 单位“斤”和“箱”混订,订单纠纷全在这里
现象:用户在供给详情里看到单价 35 元,下单 2,以为 2 斤只要 70 元,结果卖家把单位设成了“只”,35 元一只,2 只就要 70 元但重量可能只有半斤,双方在订单备注里吵起来。
原因:发布表单把单位放在可选字段,且商品卡片价格和单位样式不统一。很多用户根本不看单位标签,默认把数字当斤数。
解决:把单位做成发布页必选项,列表页价格统一样式写成“35元/斤”,详情页用大字号展示“起订量:1斤”。订单确认页在单价旁边再显示一次单位,并在提交后生成订单摘要弹窗,让买家确认“2 斤 x 3.5 元/斤 = 7 元”之后再调云函数。
5.4 数据库权限配错,买家手机号全泄露
现象:用户发现任何一个小程序使用者都能通过云函数调用入口直接读取 user 集合里的 phone 字段,连登录都不用。
原因:云开发数据库的权限设置选了“所有用户可读”,前端代码只要db.collection('user').get()就能拿到所有用户文档,云函数里的用户数据也跟着被拖走。
解决:集合权限统一设为“仅创建者可读写”,业务数据读写全走云函数。云函数本身不受集合权限限制,但要在云函数里判断用户身份。尤其 user 集合中避免明文存多余敏感字段,手机号至少要留最后四位脱敏展示,完整手机号只在订单双方确认后通过云函数获取。上线前用第二个微信号测试从另一个账号能不能访问到用户列表,能读到就是权限没配对。
5.5 前端直接改订单状态,导致已取消订单还能发货
现象:开发测试时为了省事在前端代码写了一句db.collection('orders').update({ status: 2 }),后来发现已经取消的订单点“发货”也能成功,库存还一直不恢复。
原因:状态变更逻辑没收敛到云函数,前端任何写权限都可能把订单状态改成任意值。云开发控制台的安全规则如果没有配置,前端可以自由 update。
解决:停止前端直接改数据库,共创一个updateOrder云函数并加状态机校验。初始化时要对订单集合设置安全规则,只允许通过云函数写。同时给订单状态变更写审计日志集合,每条记录包含操作者 openid、旧状态、新状态和操作时间,出问题后能对照日志回滚。
6. 从能用到好用:订阅消息推送与三天验证法
6.1 订阅消息:让农户不再错过采购需求
农产品交易的核心痛点之一是没有及时提醒,农户发完供给就退出小程序,采购商留言后干等。小程序订阅消息正好能解决这个,但它的授权机制是一次性订阅,要在用户点击按钮时让用户选择“总是保持以上选择”,否则每次只能发一条。
云函数端发消息前要先让用户在小程序里wx.requestSubscribeMessage拿到模板 ID,把订阅状态写入用户集合。之后有新订单或者有卖家接单,云函数就判断该用户是否有剩余订阅次数,再调用subscribeMessage.send。
// 发送订阅消息的云函数片段 const msgRes = await cloud.openapi.subscribeMessage.send({ touser: order.buyer_openid, templateId: '模板ID占位符', page: 'pages/order-detail/index?id=' + order._id, data: { thing1: { value: order.item_title.slice(0, 20) }, amount2: { value: order.total_fee }, character_string3: { value: order.order_no } } });逻辑说明:模板字段必须严格匹配后台申请模板时的字段名,thing 类型值限制 20 个中文,超长会直接报错。所以我在发送前会对标题做一次截断。用户如果关闭了订阅,发送接口不会抛异常,只是在后台被静默丢弃,所以后端要记录发送日志,防止重复发。
参数说明:page 路径必须是小程序内真实存在的页面并带上参数,否则点击通知后打不开。只有当订单状态变更时推送,不搞每天营销推送,否则用户很快会关闭订阅消息。
6.2 三天验证法:把供销闭环完整跑一遍再交付
拿到一套源码或者自己写完第一版,不要急着做 UI 美化,先用三天做业务验证。第一天注册两个微信号,一个设农户角色,一个设采购商角色;农户发布一条带图片、有真实产地的供给,采购发布一条需求,验证双方是否能互相看到。第二天在供给详情页发起联系,互换手机号,然后创建订单并完成发货、收获、评价全流程。第三天验证不常见场景:买家取消订单后库存是否恢复、卖家下架后商品是否从列表消失、管理员能否强制关闭异常订单。
这套验证法每次都能在真正部署前找到“库存变成负数”“取消订单不恢复库存”之类的问题。我在给模拟项目X做演示前跑了一遍,发现订单状态从 0 到 4 之后库存没回补,原因是 createOrder 里扣库存是事务,但 cancel 动作直接改 status,没有做回补逻辑。后来在 updateOrder 的 cancel 分支里加了库存加回动作,问题才解决。
6.3 运营侧的小习惯:上下架审核与数据导出
最后说一个后端容易被忽略的铺路工作:给 item 和 demand 集合各加一个“运营上下架”字段,管理员可以在后台手动隐藏某些供给,而不是直接删数据。农产品有很强的季节性,囤货卖完和应季下架是常态,隐藏比删除更能保留历史价格数据,明年做种植计划时还能翻出来参考。同时每周导出一次订单表到本地表格,按品类、单价、数量做透视表,判断哪些品类的供需缺口最大,这比任何推荐算法都更能决定下一轮运营投放重点。
这些从能用到好用的改动加起来不到 100 行代码,但它们才是自主供销平台真正能留住用户的地方。不要急着做优惠券、积分商城,先把“让别人找到你、下了单、能走完状态”这条线加固好。搞农业信息化的兄弟们,最怕的就是把系统做成漂亮的黑匣子,用户点了一圈却没人接单。希望这篇笔记能帮你避开那些坑,让每一份供给都找到买主。
本文还有配套的精品资源,点击获取