多商户平台的订单体系,天然比单商户复杂一个维度。
LikeShop 多商户标准版原生订单以店铺为维度生成,单店铺下单流程是通顺的,但在两个场景下体验和效率不足:一是用户跨店铺选购商品时,必须分店铺分别提交、分别支付,操作链路长,转化损耗大;二是同一商家的多笔订单无法合并发货、合并结算,商家运营效率低,财务对账成本高。
这类需求属于典型的二开场景:不推翻原有订单体系,在原生结构基础上扩展主子单架构,实现跨店拆单与同店合并能力,同时兼容原有库存、支付、分账、售后全链路。
本文基于 LikeShop 多商户标准版(PHP + ThinkPHP 技术栈),给出一套完整的二开改造方案,涵盖架构设计、表结构改造、核心代码实现、关联模块联动与平滑升级方案。
一、改造背景与核心目标
1.1 原生订单的局限
LikeShop 多商户标准版原生订单模型为单级结构,ls_order 表一条记录对应一个店铺的一笔订单,merchant_id 区分所属商家。这套模型在单店铺下单场景下足够简洁,但存在两个明显短板:
- 用户侧:购物车包含多家店铺商品时,必须分店铺分别提交订单、分别支付,操作步骤多,支付转化率低;
- 商家侧:同一用户的多笔订单无法合并发货,同一结算周期内订单逐笔对账,运营与财务效率低。
1.2 改造核心目标
- 跨店拆单:支持购物车多店铺商品统一提交、一次支付,系统自动按商家拆分为多个子订单,用户侧只感知一个主订单;
- 同店合并:支持同一商家的多笔待发货订单合并发货、同一结算周期合并生成结算单;
- 向下兼容:不破坏原有订单、库存、支付、分账、售后链路,历史数据可平滑兼容;
- 最小侵入:尽量通过新增表与扩展字段实现,少改原生核心文件,便于后续同步官方版本。
二、核心设计思路
2.1 两级订单架构
采用「主订单 + 子订单」双层结构,这是多商户拆单的行业标准方案。
| 层级 | 面向对象 | 核心作用 |
|---|---|---|
| 主订单(父单) | 用户 | 统一展示、统一支付、汇总金额,用户侧只看到一笔订单 |
| 子订单 | 平台/商家 | 按商家拆分,各自独立履约、分账、售后,商家端仅可见自身子单 |
核心原则:支付看主单,履约看子单。用户一次支付主订单,系统自动拆分到各子单;发货、退款、结算全部以子单为单位独立进行。
2.2 拆单规则
拆单维度可配置,默认按商家拆分,支持扩展更多维度:
- 默认维度:按商家拆分(必选):不同
merchant_id的商品必然拆分到不同子订单,这是多商户分账与履约的基础; - 扩展维度1:按仓库拆分:同一商家多仓库发货时,按仓库拆分子订单,分别出库、分别走物流;
- 扩展维度2:按品类拆分:特殊品类(如生鲜、易碎品)需要单独履约时,按品类拆单;
- 扩展维度3:按配送方式拆分:普通快递 + 同城配送混合时,按配送方式拆单。
2.3 合并规则
合并能力主要面向履约与结算场景,不改变订单本身的独立性:
- 合并支付:多店铺子订单通过主订单统一完成支付,用户只发起一次支付请求;
- 合并发货:同一商家、同一收货地址的多笔待发货子订单,可合并为一个发货单,共用一个物流单号;
- 合并结算:同一商家同一结算周期内的所有子订单,合并生成一张结算单,统一对账、统一打款。
三、数据库改造方案
遵循「新增为主、修改为辅」的原则,尽量不破坏原生表结构。
3.1 新增主订单表 ls_order_main
用户侧统一订单,承载总金额、支付信息、收货信息。
CREATE TABLE `ls_order_main` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`main_order_sn` varchar(32) NOT NULL COMMENT '主订单号',
`user_id` bigint(20) NOT NULL COMMENT '下单用户ID',
`total_amount` decimal(10,2) NOT NULL COMMENT '商品总金额',
`total_freight` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '总运费',
`total_discount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '总优惠金额',
`pay_amount` decimal(10,2) NOT NULL COMMENT '实付总金额',
`pay_status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '支付状态:0未付 1已付',
`pay_time` datetime DEFAULT NULL COMMENT '支付时间',
`pay_type` tinyint(1) DEFAULT NULL COMMENT '支付方式',
`receiver_name` varchar(32) DEFAULT NULL,
`receiver_mobile` varchar(16) DEFAULT NULL,
`receiver_address` varchar(255) DEFAULT NULL,
`sub_order_count` int(11) NOT NULL DEFAULT 0 COMMENT '子订单数量',
`status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '主单状态:0待付款 1待发货 2待收货 3已完成 4已取消',
`create_time` datetime NOT NULL,
`update_time` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_main_order_sn` (`main_order_sn`),
KEY `idx_user_id` (`user_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='主订单表';
3.2 原订单表扩展字段
原生 ls_order 作为子订单表使用,新增关联字段,不影响原有字段语义。
ALTER TABLE ls_order
ADD COLUMN `main_order_sn` varchar(32) DEFAULT '' COMMENT '主订单号,单店下单为空' AFTER `order_sn`,
ADD COLUMN `merge_ship_id` bigint(20) DEFAULT NULL COMMENT '合并发货ID' AFTER `express_no`,
ADD INDEX `idx_main_order_sn` (`main_order_sn`);
兼容说明:原生单店铺下单生成的订单,
main_order_sn为空,业务逻辑中判空即可走原有流程,不影响历史订单。
3.3 新增优惠分摊表 ls_order_discount_detail
跨店优惠(如平台满减、平台优惠券)需要按比例分摊到各子订单,退款时按分摊比例回退。
CREATE TABLE `ls_order_discount_detail` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`main_order_sn` varchar(32) NOT NULL,
`order_id` bigint(20) NOT NULL COMMENT '子订单ID',
`discount_type` varchar(32) NOT NULL COMMENT '优惠类型:platform_coupon/full_reduction/point',
`discount_amount` decimal(10,2) NOT NULL COMMENT '该子单分摊优惠金额',
`discount_rule` varchar(255) DEFAULT NULL COMMENT '优惠规则快照',
`create_time` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_main_order_sn` (`main_order_sn`),
KEY `idx_order_id` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单优惠分摊明细表';
3.4 新增合并发货表 ls_order_merge_ship
记录多笔子订单合并发货的关联关系。
CREATE TABLE `ls_order_merge_ship` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`merchant_id` bigint(20) NOT NULL,
`merge_sn` varchar(32) NOT NULL COMMENT '合并发货单号',
`express_company` varchar(32) DEFAULT NULL,
`express_no` varchar(32) DEFAULT NULL,
`ship_time` datetime DEFAULT NULL,
`operator_id` bigint(20) DEFAULT NULL,
`create_time` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_merge_sn` (`merge_sn`),
KEY `idx_merchant_id` (`merchant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单合并发货表';
四、核心业务流程与代码实现
4.1 跨店下单自动拆单
这是整个改造的核心流程:用户提交购物车多店铺商品 → 按商家分组 → 生成主订单 → 批量生成子订单 → 锁定库存。
<?php
// app/extra/service/OrderSplitService.php
namespace app\extra\service;
use app\common\model\Order;
use app\common\model\OrderGoods;
use app\extra\model\OrderMain;
use think\Db;
use think\Exception;
class OrderSplitService
{
/**
* 跨店下单:统一拆单
* @param int $userId 用户ID
* @param array $cartItems 购物车选中商品,含merchant_id
* @param array $address 收货地址
* @return array 主订单信息
* @throws Exception
*/
public function submitMultiShop(int $userId, array $cartItems, array $address): array
{
Db::startTrans();
try {
// 1. 按商家分组商品
$grouped = [];
foreach ($cartItems as $item) {
$grouped[$item['merchant_id']][] = $item;
}
// 2. 生成主订单号
$mainOrderSn = generate_order_sn('M');
// 3. 计算主订单总金额
$totalAmount = array_sum(array_column($cartItems, 'total_price'));
$totalFreight = $this->calcTotalFreight($grouped);
$totalDiscount = $this->calcTotalDiscount($cartItems);
$payAmount = $totalAmount + $totalFreight - $totalDiscount;
// 4. 创建主订单
$mainOrder = OrderMain::create([
'main_order_sn' => $mainOrderSn,
'user_id' => $userId,
'total_amount' => $totalAmount,
'total_freight' => $totalFreight,
'total_discount' => $totalDiscount,
'pay_amount' => $payAmount,
'receiver_name' => $address['name'],
'receiver_mobile' => $address['mobile'],
'receiver_address' => $address['detail'],
'sub_order_count' => count($grouped),
'status' => 0,
'pay_status' => 0,
]);
// 5. 逐商家生成子订单
$subOrders = [];
foreach ($grouped as $merchantId => $goods) {
$subOrderSn = generate_order_sn();
$subAmount = array_sum(array_column($goods, 'total_price'));
$subFreight = $this->calcShopFreight($merchantId, $goods);
$subDiscount = $this->allocateDiscount($subAmount, $totalAmount, $totalDiscount);
$subPay = $subAmount + $subFreight - $subDiscount;
$subOrder = Order::create([
'order_sn' => $subOrderSn,
'main_order_sn' => $mainOrderSn,
'user_id' => $userId,
'merchant_id' => $merchantId,
'total_amount' => $subAmount,
'freight_amount' => $subFreight,
'discount_amount' => $subDiscount,
'pay_amount' => $subPay,
'order_status' => 0, // 待付款
]);
// 写入订单商品
$this->createOrderGoods($subOrder['id'], $goods);
// 锁定库存
StockService::lockStock($subOrder['id'], $goods);
// 记录优惠分摊
$this->saveDiscountDetail($mainOrderSn, $subOrder['id'], $subDiscount);
$subOrders[] = $subOrder;
}
Db::commit();
return [
'main_order_sn' => $mainOrderSn,
'pay_amount' => $payAmount,
'sub_count' => count($subOrders),
];
} catch (Exception $e) {
Db::rollback();
throw $e;
}
}
/**
* 按金额比例分摊优惠
*/
private function allocateDiscount($subAmount, $totalAmount, $totalDiscount)
{
if ($totalAmount <= 0) return 0;
$ratio = $subAmount / $totalAmount;
return round($totalDiscount * $ratio, 2);
}
}
4.2 主订单统一支付与回调处理
用户只针对主订单发起支付,支付回调成功后,批量更新所有子订单的支付状态,并触发后续业务。
<?php
// 支付回调处理扩展
public function handleMainOrderNotify(string $mainOrderSn, string $payType)
{
Db::startTrans();
try {
$mainOrder = OrderMain::where('main_order_sn', $mainOrderSn)->find();
if (!$mainOrder || $mainOrder['pay_status'] == 1) {
// 幂等:已支付直接返回成功
return true;
}
// 1. 更新主订单支付状态
$mainOrder->save([
'pay_status' => 1,
'pay_time' => time(),
'pay_type' => $payType,
'status' => 1, // 待发货
]);
// 2. 批量更新子订单支付状态
$subOrders = Order::where('main_order_sn', $mainOrderSn)->select();
foreach ($subOrders as $sub) {
if ($sub['order_status'] == 0) {
$sub->save([
'order_status' => 1,
'pay_time' => time(),
'pay_type' => $payType,
]);
// 触发原生订单支付后事件:扣库存、通知商家、生成分账快照
event('OrderPaid', $sub);
}
}
Db::commit();
return true;
} catch (Exception $e) {
Db::rollback();
throw $e;
}
}
4.3 同店订单合并发货
同一商家、同一收货地址的多笔待发货订单,可合并为一个发货单,共用物流信息。
<?php
public function mergeShip(int $merchantId, array $orderIds, string $expressCompany, string $expressNo)
{
Db::startTrans();
try {
// 1. 校验:都属于该商家、状态都是待发货、收货地址一致
$orders = Order::whereIn('id', $orderIds)
->where('merchant_id', $merchantId)
->where('order_status', 1)
->select();
if (count($orders) != count($orderIds)) {
throw new Exception('存在不可合并发货的订单');
}
// 2. 创建合并发货记录
$mergeSn = generate_order_sn('MS');
$merge = OrderMergeShip::create([
'merchant_id' => $merchantId,
'merge_sn' => $mergeSn,
'express_company' => $expressCompany,
'express_no' => $expressNo,
'ship_time' => time(),
]);
// 3. 批量更新子订单发货状态
Order::whereIn('id', $orderIds)->update([
'order_status' => 2,
'express_company' => $expressCompany,
'express_no' => $expressNo,
'merge_ship_id' => $merge['id'],
'ship_time' => time(),
]);
Db::commit();
return $merge;
} catch (Exception $e) {
Db::rollback();
throw $e;
}
}
五、关联模块联动改造
拆单与合并不只是订单表的事,必须联动所有上下游模块,才能保证链路完整。
5.1 库存模块
- 拆单时按子单维度分别锁定对应商家的 SKU 库存,不跨商家扣库存;
- 订单取消、退款时,按子单维度回补库存;
- 合并发货不影响库存,仅同步物流状态。
5.2 分账与结算模块
- 分账以子订单为单位,每个子订单独立生成分账快照,对应各自商家;
- 结算时支持合并模式:同一商家同一结算周期内的所有子订单,合并生成一张结算单,统一计算佣金、统一打款;
- 退款按子单维度回退分账,不影响同主单下其他子订单。
5.3 售后模块
- 售后申请以子订单为单位,用户针对具体店铺的具体商品申请售后;
- 某一子单退款,不影响同主单下其他子订单的正常履约;
- 主订单所有子订单全部退款完成后,主订单状态更新为「已退款」。
5.4 商家权限
- 商家后台只能看到自身
merchant_id对应的子订单,和原生数据隔离逻辑完全一致; - 商家端看不到主订单概念,也看不到其他商家的子订单,天然符合多商户数据隔离要求。
5.5 用户端展示
- 用户订单列表默认展示主订单,点击展开可查看各子订单的物流、售后状态;
- 单店铺下单时,也可选择直接展示子订单,保持原有体验不变。
六、平滑升级与历史数据兼容
6.1 升级步骤
- 执行新增表与字段的 SQL 脚本;
- 部署新增的二开代码,原有订单相关代码保留不动;
- 先开启「购物车跨店结算」开关做灰度,初期仅部分用户可见;
- 验证支付、发货、售后、分账全链路正常后全量放开。
6.2 历史数据兼容
- 历史订单
main_order_sn为空,业务逻辑中判空即走原生单订单流程,无需数据迁移; - 商家端、平台端原有订单查询逻辑,增加
main_order_sn为空的兼容条件即可; - 统计报表默认同时统计主订单与独立子订单,保证数据口径一致。
七、常见坑与避坑建议
1. 拆单重复生成
用户重复点击提交,可能导致多次生成主订单和子订单。
避坑:提交接口加分布式锁,以用户ID + 购物车批次为Key;主订单号唯一索引兜底。
2. 优惠分摊精度问题
按比例分摊时,四舍五入会导致「各子单分摊之和 ≠ 总优惠」,出现几分钱误差。
避坑:最后一个子订单用「总优惠 - 其他子单分摊之和」倒算,把误差放在最后一子单。
3. 支付回调状态不一致
主单更新成功但部分子单更新失败,出现状态不一致。
避坑:整个回调操作放在同一数据库事务中,失败全部回滚;增加定时任务扫描状态不一致的订单,自动补偿。
4. 库存扣减原子性
拆单时多个子单分别扣库存,中间某一个失败,前面已扣的要回滚。
避坑:所有子单库存操作放在同一事务内;使用条件更新 SQL 扣库存,保证原子性。
5. 商家数据越权
合并发货时校验不严,可能导致商家操作其他商家的订单。
避坑:所有商家端操作都强制带上 merchant_id 条件,在框架层统一注入,不依赖前端传参。
结语
多商户订单拆合的改造,表面上是加一张主订单表,本质上是把「用户视角」和「商家视角」分层处理——用户要统一体验,商家要独立履约,平台要分账合规。
LikeShop 多商户标准版原生的订单、库存、分账体系已经很成熟,二开时不建议推翻重构,通过新增主子单架构、复用原有业务事件的方式,可以用最小的侵入成本实现拆单与合并能力,同时保留后续同步官方版本的空间。
对二开团队来说,核心原则只有一条:能扩展就不修改,能复用就不重写。架构上做好分层,后续再加拼团、满减、分销等营销玩法,也能平滑地嵌套进这套拆合体系里。