LikeShop 单商户标准版商品管理二开:分类、SKU 规格与库存联动实现

一、为什么商品管理是单商户二开的第一站

做过电商系统的工程师都知道:商品模块是整个系统的地基。购物车、订单、支付、营销、报表,所有模块都在引用商品表。商品数据如果设计得不清楚,后面每加一个功能都要打补丁。

LikeShop 单商户标准版(PHP 7.2~7.4 + MySQL 5.7)的商品模块已经覆盖了标准 B2C 场景:分类、品牌、商品详情、SKU、库存、运费模板。但客户二开时最常做的几件事是:

  • 分类要支持多级(不只是两级);
  • SKU 规格要支持自定义(不只预设模板);
  • 库存要和多渠道联动(小程序、H5、线下门店共用一个库存);
  • 要加库存预警、库存盘点、批次管理。

下面按分类、SKU 规格、库存联动三个层面拆解。

二、分类:树形结构的设计与实现

2.1 表结构

LikeShop 标准版的分类表是典型的邻接表(Adjacency List)设计:

sql 复制代码
ls_category (
  id,
  parent_id,              -- 父分类 ID,0 = 顶级
  name,
  short_name,             -- 简称(前台导航用)
  cover,                  -- 分类图标
  icon,
  sort,                   -- 排序
  status,                 -- 1=显示 0=隐藏
  level,                  -- 层级冗余字段
  create_time
);

邻接表的优点是简单、改动方便;缺点是查"某分类下所有子孙"要递归。LikeShop 在分类树不深(通常 2~3 级)的场景下用这个方案是够用的。

2.2 二开时常见扩展

1)多级分类支持

标准版一般支持两级,客户要做三级、四级时,不要改表结构,只要把 level 字段用起来就行。查子树时用递归:

php 复制代码
<?php
// app/service/CategoryService.php

class CategoryService
{
    /**
     * 取某分类下所有子孙分类 ID
     */
    public function getDescendantIds(int $parentId): array
    {
        $ids = [$parentId];
        $queue = [$parentId];

        while (!empty($queue)) {
            $pid = array_shift($queue);
            $children = Category::where('parent_id', $pid)
                ->where('status', 1)
                ->column('id');
            $ids = array_merge($ids, $children);
            $queue = array_merge($queue, $children);
        }
        return $ids;
    }
}

2)分类绑定品牌

客户常要求"某个分类下只显示某些品牌"。加一张关联表:

sql 复制代码
ls_category_brand (
  category_id, brand_id
);

前台进分类页时,先查这张表取品牌列表,再筛商品。

3)分类自定义属性

有些行业(如服装:尺码、颜色;数码:内存、硬盘)分类下要带固定属性。加一张 ls_category_attr(category_id, attr_name, attr_values),发布商品时自动带出这些属性。

三、SPU 与 SKU:商品数据的核心模型

3.1 概念先理清

电商行业的标准模型:

  • SPU(Standard Product Unit):标准化产品单元。一件商品就是一个 SPU,比如"iPhone 15"。
  • SKU(Stock Keeping Unit):库存最小单元。SPU 下按规格组合拆出来的具体商品,比如"iPhone 15 / 黑色 / 256G"就是一个 SKU。

LikeShop 标准版的表结构就是按这个模型设计的:

sql 复制代码
-- SPU 表(商品主表)
ls_goods (
  id,
  name, category_id, brand_id,
  goods_type,             -- real 实物 / virtual 虚拟
  unit,                   -- 单位:件、个
  intro,                  -- 富文本详情
  image,                  -- 主图
  carousel,               -- 轮播图 JSON
  video,                  -- 视频
  freight_template_id,    -- 运费模板
  status,                 -- 1=上架 0=下架
  sort,
  sales_sum,              -- 累计销量(冗余)
  create_time
);

-- 规格表(规格名,如"颜色"、"内存")
ls_spec (
  id, goods_id, name
);

-- 规格值表(规格名下的具体值,如"黑色"、"256G")
ls_spec_value (
  id, spec_id, spec_name, value, image, sort
);

-- SKU 表
ls_goods_sku (
  id, goods_id,
  sku_image,              -- SKU 主图
  spec_json,              -- 规格组合 JSON:{"颜色":"黑色","内存":"256G"}
  price,                  -- 售价
  market_price,            -- 划线价
  cost_price,              -- 成本价(内部)
  stock,                  -- 库存
  stock_alarm,             -- 库存预警值
  bar_code,                -- 条码
  sku_no,                  -- 商家编码
  weight,                 -- 重量(运费计算用)
  volume,                  -- 体积
  status
);

3.2 为什么 SKU 用 JSON 存规格组合

spec_json 这一行的设计:

json 复制代码
{"颜色": "黑色", "内存": "256G"}

好处是灵活------不同商品的规格数量、规格名都不一样,不用为每种规格建一张表。坏处是不能按"颜色"这个字段做 SQL 查询。但电商场景下,规格筛选通常在前端做,SQL 里按规格筛的需求很少,这个权衡是合理的。

3.3 发布商品的完整流程

php 复制代码
<?php
// app/service/GoodsService.php

public function createGoods(array $goodsData, array $skusData)
{
    Db::startTrans();
    try {
        // 1. 写 SPU
        $goodsId = Goods::create([
            'name' => $goodsData['name'],
            'category_id' => $goodsData['category_id'],
            // ...
        ])->id;

        // 2. 写规格和规格值
        foreach ($goodsData['specs'] as $spec) {
            $specId = Spec::create([
                'goods_id' => $goodsId,
                'name' => $spec['name'],
            ])->id;
            foreach ($spec['values'] as $value) {
                SpecValue::create([
                    'spec_id' => $specId,
                    'spec_name' => $spec['name'],
                    'value' => $value,
                ]);
            }
        }

        // 3. 写 SKU
        foreach ($skusData as $sku) {
            GoodsSku::create([
                'goods_id' => $goodsId,
                'spec_json' => json_encode($sku['spec']),
                'price' => $sku['price'],
                'stock' => $sku['stock'],
                // ...
            ]);
        }

        Db::commit();
    } catch (\Exception $e) {
        Db::rollback();
        throw $e;
    }
}

四、库存联动:整个商品模块最难的部分

商品表设计得再漂亮,库存算错了都是灾难。超卖是电商系统最严重的 bug------用户付款了,仓库没货发,客服要赔礼道歉、平台要赔券、用户要差评。

4.1 库存扣减的三种时机

电商行业有三种库存扣减时机,各有优劣:

时机 做法 优点 缺点
下单扣减 用户提交订单时立即减库存 不会超卖 用户不付款,库存被占住
付款扣减 用户付款成功时减库存 库存不被占 可能超卖
预占+结算 下单预占库存,付款转正,超时释放 兼顾 实现复杂

LikeShop 标准版用的是"下单扣减 + 超时未付款释放"的折中方案。

4.2 下单扣库存:用数据库行锁防超卖

错误的写法:

php 复制代码
// ❌ 错误:先查后改,并发下会超卖
$sku = GoodsSku::find($skuId);
if ($sku['stock'] > $buyNum) {
    $sku->decrement('stock', $buyNum);
}

正确的写法是用数据库行锁 + 条件更新:

php 复制代码
<?php
// app/service/StockService.php

class StockService
{
    /**
     * 扣减库存,返回是否成功
     */
    public function deduct(int $skuId, int $num): bool
    {
        // 关键:WHERE 里带上 stock >= num
        // 数据库保证:只有库存够时才扣成功
        $affected = Db::execute(
            "UPDATE ls_goods_sku
             SET stock = stock - ?
             WHERE id = ? AND stock >= ?",
            [$num, $skuId, $num]
        );
        return $affected > 0;
    }
}

这个写法的核心是:把"够不够"和"扣不扣"合并成一条 SQL。数据库的行锁保证并发安全,不需要在 PHP 层加锁。

4.3 订单流程中的库存联动

完整的库存状态机:

复制代码
下单 → 扣减库存(stock - num)
  ↓
付款成功 → 不动库存(已经扣过了)
  ↓
未付款超时 → 释放库存(stock + num)
  ↓
退款成功 → 释放库存(stock + num)
  ↓
发货 → 不动库存
php 复制代码
<?php
// 下单时
public function createOrder(array $items)
{
    foreach ($items as $item) {
        $ok = app(StockService::class)->deduct(
            $item['sku_id'], $item['num']
        );
        if (!$ok) {
            // 库存不足,整单回滚
            throw new Exception("商品 [{$item['name']}] 库存不足");
        }
    }
}

// 支付回调
public function onPaid(Order $order)
{
    // 已经在下单时扣过库存了,这里不用动
    $order->update(['pay_status' => 1]);
}

// 订单超时未支付
public function onOrderTimeout(Order $order)
{
    foreach ($order->items as $item) {
        app(StockService::class)->refund(
            $item['sku_id'], $item['num']
        );
    }
    $order->update(['status' => 'closed']);
}

// 退款
public function onRefund(Order $order)
{
    foreach ($order->items as $item) {
        app(StockService::class)->refund(
            $item['sku_id'], $item['num']
        );
    }
}

4.4 库存预警

标准版 SKU 表已经有 stock_alarm 字段,二开时加一个定时任务:

php 复制代码
<?php
// app/job/StockAlarmJob.php
public function handle()
{
    $lowStocks = GoodsSku::whereRaw('stock <= stock_alarm')
        ->where('status', 1)
        ->select();

    foreach ($lowStocks as $sku) {
        // 发企业微信/钉钉通知给采购
        AlertService::sendStockAlarm($sku);
    }
}

五、二开常见扩展点

5.1 多渠道库存

客户常要求"小程序、H5、线下门店共用一个库存"。方案:

  • 所有渠道的扣减都走同一个 StockService::deduct();
  • 线下门店 POS 调同一个后端接口;
  • 不要每个渠道单独存一份库存数。

5.2 批次管理(保质期商品)

食品、生鲜、化妆品要按批次管库存。加一张批次表:

sql 复制代码
ls_stock_batch (
  id, sku_id, batch_no,
  production_date, expire_date,
  quantity, remaining,
  inbound_time
);

扣库存时按"先进先出"(FIFO)规则扣最早的批次。

5.3 库存盘点

仓库定期盘点时,要支持"盘盈、盘亏"调整:

sql 复制代码
ls_stock_log (
  id, sku_id, change_type,   -- deduct / refund / adjust_in / adjust_out
  change_num,
  before_stock, after_stock,
  remark, operator,
  create_time
);

每次库存变动都写日志,方便追溯。

5.4 虚拟商品

如果客户要卖卡密、充值码,库存模型要变:

sql 复制代码
ls_virtual_card (
  id, sku_id, card_no, card_pwd,
  status,                 -- unused / used
  used_order_no, used_time
);

下单时不是简单减 stock,而是标记一张卡密为"已使用",并把卡号密码发给用户。

六、常见坑

  1. SPU 和 SKU 价格不一致:商品列表显示的价格要取 SKU 的最低价,不能在 SPU 表冗余一份价格;
  2. 库存负数 :一定要加数据库 CHECK 约束或在代码里判断 stock >= 0,别让库存变成负数;
  3. 超时订单释放库存:一定要有定时任务扫超时订单,否则库存会被"幽灵订单"占满;
  4. 退款不释放库存:退款成功后一定要回补库存,否则越卖库存越少;
  5. 规格组合爆炸:一个商品 3 个规格、每个规格 5 个值,组合数是 5×5×5=125 个 SKU,前端要做组合选择器,不能直接列 125 个按钮;
  6. 删除商品的级联 :下架商品不要物理删除,订单历史还要引用这个商品。商品表要做软删除(is_deleted 字段)。

七、二开时不要做的事

  • 不要改 ls_goods_sku 表的字段语义:价格、库存、规格 JSON 这几个字段是整个订单模块的根基,改字段名所有模块都要跟着改;
  • 不要在前端算库存:库存数只能以后端数据库为准,前端拿到的"剩余库存"只是展示用;
  • 不要跳过 StockService 直接改库存:所有库存变动都要走同一个 Service,便于加日志、加预警、加事务。

结语

LikeShop 单商户标准版的商品模块,设计上是标准的 SPU/SKU 邻接表模型,二开的核心不是推翻它,而是在它的基础上加业务------加分类层级、加批次、加多渠道、加虚拟商品。

真正要花心思的是库存联动:扣库存要用条件更新 SQL 防超卖、超时和退款一定要回补库存、每次变动都要写日志。这三件事做对了,商品模块就是稳定的地基;做错任何一件,后面每个大促都是事故现场。

相关推荐
用户2181697049304 小时前
Kotlin 语法二
android
妖星4 小时前
android 源码分析surfaceFinger是如何通过设置属性启动bootanim
android
JoyMonitor4 小时前
APK mmap 内存增长定位:pagemap 与 DEX 结构联合定位方法
android
有什么事4 小时前
永久虚拟免费云手机能实现吗,本地安卓容器与云端托管的技术边界
android·智能手机
妖星4 小时前
Android 源码 .rc 语法详解
android
JMchen4 小时前
实战项目一——实现一个高性能图片滤镜App
android·c++·性能优化
白帽攻防录5 小时前
SRC 挖洞:Android 蓝牙预认证 RCE 深度复盘,CVE-2026-55330 怎么在蓝牙范围内零交互攻破手机
android·网络安全·智能手机·src漏洞挖掘
vilya5 小时前
手机端 Agent 是怎么跑起来的:决策循环、四道护栏、两条红线
android·agent