SPU 和 SKU
在电商 / 商品管理系统里,SPU 和 SKU 是两个非常核心、但经常被混淆的概念。你可以把它们理解为:“一类商品” vs “一个具体商品”。
SPU(Standard Product Unit):标准化产品单元,表示“这是什么产品”
SKU(Stock Keeping Unit):库存量单位,表示“这个产品的具体规格”
SPU 是什么?
SPU = 一组具有相同核心属性的商品集合
它关注的是商品的共性信息,不区分具体规格。
典型包含:
- 商品名称
- 品牌
- 商品描述
- 类目
- 统一的功能属性(不可变)
SPU 不直接参与库存、下单、价格计算
示例:iPhone 15 这就是一个 SPU,不管它是黑色、白色、128G 还是 256G,本质都是 iPhone 15
SKU 是什么?
SKU = 最小库存单位,是可以被下单、发货的商品
它关注的是具体差异,每个 SKU 都是独立存在的。
典型包含:
- 规格属性(颜色 / 内存 / 尺码)
- 唯一 SKU 编码
- 库存数量
- 售价
- 条形码
SKU 才是真正参与库存管理和交易的对象
示例:
- iPhone 15 · 黑色 · 128G
- iPhone 15 · 白色 · 256G
每一个都是一个 SKU
商品案例
商品:T 恤
- SPU:纯棉 T 恤
- SKU:
- 纯棉 T 恤 · 黑色 · M
- 纯棉 T 恤 · 黑色 · L
- 纯棉 T 恤 · 白色 · M
最终表结构
spu(商品表)
├── spec(规格表)
└── sku(库存表)
SPU 表(商品主表)
描述“这是个什么商品”
CREATE TABLE spu (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL COMMENT '商品名称',
description VARCHAR(255) COMMENT '商品描述'
);
示例数据
INSERT INTO spu (name, description)
VALUES ('纯棉 T 恤', '100% 纯棉,舒适透气');
规格表(spec)
CREATE TABLE spec (
id INT PRIMARY KEY AUTO_INCREMENT,
spu_id INT NOT NULL COMMENT '所属SPU',
name VARCHAR(50) NOT NULL COMMENT '规格名,如 颜色 / 尺码',
FOREIGN KEY (spu_id) REFERENCES spu(id)
);
示例数据
INSERT INTO spec (spu_id, name) VALUES
(1, '颜色'),
(1, '尺码');
SKU 表(库存 + 交易)
SKU = 真正卖、真正扣库存的
CREATE TABLE sku (
id INT PRIMARY KEY AUTO_INCREMENT,
spu_id INT NOT NULL COMMENT '所属SPU',
spec_values VARCHAR(100) NOT NULL COMMENT '规格值,如 黑色-M',
price DECIMAL(10,2) NOT NULL COMMENT '价格',
stock INT NOT NULL COMMENT '库存',
FOREIGN KEY (spu_id) REFERENCES spu(id)
);
示例数据
INSERT INTO sku (spu_id, spec_values, price, stock) VALUES
(1, '黑色-M', 99.00, 100),
(1, '黑色-L', 99.00, 80),
(1, '白色-M', 89.00, 120);
商品页面讲解:

model 代码完善
新建 goods app
cd apps
python ../manage.py startapp goods
settings.py 注册 goods
INSTALLED_APPS = [
...
'apps.goods',
]
完善apps/goods/urls.py
from django.urls import path
from . import views
app_name = "goods"
urlpatterns = [
path('list/<int:category_id>/<int:page_num>/', views.ListView.as_view(), name='list'),
path('detail/<int:sku_id>/', views.DetailView.as_view(), name='detail'),
]
新建apps/goods/utils.py
def get_breadcrumb(category):
"""
根据分类获取面包屑
"""
breadcrumb = {}
# 三级分类
if category.parent and category.parent.parent:
breadcrumb['cat1'] = category.parent.parent
breadcrumb['cat2'] = category.parent
breadcrumb['cat3'] = category
# 二级分类
elif category.parent:
breadcrumb['cat1'] = category.parent
breadcrumb['cat2'] = category
# 一级分类
else:
breadcrumb['cat1'] = category
return breadcrumb
编辑 model
apps/goods/models.py
from django.db import models
from utils.models import BaseModel
class GoodsCategory(BaseModel):
"""商品类别"""
name = models.CharField(max_length=10, verbose_name='名称')
parent = models.ForeignKey(
'self',
related_name='subs',
null=True,
blank=True,
on_delete=models.CASCADE,
verbose_name='父类别'
)
class Meta:
db_table = 'tb_goods_category'
verbose_name = '商品类别'
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class GoodsChannelGroup(BaseModel):
"""商品频道组"""
name = models.CharField(max_length=20, verbose_name='频道组名')
class Meta:
db_table = 'tb_channel_group'
verbose_name = '商品频道组'
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class GoodsChannel(BaseModel):
"""商品频道"""
group = models.ForeignKey(
GoodsChannelGroup,
on_delete=models.CASCADE,
verbose_name='频道组名'
)
category = models.ForeignKey(
GoodsCategory,
on_delete=models.CASCADE,
verbose_name='顶级商品类别'
)
url = models.CharField(max_length=50, verbose_name='频道页面链接')
sequence = models.IntegerField(verbose_name='组内顺序')
class Meta:
db_table = 'tb_goods_channel'
verbose_name = '商品频道'
verbose_name_plural = verbose_name
def __str__(self):
return self.category.name
class Brand(BaseModel):
"""品牌"""
name = models.CharField(max_length=20, verbose_name='名称')
logo = models.ImageField(verbose_name='Logo图片')
first_letter = models.CharField(max_length=1, verbose_name='品牌首字母')
class Meta:
db_table = 'tb_brand'
verbose_name = '品牌'
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class SPU(BaseModel):
"""商品SPU"""
name = models.CharField(max_length=50, verbose_name='名称')
brand = models.ForeignKey(
Brand,
on_delete=models.PROTECT,
verbose_name='品牌'
)
category1 = models.ForeignKey(
GoodsCategory,
on_delete=models.PROTECT,
related_name='cat1_spu',
verbose_name='一级类别'
)
category2 = models.ForeignKey(
GoodsCategory,
on_delete=models.PROTECT,
related_name='cat2_spu',
verbose_name='二级类别'
)
category3 = models.ForeignKey(
GoodsCategory,
on_delete=models.PROTECT,
related_name='cat3_spu',
verbose_name='三级类别'
)
sales = models.IntegerField(default=0, verbose_name='销量')
comments = models.IntegerField(default=0, verbose_name='评价数')
desc_detail = models.TextField(default='', verbose_name='详细介绍')
desc_pack = models.TextField(default='', verbose_name='包装信息')
desc_service = models.TextField(default='', verbose_name='售后服务')
class Meta:
db_table = 'tb_spu'
verbose_name = '商品SPU'
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class SKU(BaseModel):
"""商品SKU"""
name = models.CharField(max_length=50, verbose_name='名称')
caption = models.CharField(max_length=100, verbose_name='副标题')
spu = models.ForeignKey(
SPU,
on_delete=models.CASCADE,
verbose_name='商品'
)
category = models.ForeignKey(
GoodsCategory,
on_delete=models.PROTECT,
verbose_name='从属类别'
)
price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='单价')
cost_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='进价')
market_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='市场价')
stock = models.IntegerField(default=0, verbose_name='库存')
sales = models.IntegerField(default=0, verbose_name='销量')
comments = models.IntegerField(default=0, verbose_name='评价数')
is_launched = models.BooleanField(default=True, verbose_name='是否上架销售')
default_image = models.ImageField(
max_length=200,
default='',
null=True,
blank=True,
verbose_name='默认图片'
)
class Meta:
db_table = 'tb_sku'
verbose_name = '商品SKU'
verbose_name_plural = verbose_name
def __str__(self):
return '%s: %s' % (self.id, self.name)
class SKUImage(BaseModel):
"""SKU图片"""
sku = models.ForeignKey(
SKU,
on_delete=models.CASCADE,
verbose_name='sku'
)
image = models.ImageField(verbose_name='图片')
class Meta:
db_table = 'tb_sku_image'
verbose_name = 'SKU图片'
verbose_name_plural = verbose_name
def __str__(self):
return '%s %s' % (self.sku.name, self.id)
class SPUSpecification(BaseModel):
"""商品SPU规格"""
spu = models.ForeignKey(
SPU,
on_delete=models.CASCADE,
related_name='specs',
verbose_name='商品SPU'
)
name = models.CharField(max_length=20, verbose_name='规格名称')
class Meta:
db_table = 'tb_spu_specification'
verbose_name = '商品SPU规格'
verbose_name_plural = verbose_name
def __str__(self):
return '%s: %s' % (self.spu.name, self.name)
class SpecificationOption(BaseModel):
"""规格选项"""
spec = models.ForeignKey(
SPUSpecification,
related_name='options',
on_delete=models.CASCADE,
verbose_name='规格'
)
value = models.CharField(max_length=20, verbose_name='选项值')
class Meta:
db_table = 'tb_specification_option'
verbose_name = '规格选项'
verbose_name_plural = verbose_name
def __str__(self):
return '%s - %s' % (self.spec, self.value)
class SKUSpecification(BaseModel):
"""SKU具体规格"""
sku = models.ForeignKey(
SKU,
related_name='specs',
on_delete=models.CASCADE,
verbose_name='sku'
)
spec = models.ForeignKey(
SPUSpecification,
on_delete=models.PROTECT,
verbose_name='规格名称'
)
option = models.ForeignKey(
SpecificationOption,
on_delete=models.PROTECT,
verbose_name='规格值'
)
class Meta:
db_table = 'tb_sku_specification'
verbose_name = 'SKU规格'
verbose_name_plural = verbose_name
def __str__(self):
return '%s: %s - %s' % (self.sku, self.spec.name, self.option.value)
class GoodsVisitCount(BaseModel):
"""统计分类商品访问量模型类"""
category = models.ForeignKey(
GoodsCategory,
on_delete=models.CASCADE,
verbose_name='商品分类'
)
count = models.IntegerField(default=0, verbose_name='访问量')
date = models.DateField(auto_now_add=True, verbose_name='统计日期')
class Meta:
db_table = 'tb_goods_visit'
verbose_name = '统计分类商品访问量'
verbose_name_plural = verbose_name
修改apps/goods/apps.py:
from django.apps import AppConfig
class GoodsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.goods"
完善apps/goods/views.py
from django.shortcuts import render
from django.views import View
import logging
from apps.goods.models import SKU, GoodsCategory
from apps.goods.utils import get_breadcrumb
from apps.contents.utils import get_categories
logger = logging.getLogger("django")
class ListView(View):
"""
商品列表页
/list/<category_id>/<page_num>/
"""
def get(self, request, category_id, page_num):
# 一、分类 & 面包屑
try:
category = GoodsCategory.objects.get(id=category_id)
except GoodsCategory.DoesNotExist:
return render(request, "list.html", {"errmsg": "分类不存在"})
breadcrumb = get_breadcrumb(category)
# 二、排序
sort = request.GET.get("sort")
if sort == "hot":
order_field = "-sales"
elif sort == "price":
order_field = "price"
else:
order_field = "create_time"
sort = "default"
# 三、查询 SKU
skus = SKU.objects.filter(
category_id=category_id,
is_launched=True
).order_by(order_field)
# 四、分页
try:
page_num = int(page_num)
except Exception:
page_num = 1
from django.core.paginator import Paginator
paginator = Paginator(skus, 5)
page_skus = paginator.page(page_num)
total_page = paginator.num_pages
# 五、上下文
context = {
"category": category,
"categories": get_categories(),
"breadcrumb": breadcrumb,
"page_skus": page_skus,
"sort": sort,
"page_num": page_num,
"total_page": total_page,
}
return render(request, "list.html", context)
class DetailView(View):
"""
商品详情页
/detail/<sku_id>/
"""
def get(self, request, sku_id):
try:
sku = SKU.objects.get(id=sku_id)
except SKU.DoesNotExist:
return render(request, '404.html')
categories = get_categories()
breadcrumb = get_breadcrumb(sku.category)
# 新增:SKU 图片
sku_images = sku.skuimage_set.all()
# ========= 规格处理 =========
sku_specs = sku.specs.order_by('spec_id')
sku_key = [spec.option.id for spec in sku_specs]
skus = sku.spu.sku_set.all()
spec_sku_map = {}
for s in skus:
s_specs = s.specs.order_by('spec_id')
key = [spec.option.id for spec in s_specs]
spec_sku_map[tuple(key)] = s.id
goods_specs = sku.spu.specs.order_by('id')
for index, spec in enumerate(goods_specs):
key = sku_key[:]
options = spec.options.all()
for option in options:
key[index] = option.id
option.sku_id = spec_sku_map.get(tuple(key))
spec.spec_options = list(options)
context = {
'categories': categories,
'breadcrumb': breadcrumb,
'sku': sku,
'specs': goods_specs,
'sku_images': sku_images, # ← 关键
}
return render(request, 'detail.html', context)
添加templates/list.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>美多商城-商品列表</title>
<link rel="stylesheet" href="{{ static('css/jquery.pagination.css') }}">
<link rel="stylesheet" href="{{ static('css/reset.css') }}">
<link rel="stylesheet" href="{{ static('css/main.css') }}">
<script src="{{ static('js/jquery-1.12.4.min.js') }}"></script>
<script src="{{ static('js/vue-2.5.16.js') }}"></script>
<script src="{{ static('js/axios-0.18.0.min.js') }}"></script>
<script>
let category_id = "{{ category.id }}"
</script>
</head>
<body>
<div id="app" v-cloak>
<!-- 顶部 -->
<div class="header_con">
<div class="header">
<div class="welcome fl">欢迎来到美多商城!</div>
<div class="fr">
<div class="login_btn fl" v-if="username">
欢迎您:<em>[[ username ]]</em>
<span>|</span>
<a href="/logout/">退出</a>
</div>
<div class="login_btn fl" v-else>
<a href="/login/">登录</a>
<span>|</span>
<a href="/register/">注册</a>
</div>
<div class="user_link fl">
<span>|</span>
<a href="/info/">用户中心</a>
<span>|</span>
<a href="/carts/">我的购物车</a>
<span>|</span>
<a href="/orders/">我的订单</a>
</div>
</div>
</div>
</div>
<!-- 搜索 -->
<div class="search_bar clearfix">
<a href="/" class="logo fl">
<img src="{{ static('images/logo.png') }}">
</a>
<div class="search_wrap fl">
<form method="get" action="/search/" class="search_con">
<input type="text" class="input_text fl" name="q" placeholder="搜索商品">
<input type="submit" class="input_btn fr" value="搜索">
</form>
</div>
</div>
<!-- 分类导航 -->
<div class="navbar_con">
<div class="navbar">
<div class="sub_menu_con fl">
<h1 class="fl">商品分类</h1>
<ul class="sub_menu">
{% for group in categories.values() %}
<li>
<div class="level1">
{% for channel in group.channels %}
<a href="{{ channel.url }}">{{ channel.name }}</a>
{% endfor %}
</div>
<div class="level2">
{% for cat2 in group.sub_cats %}
<div class="list_group">
<div class="group_name fl">{{ cat2.name }} ></div>
<div class="group_detail fl">
{% for cat3 in cat2.sub_cats %}
<a href="/list/{{ cat3.id }}/1/">{{ cat3.name }}</a>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</li>
{% endfor %}
</ul>
</div>
<ul class="navlist fl">
<li><a href="/">首页</a></li>
<li class="interval">|</li>
<li><a href="#">真划算</a></li>
<li class="interval">|</li>
<li><a href="#">抽奖</a></li>
</ul>
</div>
</div>
<!-- 面包屑 -->
<div class="breadcrumb">
<a href="javascript:;">{{ breadcrumb.cat1.name }}</a>
<span>></span>
<a href="javascript:;">{{ breadcrumb.cat2.name }}</a>
<span>></span>
<a href="javascript:;">{{ breadcrumb.cat3.name }}</a>
</div>
<!-- 商品列表 -->
<div class="main_wrap clearfix">
<div class="r_wrap fr clearfix">
<div class="sort_bar">
<a href="/list/{{ category.id }}/{{ page_num }}/?sort=default"
{% if sort == 'default' %}class="active"{% endif %}>默认</a>
<a href="/list/{{ category.id }}/{{ page_num }}/?sort=price"
{% if sort == 'price' %}class="active"{% endif %}>价格</a>
<a href="/list/{{ category.id }}/{{ page_num }}/?sort=hot"
{% if sort == 'hot' %}class="active"{% endif %}>人气</a>
</div>
<ul class="goods_type_list clearfix">
{% for sku in page_skus %}
<li>
<a href="/detail/{{ sku.id }}/">
<img src="{{ sku.default_image.url }}">
</a>
<h4>{{ sku.name }}</h4>
<div class="operate">
<span class="price">¥{{ sku.price }}</span>
<span class="unit">台</span>
</div>
</li>
{% endfor %}
</ul>
<div class="pagenation">
<div id="pagination"></div>
</div>
</div>
</div>
<script src="{{ static('js/jquery.pagination.min.js') }}"></script>
<script>
$('#pagination').pagination({
currentPage: {{ page_num }},
totalPage: {{ total_page }},
callback:function (current) {
location.href = '/list/{{ category.id }}/' + current + '/?sort={{ sort }}';
}
})
</script>
</div>
</body>
</html>
添加templates/detail.html
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
<title>美多商城-商品详情</title>
<link rel="stylesheet" type="text/css" href="/static/css/reset.css">
<link rel="stylesheet" type="text/css" href="/static/css/main.css">
<script type="text/javascript" src="/static/js/host.js"></script>
<script type="text/javascript" src="/static/js/common.js"></script>
<script type="text/javascript" src="/static/js/vue-2.5.16.js"></script>
<script type="text/javascript" src="/static/js/axios-0.18.0.min.js"></script>
</head>
<body>
<div id="app" v-cloak>
<!-- ================= 面包屑 ================= -->
<div class="breadcrumb">
<a href="/">全部分类</a>
<span>></span>
<a href="#">{{ breadcrumb.cat1.name }}</a>
<span>></span>
<a href="#">{{ breadcrumb.cat2.name }}</a>
<span>></span>
<span>{{ breadcrumb.cat3.name }}</span>
</div>
<!-- ================= 商品详情 ================= -->
<div class="goods_detail_con clearfix">
<!-- ===== 左侧商品图片(⚠️只用 sku_images) ===== -->
<div class="goods_detail_pic fl">
{% if sku_images %}
<img src="{{ sku_images[0].image.url }}">
{% else %}
<img src="/static/images/no_image.png">
{% endif %}
</div>
<!-- ===== 右侧商品信息 ===== -->
<div class="goods_detail_list fr">
<h3>{{ sku.name }}</h3>
<p>{{ sku.caption }}</p>
<div class="price_bar">
<span class="show_pirce">
¥<em>{{ sku.price }}</em>
</span>
</div>
<div class="goods_num clearfix">
<div class="num_name fl">数 量:</div>
<div class="num_add fl">
<input type="text" class="num_show fl" value="1">
<a href="javascript:;" class="add fr">+</a>
<a href="javascript:;" class="minus fr">-</a>
</div>
</div>
<!-- ================= 规格选择(⚠️核心区) ================= -->
{% for spec in specs %}
<div class="type_select">
<label>{{ spec.name }}:</label>
{% for option in spec.spec_options %}
{% if option.sku_id == sku.id %}
<a href="javascript:;" class="select">{{ option.value }}</a>
{% elif option.sku_id %}
<a href="{{ url('goods:detail', args=(option.sku_id,)) }}">
{{ option.value }}
</a>
{% else %}
<a href="javascript:;">{{ option.value }}</a>
{% endif %}
{% endfor %}
</div>
{% endfor %}
<div class="total">
总价:<em>{{ sku.price }}元</em>
</div>
<div class="operate_btn">
<a href="javascript:;" class="add_cart" id="add_cart">加入购物车</a>
</div>
</div>
</div>
<!-- ================= 商品详情 Tab ================= -->
<div class="main_wrap clearfix">
<div class="r_wrap fr clearfix">
<ul class="detail_tab clearfix">
<li class="active">商品详情</li>
<li>规格与包装</li>
<li>商品评价</li>
<li>售后服务</li>
</ul>
<div class="tab_content current">
<dl>
<dt>商品详情:</dt>
<dd>{{ sku.spu.desc_detail }}</dd>
</dl>
</div>
</div>
</div>
<div class="footer">
<p>CopyRight © 2016 北京美多商业股份有限公司</p>
</div>
</div>
<script>
var sku_id = "{{ sku.id }}";
</script>
<script type="text/javascript" src="/static/js/detail.js"></script>
</body>
</html>
迁移数据库
python manage.py makemigrations goods
python manage.py migrate

数据结构详解
0. 先把“商品域”一句话讲透
- SPU:一类商品(同一个“款”),比如「iPhone 15」
- SKU:可买的具体商品(某个规格组合),比如「iPhone 15 / 128G / 黑色」
- 规格:决定 SKU 的维度,比如「颜色」「容量」
- 库存/价格:一定属于 SKU(因为不同规格价格库存不同)
所以:SPU 负责“商品内容/介绍”,SKU 负责“交易属性/可购买”。
1. 类目表:GoodsCategory(tb_goods_category)
字段意义
name:类目名称(如 手机、智能手机、安卓手机)parent:父类目(自关联),支持三级类目
为什么要三级类目?
电商常用三级结构:
- 1级:手机数码
- 2级:手机通讯
- 3级:智能手机
典型数据
| id | name | parent_id |
|---|---|---|
| 1 | 手机数码 | null |
| 2 | 手机通讯 | 1 |
| 3 | 智能手机 | 2 |
2. 频道表:GoodsChannelGroup / GoodsChannel
这两张表是给首页导航用的(类似京东顶部的分类栏目)。
GoodsChannelGroup(频道组)
name:组名(如 “手机/运营商/数码”)
GoodsChannel(频道)
group:属于哪个频道组category:绑定一个顶级类目url:点击跳转链接sequence:排序
你可以理解成:“首页导航=频道组(列) + 类目(每项)”
3. 品牌表:Brand(tb_brand)
name:品牌名(Apple、小米)logo:品牌 logofirst_letter:首字母(用于筛选/索引)
品牌和商品是 1 对多:
- 一个品牌下面很多 SPU
4. 核心:SPU 表(tb_spu)——“款”的概念
SPU 是什么?
Standard Product Unit:标准产品单元
在电商里就是一个“商品款式”,它描述的不是“可下单的那一件”,而是“这个商品整体介绍”。
SPU 字段怎么理解
name:款名(如 “iPhone 15”)brand:属于哪个品牌category1/2/3:绑定三级类目(一般绑定到 3 级,但保留 1/2/3 做查询方便)sales/comments:聚合数据(通常由 SKU 汇总得到)desc_detail/desc_pack/desc_service:- 详情介绍
- 包装信息
- 售后服务
为什么描述信息放 SPU,不放 SKU?
因为「iPhone 15」的商品介绍、售后说明,不因颜色容量变化,放在 SKU 会重复 N 次。
5. 核心:SKU 表(tb_sku)——“可购买单位”
SKU 是什么?
Stock Keeping Unit:库存单位
你真正下单买的,是 SKU。
SKU 字段怎么理解
name:SKU 名(“iPhone 15 128G 黑色”)caption:副标题(营销文案)spu:属于哪个 SPU(iPhone 15)category:从属类别(通常就是 3 级类目)price/cost_price/market_price:price:售卖价cost_price:进货成本(用于利润/报表)market_price:划线价
stock:库存(只属于 SKU)sales/comments:SKU 自己的销量与评价(SPU 里也有一个汇总)is_launched:是否上架(下架后不展示)default_image:默认展示图
为什么库存一定在 SKU?
因为:
- 黑色 128G 可能只剩 2 台
- 蓝色 256G 可能还有 50 台
库存不可能挂在 SPU 上。
6. 图片表:SKUImage(tb_sku_image)
- 一个 SKU 有多张图片(轮播图)
sku:属于哪个 SKUimage:图片地址
为什么图片绑 SKU?
很多商品不同规格(颜色)图片不一样,所以绑 SKU 更准确。
7. 规格系统(最关键但最容易乱)
你这里有三张规格相关表:
A) SPUSpecification(tb_spu_specification)——“这个 SPU 有哪些规格维度”
例:iPhone 15 有两个规格维度:
- 颜色
- 容量
字段:
spu:属于哪个 SPUname:规格名(颜色/容量)
SPU 级别定义“维度”,SKU 级别选择“具体值”。
B) SpecificationOption(tb_specification_option)——“某个规格有哪些选项值”
例:
- 颜色:黑色、蓝色、粉色
- 容量:128G、256G、512G
字段:
spec:属于哪个规格(颜色/容量)value:选项值(黑色/128G)
C) SKUSpecification(tb_sku_specification)——“某个 SKU 在每个规格维度上选了哪个值”
例:
SKU = “iPhone 15 / 黑色 / 128G”
就会有两行:
- spec=颜色,option=黑色
- spec=容量,option=128G
字段:
sku:哪个 SKUspec:规格维度(颜色/容量)option:具体选项(黑色/128G)
8. 用一个完整例子把 SPU/SKU/规格串起来
例子:iPhone 15
1) SPU(iPhone 15)
- name=“iPhone 15”
- desc_detail=“xxx”
- desc_service=“全国联保…”
2) SPU 规格维度(SPUSpecification)
- 颜色
- 容量
3) 规格选项(SpecificationOption)
- 颜色:黑色、蓝色
- 容量:128G、256G
4) SKU(可买的组合)
SKU1:iPhone 15 / 黑色 / 128G(price=5999 stock=10)
SKU2:iPhone 15 / 黑色 / 256G(price=6999 stock=5)
SKU3:iPhone 15 / 蓝色 / 128G(price=5999 stock=8)
…
5) SKU 规格明细(SKUSpecification)
SKU1:
- 颜色=黑色
- 容量=128G
SKU2:
- 颜色=黑色
- 容量=256G
9. 访问量表:GoodsVisitCount(tb_goods_visit)
- 按“分类 + 日期”统计访问量
category:统计哪个分类count:当日访问量date:日期(自动写入)
用于运营后台看“哪些类目热”。
FastDFS
FastDFS 是一个开源的分布式文件存储系统,专门用来存“图片 / 文件”,不存业务数据。

作用:
- 它负责:文件上传 / 存储 / 访问
- 它不负责:用户、订单、商品、库存这些业务逻辑
- 它解决的问题是:“文件太多,不能直接放在 Web 服务器上”
FastDFS 的核心组成
FastDFS
├── Tracker(调度器)
│ └── 负责:告诉你文件该存到哪台机器
│
└── Storage(存储节点)
└── 负责:真正存文件(图片、附件等)

在「美多商城」中,FastDFS 存储图片
为什么美多商城要用 FastDFS
图片数量巨大,不能放在 Django 项目里
如果你把图片直接放在/media/
问题会是:
- Django 服务器压力巨大
- 多台 Web 服务器无法共享文件
- 扩容基本没法做
数据库只存 URL,架构清晰
MySQL FastDFS
------ --------
image_url ---> /group1/M00/00/01/xxx.jpg
docker安装运行 FastDFS
项目根目录构建目录结构
docker-compose/
├── docker-compose.fastdfs-nginx.yml
├── tracker/
│ └── data/
├── storage/
│ ├── data/
│ └── logs/
└── nginx/
├── nginx.conf
└── conf.d/
└── fastdfs.conf
创建 docker-compose.fastdfs.yml
services:
# =========================
# FastDFS Tracker(调度节点)
# =========================
tracker:
image: delron/fastdfs # 使用 FastDFS 官方镜像
container_name: fastdfs-tracker # Tracker 容器名称
command: tracker # 指定启动角色为 Tracker
ports:
- "22122:22122" # Tracker 服务端口
# 左边:宿主机端口
# 右边:容器内端口
volumes:
- ./tracker:/var/fdfs # Tracker 的运行数据和日志
# 左边:宿主机目录
# 右边:容器内 base_path
# =========================
# FastDFS Storage(存储节点)
# =========================
storage:
image: delron/fastdfs # 同一个镜像,不同角色
container_name: fastdfs-storage # Storage 容器名称
command: storage # 指定启动角色为 Storage
environment:
TRACKER_SERVER: fastdfs-tracker:22122
# Storage 启动时注册到哪个 Tracker
# fastdfs-tracker 是 Docker 内部 DNS 名
ports:
- "23000:23000" # Storage 服务端口
# 主要用于调试 / 内部通信
volumes:
- ./storage:/var/fdfs # Storage 文件真实落盘位置
# ./storage/data 里存的就是 FastDFS 文件
depends_on:
- tracker # 启动顺序:先 Tracker,后 Storage
# =========================
# Nginx(FastDFS 的 HTTP 访问入口)
# =========================
nginx:
image: nginx:1.25-alpine # 官方 Nginx 镜像(alpine 体积小)
container_name: fastdfs-nginx # Nginx 容器名称
ports:
- "8888:80" # HTTP 访问端口
# 浏览器 / Django 访问:http://127.0.0.1:8888
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
# Nginx 主配置文件
# ro = 只读,防止容器内误改
- ./nginx/conf.d:/etc/nginx/conf.d:ro
# Nginx 站点配置目录
# 这里放 fastdfs.conf
- ./storage/data:/var/fdfs/data:ro
# 把 FastDFS Storage 的数据目录
# 挂载给 Nginx 用于直接读取文件
# ro = 只读,Nginx 不写文件
depends_on:
- storage # 启动顺序:先 Storage,后 Nginx
Nginx 主配置
nginx/nginx.conf
user nginx;
worker_processes auto;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
include /etc/nginx/conf.d/*.conf;
}
FastDFS 文件访问配置
nginx/conf.d/fastdfs.conf
server {
listen 80;
server_name localhost;
location /group1/M00 {
alias /var/fdfs/data;
autoindex on;
}
}
架构:

FastDFS上传图片文件
目前架构初步认定Django 在宿主机跑,调用过程为:
┌────────────────────┐
│ Django 应用 │
│ (Python / ORM / API)│
└─────────┬──────────┘
│
只保存 / 使用 file_id
│
▼
┌────────────────────┐
│ 数据库 │
│ group1/M00/... │
└────────────────────┘
│
通过 HTTP 访问
│
▼
┌────────────────────┐
│ Nginx │
│ http://:8888 │
│ alias /var/fdfs │
└─────────┬──────────┘
│ 读文件
▼
┌────────────────────┐
│ FastDFS Storage │
│ ./storage/data │
└─────────┬──────────┘
│ 注册 / 心跳
▼
┌────────────────────┐
│ FastDFS Tracker │
└────────────────────┘
安装客户端
python -m pip install fdfs_client-py
utils 目录下构建包,目录结构为

utils/fdfs/client.conf
# ===== FastDFS client config =====
# 连接超时(秒)
connect_timeout=30
# 网络超时(秒)
network_timeout=120
# 客户端日志目录(必须存在)
base_path=/tmp
# Tracker 地址(通过 docker-compose 暴露出来的端口)
tracker_server=127.0.0.1:22122
# 日志级别
log_level=info
# 是否使用连接池
use_connection_pool = false
# 是否从 tracker 加载参数
load_fdfs_parameters_from_tracker=false
utils/fdfs/faststorage.py
from django.core.files.storage import Storage
from django.utils.deconstruct import deconstructible
from django.conf import settings
@deconstructible
class MyStorage(Storage):
"""
FastDFS(HTTP/Nginx 模式)自定义存储后端
Django 不直连 FastDFS
"""
def _open(self, name, mode='rb'):
# FastDFS 不支持 Django open
raise NotImplementedError("FastDFS does not support open")
def _save(self, name, content, max_length=None):
"""
HTTP 模式下:
- Django 不负责真正上传
- 前端 / 其他服务已经上传完成
- name 就是 FastDFS 返回的 file_id
"""
return name
def exists(self, name):
# FastDFS 不做重名判断
return False
def url(self, name):
"""
name = file_id
"""
return settings.FDFS_BASE_URL + name
meiduo_mall/settings.py 添加FastDFS自定义文件存储配置:
# ================= FastDFS配置 =================
# FastDFS 通过 Nginx 访问的前缀
FDFS_BASE_URL = "http://127.0.0.1:8888/"
# 指定默认存储后端
DEFAULT_FILE_STORAGE = "utils.fdfs.faststorage.MyStorage"
# 媒体
MEDIA_URL = 'http://127.0.0.1:8888/'
运行FastDFS
docker compose -f docker-compose.fastdfs.yml up -d

- FastDFS 已经是“文件仓库”,
- Nginx 是“HTTP 文件服务器”,
- Django :不再参与上传、不再调用 FastDFS 客户端、不再关心 Tracker。
测试FastDFS 里文件可以访问:
进入 Storage 容器
docker exec -it fastdfs-storage bash
用 FastDFS 命令上传一个测试文件(推荐)
fdfs_upload_file /etc/fdfs/client.conf /etc/hosts
你会看到输出类似:
group1/M00/00/00/rBMAA2legAuAFWN3AAAArICyYWY0983146

这个字符串 就是 file_id。
确认文件在宿主机真实落盘
find docker-compose/storage/data -type f | head

用浏览器HTTP 测试
http://127.0.0.1:8888/group1/M00/00/00/rBMAA2legAuAFWN3AAAArICyYWY0983146
host成功下载:

录入商品数据和图片数据
mysql -u root -p meiduo_mall < goods_data_8.0.sql
SQL 主要导入了哪些数据?
① 商品分类数据(导航菜单用)
涉及表
tb_channel_grouptb_goods_channeltb_goods_category

② 品牌 + 商品基础数据
涉及表
tb_brandtb_sputb_skutb_sku_image
导入了什么?
- 品牌:华为 / 小米 / 360
- SPU:360手机 N6 Pro
- SKU:
- 黑色 64G ¥16.80
- 白色 128G ¥28.80
- 商品图片
③ 商品规格数据(颜色 / 内存 / 尺寸)
涉及表
tb_spu_specificationtb_specification_optiontb_sku_specification
导入了什么?
- 规格名:颜色 / 内存
- 规格值:黑色 / 白色 / 64G / 128G
- SKU 与规格的对应关系
④ 首页内容管理数据(轮播图 / 楼层)等CMS 数据
涉及表
tb_content_categorytb_content
导入了什么?
- 首页轮播图
- 首页快讯
- 1F / 2F / 3F 楼层商品
- 推荐位、广告位

解压并导入 fdfs 图片
链接: https://pan.baidu.com/s/1Im4Wb7K_Yrj03lZEfwQy2g 提取码: qw37
docker compose -f docker-compose.fastdfs.yml down
tar -xzvf data.tar.gz -C storage/
docker compose -f docker-compose.fastdfs.yml up -d
测试访问图片:
可以先找个图片
file storage/data/00/00/* | grep -i image | head

就拿这个图片文件为例:
file storage/data/00/00/CtM3BVni03-ANUDwAAAmv27pX4k9203075
修改 meiduo_mall/docker-compose/nginx/nginx.conf
user nginx;
worker_processes auto
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
include /etc/nginx/conf.d/*.conf;
}
修改 meiduo_mall/docker-compose/nginx/conf.d/fastdfs.conf
server {
listen 80;
server_name localhost;
location /group1/M00/ {
alias /var/fdfs/data/;
# FastDFS 图片无扩展名,统一当图片处理
default_type image/jpeg;
add_header Content-Disposition inline;
# 强缓存(商品图必开)
expires 30d;
add_header Cache-Control "public";
# 禁止目录列表
autoindex off;
# 防盗链(按需)
valid_referers none blocked img.yourdomain.com;
if ($invalid_referer) {
return 403;
}
}
}
docker restart fastdfs-nginx
测试 nginx 访问路径:http://127.0.0.1:8888/group1/M00/00/00/CtM3BVni03-ANUDwAAAmv27pX4k9203075

安装解析音视频“内容本身”的信息,主动向“外部 HTTP 服务”发请求
pip install mutagen
pip install requests
商品首页展示
封装首页商品频道分类以及展示首页商品广告
新增商品内容模块
cd apps
python ../manage.py startapp contents
meiduo_mall/urls.py
urlpatterns = [
# 商品模块
path("", include(("apps.goods.urls", "goods"), namespace="goods")),
path("", include(("apps.contents.urls", "contents"), namespace="contents")),
]
apps 添加 contents
meiduo_mall/settings.py
INSTALLED_APPS = [
...
'apps.contents',
]
apps/contents/apps.py
from django.apps import AppConfig
class ContentsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.contents"
apps/contents/models.py
from django.db import models
from utils.models import BaseModel
from django.conf import settings
from utils.fdfs.faststorage import MyStorage
class ContentCategory(BaseModel):
"""广告内容类别"""
name = models.CharField(max_length=50, verbose_name='名称')
key = models.CharField(max_length=50, verbose_name='类别键名')
class Meta:
db_table = 'tb_content_category'
verbose_name = '广告内容类别'
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class Content(BaseModel):
"""广告内容"""
category = models.ForeignKey(
ContentCategory,
on_delete=models.PROTECT,
verbose_name='类别'
)
title = models.CharField(max_length=100, verbose_name='标题')
url = models.CharField(max_length=300, verbose_name='内容链接')
image = models.ImageField(storage=MyStorage(),null=True, blank=True, verbose_name='图片')
text = models.TextField(null=True, blank=True, verbose_name='内容')
sequence = models.IntegerField(verbose_name='排序')
status = models.BooleanField(default=True, verbose_name='是否展示')
class Meta:
db_table = 'tb_content'
verbose_name = '广告内容'
verbose_name_plural = verbose_name
def __str__(self):
return self.category.name + ': ' + self.title
apps/contents/urls.py
from django.urls import path
from .views import IndexView
app_name = "contents"
urlpatterns = [
path("", IndexView.as_view(), name="index"),
]
新增:apps/contents/utils.py
from collections import OrderedDict
from apps.goods.models import GoodsChannel
def get_categories():
"""
封装首页商品频道和分类数据
返回结构:
{
group_id: {
'channels': [...],
'sub_cats': [...]
}
}
"""
categories = OrderedDict()
# 按频道组 + 组内顺序排序
channels = GoodsChannel.objects.select_related(
'group', 'category'
).order_by('group_id', 'sequence')
for channel in channels:
group_id = channel.group_id
if group_id not in categories:
categories[group_id] = {
'channels': [],
'sub_cats': []
}
# 一级分类
cat1 = channel.category
categories[group_id]['channels'].append({
'id': cat1.id,
'name': cat1.name,
'url': channel.url
})
# 二级 / 三级分类
for cat2 in cat1.subs.all():
cat2.sub_cats = []
for cat3 in cat2.subs.all():
cat2.sub_cats.append(cat3)
categories[group_id]['sub_cats'].append(cat2)
return categories
apps/contents/views.py
# apps/contents/views.py
from django.shortcuts import render
from django.views import View
from apps.contents.utils import get_categories
from apps.contents.models import ContentCategory
from django.conf import settings
class IndexView(View):
def get(self, request):
# 1. 商品分类(左侧分类栏)
categories = get_categories()
# 2. 首页内容(轮播 / 快讯 / 楼层)
contents = {}
content_categories = ContentCategory.objects.all()
for cat in content_categories:
contents[cat.key] = cat.content_set.filter(
status=True
).order_by('sequence')
context = {
'categories': categories,
'contents': contents,
'FDFS_BASE_URL': settings.FDFS_BASE_URL,
}
return render(request, 'index.html', context)
完善首页:templates/index.html
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
<title>美多商城-首页</title>
<link rel="stylesheet" type="text/css" href="../static/css/reset.css">
<link rel="stylesheet" type="text/css" href="../static/css/main.css">
<script type="text/javascript" src="../static/js/host.js"></script>
<script type="text/javascript" src="../static/js/common.js"></script>
<script type="text/javascript" src="../static/js/vue-2.5.16.js"></script>
<script type="text/javascript" src="../static/js/axios-0.18.0.min.js"></script>
<script type="text/javascript" src="../static/js/jquery-1.12.4.min.js"></script>
<script>
var username = "{{ request.user.username | default('') }}";
</script>
</head>
<body>
<div id="app" v-cloak>
<div class="header_con">
<div class="header">
<div class="welcome fl">欢迎来到美多商城!</div>
<div class="fr">
<div class="login_btn fl" v-if="username">
欢迎您:<em>[[ username ]]</em>
<span>|</span>
<a href="{{ url('users:logout') }}" >退出</a>
</div>
<div class="login_btn fl" v-else>
<a href="{{ url('users:login') }}">登录</a>
<span>|</span>
<a href="{{ url('users:register') }}">注册</a>
</div>
<div class="user_link fl">
<span>|</span>
<a href="users/user_center_info.html">用户中心</a>
<span>|</span>
<a href="../static/cart.html">我的购物车</a>
<span>|</span>
<a href="../static/user_center_order.html">我的订单</a>
</div>
</div>
</div>
</div>
<div class="search_bar clearfix">
<a href="index.html" class="logo fl"><img src="../static/images/logo.png"></a>
<div class="search_wrap fl">
<form method="get" action="/search/" class="search_con">
<input type="text" class="input_text fl" name="q" placeholder="搜索商品">
<input type="submit" class="input_btn fr" name="" value="搜索">
</form>
<ul class="search_suggest fl">
<li><a href="#">索尼微单</a></li>
<li><a href="#">优惠15元</a></li>
<li><a href="#">美妆个护</a></li>
<li><a href="#">买2免1</a></li>
</ul>
</div>
<div class="guest_cart fr">
<a href="/carts/" class="cart_name fl">我的购物车</a>
<div class="goods_count fl" id="show_count">[[ cart_total_count ]]</div>
<ul class="cart_goods_show">
<li v-for="cart in carts">
<img :src="cart.default_image_url" alt="商品图片">
<h4>[[ cart.name ]]</h4>
<div>[[ cart.count ]]</div>
</li>
</ul>
</div>
</div>
<div class="navbar_con">
<div class="navbar">
<h1 class="fl">商品分类</h1>
<ul class="navlist fl">
<li><a href="">首页</a></li>
<li class="interval">|</li>
<li><a href="">真划算</a></li>
<li class="interval">|</li>
<li><a href="">抽奖</a></li>
</ul>
</div>
</div>
<!-- ================= 轮播图 ================= -->
<div class="pos_center_con clearfix">
<ul class="slide">
{% for content in contents.index_lbt %}
<li>
<a href="{{ content.url }}">
<img src="{{ content.image.url }}" alt="{{ content.title }}">
</a>
</li>
{% endfor %}
</ul>
<ul class="sub_menu">
{% for group in categories.values() %}
<li>
<div class="level1">
{% for channel in group.channels %}
<a href="{{ channel.url }}">{{ channel.name }}</a>
{% endfor %}
</div>
<div class="level2">
{% for cat2 in group.sub_cats %}
<div class="list_group">
<div class="group_name fl">{{ cat2.name }} ></div>
<div class="group_detail fl">
{% for cat3 in cat2.sub_cats %}
<a href="/list/{{ cat3.id }}/1/">{{ cat3.name }}</a>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</li>
{% endfor %}
</ul>
<!-- 快讯 -->
<div class="news">
<div class="news_title">
<h3>快讯</h3>
</div>
<ul class="news_list">
{% for content in contents.index_kx %}
<li><a href="{{ content.url }}">{{ content.title }}</a></li>
{% endfor %}
</ul>
{% for content in contents.index_ytgg %}
<a href="{{ content.url }}" class="advs">
<img src="{{ FDFS_BASE_URL }}{{ content.image }}">
</a>
{% endfor %}
</div>
</div>
<!-- ================= 1F ================= -->
<div class="list_model">
<div class="list_title clearfix">
<h3 class="fl">1F</h3>
</div>
<div class="goods_con clearfix">
<div class="goods_banner fl">
<img src="{{ contents.index_1f_logo.0.image.url }}">
<div class="channel">
{% for content in contents.index_1f_pd %}
<a href="{{ content.url }}">{{ content.title }}</a>
{% endfor %}
</div>
<div class="key_words">
{% for content in contents.index_1f_bq %}
<a href="{{ content.url }}">{{ content.title }}</a>
{% endfor %}
</div>
</div>
<div class="goods_list_con">
<ul class="goods_list fl">
{% for content in contents.index_1f_ssxp %}
<li>
<a href="{{ content.url }}" class="goods_pic">
<img src="{{ content.image.url }}">
</a>
<h4><a href="{{ content.url }}">{{ content.title }}</a></h4>
<div class="price">{{ content.text }}</div>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<!-- ================= 2F ================= -->
<div class="list_model model02">
<div class="list_title clearfix">
<h3 class="fl">2F</h3>
</div>
<div class="goods_con clearfix">
<div class="goods_banner fl">
<img src="{{ contents.index_2f_logo.0.image.url }}">
<div class="channel">
{% for content in contents.index_2f_pd %}
<a href="{{ content.url }}">{{ content.title }}</a>
{% endfor %}
</div>
<div class="key_words">
{% for content in contents.index_2f_bq %}
<a href="{{ content.url }}">{{ content.title }}</a>
{% endfor %}
</div>
</div>
<div class="goods_list_con">
<ul class="goods_list fl">
{% for content in contents.index_2f_cxdj %}
<li>
<a href="{{ content.url }}" class="goods_pic">
<img src="{{ content.image.url }}">
</a>
<h4><a href="{{ content.url }}">{{ content.title }}</a></h4>
<div class="price">{{ content.text }}</div>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<!-- ================= 3F ================= -->
<div class="list_model model03">
<div class="list_title clearfix">
<h3 class="fl">3F</h3>
</div>
<div class="goods_con clearfix">
<div class="goods_banner fl">
<img src="{{ contents.index_3f_logo.0.image.url }}">
<div class="channel">
{% for content in contents.index_3f_pd %}
<a href="{{ content.url }}">{{ content.title }}</a>
{% endfor %}
</div>
<div class="key_words">
{% for content in contents.index_3f_bq %}
<a href="{{ content.url }}">{{ content.title }}</a>
{% endfor %}
</div>
</div>
<div class="goods_list_con">
<ul class="goods_list fl">
{% for content in contents.index_3f_shyp %}
<li>
<a href="{{ content.url }}" class="goods_pic">
<img src="{{ content.image.url }}">
</a>
<h4><a href="{{ content.url }}">{{ content.title }}</a></h4>
<div class="price">{{ content.text }}</div>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<div class="footer">
<div class="foot_link">
<a href="#">关于我们</a>
<span>|</span>
<a href="#">联系我们</a>
<span>|</span>
<a href="#">招聘人才</a>
<span>|</span>
<a href="#">友情链接</a>
</div>
<p>CopyRight © 2016 北京美多商业股份有限公司 All Rights Reserved</p>
<p>电话:010-****888 京ICP备*******8号</p>
</div>
</div>
<script type="text/javascript" src="../static/js/slide.js"></script>
<script type="text/javascript" src="../static/js/common.js"></script>
<script type="text/javascript" src="../static/js/index.js"></script>
</body>
</html>
访问首页:http://127.0.0.1:8000
可以用 navicat 修改数据测试数据来自数据库



导航栏支持点击进入,实现商品的初步查看


发布者:LJH,转发请注明出处:https://www.ljh.cool/44536.html