拼单模块设计实战

今天我们说一下拼单功能的设计实现。支付模型采用发起人统一支付,支付完成后通过群收款向参与者收取各自的费用。

拼单

可以简单理解为是多人协作下一笔订单。多个人选各自的商品,汇总成一笔订单统一履约(比如统一配送到同一个地址),由发起人统一支付。拼单改变的不是订单结构,而是订单的生成过程:多了一个「协作选品」的前置阶段。

业务流程

完整流程分三个阶段:

选品阶段:

  1. 发起人创建拼单组,选择门店和配送方式
  2. 系统生成一个10位唯一标识(uniqueId),发起人把拼单组链接分享给朋友
  3. 朋友通过链接加入拼单组,各自选商品
  4. 选完的人点「确认」,等待其他人选完
  5. 发起人确认所有人选完后,点「去下单」,系统锁定拼单组

下单支付阶段:

  1. 拼单组锁定后,发起人进入正常下单流程,提交订单时携带拼单组的uniqueId
  2. 系统把各人的选品合并成一笔订单,发起人支付
  3. 支付和普通订单完全一致,一个人付,一笔钱

费用分摊阶段:

  1. 支付完成后,系统计算每个参与者应付的金额
  2. 发起人通过群收款向参与者收取各自应付的费用

几个关键约束:

  • 一人一组:同一用户在同一时间只能加入一个进行中的拼单组。创建新的之前必须退出已有的
  • 发起人不能退出:只能取消整个拼单组。取消时系统会记录取消原因(比如「选的人太少」「门店太远」等),用于后续运营分析
  • 锁定与解锁:发起人锁定拼单组后进入下单流程,如果想修改可以解锁回到选品状态。但一旦订单已经生成,就不能再解锁了
  • 拼单组过期:拼单组创建后48小时内没有提交订单,自动过期。用消息队列的延时任务处理

订单域和支付域的改造

这是最容易被过度设计的地方。拼单系统对订单域和支付域的改动极小,小到可能出乎你的预期。

订单域:加一个类型值,加一张关系表

orders表的改动只有一处:在已有的order_type字段里新增一个枚举值(比如4=拼单订单)。不新增字段,不改表结构。

为什么需要这个类型值?因为订单列表查询、客服后台筛选、运营数据统计,都需要快速知道一笔订单是什么来源。如果每次都要JOIN关系表才能判断是不是拼单订单,查询成本不合理。一个tinyint字段就能解决的事,没必要搞复杂。

拼单组和订单的详细关联用一张独立的关系表:

SQL 复制代码
CREATE TABLE order_group_order (
  id bigint unsigned NOT NULL AUTO_INCREMENT,
  group_id bigint NOT NULL COMMENT '拼单组ID',
  order_id bigint NOT NULL COMMENT '订单ID',
  created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  KEY idx_group_id (group_id),
  KEY idx_order_id (order_id)
) COMMENT='拼单组与订单关系表';

order_type告诉你这是拼单订单,order_group_order告诉你它属于哪个拼单组。两者各司其职。

关系表的设计理由是:拼单是一个可插拔的功能模块。它可能上线、可能下线、可能做灰度发布。拼单组的详细数据(成员、选品、费用分摊)都在独立表中管理,不侵入订单核心表。哪天拼单功能下线,这些表直接废弃即可。

订单提交时,前端在提交参数中直接标明订单类型为拼单,同时携带拼单组的uniqueId。后端根据order_type写入订单表,根据uniqueId创建关系记录并把拼单组状态改为「已提交」。

支付域:回调里加一行

支付域唯一的改动是在支付成功回调里多调一个方法:

TypeScript 复制代码
支付成功回调:
  → 正常标记订单已支付
  → 更新拼单组状态为「已完成」  // 新增这一行
  → 触发费用分摊计算

不改支付链路、不改支付接口、不改退款逻辑。一行状态同步,完事。

这就是拼单系统的一个反直觉的点:看起来「多人一起下单」应该对订单和支付产生很大影响,但实际上这两个核心域几乎不需要改。 所有复杂度都收敛在订单生成之前的协作阶段,和支付完成之后的费用分摊。

拼单组表设计

拼单系统真正需要独立设计的数据模型在这里。

拼单组表 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结构按天分片:

TypeScript 复制代码
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

TypeScript 复制代码
POST https://api.weixin.qq.com/wxa/business/groupBuy/createOrder

这是一个正式的微信小程序接口,需要小程序具备相应的权限。调用流程:

  1. 支付成功后,计算每个参与者应付金额
  2. 获取参与者的微信openId
  3. 构造请求体(包含每人的openId和金额),调用微信API
  4. 微信返回群收款页面,发起人可以分享到群聊
  5. 参与者在微信中看到收款通知,自行付款

群收款按钮的显示条件很严格:

  • 当前用户必须是发起人
  • 客户端必须是微信小程序
  • 拼单组的创建渠道必须是微信小程序
  • 拼单组的分享渠道也必须是微信小程序

只要有一个环节走了支付宝或原生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
相关推荐
Mike117.1 小时前
GBase 8a 宽表查询里的压缩和行存列取舍
java·开发语言·数据库
我有医保我先冲1 小时前
【无标题】
java·大数据·人工智能
XiYang-DING2 小时前
【Java EE】UDP(User Datagram Protocol)协议
java·udp·java-ee
富士康质检员张全蛋2 小时前
Kafka架构 数据发送保障
分布式·架构·kafka
CODE202203182 小时前
promptfoo自定义prompt生成器
java·前端·prompt
_waylau2 小时前
“Java+AI全栈工程师”问答02:Spring Boot 自动配置原理
java·开发语言·spring boot·后端·spring
JAVA面经实录9172 小时前
Java架构师最终完整版学习路线图
java·开发语言·学习
上海蓝色星球2 小时前
从工具到资产:CER V2.0 造价机器人如何重构企业核心竞争力
java·数据库·mysql
spencer_tseng2 小时前
System2.java
java·system