前阵子帮朋友做了一套基于Python的农产品商城销售团购系统小程序,从需求梳理、数据库设计、后端接口开发到小程序端联调,前后折腾了差不多两个月。中间踩过不少坑,也沉淀下来一些反复验证过的方案。这篇把整套系统从架构到实现要点拆开讲一讲,重点放在三块:小程序端怎么做交易闭环、Python后端怎么处理农产品这种“非标又容易损耗”的商品、以及团购成团和退款里面的并发细节。如果你打算做社区团购类小程序,或者正在拿这类项目当毕设课题、面试项目,可以参考一下。
1. “农产品+团购+小程序”这个组合,为什么偏偏能跑通
很多开发者第一次接触农产品商城,会觉得浑身别扭——SKU不好定义、库存算不清楚、用户下单决策慢、物流成本还高。这不是代码能力的问题,而是用做普通电商的思维去做生鲜,从一开始就走错了方向。
1.1 农产品电商的“三高一低”,先说清楚
农产品不是标准品。一件T恤有明确的尺码、颜色、材质,但一棵白菜的份量、新鲜程度、产地批次,很难用一个固定属性说清楚。这意味着商品模型不能照抄服装鞋帽那套“规格+颜色”的SKU设计。
然后是损耗。普通商品的库存“放着就是钱”,农产品的库存放着就是烂。今天到货的100斤番茄,明天可能只剩80斤能卖。如果按普通电商的“永久库存”来管理,账一定对不上。
再一个是信任问题。用户看不到实物,不敢下单。水果甜不甜、青菜嫩不嫩,图片看不出来。这个问题的解法不在技术上,而在运营设计上——预售、可溯源、团长背书。
最后是物流。单独给一个用户送20块钱的菜,配送成本可能超过商品本身。
1.2 团购模式怎么对症下药
团购模式恰好把这几个问题一起解决了。
- 预售制:先收订单,再组织货源。损耗风险转移给了供应链上游,而不是平台自己赌库存。
- 以量换价:一个社区一次性采购200斤土豆,采购价可以谈下来,用户也愿意为低价等待一天。
- 集单配送:订单集中在自提点,一辆车送到一个点,用户自提。最后一公里成本从“每单配送”变成“每点配送”。
- 信任背书:自提点团长是邻居、是熟人,用户的试错成本被拉得很低。
1.3 为什么是小程序,而不是App或者H5
小程序的获客成本低,用户扫个码就进来了,不用下载安装。拼团活动天然适合在微信群和朋友圈传播,微信的支付闭环让交易路径最短。对中小规模的创业者来说,小程序是农产品社区团购目前最合适的载体。而后端用Python维护起来省心,生态成熟,招人也不难。
我个人的判断是:这类项目技术复杂度不高,真正的复杂度在业务规则上。所以做系统之前,先把“谁来卖、谁来买、怎么履约、坏了怎么办”这些规则定清楚,代码写起来才有方向。
2. 系统架构与后端技术选型
项目前期最纠结的不是功能,而是技术选型。这里把最终方案列出来,再讲讲为什么这么选。
2.1 整体架构:小程序端+Python后端+管理后台
系统分三条线:
- 用户端(微信小程序):商品浏览、团购活动页、下单、微信支付、订单查询、自提点选择。
- 后端服务(Python):给小程序提供RESTful接口,处理订单、库存、成团判定、退款等业务逻辑。
- 管理后台(Web):运营人员上架商品、创建团购活动、查看订单、处理退款、维护自提点和团长信息。
数据层我用了MySQL 8.0 + Redis 6.x。MySQL存商品、订单、活动等核心业务数据,Redis做热点缓存、库存扣减和轻量级延迟任务。对象存储放商品图片,一台2核4G的云服务器在初期完全够用。
2.2 FastAPI vs Flask vs Django,到底怎么选
Python后端框架,主流就是这三个。我列了个对比表:
| 框架 | 开发效率 | 性能 | 异步支持 | 生态成熟度 | 适合场景 |
|---|---|---|---|---|---|
| Flask | 高,简单直接 | 一般 | 需要额外配置 | 很成熟 | 中小型项目、快速原型 |
| Django | 高,全家桶 | 一般 | 需要额外配置 | 极成熟 | 大型Web应用、内容管理 |
| FastAPI | 高,代码量少 | 较高 | 原生异步支持 | 快速成长 | 高性能API、前后端分离 |
我最终选了FastAPI,主要三个理由。
第一,性能好。FastAPI基于Starlette,异步能力是原生的,高并发下比Flask的同步模型稳得多。农产品的团购活动经常在某个时间点集中开抢,接口性能必须扛得住。
第二,类型提示。FastAPI强制使用Pydantic做请求参数和响应模型校验,小程序端传过来的数据格式对不对,接口层直接帮你挡掉。开发阶段省了大量联调时间。
第三,自动生成OpenAPI文档。FastAPI启动后自带Swagger文档页面,小程序端开发直接照着文档联调,不用再单独维护一份接口文档。
但如果你团队里有Django老手,用Django + DRF也没问题。选型的关键不是“谁最好”,而是“谁能让你们最快上线、最好维护”。FastAPI在中型API项目里,目前是最省心的选择。
2.3 小程序端:原生还是uni-app
小程序端我选了原生微信小程序。主要考虑是项目只做微信端,原生框架性能最好,组件开箱即用,排查问题也方便。如果你要同时发布到支付宝小程序、抖音小程序,那uni-app更合适,一套代码多端运行。两个方案对比如下:
| 方案 | 多端支持 | 性能 | 学习成本 | 调试体验 |
|---|---|---|---|---|
| 原生微信小程序 | 仅微信 | 最优 | 低 | 微信开发者工具足够 |
| uni-app | 微信/支付宝/抖音/H5 | 略差于原生 | Vue语法,前端友好 | 需要配合HBuilderX |
选型建议很简单:只做微信端,用原生;要铺多端,上uni-app。不用在这上面纠结太久。
3. 数据模型设计:把“一把青菜”变成可交易的订单
这套系统的核心难点,其实在数据库设计。农产品本身是非标的,怎么把它容纳进标准的关系型数据库,是第一步要解决的问题。
3.1 商品和SKU,别照抄服装电商
普通电商的SKU是“红色 + M码 + 纯棉”这样确定的组合,但农产品没法这样建模。一根萝卜的规格只有“重量区间”能描述,而且不同批次的品质可能差很多。
我在商品表里建议这样设计:
CREATE TABLE `product` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `name` VARCHAR(100) NOT NULL COMMENT '商品名称', `category_id` INT NOT NULL COMMENT '分类,如蔬菜/水果/禽蛋', `origin` VARCHAR(50) DEFAULT '' COMMENT '产地', `main_image` VARCHAR(255) DEFAULT '' COMMENT '主图URL', `detail_images` TEXT COMMENT '详情图,JSON数组', `spec_desc` VARCHAR(255) DEFAULT '' COMMENT '规格描述,例如约500g±50g', `storage_condition` VARCHAR(100) DEFAULT '' COMMENT '储存条件,常温/冷藏', `shelf_status` TINYINT DEFAULT 1 COMMENT '1上架 0下架', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意spec_desc这个字段。它不是一个结构化的SKU属性,而是专门用来展示“约500g±50g”“约4-5个/斤”这类模糊表述的。非标品不追求精确定义,而是要诚实告知,避免用户收到货后觉得“缺斤少两”。
3.2 团购活动与库存:核心表结构
普通商城的库存是永续的,团购活动的库存是临时的。这两者的管理逻辑完全不同,所以我把活动库存单独拆了一张表。
CREATE TABLE `groupon_activity` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `product_id` INT NOT NULL COMMENT '关联商品', `title` VARCHAR(150) NOT NULL COMMENT '活动标题,如:本地西红柿基地直采', `target_num` INT NOT NULL COMMENT '成团目标人数', `per_limit` INT DEFAULT 1 COMMENT '每人限购份数', `start_time` DATETIME NOT NULL, `end_time` DATETIME NOT NULL, `groupon_price` DECIMAL(10,2) NOT NULL COMMENT '团购价', `original_price` DECIMAL(10,2) NOT NULL COMMENT '划线原价', `pickup_point_ids` VARCHAR(255) DEFAULT '' COMMENT '可选自提点ID,逗号分隔', `status` TINYINT DEFAULT 0 COMMENT '0未开始 1进行中 2已结束 3已关闭', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `groupon_stock` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `activity_id` INT NOT NULL, `batch_no` VARCHAR(30) NOT NULL COMMENT '批次号', `total_num` INT NOT NULL COMMENT '本批总库存', `sold_num` INT NOT NULL DEFAULT 0 COMMENT '已售数量', PRIMARY KEY (`id`), UNIQUE KEY `uk_activity_batch` (`activity_id`, `batch_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;为什么库存要按“批次”拆分?因为农产品是分批到货的,第一批卖完了可能第二批还没到。拆批次一方面方便后台做进销存,另一方面在订单备注里可以明确告诉用户“预计5天内自提”,履约体验更好。
3.3 订单表与状态机:设计清楚,退款省心
订单表是整套系统的核心,字段不能省:
CREATE TABLE `orders` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号', `user_id` INT NOT NULL, `activity_id` INT NOT NULL, `product_name` VARCHAR(100) NOT NULL COMMENT '商品名快照', `spec_desc` VARCHAR(255) NOT NULL COMMENT '规格快照', `product_image` VARCHAR(255) NOT NULL COMMENT '主图快照', `quantity` INT NOT NULL DEFAULT 1, `amount` DECIMAL(10,2) NOT NULL COMMENT '实付金额', `pay_status` TINYINT DEFAULT 0 COMMENT '0未支付 1已支付 2已退款', `order_status` VARCHAR(30) DEFAULT 'pending_payment' COMMENT '状态机值', `pickup_point_id` INT NOT NULL, `pickup_point_name` VARCHAR(100) NOT NULL COMMENT '自提点快照', `pickup_address` VARCHAR(255) NOT NULL COMMENT '自提点地址快照', `groupon_status` TINYINT DEFAULT 0 COMMENT '0成团中 1已成团 2未成团', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_activity_id` (`activity_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;订单状态机我建议这样设计:
| 状态值 | 含义 | 可流转到 |
|---|---|---|
| pending_payment | 待支付 | paid、closed |
| paid | 已支付,等待成团 | groupon_success、refunding |
| groupon_success | 已成团 | preparing、refunding |
| preparing | 备货中 | ready |
| ready | 待自提 | completed |
| completed | 已完成 | 无 |
| refunding | 退款中 | refunded |
| refunded | 已退款 | 无 |
| closed | 已关闭(超时未支付) | 无 |
订单里强制冗余了商品名、规格、图片、自提点等字段。原因很简单:团购活动结束,商品可能下架改价,如果订单再去关联活动表查商品信息,历史订单随时可能查不到数据。快照是电商系统的标准做法。
4. 小程序端从零搭建:首页、商品详情、拼团下单
小程序端的核心是三个页面:首页信息流、商品详情、下单支付。农产品平台不需要复杂的推荐算法,把交易路径走顺才是关键。
4.1 首页信息流:卖的是“活动”,不是“商品”
首页最好不要放成排的商品卡片,因为用户来团购平台,买的是“今天团什么”,不是“有哪些商品”。所以首页建议用活动卡片流。
接口设计很简单:
GET /api/v1/home?page=1&page_size=20返回结构:banner列表 + 活动列表。活动卡片上显示商品主图、活动名称、团购价格、已拼份数/目标份数、剩余时间。
关键点:接口要返回has_more字段,让前端知道还有没有下一页。前端拿到数据后拼接到列表尾部,而不是覆盖。
4.2 列表加载更多,新手最容易翻车的分页逻辑
“微信小程序页面列表加载更多”是搜索频率很高的问题,我一开始也在这里翻过车。原生小程序实现分页,核心是三个状态:page、hasMore、loading。
Page({ data: { activities: [], page: 1, hasMore: true, loading: false }, onReachBottom() { if (this.data.loading) return; if (!this.data.hasMore) return; this.loadActivities(); }, loadActivities() { this.setData({ loading: true }); wx.request({ url: 'https://api.example.com/api/v1/home', data: { page: this.data.page, page_size: 20 }, success: (res) => { const newList = this.data.activities.concat(res.data.list); this.setData({ activities: newList, page: this.data.page + 1, hasMore: res.data.has_more, loading: false }); }, fail: () => this.setData({ loading: false }) }); } });几个容易踩的坑:
- 请求还没返回时,用户快速滑动到底,会连续触发
onReachBottom,所以要用loading状态拦截。 - 下拉刷新时,先把
page重置为1,activities清空,否则数据会重复。 - 判断
hasMore要相信后端返回值,不要靠“返回数量是否等于page_size”去猜。万一最后一页恰好满20条,你的判断就会多请求一次空数据。
这类问题看起来简单,真到上线后高手指点你翻代码的时候,改起来还是很费劲的,不如一开始就写规范。
4.3 商品详情与拼团进度展示
商品详情页需要展示:活动倒计时、团购进度条、参团列表、自提点选择、团购价和划线价。
拼团进度条的数据结构:
{ "sold_num": 35, "target_num": 50, "progress": 70 }前端根据sold_num / target_num计算百分比宽度。
一个运营上的小技巧:新用户看到“0人已拼”可能不敢参与,所以展示参团列表时,可以展示前几单的“虚拟拼团记录”(已经成团的用户透明公开)。这是常见的电商运营手段,技术上不复杂,但对转化率影响很大。
自提点选择用picker组件先做一版,上线后如果用户反馈看不清地址,再上地图选点。
4.4 下单流程与支付唤醒:别把逻辑写在前端
小程序端支付流程,标准做法是:
- 用户提交订单,前端调用后端接口
POST /api/v1/orders,后端生成订单,返回pending_payment状态和微信支付所需参数。 - 前端拿到支付参数后调用
wx.requestPayment。 - 支付成功,前端跳转到“我的订单”页,通过轮询或调用
GET /api/v1/orders/{id}刷新订单状态。
这里的关键教训:支付成功与否的判断,必须以微信支付回调为准,不能信前端回调。用户在支付页面可能选择了“取消”,也可能支付成功但网络中断导致前端没收到回调。后端在收到微信支付结果通知后,才把订单状态从pending_payment更新为paid。
5. Python后端核心逻辑:锁库存、成团、自动退款
这部分是整套系统的技术核心,也是最容易出现并发问题的地方。
5.1 “锁库存”到底锁在哪一层
我见过很多初学项目写这样的代码:
stock = db.query(GrouponStock).filter(...).first() if stock.sold_num + quantity <= stock.total_num: stock.sold_num += quantity db.commit()这段代码在单用户场景没问题,但一旦两个人同时下单,两个请求同时读到sold_num=5,都认为可以再买5份,最后sold_num变成10而不是实际扣减后的正确值。这就是经典的“先查后写”超卖问题。
正确做法是用数据库原子更新:
result = db.execute( update(GrouponStock) .where( GrouponStock.activity_id == activity_id, GrouponStock.sold_num + quantity <= GrouponStock.total_num ) .values(sold_num=GrouponStock.sold_num + quantity) ) if result.rowcount == 0: raise SoldOutError("手慢了,已售罄")这行SQL的语义是:只有“已售数量 + 本次购买数量 <= 总库存”时,才执行加库存操作。数据库的行锁保证了并发安全。如果影响行数为0,说明库存不够,直接返回“已售罄”。
库存充足时,再创建订单。如果创建订单失败,还要把刚才加的sold_num回滚回去。严谨一点,可以把加库存和创建订单放在同一个数据库事务里。
5.2 成团判定:什么时候通知用户“已成团”
成团判定有两个时机:
第一个时机是每次新订单落库后立即检查。用户下完单,后台逻辑先更新库存,再查一下当前活动已售数量,如果sold_num >= target_num,就把活动里的groupon_status从0改成1,并立刻批量更新该活动下所有paid状态订单为groupon_success。这时候再推送“恭喜你,已成团!”的消息,体验最好。
第二个时机是活动结束时兜底扫描。因为理论上可能存在一种情况:某些订单还在pending_payment状态,在支付后活动恰好结束。所以活动到期后,需要一个定时任务把所有paid且未成团的订单统一判定:成团了更新状态,没成团就走退款流程。
成团判定的逻辑要“幂等”。同一个活动成团判断只能触发一次,即使并发请求同时进来,也不能重复发通知。实现上可以用activity.status字段判断:只有status=1(进行中)的活动才允许成团,更新时把status改成2(已结束),后到的请求看到已结束就跳过。
5.3 活动到期未成团:自动退款怎么做
未成团的订单要自动退款。最稳妥的方案不是用time.sleep(),而是用定时任务。
我的做法是:用Celery的beat调度,每分钟执行一次扫描任务,找出所有满足条件的订单:
WHERE order_status = 'paid' AND groupon_status = 0 AND activity.end_time < NOW()然后逐单调用退款接口。
退款要注意幂等性。退款接口可能因为网络问题被重复调用,所以订单表里必须有pay_status状态控制:只有pay_status=1(已支付)才能发起退款,退款进行中置为refunding,退款完成后置为refunded。重复请求看到refunding或refunded就直接返回成功,不重复发起。
5.4 超时未支付:自动关单与库存回补
用户下单后如果一直不支付,订单要自动关掉,否则库存一直被占着。这是和成团退款完全不同的另一个定时任务。
最简单的实现也是用定时扫描:查所有pending_payment且创建时间超过15分钟的订单,将其置为closed,同时把库存sold_num减去对应数量。这个任务粒度可以设为每5分钟一次,损耗可以接受。
订单量大了之后,可以换成延迟队列方案:下单时往MQ里塞一条延迟消息,15分钟后消费,检查订单是否已支付,未支付就关单。前期单量不大,定时扫描完全够用,别上来就搞MQ,运维成本不低。
6. 联调排错实录:抓包、域名配置、页面适配
小程序开发和普通Web开发最大的区别在联调环境。这里记录几个我实测过的排错经验。
6.1 用Charles抓小程序请求:实测过的步骤
小程序页面上的数据不对,或者接口报错看不清response,最直接的办法是抓包。
Charles抓小程序HTTPS请求的步骤:
- 电脑和手机连同一个WiFi。
- Charles开启代理,并开启SSL Proxying,添加需要抓包的域名。
- 手机WiFi设置代理指向电脑IP和Charles端口。
- 手机上安装Charles的根证书,并信任证书。
- 打开微信小程序,请求就能在Charles里看到了。
抓包的价值有两个。一是看小程序实际发出的请求参数和后端返回的原始JSON,接口问题一眼定位;二是把自己小程序的请求头(token、content-type)抓出来,直接放到API调试工具里重复测试,排查问题比在页面上点来点去快得多。
提示:抓包只用于调试自己的小程序和接口,不要拿这套方法去分析别人的小程序,涉及敏感数据的问题没必要碰。
6.2 顶部导航栏高度,不同机型真的不一样
自定义导航栏之前也踩过坑。微信小程序的胶囊按钮位置在不同机型上不一样,尤其iPhone的刘海屏和安卓的挖孔屏,顶部安全区高度差异很大。
正确做法是动态获取胶囊位置:
const menuRect = wx.getMenuButtonBoundingClientRect(); const statusBarHeight = wx.getSystemInfoSync().statusBarHeight; const navBarHeight = (menuRect.top - statusBarHeight) * 2 + menuRect.height;拿到navBarHeight后,再设置自定义导航栏的高度和“胶囊不遮挡内容”的边距。不要写死在样式里,否则换个手机就乱套。
6.3 请求合法域名、HTTPS与“真机白屏”
开发模式下可以在微信开发者工具里勾选“不校验合法域名”,但一旦发布上线,小程序所有wx.request的域名必须:
- 已备案
- 使用HTTPS
- 在小程序后台的“request合法域名”里配置
我上线前就吃过亏。在开发工具里跑得好好的,一扫码真机就白屏。排查后发现是后端HTTPS证书链不完整,微信WebView不认。解决办法是检查证书链是否配全,别只配了叶子证书。
HTTPS证书推荐用免费证书。云厂商提供的免费证书有效期通常一年,记得设置自动续期脚本,否则第二年会突然发现线上接口全挂了。
7. 上线前的检查清单与运营落地细节
技术做完了,还得保证它能真正跑起来。
7.1 一个可以直接拿来用的上线检查清单
- 小程序已完成微信认证,选择的类目覆盖“食品/生鲜/社区团购”等。
- 微信支付商户号已申请,支付回调地址已配置为HTTPS。
- 后端API域名已加入小程序request合法域名。
- HTTPS证书已部署,并确认证书链完整。
- MySQL和Redis都已配置自动备份,备份策略至少每天一次。
- 后端日志已接入按天切割,错误告警能推送到群里。
- 定时任务(成团扫描、退款扫描、关单扫描)已全部部署并验证过。
- 用两个微信号实测了完整流程:下单、支付、成团、退款。
7.2 技术做完了,为什么项目还可能挂
很多团队以为系统上线就完事,但农产品团购真正的难点在业务侧。
预售库存和实际到货可能不一致。所以后台要提供“分批到货”的入库功能,让运营把groupon_stock按批次增加;同时支持“已售罄但临时补货”的追加库存操作。
售后必须有人处理。生鲜商品售后率比普通商品高,后台至少要支持:订单改价、部分退款、整单退款、备注。不要让用户只能通过电话找团长处理,更不能让技术小哥打电话问用户情况。
拼团数据要看,但要会看。成团率、单团平均销量、各自提点订单量,这些数据后台都要有。运营要能看明白哪个商品适合开团、哪个自提点业绩差需要调整。我见过项目上线后运营问“哪里看数据”,才发现后台根本没有统计页面,这就很被动了。
先跑通一个自提点,再复制到十个。系统批量扩张的前提是把复制的流程标准化。技术层面要做的是:自提点的数据模型独立,团长ID、地址、营业时间、结算规则都单独成表,后续复制时不用改代码。
最后聊点我自己的体会。这类小程序真正难的根本不是技术——FastAPI、小程序原生、Redis这些,网上教程多得是。难的是把“农产品非标、损耗、信任、集单”这些业务需求,翻译到数据库表和订单状态机里。数据模型对了、状态流转清晰了、库存扣减安全了,系统就成功了一大半。希望这份拆解能让你在动手前想清楚这几个关键点,少走点弯路。