
做农产品上网商城之前我其实先做过好几版纯网页的商城系统。后来发现一个特别现实的问题在乡镇和农村手机才是农户接触互联网的主要工具他们不太会坐到电脑前打开浏览器去下单但几乎人人都在用微信。于是把商城搬到微信小程序上就成了一个顺理成章的选择。服务端我选了 Node.js Express MySQL这套组合做小程序后端有个天然优势小程序端和后端都用 JSON 交互数据Node.js 处理 JSON 非常顺手开发效率比传统 Java 那套要高不少部署也轻量一台低配云服务器就能带起来。这个项目不是单纯的“商品列表下单”而是把农产品网上商城和农商信息交流平台两个场景揉在了一起。商城解决“农产品怎么卖出去、消费者怎么买得放心”的问题信息交流解决“供求信息、产地动态、农业资讯怎么触达农户”的问题。如果你正准备做类似的小程序电商项目或者已经在用 Node.js 开发小程序后端但总感觉结构不太顺这篇内容应该能给你一份可以直接照着改的参考。1. 项目设计与技术选型为什么是Node.js小程序1.1 需求拆解这不只是商城还是信息站很多新手拿到“农产品商城”这个需求第一反应就是照搬电商模板商品、购物车、订单、支付完事。但真正下乡调研一圈就会发现农产品电商和标品电商有本质区别。农产品有几个特性季节性极强比如荔枝只有一个月货架期规格不统一有按斤卖的、有按箱卖的、还有按棵卖的价格波动大产地价一天一个样对图片真实度要求高消费者非常在意“是不是实拍”。这些特性直接决定了商品表不能套用标准电商那种“统一SPU/SKU模型”必须支持灵活的规格描述和单位字段。农商信息交流平台的需求也很有特点。农户需要发布“求购某种树苗”“供应大量土豆”合作社需要发通知农技站可能要发种植技术文章。这个模块和商城是独立的但用户体系是相通的——同一个农户既可以在商城里卖货也可以去信息板块发供需帖。所以后端在设计时要让用户表、认证逻辑统一但业务表分开。我给项目定了三个核心角色普通消费者买家、农户/合作社卖家信息发布者、平台管理员审核运营。权限上不需要做得太重前端路由和后端中间件做两层校验就够了但底子要打好后面加运营后台会省很多事。1.2 技术选型为什么不用Java也不用Vue选 Node.js 做服务端最核心的理由是前后端语言统一。小程序端用的是 JavaScript服务端用 Node.js很多工具函数、数据校验逻辑甚至可以两端共用。做商品价格计算、订单金额分摊这种逻辑时不用在两种语言之间来回切换思维。Express 框架本身非常成熟生态里要什么有什么上传用 multer、鉴权用 jsonwebtoken、数据库用 mysql2都是久经考验的库。数据库我选了 MySQL 而不是 MongoDB。农产品商城有大量关联查询——订单要联商品、联地址、联用户信息帖要联发布者。关系型数据库在这种业务下写 SQL 更直接事务支持也成熟下单扣库存这种操作必须靠事务保证一致性。如果你数据量不大甚至可以直接用 SQLite 起步但线上部署我还是建议 MySQL 8.0。前端为什么不选 uni-app 而用原生微信小程序类目原因比较多但最关键的是原生小程序对微信支付、手机号快捷登录、订阅消息这类能力的接入路径最直观社区上的踩坑方案也最全。用了跨端框架出问题时要多绕一层排查成本反而高。如果你的项目未来一定要同时上支付宝小程序那再考虑 uni-app 或 Taro否则原生就够了。1.3 整体架构与目录规划项目分两个工程serverNode.js 后端和miniprogram微信小程序前端。服务端目录我习惯按业务模块分而不是按技术分层分server/ app.js # 入口初始化中间件 routes/ # 路由定义 user.js product.js cart.js order.js info.js upload.js controllers/ # 业务逻辑 models/ # 数据库查询封装 middlewares/ # 鉴权、错误处理、限流 utils/ # 通用工具 uploads/ # 本地上传的图片小程序端目录按页面功能分miniprogram/ pages/ index/ # 首页 category/ # 分类 cart/ # 购物车 mine/ # 我的 goods-detail/# 商品详情 order-confirm/# 确认订单 order-list/ # 订单列表 info-list/ # 信息交流列表 info-publish/# 发布信息 components/ # 公共组件 utils/ app.js app.json这样规划的好处是每个页面能快速定位到对应的后端路由联调时不用来回翻目录。我个人不太推荐那种“contollers 一层、services 一层、dao 一层”的极端分层小项目会越写越累业务函数直接写在 controller 里只有被多处复用的逻辑才抽出来。2. 后端核心模块商城与信息交流的Node.js实现2.1 数据库设计先想清楚业务关系再建表数据库表我建议拆成这几个核心表用户表、商品表、分类表、购物车表、订单主表、订单明细表、地址表、信息帖表。字段设计上有几个容易忽略的点单独提醒一下。商品表里价格字段一定用DECIMAL(10,2)不要用FLOAT否则算总价时会出现 0.10.2 不等于 0.3 的浮点误差。库存字段用INT上下架用TINYINT配一个sales字段记录销量用于排序。商品主图只存一张详情图可以存个 JSON 数组转成的字符串这样避免建多对多关联表。订单表需要两个关键字段order_no唯一订单号推荐用时间戳随机数生成不要用自增 ID 直接暴露给客户status状态字段用整数表示我用的 0 待付款、1 待发货、2 待收货、3 已完成、4 已取消、5 退款中前端和后端统一维护一个常量映射。信息帖表比较特殊建议加type字段区分“求购/供应/公告”再加verify_status审核状态。农产品信息平台最怕垃圾广告发布接口先写成待审核管理员在后台通过后才会在前端显示。浏览量views字段用INT每次请求详情时1不需要太精确。列一下核心表的建表思路方便参考表名核心字段说明usersopenid, nickname, avatar, phone, roleopenid唯一索引role区分买家/农户/管理员productscategory_id, farmer_id, title, price, unit, stock, main_img归属农户unit灵活描述规格ordersorder_no, user_id, total_amount, status, address_id金额用DECIMALorder_itemsorder_id, product_id, title, price, quantity冗余商品快照防止商品改价后历史订单受影响info_postsuser_id, type, title, content, verify_status区分供应和求购addressesuser_id, name, phone, region, detail默认地址用is_default标记2.2 商品模块分类、列表与详情接口商品列表接口是商城最核心的接口之一。我的实现是用 Express 路由加 MySQL 查询支持分类筛选、关键词搜索、销量排序和分页。分页参数用page和pageSize返回结果里同时带total和hasMore前端滚动加载时判断hasMore来决定是否停止请求。商品详情接口需要注意一个性能细节每次请求都要查商品主表、农场主信息、商品图片列表。如果直接用三四个await串行查询接口耗时会叠加到几百毫秒。我的做法是并行查询Promise.all同时发起详情接口响应时间能压到 80ms 左右。图片存的是 URL 路径静态文件用express.static映射到uploads目录。商品上下架、修改库存这种操作建议加个简单的操作日志表谁在什么时候改了什么后面出纠纷时能追溯。虽然前期开发麻烦一点但农产品交易涉及到“货不对板”投诉时这个记录价值很大。分页查询的 SQL 有个就地经验用LIMIT offset, size时页码越大查询越慢因为 MySQL 要跳过很多行。对商城这种场景商品总量一般不会超过几万条暂时不用优化。如果以后数据量大再改成基于游标的分页传lastId而不是page。示例商品列表接口的核心逻辑// routes/product.js router.get(/list, async (req, res) { const { categoryId, keyword, page 1, pageSize 10, sort default } req.query; const offset (page - 1) * pageSize; let where status 1; const params []; if (categoryId) { where AND category_id ?; params.push(categoryId); } if (keyword) { where AND title LIKE ?; params.push(%${keyword}%); } let orderBy id DESC; if (sort sales) orderBy sales DESC; if (sort price_asc) orderBy price ASC; const [rows] await db.query( SELECT id, title, price, unit, main_img, sales FROM products WHERE ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?, [...params, pageSize, offset] ); const [[{ total }]] await db.query( SELECT COUNT(*) as total FROM products WHERE ${where}, params ); res.json({ code: 0, data: { list: rows, total, hasMore: offset rows.length total } }); });2.3 订单与支付小程序支付的完整链路小程序支付比 H5 支付简单很多整个链路是前端wx.requestPayment发起支付后端调用微信支付的“统一下单”接口拿到prepay_id再用这个 ID 生成支付参数返回给前端。注意这里的签名算法是 MD5 或 HMAC-SHA256微信支付 v2 的文档里写得很清楚但坑也不少。我在这个项目里遇到第一个大坑是支付回调地址必须配置在微信支付商户平台里而且必须是 HTTPS 的公网 URL不能用局域网 IP。回调地址写错的话用户钱付了但订单状态不会自动更新前端查订单还是“待付款”。排查方法也很简单在回调接口里第一行写日志看微信服务器到底有没有请求到没有请求到基本就是域名配置或者防火墙问题。第二个坑是回调验签。微信支付回调里的数据是 XML 格式拿到后必须用商户密钥做签名校验验签通过后才更新订单状态。很多教程为了省事直接忽略验签这在生产环境是绝对不行的攻击者完全可以伪造回调把订单标记成已支付。简化版的统一下单核心代码// controllers/pay.js const crypto require(crypto); async function wxUnifiedOrder(order) { const params { appid: config.wx.appid, mch_id: config.wx.mchId, out_trade_no: order.orderNo, body: order.title, total_fee: Math.round(order.totalAmount * 100), // 单位转换为分 notify_url: config.wx.notifyUrl, trade_type: JSAPI, openid: order.openid }; // 生成签名并请求微信支付接口 // 返回 prepay_id 后前端用这个值调 wx.requestPayment }下单接口的业务逻辑是典型的“事务锁库存”场景。用户提交订单后后端先检查库存是否足够足够则扣减库存、创建订单记录这一个流程必须放在数据库事务里。我在一开始漏掉了事务结果测试时发现并发下单会出现“超卖”——两个人同时买最后一件商品两个人订单都建成功了。后来用mysql2的getConnection手动开启事务在扣库存的 SQL 里加上stock ?条件如果更新影响行数为 0说明库存不足直接回滚。订单状态机我建议做成单向流转待付款 → 待发货 → 待收货 → 已完成 / 取消 / 退款。前端不直接修改订单状态而是调用后端接口触发流转。比如“确认收货”就是一个独立的接口后端校验当前状态是待收货才会改成已完成。这种设计避免了很多脏数据问题。2.4 手机号登录获取手机号小程序独有的登录链路小程序登录和网页登录最大的区别是小程序不能自己做账号密码体系只能通过微信授权。我这里用了两种能力配合正确做法是用wx.login拿到code换openid这个openid是用户的唯一标识用它来识别用户。之后如果需要手机号比如发货需要联系方式再通过button open-typegetPhoneNumber让用户授权。wx.login的流程是前端调用wx.login()获取临时 code把 code 传给后端后端用appid secret调用微信接口jscode2session返回openid和session_key。拿到openid后在 user 表里查一下有没有记录没有就自动注册一个。同时用jsonwebtoken签发一个 token 给小程序后续每次请求带上 token后端校验 JWT 通过后从 token 里解析出userId。手机号获取在 2023 年后改成了收费接口需要企业认证的小程序才能申请。如果个人主体的小程序可以用最原始的“输入手机号验证码”方案兜底。具体做法是前端弹出一个手机号输入框用户填写后后端调用短信服务商接口发送验证码。这个方案虽然多一步操作但兼容性最好。JWT 需要注意一个细节token 不要设置太长的过期时间我一般设 7 天。用户每次打开小程序时先用wx.checkSession检查登录态是否有效有效就直接用缓存里的 token无效才重新走wx.login。这样既保证安全又不会让用户频繁重新授权。2.5 农商信息交流模块内容发布与审核信息交流模块的业务逻辑不复杂但有两个点必须处理图片和审核。信息发布页允许上传最多 9 张图片。我用multer做文件上传限制文件类型为jpg/png/webp大小限制 5MB文件名用时间戳加随机数生成避免中文名导致乱码。图片存到uploads/info/目录前端展示时拼接完整 URL。如果生产环境担心磁盘空间可以接入云存储但初期本地文件足够用了。审核这块我的做法是信息发布时默认verify_status 0前端信息列表只查询verify_status 1的帖子。管理员审核通过后改成1。另外加了一个简单的敏感词过滤词库放在一个 JSON 文件里发布内容如果命中敏感词就直接返回“内容包含敏感词”没必要存储。真实场景下这个过滤肯定不完善但至少能把最明显的垃圾广告挡在外面。浏览量统计我也是用最简单的方式详情接口里UPDATE info_posts SET views views 1 WHERE id ?。这种写法在高并发下有性能问题但农产品信息平台日活通常不大简洁优先。信息列表的分类筛选可以用type字段求购、供应、公告、技术文章。首页信息流默认按发布时间倒序再加上一个“只看供应”的 tab。记住一个原则信息交流模块的核心是“让农户快速找到买卖对手方”所以列表页的信息密度要高标题、价格/数量、产地、联系人、发布时间这几个字段直接展示不要藏着。3. 小程序端核心交互实现从首页到下单3.1 页面骨架与底部导航小程序的app.json里配置tabBar我设置了四个 tab首页、分类、购物车、我的。农产品商城首页不要搞得花里胡哨用户进店第一眼要看到“今天有什么新鲜农产品”所以首页的布局是“搜索框 轮播图 分类金刚区 推荐商品流”。轮播图的接口可以复用后端的 banner 表运营人员可以在后台配置。购物车 tab 的角标需要动态获取购物车商品总数。这里有个小技巧小程序的 tabBar 不支持自定义角标我用wx.setTabBarBadge来实现。但要注意如果数量为 0 时要调用wx.removeTabBarBadge否则会一直显示一个“0”的红点看着很蠢。页面跳转时商品列表页跳详情、详情页跳确认订单、确认订单跳支付成功页这些路径要在app.json的pages数组里提前注册。如果页面没注册跳转时会报page is not found这个错误排查起来很简单找到app.json加上路径就行。小程序页面路径大小写敏感官方工具会有提醒但真机上偶发不提示我自己就吃过一次亏goodsDetail写成了goodsdetail在开发者工具里正常真机预览白屏。3.2 商品列表分页与下拉加载小程序的分页逻辑和 H5 不太一样H5 常用“点击加载更多”小程序里更顺手的做法是onReachBottom触底加载。这个生命周期函数是页面滚动到底部时自动触发的和onPullDownRefresh搭配使用。核心逻辑是维护三个变量page当前页码、list商品数组、loading是否请求中。每次触底时如果loading为 true 就直接 return防止重复请求否则page请求下一页数据追加到list后面。请求完成后判断hasMore没有更多了就在页面底部显示“已经到底啦”。水果蔬菜这种商品用户很在意图片质量。小程序里图片用image标签时建议给modewidthFix这样图片宽度撑满高度按比例自适应不会出现拉伸变形。同时设置lazy-load属性列表很长时能显著减少流量消耗。注意lazy-load只对image组件生效背景图不生效。3.3 购物车状态管理与结算购物车是最容易写出烂代码的模块因为它是“本地交互 服务端同步”混合体。我的方案是购物车数据存后端数据库前端只做展示和操作。每次进入购物车页面请求/api/cart/list获取列表点击加减数量时调用接口更新数量同时前端用setData更新当前项的数量避免整页刷新。购物车的勾选状态需要特别注意。我最初把勾选状态存在前端本地结果用户选了几件商品后退出再进入勾选状态全部丢失。后来改成勾选状态也存后端cart表加一个checked字段每次勾选都调接口保存。这样用户体验稳定结算页拿到的永远是最新状态。结算按钮需要计算“选中商品的总金额”。前端每次勾选状态变化或数量变化时重新计算计算逻辑是reduce遍历选中项累加price * quantity。这里要注意金额的小数处理JavaScript 的浮点运算会丢精度我统一用Math.round(amount * 100) / 100保留两位小数或者直接用整数分计算展示时再除以 100。结算时调/api/order/create创建订单后端校验库存是否足够。如果不够返回“部分商品库存不足”前端定位到具体商品并把数量改回库存上限。这个交互细节很影响体验用户不需要知道发生了什么只要看到“土豆库存只剩3件已为你调整数量”就明白了。3.4 自定义导航栏高度适配小程序顶部导航栏高度在不同机型上差异很大尤其是有刘海的全面屏和普通屏的状态栏高度能差一倍。如果直接用系统默认导航栏标题位置还算标准但你要做“自定义导航栏”样式比如嵌入搜索框就必须自己算高度。正确做法是先查状态栏高度再查微信胶囊按钮的位置然后算出导航栏总高度const sysInfo wx.getSystemInfoSync(); const menuRect wx.getMenuButtonBoundingClientRect(); // 导航栏高度 胶囊上边界到状态栏的距离 胶囊高度 胶囊下边界到状态栏下方距离 const navBarHeight (menuRect.top - sysInfo.statusBarHeight) * 2 menuRect.height;这个公式是一个经验值实测下来基本准。页面里用padding-top撑开内容区否则自定义导航栏会遮住页面内容。这个坑我在 iOS 上遇到过iPhone 12 状态栏高度 47pxAndroid 一般是 20-30px测试机和真机效果差很远真机调试是唯一可靠的验证方式。3.5 订单列表与状态操作订单列表页我按状态分 tab全部、待付款、待发货、待收货、已完成。每个 tab 拉取对应状态订单。为了提高体验订单列表接口一次返回每个订单的商品明细快照前端渲染时直接按订单分组展示不需要再串行请求商品详情。订单详情页有几类操作立即支付待付款、申请退款待付款或待发货、确认收货待收货。这几个操作都会调后端接口成功后刷新当前订单状态。这里有一个小的体验细节支付成功后小程序的wx.requestPayment会返回errMsg: requestPayment:ok在这个回调里不要立刻跳转“支付成功页”而是先调后端/api/order/pay/status确认订单状态确实变成已支付再做跳转。否则用户支付成功后接口还没更新就会看到订单还是待付款造成困惑。4. 部署联调与常见故障排查实录4.1 Node.js环境配置与部署这些坑我都踩过Node.js 环境配置是老生常谈但每个项目总有人栽在这里。我单独拿出来讲是因为这个小程序项目从开发到部署几乎每一步都有人踩环境坑。第一是安装源。直接用npm install下载依赖在国内网络下经常会卡住或者超时。解决办法是配置国内镜像源在项目根目录建.npmrc文件写入registryhttps://registry.npmmirror.com或者执行npm config set registry https://registry.npmmirror.com。如果你用 Electron、node-sass 这类带原生模块的库光换 registry 还不够还得设置二进制镜像源否则编译时会从 github 下载二进制文件大概率失败。好消息是 Express、mysql2、jsonwebtoken 这些纯 JS 库换 registry 就够了。第二是 Windows 下的 PowerShell 执行策略问题。有次同事在 Windows 上跑npm run dev报“无法加载文件 C:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本”。这不是 npm 坏了而是 PowerShell 的 ExecutionPolicy 默认是 Restricted禁止执行脚本文件。解决办法是用管理员身份打开 PowerShell执行Set-ExecutionPolicy RemoteSigned。如果你不想改全局策略也可以直接用npm.cmd run dev绕过 .ps1 脚本。第三是端口占用。Node.js 服务默认监听 3000 端口如果开发时反复CtrlC再重启偶尔会报EADDRINUSE。可以用lsof -i:3000macOS或netstat -ano | findstr 3000Windows查看占用进程kill 掉再重启。更省事的做法是用nodemon监视文件变化自动重启配合kill-port工具能彻底解放双手。部署到服务器上我推荐用pm2管理 Node 进程。pm2 start app.js --name farm-mall会自动守护进程服务挂了能自动拉起。再配合 Nginx 做反向代理把https://api.example.com指向http://127.0.0.1:3000。小程序要求所有请求域名必须 HTTPS 且在后台配置白名单所以 SSL 证书是必须的。开发阶段可以在微信开发者工具里勾选“不校验合法域名”真机预览时也可以在详情设置里打开调试模式但正式上线前一定要把白名单配置好。4.2 小程序接口联调与抓包排查技巧小程序开发最痛苦的部分是联调因为真机上不能直接看 Network 面板。我常用的排查方案分两种局域网真机调试和抓包工具分析。局域网真机调试要求手机和电脑在同一网络开发者工具里勾选“真机调试”二维码扫描后手机上的请求会转发到电脑的调试面板能看到每个请求的耗时和返回数据。但这种方式有个局限微信小程序的正式版会强制走合法域名开发版才能用局域网 IP。如果需要抓包分析正式环境的接口问题我常用Reqable做中间人代理。原理是在电脑上启动一个代理服务手机设置代理指向电脑电脑上配置 HTTPS 证书就能解密手机和服务器之间的通信。需要重点提醒的是抓包工具只能用于自己拥有或已授权调试的应用和技术验证不要尝试对他人应用做未授权抓包这个边界要非常清楚。抓包看到的核心内容是请求 URL 是否正确、请求头是否带了 token、返回值是否符合预期。抓包排查时有个高频问题小程序请求报request:fail。原因可能是域名不在白名单、HTTPS 证书过期、服务器 CORS 未配置、请求被限流等。排查顺序建议是先用 Reqable 看请求是否真的发出去了再在 Node 端把访问日志打开确认服务器到底收没收到请求。微信开发者工具里报request:fail有时候只是开发工具本地网络问题重启工具反而能解决。4.3 性能优化与安全加固上线前必做的几件事性能优化我做了三件事。第一是 SQL 查询加索引products 表的category_id、statusorders 表的user_idstatusinfo_posts 表的verify_statustype这些字段几乎每个查询都会用到。用EXPLAIN检查一下查询计划看到type: ALL扫全表的 SQL 就加索引。第二是图片压缩。农产品商城图片量大原图直接传到服务器一个详情页可能加载 5MB 图片用户流量扛不住。我在服务端用sharp库做压缩上传时检测图片尺寸超过 1200px 就等比缩小质量压缩到 80%转成webp格式。实测下来详情页图片体积可以从 2MB 降到 300KB 左右加载速度提升非常明显。安全方面项目上线前至少要检查这四个点所有请求入口做 JWT 鉴权公开接口商品列表、详情除外用户信息、订单、购物车等接口必须登录才能访问。数据库操作统一用参数化查询杜绝拼接字符串进 SQL防止注入。Express 里最容易出问题的地方是req.query和req.body直接进 SQL我在模型层强制用参数占位符。上传文件做严格类型校验只允许白名单后缀内容用file-type库校验真实格式防止上传伪装的脚本文件。给接口加简单的限流中间件比如每个 IP 每秒钟最多 20 次请求用户 token 每分钟最多 60 次。农产品平台初期人不多但羊毛党到处扫接口提前挡住更省心。4.4 高频问题速查表最后整理一份我在这类项目里常遇到的高频问题清单按症状、原因、解决办法三栏整理方便你直接对照处理。症状可能原因解决办法小程序请求一直转圈域名未配白名单或 HTTPS 证书无效后台配置合法域名开发工具勾选“不校验合法域名”用户支付成功但订单还是待付款支付回调地址错误或验签失败查后端日志有无回调请求核对商户平台回调地址图片加载显示裂图图片 URL 拼接错误或文件被删检查 image 标签 src看 uploads 目录文件是否存在自定义导航栏在真机上偏移状态栏高度计算不准用 getMenuButtonBoundingClientRect 动态计算高度真机无法登录code2session 接口的 secret 配置错误核对 appid 和 secret 是否匹配且来源正确Node 服务启动报 EADDRINUSE端口被占用杀掉占用进程或换 PORT 环境变量npm install 卡死默认源下载慢配置国内镜像源再安装数据列表每次都全量返回没做分页接口加入 page/pageSize前端滚动加载项目从第一版到现在迭代了大半年我个人最大的体会是农产品电商和小程序这个组合真正难的不是技术而是把“农户习惯”和“消费者预期”用产品逻辑串起来。技术层面 Node.js 和小程序都是成熟的方案照着上面的思路拆解、实现基本不会有大问题。最后再分享一个细节我当时给商品详情页加了一个“产地实拍”的小标签凡是农户在小程序后台上传了当天实拍图的商品优先展示并标注。这个功能只花了一个下午写接口却在运营数据上起到了不错的效果——商品转化率比普通商品高了一截。做这类接地气的功能比堆砌大而全的营销组件有价值得多。