一、为什么商品管理是单商户二开的第一站
做过电商系统的工程师都知道:商品模块是整个系统的地基。购物车、订单、支付、营销、报表,所有模块都在引用商品表。商品数据如果设计得不清楚,后面每加一个功能都要打补丁。
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,而是标记一张卡密为"已使用",并把卡号密码发给用户。
六、常见坑
- SPU 和 SKU 价格不一致:商品列表显示的价格要取 SKU 的最低价,不能在 SPU 表冗余一份价格;
- 库存负数 :一定要加数据库 CHECK 约束或在代码里判断
stock >= 0,别让库存变成负数; - 超时订单释放库存:一定要有定时任务扫超时订单,否则库存会被"幽灵订单"占满;
- 退款不释放库存:退款成功后一定要回补库存,否则越卖库存越少;
- 规格组合爆炸:一个商品 3 个规格、每个规格 5 个值,组合数是 5×5×5=125 个 SKU,前端要做组合选择器,不能直接列 125 个按钮;
- 删除商品的级联 :下架商品不要物理删除,订单历史还要引用这个商品。商品表要做软删除(
is_deleted字段)。
七、二开时不要做的事
- 不要改
ls_goods_sku表的字段语义:价格、库存、规格 JSON 这几个字段是整个订单模块的根基,改字段名所有模块都要跟着改; - 不要在前端算库存:库存数只能以后端数据库为准,前端拿到的"剩余库存"只是展示用;
- 不要跳过 StockService 直接改库存:所有库存变动都要走同一个 Service,便于加日志、加预警、加事务。
结语
LikeShop 单商户标准版的商品模块,设计上是标准的 SPU/SKU 邻接表模型,二开的核心不是推翻它,而是在它的基础上加业务------加分类层级、加批次、加多渠道、加虚拟商品。
真正要花心思的是库存联动:扣库存要用条件更新 SQL 防超卖、超时和退款一定要回补库存、每次变动都要写日志。这三件事做对了,商品模块就是稳定的地基;做错任何一件,后面每个大促都是事故现场。