一、原生商品与购物车体系拆解

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 标识区分,普通商品的逻辑不变;套餐管理作为独立的后台模块开发,不修改原生的商品发布和编辑逻辑。