拼单功能的设计实战
今天我们说一下拼单功能的设计实现。支付模型采用发起人统一支付,支付完成后通过群收款向参与者收取各自的费用。
拼单
可以简单理解为是多人协作下一笔订单。多个人选各自的商品,汇总成一笔订单统一履约(比如统一配送到同一个地址),由发起人统一支付。拼单改变的不是订单结构,而是订单的生成过程:多了一个「协作选品」的前置阶段。
业务流程
完整流程分三个阶段:
选品阶段:
- 发起人创建拼单组,选择门店和配送方式
- 系统生成一个10位唯一标识(uniqueId),发起人把拼单组链接分享给朋友
- 朋友通过链接加入拼单组,各自选商品
- 选完的人点「确认」,等待其他人选完
- 发起人确认所有人选完后,点「去下单」,系统锁定拼单组
下单支付阶段:
- 拼单组锁定后,发起人进入正常下单流程,提交订单时携带拼单组的uniqueId
- 系统把各人的选品合并成一笔订单,发起人支付
- 支付和普通订单完全一致,一个人付,一笔钱
费用分摊阶段:
- 支付完成后,系统计算每个参与者应付的金额
- 发起人通过群收款向参与者收取各自应付的费用
几个关键约束:
- 一人一组:同一用户在同一时间只能加入一个进行中的拼单组。创建新的之前必须退出已有的
- 发起人不能退出:只能取消整个拼单组。取消时系统会记录取消原因(比如「选的人太少」「门店太远」等),用于后续运营分析
- 锁定与解锁:发起人锁定拼单组后进入下单流程,如果想修改可以解锁回到选品状态。但一旦订单已经生成,就不能再解锁了
- 拼单组过期:拼单组创建后48小时内没有提交订单,自动过期。用消息队列的延时任务处理
订单域和支付域的改造
这是最容易被过度设计的地方。拼单系统对订单域和支付域的改动极小,小到可能出乎你的预期。
订单域:加一个类型值,加一张关系表
orders表的改动只有一处:在已有的order_type字段里新增一个枚举值(比如4=拼单订单)。不新增字段,不改表结构。
为什么需要这个类型值?因为订单列表查询、客服后台筛选、运营数据统计,都需要快速知道一笔订单是什么来源。如果每次都要JOIN关系表才能判断是不是拼单订单,查询成本不合理。一个tinyint字段就能解决的事,没必要搞复杂。
拼单组和订单的详细关联用一张独立的关系表:
CREATETABLEorder_group_order(idbigintunsignedNOTNULLAUTO_INCREMENT,group_idbigintNOTNULLCOMMENT'拼单组ID',order_idbigintNOTNULLCOMMENT'订单ID',created_atdatetimeNOTNULLDEFAULTCURRENT_TIMESTAMP,PRIMARYKEY(id),KEYidx_group_id(group_id),KEYidx_order_id(order_id))COMMENT='拼单组与订单关系表';order_type告诉你这是拼单订单,order_group_order告诉你它属于哪个拼单组。两者各司其职。
关系表的设计理由是:拼单是一个可插拔的功能模块。它可能上线、可能下线、可能做灰度发布。拼单组的详细数据(成员、选品、费用分摊)都在独立表中管理,不侵入订单核心表。哪天拼单功能下线,这些表直接废弃即可。
订单提交时,前端在提交参数中直接标明订单类型为拼单,同时携带拼单组的uniqueId。后端根据order_type写入订单表,根据uniqueId创建关系记录并把拼单组状态改为「已提交」。
支付域:回调里加一行
支付域唯一的改动是在支付成功回调里多调一个方法:
支付成功回调: → 正常标记订单已支付 → 更新拼单组状态为「已完成」 // 新增这一行 → 触发费用分摊计算不改支付链路、不改支付接口、不改退款逻辑。一行状态同步,完事。
这就是拼单系统的一个反直觉的点:看起来「多人一起下单」应该对订单和支付产生很大影响,但实际上这两个核心域几乎不需要改。所有复杂度都收敛在订单生成之前的协作阶段,和支付完成之后的费用分摊。
拼单组表设计
拼单系统真正需要独立设计的数据模型在这里。
拼单组表 order_group
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| unique_id | varchar(10) | 唯一标识,用于分享链接和缓存Key |
| creator_id | bigint | 发起人用户ID |
| shop_id | bigint | 门店ID |
| address_id | bigint | 配送地址ID,自取为0 |
| status | tinyint | 0选品中 1已提交 2已完成 3已取消 4已过期 |
| create_channel | varchar(20) | 创建渠道(weapp/alipay/app) |
| share_channel | varchar(20) | 分享渠道 |
| expire_at | datetime | 过期时间(创建时间+48小时) |
拼单组成员表 order_group_member
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| group_id | bigint | 拼单组ID |
| user_id | bigint | 用户ID |
| join_channel | varchar(20) | 加入渠道 |
| status | tinyint | 0未选品 1选品中 2已确认 3已完成 4已取消 5已过期 6已退出 |
用户选品明细表 order_group_item
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| user_id | bigint | 选购人 |
| order_id | bigint | 订单生成后回填 |
| order_item_id | bigint | 对应订单明细ID |
| quantity | int | 数量 |
| price | decimal(10,2) | 结算价(含折扣后) |
| origin_price | decimal(10,2) | 原价 |
| discount_fee | decimal(10,2) | 分摊优惠金额 |
| promo_code | varchar(50) | 命中的活动编码 |
| has_box_fee | tinyint | 是否需要包装费 |
| add_item_channel | varchar(20) | 选品渠道 |
费用分摊表 order_group_fee_split
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| group_id | bigint | 拼单组ID |
| order_id | bigint | 订单ID |
| user_id | bigint | 应付人 |
| goods_amount | decimal(10,2) | 商品金额 |
| delivery_fee | decimal(10,2) | 分摊配送费 |
| box_fee | decimal(10,2) | 分摊包装费 |
| discount_amount | decimal(10,2) | 分摊优惠金额 |
| total_amount | decimal(10,2) | 应付总额 |
表设计速查
| 表 | 核心职责 | 数据写入时机 |
|---|---|---|
| order_group | 拼单组生命周期管理 | 发起人创建时 |
| order_group_member | 参与者管理和状态追踪 | 用户加入时 |
| order_group_item | 记录每人选了什么(含最终价格) | 订单提交时从Redis读取并持久化 |
| order_group_order | 关联拼单组和订单 | 订单提交时 |
| order_group_fee_split | 记录每人应付多少 | 支付成功后计算并写入 |
| orders | l订单类型枚举值加多一个拼单的类型 | 正常订单流程 |
选品阶段的协作设计
选品阶段的数据全部走Redis缓存,不落库。原因是选品阶段的数据变动非常频繁(加商品、改数量、删商品),而且有大量废弃数据(用户退出、拼单取消),如果每次操作都写数据库会产生大量无意义的IO。
Redis的Key结构按天分片:
order:group:{日期}:members:{uniqueId} → Hash,存储成员信息 order:group:{日期}:goods:{uniqueId} → Hash,存储各人选品 order:group:{日期}:status:{uniqueId} → String,拼单组状态快照选品数据只在一个时刻持久化到数据库:发起人提交订单的那一刻。从Redis读取所有成员的选品数据,合并成订单明细写入orders相关表,同时写入order_group_item表记录「谁选了什么」。
这个设计带来两个好处:
- 选品阶段的读写性能极高,纯内存操作
- 拼单取消或过期时不需要清理数据库,Redis的TTL自动过期即可
Redis缓存TTL设为1天,配合拼单组48小时过期的业务规则,缓存一定不会比业务状态先失效。
费用分摊的计算
费用分摊在支付成功后触发计算。每个人应付多少钱,涉及四项费用的拆分:
商品费:每个人自己选的商品结算总价,已经在下单时确定。
配送费分摊:整单配送费按人头均分。配送费 ÷ 参与人数,保留两位小数,用HALF_UP舍入。
包装费分摊:按各人需要包装的商品件数占比分摊。如果A选了2杯需要包装的、B选了1杯需要包装的,总包装费6元,A承担4元,B承担2元。有些商品不需要独立包装(比如加料),通过has_box_fee字段标记。
优惠金额分摊:满减、优惠券等整单优惠,按各人商品原价占整单商品原价的比例分摊。公式:用户优惠 = 用户商品原价 ÷ 全单商品原价 × 总优惠金额
精度处理:分摊计算用BigDecimal,保留两位小数,HALF_UP模式。计算完所有人后,检查分摊优惠总和是否等于实际总优惠。如果有差额(通常是一分钱),把差额补到发起人的优惠里。如果发起人自己没选品(只帮别人下单),差额补给第一个参与者。
每人最终应付:商品原价 - 分摊优惠 + 分摊配送费 + 分摊包装费
有一个边界校验:如果某个参与者计算出来的应付金额为0或负数(极端折扣场景),群收款时会报错,需要在前端提示发起人。
| 分摊项 | 分摊规则 | 精度处理 |
|---|---|---|
| 商品费 | 各自商品结算总价,无需分摊 | 下单时已确定 |
| 配送费 | 人头均分 | HALF_UP保留两位小数 |
| 包装费 | 按需包装的商品件数占比 | HALF_UP保留两位小数 |
| 优惠金额 | 按商品原价占比 | 差额归发起人 |
群收款
群收款不是微信的个人社交转账功能,而是微信官方提供给小程序的拼单群收款API:
POST https://api.weixin.qq.com/wxa/business/groupBuy/createOrder这是一个正式的微信小程序接口,需要小程序具备相应的权限。调用流程:
- 支付成功后,计算每个参与者应付金额
- 获取参与者的微信openId
- 构造请求体(包含每人的openId和金额),调用微信API
- 微信返回群收款页面,发起人可以分享到群聊
- 参与者在微信中看到收款通知,自行付款
群收款按钮的显示条件很严格:
- 当前用户必须是发起人
- 客户端必须是微信小程序
- 拼单组的创建渠道必须是微信小程序
- 拼单组的分享渠道也必须是微信小程序
只要有一个环节走了支付宝或原生App,群收款按钮就不展示。这是因为微信群收款API只能在微信生态内闭环。对于非微信渠道的拼单,发起人只能看到分摊明细,自行和参与者结算。
群收款的最大参与人数是100人。这是微信API的限制,超过100人调用会报错。这个限制更多是防御性校验。
群收款和订单履约是解耦的。发起人付完款,订单就开始制作配送。参与者给不给钱是发起人和参与者之间的社交问题,不影响订单流程。拼单天然依赖熟人关系做信任担保,陌生人之间不会拼单。
取消和退款
取消流程
取消有两个入口,对应两种场景:
手动取消(支付前):用户在待支付状态取消订单,触发拼单组状态变为「已取消」,所有成员状态变为「已取消」,清除Redis缓存。
超时取消(支付超时):订单超时未支付,由消息队列的延时消费者处理。拼单组状态变为「已过期」,所有成员状态变为「已过期」。
两种取消用不同的状态码区分(取消=3,过期=4),方便运营统计哪些拼单是主动放弃、哪些是忘了支付。
退款处理
退款没有额外的拼单逻辑。退款走正常订单退款流程,钱退到发起人账户。
为什么不把退款直接退给对应的参与者?因为参与者不是付款人。微信支付的退款只能退到原支付账户。参与者通过群收款给的钱是微信社交转账,不在订单系统的支付链路里,平台无法操作。
退款后费用分摊记录不删除,保留作为历史数据。发起人如果要把钱退给参与者,自行在微信里处理。
状态机
拼单组状态
| 状态值 | 含义 | 触发条件 |
|---|---|---|
| 0 | 选品中 | 创建拼单组后的初始状态 |
| 1 | 已提交 | 发起人点击下单,订单生成 |
| 2 | 已完成 | 关联订单支付成功 |
| 3 | 已取消 | 发起人主动取消 / 支付前取消订单 |
| 4 | 已过期 | 48小时未提交 / 订单支付超时 |
成员状态
| 状态值 | 含义 | 触发条件 |
|---|---|---|
| 0 | 未选品 | 刚加入拼单组 |
| 1 | 选品中 | 开始浏览菜单选商品 |
| 2 | 已确认 | 选完点确认 |
| 3 | 已完成 | 订单提交成功 |
| 4 | 已取消 | 拼单组被取消 |
| 5 | 已过期 | 拼单组超时 |
| 6 | 已退出 | 主动退出拼单组 |
两层状态之间只有一个联动点:订单支付成功时,拼单组从「已提交」变为「已完成」,同时触发费用分摊计算。
生产环境约束速查
| 约束项 | 规则 | 原因 |
|---|---|---|
| 拼单组过期时间 | 创建后48小时 | 防止僵尸拼单占用缓存和门店资源 |
| 群收款人数上限 | 100人 | 微信API限制 |
| 加入人数上限 | 不限制 | 加入时不校验,群收款时才校验100人 |
| 一人一组 | 同一用户同一渠道同时只能在一个进行中的拼单组 | 防止数据混乱 |
| 发起人退出 | 不允许退出,只能取消 | 发起人退出后无人能操作拼单组 |
| 锁定后修改 | 未生成订单可以解锁,已生成订单不可逆 | 防止订单和选品数据不一致 |
| 群收款渠道约束 | 全链路微信小程序才显示群收款按钮 | 微信API只在微信生态内可用 |
| 门店/地址修改 | 发起人在提交前可修改 | 灵活应对临时变更 |
| 选品数据存储 | 全部在Redis,提交时才持久化 | 高频读写+大量废弃数据不适合直接落库 |
| 取消原因记录 | 独立表存储取消标签 | 运营分析拼单取消原因 |
小结
拼单系统给人的第一印象是「多人协作下单」,直觉上会觉得订单模型和支付流程需要大改。但看过生产级别的实现后会发现,订单域和支付域的改造加起来不超过20行代码。一张关系表、一个支付回调hook,就把两个核心域打通了。
真正的工程量集中在两个地方:选品阶段的实时协作体验(Redis缓存方案、状态同步、多渠道支持),和费用分摊的准确计算(BigDecimal精度、差额处理、边界校验)。这两块做好了,产品体验就到位了。
最近在知乎出了
- 「应付6000万会员的秒杀系统专栏」和
- 「几亿用户,百万并发的C端商品系统实战」
- 「技术团队DDD领域驱动设计三年落地实战」
专栏,感兴趣的可以订阅一下。至于知识星球的,可以搜:
- 老码头的技术浮生录
它是一个能实际帮你解决难题的星球。有问题的,找知心的Sam哥,支持无限次语音一对一解决你遇到的难题。「另外后续我新写的所有对外的付费专栏,在星球内都是免费的,且可以拿到所有源代码。」
当前星球里免费看的专栏有:
- 「几亿用户,百万并发的C端商品系统实战」
- 「技术团队DDD领域驱动设计三年落地实战」
知识星球内后续将推出20+个付费专栏,覆盖电商全链路:
| 选购线 | 用户会员营销线 | 中后台 |
|---|---|---|
| 购物车服务 | 营销系统 | 订单系统 |
| 商品服务 | 用户系统 | 支付系统 |
| 菜单服务 | 结算服务 |
从前台选购到中后台结算,星球成员全部免费,后续新增也不额外收费。
我的知乎账号:
- SamDeepThinking
