直接把一套带地址自提、预售团购、多人成团的农产品小程序,硬生生当成“通用商城”来搭,项目做到一半基本就要返工。
我第一次接“SpringBoot地区特色农产品团购平台 小程序”这个需求时,原型评审上大家的第一反应也是:商品列表、购物车、订单、微信支付、后台管理,照着标准电商模板改一改不就完了?等到整理业务细节才发现,农产品团购跟标品电商的差别不是“换几个字段”能搞定的。规格怎么定、今日可售量怎么约束、成团条件如何触发、自提点如何履约,这些才是决定系统能不能真正用起来的关键。
这篇文章我按自己的实操过程来梳理,覆盖 Spring Boot 后端、微信小程序端、后台任务、上线后的坑。适合准备做农产品电商、社区团购、产地直发类小程序的同学参考,也适合给客户做定制开发时提前避雷。
1. 先别急着搭工程:这轮需求里的农产品属性才是真正的主线
很多开发团队一拿到需求就开建项目,但农产品团购最需要先花时间统一的,其实是需求模型。领域模型没想清楚,后面写的代码全是补丁。
1.1 非标商品如何定义可购买单位
农产品不是可乐,不是一卷卫生纸。它没有一个厂家给的“标准条码”,同样是一箱苹果,不同产地、不同大小、不同成熟度,价格可能差出一倍。标准电商里的SPU/SKU模型能用,但要改造成更适合农产品的形态。
我做这套系统时,商品主数据只维护“这是什么产品”,真正卖的是“份”——比如砂糖橘5斤装、土鸡蛋30枚装、腊肉礼盒2斤装。每份具体重量允许有±10%的浮动,这是农业行业本身的客观情况。如果硬要像工业品那样做到每个单位重量一致,供应商做不到,你也别硬逼。
所以在数据库里,我不建议只设计一张商品表就开卖,会让运营非常痛苦。
- 商品基础层:维护品名、图片、产地、介绍、供应单位,比如“阳光玫瑰葡萄”“山地放养土鸡”。
- 规格层:一个商品可以有多个销售规格,比如“5斤家庭装”“10斤礼盒装”。
- 场次层:同一规格在不同时间段可能做不同的价格,或限量售卖,必须挂到独立的场次活动表。
这个分层以前很多人不在意,遇到客户问“为什么不能把今天的果子挪到明天的团里卖”时才反应过来,库存和价格都应该跟着“场次”走,而不是跟着商品跑。
1.2 “团购”到底是商城拼团还是预售集中采购
从字面上说,地区特色农产品团购平台,尤其面向本地社区或城区消费者,通常有两种模式:
一种是“预售集中采购”。农民今天摘多少货,就开多少个预售名额,卖完即止;用户先下单付款,达到一定起送量后,隔天采摘、分拣、配送或自提。这种模式让农户没有库存压力,也避免损耗。
另一种是“多人拼团”。用户发起一个团,需要拉足够的人参团,成团后才发货,不成团自动退款。好处是增加传播,坏处是用户下单到最终成团之间有一段等待期,售后压力大。
我在第一版里没有强行把两种揉在一起,而是通过一个“活动类型”字段区分。一个场次可以配置成“总量售完即成团”,也可以配置成“达到最低人数成团”。底层逻辑接近,用户端虽然体验不同,后台任务和状态机却是同一套。
如果你一开始就把两种模式写成两套代码,后面维护会非常累。
1.3 自提与快递,决定了下单页要早做区分
社区农产品团购大多走“到点自提”,用户在自己小区门口、驿站、团长家里取货。长途特产则走快递,或者区域代理商集中配送。这个小细节看着不起眼,但对下单流程影响特别大。
自提需要维护提货点表,需要用户选择提货点,后台还要能够导出按自提点分组的分拣单;快递则需要有完整的收货人信息,有物流单号回填。如果同一个场次里既有自提也有快递,订单表和配送计划表就要设计得足够灵活,不能写死只有一个收货地址字段。
我通常建议第一版就把“配送方式”作为订单主表的独立字段,并在下单页根据当前场次的默认配送范围自动带出。这样运营在创建场次时就能选择是“仅自提”“仅快递”还是“都支持”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 后端技术选型与工程结构,别被 Spring Boot 3 的新版本带偏
Spring Boot 是这套系统比较稳妥的后端选择。但“用哪个版本”这个问题,比想象中更容易出问题。我看到很多刚起步的团队直接选了 Spring Boot 3.x + JDK17,然后被老牌工具链整得焦头烂额。
2.1 JDK8 + Spring Boot 2.7 还是 JDK17 + Spring Boot 3.x
先放一个不容易后悔的方案:如果客户没有明确要求新版本特性,或者团队对 Spring 生态没有长期维护经验,建议先选 Spring Boot 2.7.x + JDK8。
原因比较实际:
- 很多做农业信息化的公司,客户手里的服务器还是几年前的配置,预装的 JDK 往往是 8。
- Spring Boot 2.7 是 2.x 的最后一个大版本,社区仍有很多项目依赖基于它维护。
- 老牌生成工具比如代码生成器、部分低代码平台、便宜的短信服务商 SDK,很多是基于 JDK8 写的,放到 Boot3 下会出现包名不兼容或者 javax/jakarta 命名空间冲突。
如果非要用 Spring Boot 3.x,就需要接受 javax.* 全面改成 jakarta.*。比如引入参数校验时,代码里不能再写 javax.validation,要写 jakarta.validation。Redis、MyBatis、MQ 这些中间件如果版本太老,也要一并升级。
我自己的建议是:新项目,无历史包袱,团队 JDK 都比较熟,选 Boot 3.2+ 没毛病;但如果你接的是一个要给客户长期维护、需要控制部署成本的项目,Boot 2.7 会省很多事。
2.2 工程目录划分与模块职责
小程序后端不要把所有代码堆到一个包下面。农产品团购平台涉及用户端、商家运营端、定时任务、微信对接,至少按以下边界拆一下:
text复制farm-group-api/
├── farm-common/ # 通用返回结构、异常、工具类
├── farm-admin/ # 运营后台接口,给管理员/团长用
├── farm-user/ # 小程序 C 端接口
├── farm-job/ # 定时任务:关单、成团检查、退款
├── farm-wx/ # 微信登录、支付、订阅消息封装
每块之间通过服务接口调用,领域对象不要互相透传。特别是微信相关的逻辑,抽到 farm-wx 里单独维护比较好。微信支付签名、回调验签、解密手机号这类代码夹杂在各业务 Service 中,后面排查问题会很难受。
Spring Boot 项目我建议保持传统 package by feature 的做法,按 controller/service/mapper/entity 分包即可,不需要为了“整洁架构”上太多抽象层。这个体量的系统,最怕的就是过度设计。
2.3 ORM 与接口文档选型
ORM 我用 MyBatis-Plus,因为大多数做过 SSM/Spring Boot 的开发者上手成本低,也方便写自定义复杂 SQL。不要去神话 JPA,农产品团购里报表和分页统计特别多,MyBatis-Plus 的 LambdaQueryWrapper 配合 XML 可以覆盖几乎所有场景。
接口文档我建议直接用 knife4j,集成简单,生成出的 OpenAPI 文档可以直接给小程序端开发调用。如果要交付给第三方团队,这一点特别加分。
3. 核心数据模型:把“今天能供多少货”变成一张场次表
第一次设计数据库时最容易犯的错,就是照搬电商系统的商品+SKU+库存三件套,然后给库存表加了个“地区”字段,就以为支持了地区特色业务。实际跑起来后会碰到一个很尴尬的情况:商品在不同日期有不同定价、不同可售数量,同一 SKU 的库存和价格每天都在变化,光靠 SKU 表根本没法直观配置。
我在第二版里把“场次活动”提到了核心位置。
3.1 商品、场次和 SKU:三层结构的基本认识
清晰的数据模型应该是这样的:
- 商品(product):代表一种农产品,比如“高山绿茶”。只维护名称、产地、图文详情、主图。
- 规格(product_sku):属于商品下的可购买规格,比如“250g袋装”“500g礼盒装”。维护原价、划线价、规格描述。
- 场次(activity):类似一次“开团计划”。一个场次可以包含多个规格,分别设置活动价、今日可售库存、起售时间、结束时间、成团门槛、配送方式。
用户下单时不会直接买“商品”,而是对某个“场次规格”进行购买。当天有多少货、什么价格,都由场次决定。
如果你的客户天天在群里喊“今天的草莓开始订了”,她要创建的其实就是一条新的场次记录,而不是修改商品价格。
3.2 核心表结构和字段
核心表可以精简成下面这几张,但字段设计比数量更关键。
商品表与场次表可以根据标题拆解如下:
| 表 | 关键字段 | 作用说明 |
|---|---|---|
| product | id, name, category_id, region_code, cover, detail, status | 维护农产品基础资料与地区属性 |
| product_sku | id, product_id, spec_name, weight, original_price, status | 定义可购买的规格颗粒度 |
| activity | id, title, product_id, start_time, end_time, group_min_num, delivery_type, status | 定义一次开团计划与成团条件 |
| activity_sku | id, activity_id, sku_id, activity_price, stock, lock_stock, sort | 设置场次内规格的活动价与库存 |
| pickup_point | id, name, address, manager_name, manager_phone, region_code, status | 用于线下自提履约 |
| member_order | id, order_no, member_id, activity_id, total_price, pay_price, status, delivery_type, pickup_point_id, pay_time, finish_time | 订单主表,记录下单归属场次 |
| order_item | id, order_id, sku_id, spec_name, price, num | 订单明细 |
“团购”的核心条件在 activity 上有一个 group_min_num 字段。这个字段表示“达到多少购买人数就成团”。如果值为1,那说明这个场次不设门槛,现货售卖;如果值是20,则意味着用户下单后处于“待成团”状态,直到这个场次的购买人数达到20时自动成团。
每一个订单里都要存 activity_id,并且 order_item 里冗余一份 spec_name。这样做的好处是,即使运营后期改了规格名称,历史订单仍能正确展示当时的商品名。日常开发中千万不要过度依赖 join 去回查商品信息,订单一旦沉淀下来,商品改了个标题,老订单展示就乱了。
3.3 库存的预占与回补
农产品做活动时经常出现“开团5分钟全部抢光”的场面,因此库存字段一定要做到可锁可回补。
活动规格库存我不建议直接在 product_sku 的 stock 字段上扣减,而是单独在 activity_sku 上存 stock(初始总量)和 lock_stock(已售且未取消的数量)。下单支付后同步增加 lock_stock,取消订单后扣减 lock_stock。通过这种方式,场次本身的总可售量不会因为取消而无限恢复,但如果库存设少了,取消订单后也能回补名额。
如果你只想用一张普通商品库存表抗住所有场景,活动之间还可能互相串库存,等到开团卖超了,就很难查了。
4. 下单与成团链路实现:从支付回调到自动取消订单的关键代码
业务模型梳理清楚后,最核心的工作是下单和成团的状态流转。
4.1 用户下单时的防超卖代码
库存扣减不能先查库存再 update,这样做并发时必然会超卖。最稳妥的写法是使用数据库行锁实现原子更新:
java复制@Update("UPDATE activity_sku SET lock_stock = lock_stock + #{num} " +
"WHERE id = #{skuId} AND activity_id = #{activityId} " +
"AND lock_stock + #{num} <= stock")
int lockStock(Long skuId, Long activityId, Integer num);
如果返回值为 0,说明场次已经没有库存,或者锁定的数量超过了总库存,此时直接给前端返回“手慢了,已售罄”。对于大量并发同时抢购的情况,还可以用 Redis 做一层预扣,再异步同步到数据库。但如果系统每天订单量没过几百,直接走数据库原子更新加唯一索引反而最简单可靠。
这里有一个常见陷阱:lock_stock + #{num} <= stock 这个条件,在 MySQL 里要用到索引,否则并发高时会有行锁排队。给 activity_sku 表的主键或者 activity_id + sku_id 联合索引加好,才能保证 update 操作锁定的是同一行记录。
4.2 支付回调必须保证幂等
用户在小程序里付款后,微信服务器会回调你的后端接口,通知订单已经支付成功。这个回调通知可能发送不止一次,所以支付回调处理方法首先要保证幂等。
下面是一段核心处理逻辑的简化写法:
java复制public void handleWxPayNotify(PayNotifyVO notify) {
String orderNo = notify.getOutTradeNo();
// 1. 所有操作都必须先查一次订单状态
MemberOrder order = orderMapper.selectByOrderNo(orderNo);
if (order == null) {
throw new BusinessException("订单不存在");
}
// 2. 如果状态已经是 PAID,说明已处理过,直接返回成功
if (OrderStatusEnum.PAID.equals(order.getStatus())
|| OrderStatusEnum.GROUPED.equals(order.getStatus())) {
return;
}
// 3. 校验金额,防止篡改
if (!order.getPayPrice().equals(notify.getTotalFee())) {
log.error("订单金额不一致,orderNo={}", orderNo);
return;
}
// 4. 更新状态
orderMapper.updateStatus(orderNo, OrderStatusEnum.PAID);
// 5. 通知用户,触发成团检查
groupService.checkActivityGroup(order.getActivityId());
}
这里的核心原则是:支付金额、订单状态、库存扣减都必须在一个事务里闭环。
4.3 定时任务在成团与关单中的角色
支付后,用户会看到一个状态:“已支付,等待成团”。但这个状态不能一直等下去。因此平台必须有一套明确的成团判断逻辑。
我采取的方案是:活动表上维护一个 group_min_num(最低成团人数),并记录当前 order_user_count(本场次已支付且未取消的购买人数)。每次支付回调触发的 checkActivityGroup 方法判断,若条件满足就自动更新场次状态为“已成团”,同时把该场次下所有待成团订单统一改为“待发货”。
java复制public void checkActivityGroup(Long activityId) {
Activity activity = activityMapper.selectById(activityId);
// 当前已支付人数
Integer paidCount = orderMapper.countPaidUserByActivity(activityId);
if (paidCount >= activity.getGroupMinNum()) {
// 场次达到成团条件,批量更新所有待成团订单
orderMapper.updateStatusByActivity(
activityId,
OrderStatusEnum.WAIT_GROUP,
OrderStatusEnum.WAIT_DELIVER
);
activityMapper.updateStatus(activityId, ActivityStatusEnum.GROUP_OK);
}
}
有些朋友问:如果用户是买了4件商品,算1个人还是4个人?这个由业务决定。为了避免一个用户可以靠多下单“自刷成团”,我计数时优先统计 member_id 的去重人数,而不是订单总件数。这样拼团门槛更公平。
同时,还要写一个定时任务,把超时未支付的订单自动关掉,释放库存:
java复制@Component
public class OrderCloseJob {
@Scheduled(cron = "0 */5 * * * *")
public void closeExpiredOrder() {
// 查出创建时间超过30分钟且未支付、未关闭的订单
List<MemberOrder> expiredOrders = orderMapper.selectExpiredUnpaidOrders(30);
for (MemberOrder order : expiredOrders) {
// 防止并发重复关闭,用订单状态做CAS更新
int rows = orderMapper.closeOrderIfWaiting(order.getOrderNo());
if (rows > 0) {
// 回补库存
skuStockService.releaseLockStock(order);
}
}
}
}
定时任务跑的频次不用太密,5分钟一次就够了。关单时要注意:如果用户是在支付回调还没收到的时候刚好被关单了,那么支付回调到达后要判断订单状态不是“待支付”,就不允许继续走支付成功流程,需要引导进入退款流程。
4.4 未成团如何自动退款
当活动结束时间到了,但 order_user_count 还没有达到设定的 group_min_num,平台不能把这个场次下的订单永远挂在那边。我实现的逻辑是,新增一个每日凌晨执行的启停任务,扫描“已过期且未达到成团人数”的场次,将其下所有订单状态更新为“已取消”,并调用微信退款接口。
整体状态流转看起来像这样:
- 待支付 -> 已支付(支付回调成功)
- 已支付 -> 待发货(达到成团人数)
- 已支付 -> 已退款(活动结束未成团)
- 待发货 -> 已发货(后台录入物流单号)
- 已发货 -> 已完成(用户确认收货)
需要注意的一点是,不要直接在订单表上改状态完事。建议设计一个独立的 order_status_log 表,记录订单每次状态变更的时间、操作人、操作原因。用户投诉时,后台能查出“为什么我的订单被取消了”,这会减少大量扯皮。
5. 微信小程序端最容易踩坑的四个接入点
后端搭得再稳,如果小程序端接入姿势不对,一样会被打回。这里我把最容易出问题的登录、手机号、支付和订阅消息单独拿出来说。
5.1 code2Session 登录与“session 过期”问题
小程序登录的常规做法是:前端调用 wx.login 拿到一个临时 code,后端拿 code 去微信接口换 openid 和 session_key。
这里有一个很多人第一次做时必然踩的坑:在实际的微信开发中,登录时的 wx.login 返回 code 只能使用一次,而且有效期只有5分钟。如果前端重复发送同一个 code,后端就会收到微信返回的错误码 40029,对应提示是“invalid code”。很多人在社区搜索时看到错误是“wx1cb4398e1413dce7”,其实本质都是 code 失效或重复使用。
正确写法是:
java复制public String wxLogin(String code) {
String url = "https://api.weixin.qq.com/sns/jscode2session"
+ "?appid=" + appId
+ "&secret=" + appSecret
+ "&js_code=" + code
+ "&grant_type=authorization_code";
String result = restTemplate.getForObject(url, String.class);
JSONObject json = JSON.parseObject(result);
if (json.getString("openid") == null) {
log.error("微信登录失败,code={}, result={}", code, result);
throw new BusinessException("登录凭证已失效,请重新进入小程序");
}
String openid = json.getString("openid");
// 根据 openid 找到或创建用户
// 生成自定义登录态 token 返回给前端
}
小程序开发时,经常会遇到两个不同环境的问题。后端在本地开发用的可能是测试小程序 AppID,上线后被替换成正式 AppID。如果没有同步更换后端的 appid/secret 配置,就会出现登录永远失败的情况。建议将 appid/secret 放到配置中心或环境变量里,每次切换环境时能快速替换。
5.2 手机号获取,新一代接口别再用 getPhoneNumber 老回调
如果你近期在小程序里做手机号绑定,一定不要再用旧版 getPhoneNumber 直接返回手机号的方式,那个接口已经改成需要先触发获取用户手机号组件,并且需要收集回传的 code。现在的流程是:
- 在按钮内放置
<button open-type="getPhoneNumber" bindgetphonenumber="getPhoneNumber"> - 前端拿到动态
code - 前端把 code 传给后端
- 后端调用微信接口
phonenumber.getPhoneNumber换手机号。
这块涉及微信敏感数据,不能用前端传过来的明文手机号去绑定用户,否则安全和合规都有问题。
5.3 wx.requestPayment 与支付回调
小程序端发起微信支付时,后端需要先生成预支付交易单,拿到 prepay_id,再把签名参数返回给小程序。前端拿到 5 个参数后调用 wx.requestPayment。
先提供一个最基础的 controller:
java复制public WxPayOrderResultVO createPayOrder(String orderNo) {
MemberOrder order = orderService.getByOrderNo(orderNo);
WxPayUnifiedOrderV3Request request = new WxPayUnifiedOrderV3Request();
request.setOutTradeNo(orderNo);
request.setDescription("农产品团购订单");
request.setAmount(WxPayAmount.builder()
.total(order.getPayPrice().movePointRight(2).intValue()) // 单位分
.build());
// 省略:调用微信支付 SDK
return wxPayService.createOrderV3(request);
}
这里最容易犯的低级错误就是金额单位。微信支付的金额单位是“分”,不是“元”。如果你把 19.9元 直接传给微信接口,微信会认为支付 19.9 分,约等于 0.2 元。测试时发现问题还容易改,上线后出现资损就很难处理。
支付回调地址建议和登录接口区分开,接口响应必须是微信指定格式。收到回调后先验证签名,再处理业务。处理成功返回 {"code": "SUCCESS"},处理失败返回 {"code": "FAIL", "message": "失败原因"}。微信如果没收到成功的响应的失败响应,会按一定频次重试回调,所以不能为了图省事什么都返回 SUCCESS,否则一笔订单会被微信多次重试,就会造成重复发券、重复通知。
5.4 订阅消息:一次订阅只能推送一条
用户成团、发货、自提时,我们需要给用户推送微信订阅消息。这里容易踩的坑是,小程序的订阅消息不是“关注公众号”那种长期通知。
每次用户授权订阅,就只能给你发送一条消息。如果用户操作一次,并且你需要发送两条(比如“已成团”和“已发货”),那就要在合适时机让用户订阅两次,或者合并在一条模板里发出。
实际操作中,我会在用户支付成功页面放一个按钮“接收提货通知”,点击时触发 wx.requestSubscribeMessage,向用户申请一次订阅授权。由于模板需要用户主动触发才有效,支付后页面弹窗申请刚刚好。
javascript复制wx.requestSubscribeMessage({
tmplIds: ['模板ID_1'],
success(res) {
// 这里 res['模板ID_1'] === 'accept' 表示用户同意
}
})
后端发送订阅消息时,需要提前获取到用户的 openid,并调用微信的 subscribeMessage.send 接口。注意如果用户小程序端没有点过授权,后端调用会报错“40101 用户拒绝订阅消息”。解决思路是不在发送时兜底,而是在下单页、支付成功页都设计订阅入口,让用户多一次订阅机会。
5.5 提审前的检查
小程序的审核跟普通网页不一样,当前农产品销售如果直接涉及实物商品交易,基本都会被要求选择“电商平台”或“商家自营”类目。如果小程序的主体没有相关资质,很可能在提审阶段被卡住。为了避免反复:
- 后台先增加“商品详情页必须展示价格、库存、运费、售后说明”;
- 用户协议和隐私政策要能正常打开,这是一个高频拒审点;
- 支付必须是微信支付,不支持个人转账;
- 后台不要放“测试”“联调”等字样的明显开发痕迹;
- 使用测试账号测试一键登录、支付全流程后再提审。
另外,上线后还有一个容易忽略的问题:手机端访问后端接口必须使用 HTTPS 域名,并且已经配置在小程序后台的“服务器域名”里。如果用户直接拿 IP 地址请求,真机调试会失败。这个问题在开发工具里表现不明显,因为开发者工具默认勾选了“不校验合法域名”,但真机上会立刻报错。
6. 运营侧必须提前做好的兜底设计
6.1 后端资源映射与静态文件隔离
农产品商品的详情页通常要放很多实拍图,图片多是由运营上传的。如果图片直接放在 Spring Boot 的 classpath 或者本地磁盘,默认是不会被浏览器访问的,你需要加一个资源映射配置。
比如本地磁盘 /data/farm-images/ 保存图片,要映射成访问路径:
java复制@Configuration
public class WebConfig implements WebMvcConfigurer {
@Value("${file.upload-dir}")
private String uploadDir;
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/images/**")
.addResourceHandler("file:" + uploadDir + "/");
}
}
个人开发阶段可以用这种方式,生产环境建议直接把图片放到 OSS/对象存储上,小程序端加载图片走 CDN。否则后端服务器一重启,图片就可能会丢。
6.2 服务重启后的状态修复
运营期比较常见的一个问题是,后台服务在拼团过程中重启,会导致一些订单状态没有及时更新。虽然定时任务可以兜底,但一个已成团活动如果恰好在下单支付后服务中断,用户端看到的状态可能一直是“待成团”。
我在后台加了一个“活动状态重算”按钮,管理员可以随时手动触发某个活动下面的所有订单状态重算。对 1000 人以下的小型订单量来说,每次重算扫描一次所有订单也很快。
类似的后台补偿操作还包括手动退款、手动发货、手动关闭活动。要给运营同学留一个清晰的补救入口,而不是什么事都让开发去写 SQL。
6.3 多实例部署时定时任务的重复执行问题
如果应用被部署成多节点,Spring Boot 自带的 @Scheduled 会在每个节点上同时执行,就会发生重复关单、重复退款。最简单的解决办法是给任务加 Redis 分布式锁,保证同一时间只有一个节点执行。若项目还没引入 Redis,可以用 MySQL 做一个任务执行记录表,在任务入口先插入一条唯一记录,利用唯一索引防止重复执行。
java复制public void executeWithLock() {
String lockKey = "job:close-expired-order";
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(30));
if (!locked) {
log.info("任务已在其他节点执行,跳过");
return;
}
try {
// 业务逻辑
} finally {
redisTemplate.delete(lockKey);
}
}
对一个初创农产品平台来说,单服务部署加数据库主从已经足够,不需要一开始就搞微服务。引入太多中间件会让后续运维变成负担。
6.4 实际运营中的打印与核销
如果平台支持自提,后台还应该准备“提货单打印”功能。运营在活动结束后,按自提点分组导出或者打印每个用户的提货二维码/提货码。用户到自提点提货时,核销员在手机端输入提货码完成核销状态更新。
这个功能看似简单,但直接决定了运营人员愿不愿意用你的系统。没有提货码的农产品团购系统,运营只能自己拿 Excel 手工作业,订单量一大就乱。
提货码的设计可以不用太长,6位数字即可,后台在用户支付成功时生成,并支持按自提点搜索、按手机尾号搜索。核销表里记录核销操作员、核销时间,后续对账就都有了依据。
7. 把“地区特色”做成可复制能力,而不是写死一个县城
最后分享一个我自己的判断。
这类项目如果只做单点定制,做完一个客户就结束了,后面接同类型需求还得从零开始。比较聪明的做法是,把“地区”抽象成整个系统的基础维度。商品表里有 region_code,订单表冗余了地区信息,后台可以按地区配置运费模板、配送范围、自提点。这样当运营想把平台复制到旁边的县市时,不需要改代码,只需要新增地区编码和对应的自提点负责人。
同样的道理也适用于“团购”逻辑。团队里如果有人想把某个场次做成“老客户专享团”“新客尝鲜团”,只需要给活动表加一个 group_type 或者 member_level_limit 字段,不需要动主流程。
我做这个项目时,最深的一个体会就是:前端小程序和后端 Spring Boot 本身都不太难,难的是理解农产品业务背后的复杂性和不确定性。把非标品模型设计好,把成团和履约状态机理清楚,再给运营留足后台操作空间,这个项目才真正算有生命力。
