一、原生商品与购物车体系拆解
1.1 商品数据模型回顾
LikeShop 的商品体系采用经典的主表(SPU)+ 规格表 + SKU 表三层结构,ls_goods 存储商品基础信息,ls_goods_item 存储规格组合的价格与库存;每个 SKU 通过 spec_value_ids 唯一标识,拥有独立的 sell_price 和 stock。
购物车表 ls_cart 记录与加入购物车的商品,通过 goods_id 和 item_id 定位具体的 SKU,不存储价格和名称,展示时实时查询;结算时,OrderLogi::create() 依次执行优惠券校验、积分抵扣计算、分销绑定等步骤,最终生成订单。
1.2 组合套餐的场景需求
电商平台中,组合套餐有多种形态;参考有赞等平台的套餐类型划分,主要有三种模式:
固定套餐:商家预先搭配好商品组合(如“手机+手机壳+贴膜”),设置统一的一口价,用户之间按组合下单;适合品类关联性强的商品。
搭配套餐:以一款主商品为核心,用户购买主商品后可从搭配池中任选至少一件搭配商品,每件商品单独配置优惠价;适合“主商品+配件“的场景。
N件任选套餐:商家设定商品池,用户在池中任选 N 件即可享受优惠价;例如“任选3件99元”,适合清仓或提升客单价。
LikeShop 原生的营销活动体系基于活动区域表组织,活动类型相对基础,不具备“多商品组合定价”的能力;需要在不修改原生商品结构和订单核心逻辑的前提下,新建套餐数据模型和计价引擎。
1.3 二开策略定位
遵循 LikeShop 的“扩展优先”的原则,套餐模块的二开策略为:套餐以“虚拟商品”的形式挂接到现有商品体系中,套餐的主记录为一条特殊的 ls_goods 记录存在,从而复用到原生的商品详情页、购物车、订单等基础设施;套餐的子商品关联关系和优惠规则通过独立的数据表关联,不修改 ls_goods 和 ls_goods_item 的核心字段结构。
二、数据库扩展设计
2.1 套餐主表
sql
CREATE TABLE `ls_package` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`package_name` varchar(100) NOT NULL COMMENT '套餐名称',
`package_type` tinyint NOT NULL DEFAULT 1 COMMENT '套餐类型:1-固定套餐,2-搭配套餐,3-N件任选套餐',
`main_goods_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '主商品ID(搭配套餐使用)',
`package_price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '套餐一口价(固定套餐使用)',
`main_goods_price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '主商品优惠价(搭配套餐使用)',
`select_num` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '任选件数(N件任选套餐使用)',
`select_min_num` int UNSIGNED NOT NULL DEFAULT 1 COMMENT '最少选购件数',
`select_max_num` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '最多选购件数,0为不限',
`allow_repeat` tinyint NOT NULL DEFAULT 0 COMMENT '是否允许重复选择同一商品:0-否,1-是',
`stock_type` tinyint NOT NULL DEFAULT 1 COMMENT '库存模式:1-组件扣减,2-独立库存',
`package_stock` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '套餐独立库存(库存模式为2时使用)',
`limit_buy` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '每人限购数量,0为不限',
`is_show_original_price` tinyint NOT NULL DEFAULT 1 COMMENT '是否展示原价合计',
`tag` varchar(20) NOT NULL DEFAULT '' COMMENT '套餐标签:超值/限时/热销',
`sort` int NOT NULL DEFAULT 0 COMMENT '排序',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
`audit_status` tinyint NOT NULL DEFAULT 0 COMMENT '审核状态:0-待审核,1-已通过,2-已驳回',
`create_time` int UNSIGNED NOT NULL DEFAULT 0,
`update_time` int UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_package_type` (`package_type`),
KEY `idx_main_goods` (`main_goods_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='组合套餐主表';
stock_type 区分两种库存模式:组件扣减模式(套餐的可售数量取决于库存最紧张的组件)和独立库存模式(套餐不依赖组件库存,由商家手动设置套餐可售数量);组件扣减模式下,套餐售出后自动拆解并扣减各组件库存。
2.2 套餐商品关联表
sql
CREATE TABLE `ls_package_goods` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`package_id` int UNSIGNED NOT NULL COMMENT '套餐ID',
`goods_id` int UNSIGNED NOT NULL COMMENT '商品ID',
`item_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT 'SKU规格ID,0为多规格商品',
`goods_num` int UNSIGNED NOT NULL DEFAULT 1 COMMENT '套餐内该商品的数量',
`item_price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '规格原价',
`item_discount_price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '搭配套餐中该商品的优惠价',
`is_main` tinyint NOT NULL DEFAULT 0 COMMENT '是否为主商品:0-否,1-是',
`is_fixed` tinyint NOT NULL DEFAULT 1 COMMENT '是否固定搭配:0-可选,1-固定',
`group_name` varchar(30) NOT NULL DEFAULT '' COMMENT '分组名称(N件任选套餐的组标识)',
`max_select` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '该商品最多可选数量(N件任选套餐使用)',
`sort` int NOT NULL DEFAULT 0 COMMENT '排序',
`create_time` int UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_package_id` (`package_id`),
KEY `idx_goods_id` (`goods_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='套餐商品关联表';
ls_main 标记搭配套餐中的主商品,ls_fixed 标记固定套餐中的必选商品,group_name 用于 N 件任意套餐的分组(如“上衣组”“裤子组”各选一件)。
2.3 套餐订单快照表
sql
CREATE TABLE `ls_package_order` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`order_id` int UNSIGNED NOT NULL COMMENT '订单ID',
`order_sn` varchar(32) NOT NULL DEFAULT '' COMMENT '订单号',
`package_id` int UNSIGNED NOT NULL COMMENT '套餐ID',
`package_name` varchar(100) NOT NULL DEFAULT '' COMMENT '套餐名称快照',
`package_type` tinyint NOT NULL DEFAULT 1 COMMENT '套餐类型快照',
`package_price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '套餐成交价',
`original_price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '原价合计',
`discount_amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '优惠金额',
`goods_snapshot` text COMMENT '套餐商品明细快照,JSON格式',
`create_time` int UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_order_id` (`order_id`),
KEY `idx_package_id` (`package_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='套餐订单快照表';
三、套餐计价引擎实现
3.1 固定套餐计价
固定套餐的计价最直接:套餐价由商家预设为一口价,用户下单时直接按 package_price 计算;原价合计为套餐内所有商品的 item_price x goods_num 之和,优惠金额 = 原价合计 - 套餐价。
php
class PackagePriceService
{
/**
* 计算固定套餐价格
*/
public function calcFixedPackagePrice($packageId)
{
$package = PackageModel::find($packageId);
$packageGoods = PackageGoodsModel::where('package_id', $packageId)
->order('sort', 'asc')->select();
$originalPrice = 0;
foreach ($packageGoods as $item) {
$originalPrice += $item['item_price'] * $item['goods_num'];
}
return [
'package_price' => $package['package_price'],
'original_price' => round($originalPrice, 2),
'discount_amount'=> round($originalPrice - $package['package_price'], 2),
'goods_list' => $packageGoods,
];
}
}
3.2 搭配套餐计价
搭配套餐的主商品和搭配商品分别定价;主商品必须购买,搭配商品用户可自行选择但至少选择一件;每件商品有独立的优惠券,总价 = 主商品优惠 + 已选搭配商品优惠价之和。
php
/**
* 计算搭配套餐价格
* @param int $packageId 套餐ID
* @param array $selectedItems 用户选择的搭配商品,格式:[package_goods_id => num]
*/
public function calcMatchPackagePrice($packageId, $selectedItems)
{
$package = PackageModel::find($packageId);
if ($package['package_type'] != 2) {
throw new \Exception('套餐类型不正确');
}
// 查询主商品
$mainGoods = PackageGoodsModel::where('package_id', $packageId)
->where('is_main', 1)->find();
if (!$mainGoods) {
throw new \Exception('搭配套餐未配置主商品');
}
$totalPrice = $package['main_goods_price'];
$originalPrice = $mainGoods['item_price'];
$goodsList = [[
'package_goods_id' => $mainGoods['id'],
'goods_id' => $mainGoods['goods_id'],
'item_id' => $mainGoods['item_id'],
'goods_num' => 1,
'price' => $package['main_goods_price'],
'original_price' => $mainGoods['item_price'],
'is_main' => 1,
]];
// 查询搭配商品
$matchGoods = PackageGoodsModel::where('package_id', $packageId)
->where('is_main', 0)->select();
$matchGoodsMap = array_column($matchGoods->toArray(), null, 'id');
// 至少选择一件搭配商品
if (empty($selectedItems)) {
throw new \Exception('请至少选择一件搭配商品');
}
foreach ($selectedItems as $pgId => $num) {
if (!isset($matchGoodsMap[$pgId])) continue;
$goods = $matchGoodsMap[$pgId];
$num = min(max(1, (int)$num), $goods['goods_num']);
$totalPrice += $goods['item_discount_price'] * $num;
$originalPrice += $goods['item_price'] * $num;
$goodsList[] = [
'package_goods_id' => $pgId,
'goods_id' => $goods['goods_id'],
'item_id' => $goods['item_id'],
'goods_num' => $num,
'price' => $goods['item_discount_price'],
'original_price' => $goods['item_price'],
'is_main' => 0,
];
}
return [
'package_price' => round($totalPrice, 2),
'original_price' => round($originalPrice, 2),
'discount_amount'=> round($originalPrice - $totalPrice, 2),
'goods_list' => $goodsList,
];
}
3.3 N件任选套餐计价
N 件任选套餐的计价有三种常见模式:按件固定价(任选N件,每件统一X元)、按件折扣(任选N件,每件打折)、一口价(任选N件,统一Y元);计价引擎需要支持这三种模式的切换:
php
/**
* 计算N件任选套餐价格
* @param int $packageId 套餐ID
* @param array $selectedItems 用户选择的商品,格式:[package_goods_id => num]
*/
public function calcSelectPackagePrice($packageId, $selectedItems)
{
$package = PackageModel::find($packageId);
if ($package['package_type'] != 3) {
throw new \Exception('套餐类型不正确');
}
// 校验任选件数
$totalNum = array_sum($selectedItems);
if ($totalNum < $package['select_min_num']) {
throw new \Exception("至少选择{$package['select_min_num']}件商品");
}
if ($package['select_max_num'] > 0 && $totalNum > $package['select_max_num']) {
throw new \Exception("最多选择{$package['select_max_num']}件商品");
}
// 校验分组规则(如每组至少选一件)
$this->validateGroupRule($packageId, $selectedItems);
// 校验商品池权限
$packageGoods = PackageGoodsModel::where('package_id', $packageId)
->select();
$packageGoodsMap = array_column($packageGoods->toArray(), null, 'id');
$originalPrice = 0;
$goodsList = [];
foreach ($selectedItems as $pgId => $num) {
if (!isset($packageGoodsMap[$pgId])) continue;
$goods = $packageGoodsMap[$pgId];
if ($goods['max_select'] > 0 && $num > $goods['max_select']) {
throw new \Exception("商品「{$goods['goods_id']}」最多可选{$goods['max_select']}件");
}
$originalPrice += $goods['item_price'] * $num;
$goodsList[] = [
'package_goods_id' => $pgId,
'goods_id' => $goods['goods_id'],
'item_id' => $goods['item_id'],
'goods_num' => $num,
'original_price' => $goods['item_price'],
];
}
// 根据计价模式计算套餐价
$packagePrice = $this->applySelectPackagePricing($package, $goodsList, $originalPrice);
return [
'package_price' => round($packagePrice, 2),
'original_price' => round($originalPrice, 2),
'discount_amount'=> round($originalPrice - $packagePrice, 2),
'goods_list' => $goodsList,
];
}
/**
* 根据计价模式计算套餐价
*/
private function applySelectPackagePricing($package, $goodsList, $originalPrice)
{
$pricingMode = $package['pricing_mode'] ?? 1;
switch ($pricingMode) {
case 1: // 按件固定价:每件统一价
$perPrice = $package['per_item_price'] ?? 0;
$totalNum = array_sum(array_column($goodsList, 'goods_num'));
return $perPrice * $totalNum;
case 2: // 按件折扣:每件按折扣率
$discount = $package['discount_rate'] ?? 1.0;
return $originalPrice * $discount;
case 3: // 一口价:统一价格
return $package['package_price'];
default:
return $originalPrice;
}
}
3.4 套餐与优惠券、积分的叠加规则
套餐作为整体参与订单的优惠计算;套餐价作为套餐内所有商品的“总价”参与优惠券门槛判断和积分抵扣计算;套餐内的商品不再单独参与原生单品级的优惠活动(如单品秒杀、单品优惠券),避免重复优惠;如果套餐本身设置了会员折扣,则在套餐价基础上再计算会员折扣。
四、购物车与结算页改造
4.1 购物车数据模型扩展
套餐加入购物车时,需要在 ls_cart 表增加套餐相关的字段:
sql
ALTER TABLE `ls_cart`
ADD COLUMN `package_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '套餐ID,0为普通商品' AFTER `item_id`,
ADD COLUMN `package_goods_snapshot` text COMMENT '套餐商品选择快照,JSON格式' AFTER `package_id`,
ADD COLUMN `package_price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '套餐成交价快照' AFTER `package_goods_snapshot`;
ALTER TABLE `ls_cart`
ADD INDEX `idx_package_id` (`package_id`);
package_goods_snapshot 存储用户在套餐中选择的具体商品和数量;对于固定套餐,快照内容固定;对于搭配套餐和 N 件任选套餐,快照内容因用户选择而异。
4.2 购物车列表的套餐展示
购物车列表接口在返回数据时,需要识别套餐商品并做特殊展示:
php
public function getCartList($userId)
{
$cartItems = CartModel::where('user_id', $userId)
->order('create_time', 'desc')->select();
$result = [];
foreach ($cartItems as $cart) {
if ($cart['package_id'] > 0) {
// 套餐商品:展示套餐整体信息
$package = PackageModel::find($cart['package_id']);
$snapshot = json_decode($cart['package_goods_snapshot'], true);
$result[] = [
'id' => $cart['id'],
'is_package' => 1,
'package_id' => $cart['package_id'],
'package_name' => $package['package_name'],
'package_type' => $package['package_type'],
'package_price' => $cart['package_price'],
'goods_list' => $snapshot,
'selected' => $cart['selected'],
];
} else {
// 普通商品:走原有逻辑
$result[] = $this->formatNormalCartItem($cart);
}
}
return $result;
}
4.3 结算页的拆单处理
购物车跨店铺下单时,系统按店铺自动拆分订单;套餐商品的拆单需要额外处理:一个套餐作为一个不可拆分的整体;即使套餐内的商品来自不同店铺,套餐也必须归属到同一个订单中,不能按店铺拆散;如果套餐内的商品确实来自不同店铺,建议在套餐创建时限制为同一店铺的商品。
结算页的金额计算顺序为:套餐价合计 → 优惠券抵扣 → 积分抵扣;套餐价作为整体参与优惠券门槛判断。
五、套餐库存与拆分发货
5.1 组件扣减模式的库存计算
组件扣减模式下,套餐的可售数量取决于库存最紧张的组件;系统需要实时计算各组件的可用量并取较小值作为套餐可售上限:
php
class PackageStockService
{
/**
* 计算套餐可售库存(组件扣减模式)
*/
public function calcAvailableStock($packageId)
{
$package = PackageModel::find($packageId);
if ($package['stock_type'] == 2) {
// 独立库存模式,直接返回套餐库存
return $package['package_stock'];
}
// 组件扣减模式:取各组件可售数量的最小值
$packageGoods = PackageGoodsModel::where('package_id', $packageId)->select();
if ($packageGoods->isEmpty()) return 0;
$minStock = PHP_INT_MAX;
foreach ($packageGoods as $item) {
$skuStock = GoodsItemModel::where('id', $item['item_id'])
->value('stock') ?? 0;
$availableForPackage = floor($skuStock / $item['goods_num']);
$minStock = min($minStock, $availableForPackage);
}
return max(0, $minStock);
}
/**
* 扣减套餐库存(下单时调用)
*/
public function deductPackageStock($packageId, $quantity = 1)
{
$package = PackageModel::lock(true)->find($packageId);
// 校验库存
$availableStock = $this->calcAvailableStock($packageId);
if ($availableStock < $quantity) {
throw new \Exception('套餐库存不足');
}
// 组件扣减模式:逐组件扣减库存
if ($package['stock_type'] == 1) {
$packageGoods = PackageGoodsModel::where('package_id', $packageId)->select();
foreach ($packageGoods as $item) {
$deductNum = $item['goods_num'] * $quantity;
$affected = GoodsItemModel::where('id', $item['item_id'])
->where('stock', '>=', $deductNum)
->dec('stock', $deductNum)->update();
if (!$affected) {
throw new \Exception("商品库存不足,扣减失败");
}
}
} else {
// 独立库存模式:扣减套餐库存
PackageModel::where('id', $packageId)
->dec('package_stock', $quantity)->update();
}
}
/**
* 回退套餐库存(取消订单/退款时调用)
*/
public function restorePackageStock($packageId, $quantity = 1)
{
$package = PackageModel::find($packageId);
if ($package['stock_type'] == 1) {
$packageGoods = PackageGoodsModel::where('package_id', $packageId)->select();
foreach ($packageGoods as $item) {
GoodsItemModel::where('id', $item['item_id'])
->inc('stock', $item['goods_num'] * $quantity)->update();
}
} else {
PackageModel::where('id', $packageId)
->inc('package_stock', $quantity)->update();
}
}
}
5.2 拆分发货处理
套餐发货有三种模式:
整体发货:套餐内所有商品在同一仓库、打包为整体发货;适用于固定套餐且商品库存在同一仓库的场景;订单发货时,物流单号关联整个套餐。
按组件拆单发货:套餐内的组件分布在不同仓库时,需要按组件拆分发货;系统在发货时将套餐拆解为多个发货包裹,每个包裹独立生成物流单号。
部分组件缺货处理:如果套餐内某个组件暂时缺货,但其他组件有货,可以选择“有货先发”,缺货组件后续补发;需要在套餐订单中记录拆分明细。
php
/**
* 套餐订单发货处理
*/
public function shipPackageOrder($orderId, $expressData)
{
$packageOrder = PackageOrderModel::where('order_id', $orderId)->find();
if (!$packageOrder) {
// 非套餐订单,走原生发货逻辑
return $this->normalShip($orderId, $expressData);
}
$snapshot = json_decode($packageOrder['goods_snapshot'], true);
$package = PackageModel::find($packageOrder['package_id']);
if ($package['delivery_type'] == 1) {
// 整体发货:所有商品一个物流单号
OrderModel::where('id', $orderId)->update([
'order_status' => 2, // 待收货
'express_name' => $expressData['express_name'],
'invoice_no' => $expressData['invoice_no'],
'delivery_time' => time(),
]);
} else {
// 拆单发货:按组件分别生成物流单号
foreach ($expressData['packages'] as $packageData) {
PackageDeliveryModel::create([
'order_id' => $orderId,
'goods_ids' => json_encode($packageData['goods_ids']),
'express_name' => $packageData['express_name'],
'invoice_no' => $packageData['invoice_no'],
'delivery_time'=> time(),
]);
}
OrderModel::where('id', $orderId)->update(['order_status' => 2]);
}
}
六、后台配置与开发注意事项
6.1 后台配置清单
在“商品管理 → 组合套餐”中增加套餐管理页面:
| 配置项 | 类型 | 说明 |
|---|---|---|
| 套餐名称 | 文本输入 | |
| 套餐类型 | 单选 | 固定套餐 / 搭配套餐 / N件任选 |
| 主商品 | 商品选择器 | 搭配套餐使用 |
| 套餐一口价 | 金额输入 | 固定套餐使用 |
| 主商品优惠价 | 金额输入 | 搭配套餐使用 |
| 任选件数 | 数字输入 | N件任选套餐的选购件数范围 |
| 计价模式 | 单选 | 按件固定价 / 按件折扣 / 一口价 |
| 商品池 | 商品多选 | 从商品列表中选择,支持按分类筛选 |
| 库存模式 | 单选 | 组件扣减 / 独立库存 |
| 限购数量 | 数字输入 | 0为不限 |
| 套餐标签 | 文本输入 | 超值/限时/热销 |
| 展示原价 | 开关 | 是否展示原价合计和优惠金额 |
6.2 开发注意事项
套餐不可与单品优惠叠加。套餐价作为整体定价,套餐内的商品不再单独参与原生的单品级优惠(如单品优惠券、单品秒杀);避免用户通过“套餐优惠 + 单品优惠”双重叠加导致商家亏损。
组件扣减模式的超卖防护。组件扣减模式下,多个套餐可能共用同一个组件SKU;套餐A的库存扣减可能影响到套餐B的可售库存;在扣减库存时必须使用行锁(lock(true)),防止并发下单导致的超卖。
套餐订单的退款处理。套餐退款时,组件扣减模式下需要按比例回退各组件库存;独立库存模式下直接回退套餐库存;如果套餐内部分商品需要单独退款(如质量问题),需要拆解套餐订单后按单品退款流程处理。
兼容性。新增字段全部设置默认值(package_id 默认为0),保证存量购物车和订单数据不受影响;套餐商品在购物车和订单列表中的展示通过 is_package 标识区分,普通商品的逻辑不变;套餐管理作为独立的后台模块开发,不修改原生的商品发布和编辑逻辑。