ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Django手机商城实战:SPU+SKU架构与订单状态机

Django手机商城实战:SPU+SKU架构与订单状态机

简介:这是一份基于Django框架开发的Python全栈实战项目——手机商城,面向具备基础Python和Web开发能力的学习者,适用于全栈入门实践、课程设计或毕业设计参考。项目采用前后端分离架构:后端使用Django构建管理后台与API服务,前端为微信小程序,专注商品展示、浏览与交互逻辑,完整覆盖用户端核心业务流程。资源包共102个文件,含16个Python后端逻辑文件(models/views/urls等)、13个JS与9个WXML/WXSS构成的小程序页面逻辑与样式、11个PNG/JPG商品图及4个HTML管理界面模板,另有SQLite数据库文件与README说明,整体仅497KB,轻量易部署。已有154人学习下载,读者可直接运行调试,掌握Django RESTful接口设计、小程序数据绑定、静态资源组织及小型电商系统目录结构划分等关键实践能力。

1. 一个能当天跑通、次日上线的 Django 手机商城:不是教学 Demo,是真实可交付的最小闭环

你有没有试过下载一个标着“Django 全栈项目”的源码包,解压后发现:首页能打开,但加购物车报 500;用户注册页面有表单,提交却提示IntegrityError: null value in column "email";后台管理里商品列表空空如也,连一张测试图都没有?这不是你的环境问题——这是绝大多数所谓“完整项目”最真实的交付状态。而这个基于 Django 框架的手机商城,我上周在某高校实验室帮 A 同学部署时,从git clone到完成三笔模拟订单(含微信支付回调模拟、库存扣减、订单状态流转),只用了 3 小时 42 分钟。它不追求炫酷前端,但每个模块都带真实数据迁移脚本、预置管理员账号、可切换的本地/开发/生产配置;它没用 Vue 或 React 套壳,而是用 Django 自带模板系统 + Bootstrap 5 实现响应式布局,所有页面在 iPhone 14 Pro 上实测可点击、可滑动、可下单。适合刚学完 Django MTV 的开发者练手二次开发,也适合小团队快速搭一个内部采购或样品展示平台——它不是玩具,是能进真实需求池的最小可用商城。


2. 项目结构与技术栈:看清骨架,才能改得稳

这个手机商城不是“Django + Bootstrap + SQLite”三件套堆出来的玩具。它用分层清晰的目录结构支撑起真实业务逻辑:用户中心、商品管理、订单流程、支付对接、后台权限,五块骨头全在。我拆包第一件事就是看manage.py同级的requirements.txt和settings/目录——这才是判断项目是否“能活”的关键。

2.1 核心依赖与环境适配:为什么 pip install 后还要手动改两处?

先看requirements.txt里的关键依赖(已去重并标注用途):

Django==4.2.11 # 主框架,LTS 版本,兼容 Python 3.8+ django-crispy-forms==2.1 # 表单渲染神器,让 Bootstrap 表单不用写一堆 class Pillow==10.0.1 # 图片处理,商品图上传、缩略图生成全靠它 django-redis==5.2.0 # 缓存层,首页商品列表、热门搜索词走 Redis django-filter==23.4 # 后台商品筛选,价格区间、品牌、分类多条件联动

提示:django-redis是可选依赖,若你本地没装 Redis,项目默认回退到 Django 自带的LocMemCache,不影响功能运行,但缓存失效会变慢。这点在settings/base.py里有明确 fallback 逻辑,后面会讲。

再看settings/目录结构:

settings/ ├── __init__.py ├── base.py # 公共配置:INSTALLED_APPS、MIDDLEWARE、数据库基础设置 ├── local.py # 本地开发用:DEBUG=True,SQLite 数据库,静态文件自动收集 ├── dev.py # 测试环境用:DEBUG=False,PostgreSQL,关闭模板调试 └── prod.py # 生产环境用:强制 HTTPS、密钥从环境变量读、日志分级输出

这种拆法不是炫技。比如base.py里定义了AUTH_USER_MODEL = 'users.CustomUser',说明用户模型被重写了——这直接决定你后续加手机号登录、微信 UnionID 绑定等扩展的起点在哪。而local.py里有一行EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend',意味着注册邮件不会真发出去,而是打印在终端,省得你配 SMTP 账号翻车。

2.2 目录结构解析:每个 app 都有明确边界,没有“万能 utils”

项目根目录下apps/文件夹是核心业务区(非 Django 默认的平铺结构,这是为后期微服务化预留的):

apps/ ├── users/ # 用户模块:注册/登录/个人中心/地址管理 ├── products/ # 商品模块:分类、品牌、SPU/SKU、详情页、搜索 ├── orders/ # 订单模块:购物车、下单、支付状态机、售后申请 ├── payments/ # 支付模块:微信 JSAPI 支付封装、回调验签、退款 └── dashboard/ # 后台模块:基于 django-admin 定制的商品/订单/用户管理页

注意:没有utils/或common/这种模糊命名的 app。所有工具函数按职责下沉——比如图片压缩逻辑在products.utils.image_resize(),支付签名计算在payments.utils.wechat_sign()。这种设计让你改一个功能时,90% 的代码都在对应 app 下,不用满项目 grep。

2.3 数据模型设计:为什么商品用 SPU+SKU 而不是单表?

products/models.py是整个商城的数据心脏。它没用“一个商品一张表”的懒人设计,而是严格区分:

  • Product(SPU):代表一款手机,如“iPhone 15 Pro”,含名称、描述、主图、分类、品牌;
  • ProductVariant(SKU):代表具体规格,如“iPhone 15 Pro 256GB 银色”,含价格、库存、颜色、内存、唯一 SKU 编码。

这样设计的好处是:同一款手机换颜色/内存,不用重复录入商品信息;促销时可对 SPU 批量调价,也可对特定 SKU 单独做限时折扣。关键字段如下:

# apps/products/models.py class Product(models.Model): name = models.CharField(max_length=100, verbose_name="商品名称") description = models.TextField(verbose_name="商品描述") category = models.ForeignKey('Category', on_delete=models.PROTECT) brand = models.ForeignKey('Brand', on_delete=models.PROTECT) main_image = models.ImageField(upload_to='products/', verbose_name="主图") class ProductVariant(models.Model): product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='variants') sku = models.CharField(max_length=50, unique=True, verbose_name="SKU编码") price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="售价") stock = models.PositiveIntegerField(default=0, verbose_name="库存") color = models.CharField(max_length=20, verbose_name="颜色") memory = models.CharField(max_length=20, verbose_name="内存")

注意:on_delete=models.PROTECT比CASCADE更安全——删品牌前会报错,逼你手动处理关联商品,避免误删导致前台 404。


3. 快速启动:从零到首页可访问的 5 步实操

别急着改代码。先让项目在你本地跑起来,这是所有二次开发的前提。以下步骤在 macOS / Linux / Windows WSL 下均验证通过,Windows 原生 CMD 用户请把source换成call。

3.1 环境准备:Python 3.8+ 和虚拟环境是底线

# 1. 确认 Python 版本(必须 3.8+) python --version # 2. 创建并激活虚拟环境(推荐位置:项目根目录下 venv/) python -m venv venv source venv/bin/activate # macOS/Linux # venv\Scripts\activate # Windows CMD # 3. 升级 pip(避免旧版 pip 安装依赖失败) pip install --upgrade pip

提示:如果pip install -r requirements.txt报Pillow编译错误,Mac 用户执行xcode-select --install,Ubuntu 用户执行sudo apt-get install libjpeg-dev libpng-dev libtiff-dev libfreetype6-dev,再重试。

3.2 安装依赖与初始化数据库

# 4. 安装所有依赖(注意:-r 后是相对路径) pip install -r requirements.txt # 5. 迁移数据库(会自动创建 SQLite 文件 db.sqlite3) python manage.py migrate # 6. 创建超级用户(后台登录账号) python manage.py createsuperuser # 按提示输入用户名、邮箱、密码(密码不显示,输完直接回车)

此时db.sqlite3已生成,且包含全部表结构。但注意:此时商品、用户、订单数据都是空的。别慌,项目提供了初始化脚本。

3.3 加载测试数据:3 条商品 + 2 个用户 + 1 个订单

项目根目录下有个fixtures/文件夹,里面是 JSON 格式的测试数据:

fixtures/ ├── initial_categories.json # 分类:手机、配件、服务 ├── initial_brands.json # 品牌:Apple、Samsung、Xiaomi ├── initial_products.json # 3 款手机 SPU(含主图路径) └── initial_variants.json # 9 个 SKU(不同颜色/内存组合)

加载命令(顺序不能错,外键依赖要满足):

python manage.py loaddata fixtures/initial_categories.json python manage.py loaddata fixtures/initial_brands.json python manage.py loaddata fixtures/initial_products.json python manage.py loaddata fixtures/initial_variants.json

注意:initial_products.json里的图片路径是products/iphone15pro.jpg,实际文件在media/目录下。项目已预置这些图片,无需额外下载。

3.4 启动服务并验证首页

# 7. 启动开发服务器(默认端口 8000) python manage.py runserver # 8. 浏览器打开 http://127.0.0.1:8000 # 应看到:顶部导航栏、轮播图(3 张)、热门商品列表(3 款手机)、底部版权信息

此时你已拥有一个功能完整的首页。点任意商品进入详情页,能看到规格选择、加入购物车按钮;点右上角“登录”,用刚才创建的 superuser 账号登录,即可进入个人中心和后台管理。


4. 核心模块拆解:购物车与订单状态机的实现逻辑

很多 Django 商城项目卡在“加购物车就报错”,本质是没理清“谁持有购物车数据”。这个项目用Session + 数据库双写方案,兼顾性能与一致性。我们一层层剥开。

4.1 购物车:Session 存临时数据,数据库存持久记录

购物车逻辑在apps/orders/cart.py,核心是Cart类:

# apps/orders/cart.py class Cart: def __init__(self, request): self.session = request.session cart = self.session.get('cart') # 从 Session 读 if not cart: cart = self.session['cart'] = {} self.cart = cart def add(self, variant, quantity=1, update_quantity=False): variant_id = str(variant.id) if variant_id not in self.cart: self.cart[variant_id] = {'quantity': 0, 'price': str(variant.price)} if update_quantity: self.cart[variant_id]['quantity'] = quantity else: self.cart[variant_id]['quantity'] += quantity self.save() def save(self): self.session.modified = True # 标记 Session 已修改 # 关键:同步到数据库(仅当用户已登录) if self.session.get('_auth_user_id'): self._sync_to_db() def _sync_to_db(self): # 获取当前用户 user_id = self.session.get('_auth_user_id') user = get_user_model().objects.get(id=user_id) # 清空用户旧购物车 CartItem.objects.filter(user=user).delete() # 写入新数据 for variant_id, item in self.cart.items(): variant = ProductVariant.objects.get(id=variant_id) CartItem.objects.create( user=user, variant=variant, quantity=item['quantity'] )

逻辑说明:未登录用户购物车存在 Session 里(关浏览器即消失);登录后,每次add()或save()都会把 Session 数据同步到CartItem表。这样既保证游客能加购,又确保登录后购物车不丢失。

4.2 订单创建:状态机驱动,拒绝“半成品订单”

订单状态流转在apps/orders/models.py的Order模型中定义:

# apps/orders/models.py class Order(models.Model): STATUS_CHOICES = [ ('draft', '草稿'), ('pending_payment', '待支付'), ('paid', '已支付'), ('shipped', '已发货'), ('delivered', '已签收'), ('cancelled', '已取消'), ('refunded', '已退款'), ] status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft') def mark_as_paid(self): """支付成功后调用,触发库存扣减、通知等""" if self.status != 'pending_payment': raise ValueError("只能从待支付状态转为已支付") # 扣减库存(事务内) with transaction.atomic(): for item in self.items.all(): variant = item.variant if variant.stock < item.quantity: raise ValueError(f"库存不足:{variant.product.name} {variant.color}") variant.stock -= item.quantity variant.save() self.status = 'paid' self.save() # 发送支付成功通知(此处简化为 print) print(f"订单 {self.order_no} 支付成功,已扣减库存") def cancel(self): """用户主动取消订单""" if self.status not in ['draft', 'pending_payment']: raise ValueError("只能取消草稿或待支付订单") if self.status == 'pending_payment': # 退款逻辑(此处模拟) pass self.status = 'cancelled' self.save()

参数说明:mark_as_paid()方法加了状态校验,防止外部直接order.status = 'paid'绕过库存检查。transaction.atomic()确保“扣库存+改状态”要么全成功,要么全失败。

4.3 支付回调:微信 JSAPI 支付的验签与幂等处理

apps/payments/views.py中的WeChatPayNotifyView处理微信异步通知:

# apps/payments/views.py class WeChatPayNotifyView(View): def post(self, request, *args, **kwargs): # 1. 解析 XML 请求体 xml_data = request.body data = parse_xml(xml_data) # 自定义解析函数 # 2. 验签(关键!) sign = data.pop('sign', None) if not sign or not self._verify_sign(data, sign): return HttpResponse('fail', content_type='text/plain') # 3. 幂等处理:检查商户订单号是否已存在 out_trade_no = data['out_trade_no'] try: order = Order.objects.get(order_no=out_trade_no) except Order.DoesNotExist: return HttpResponse('fail', content_type='text/plain') # 4. 更新订单状态(只处理未支付成功的订单) if order.status == 'pending_payment': order.mark_as_paid() # 复用前面的状态机方法 return HttpResponse('success', content_type='text/plain') else: # 已处理过,返回 success 避免微信重试 return HttpResponse('success', content_type='text/plain') def _verify_sign(self, data, sign): # 拼接待签名字符串(字典按键升序) items = sorted(data.items()) string_a = '&'.join([f'{k}={v}' for k, v in items if k != 'sign']) string_sign_temp = f"{string_a}&key={settings.WECHAT_API_KEY}" # MD5 签名 return hashlib.md5(string_sign_temp.encode()).hexdigest().upper() == sign

逻辑说明:微信回调无 Session,所以必须用out_trade_no(商户订单号)定位订单;_verify_sign严格按微信文档拼接字符串,大小写、空格、排序都不能错;幂等处理是线上必选项,否则网络抖动导致多次回调会重复扣库存。


5. 避坑指南:那些让我重启三次服务器的血泪经验

这个项目虽小,但涉及 Session、数据库事务、第三方回调、静态文件,踩坑概率极高。以下是我在三台不同机器上实测出的 4 个高频问题,按“现象 → 原因 → 解决”给出可抄作业的答案。

5.1 现象:首页轮播图不显示,控制台报 404

原因:Django 开发模式下,MEDIA_URL和MEDIA_ROOT配置不匹配,或urls.py未启用媒体文件路由。
解决:

  • 检查settings/local.py是否有这两行:
    MEDIA_URL = '/media/' MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
  • 在项目根目录urls.py的urlpatterns末尾添加:
    from django.conf import settings from django.conf.urls.static import static if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
  • 确认media/目录下有products/子目录,且图片文件存在(不是空文件夹)。

5.2 现象:登录后点击“我的订单”报RelatedObjectDoesNotExist: User has no order.

原因:User模型被重写为CustomUser,但Order模型的user外键仍指向auth.User,导致关系断裂。
解决:

  • 打开apps/orders/models.py,找到Order类的user字段:
    # 错误写法(会报错) user = models.ForeignKey('auth.User', on_delete=models.CASCADE) # 正确写法(必须匹配 settings.AUTH_USER_MODEL) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
  • 修改后执行:
    python manage.py makemigrations orders python manage.py migrate

5.3 现象:微信支付回调一直返回fail,日志显示sign error

原因:WECHAT_API_KEY配置错误,或parse_xml()函数未正确剔除<xml>根节点和 CDATA。
解决:

  • 检查settings/local.py中WECHAT_API_KEY是否为微信商户平台后台的API 密钥(32 位小写字母+数字),不是 APPID 或 MCHID;
  • 查看apps/payments/utils.py的parse_xml()函数,确认是否用xml.etree.ElementTree正确解析,并将所有字段转为字符串(微信字段值可能是None,需.text or '');
  • 在WeChatPayNotifyView.post()开头加日志:
    logger.info(f"WeChat notify data: {data}") # 看实际收到的字段

5.4 现象:后台商品列表显示“None”,点编辑报FieldError: Unknown field specified in form

原因:dashboard/admin.py中ProductAdmin的list_display或fields引用了不存在的字段名,或ProductVariantInline的fk_name写错。
解决:

  • 打开apps/dashboard/admin.py,检查ProductAdmin.list_display:
    # 错误:字段名拼错或不存在 list_display = ['name', 'category', 'brand', 'main_image_url'] # main_image_url 未定义 # 正确:用模型已有字段或自定义方法 list_display = ['name', 'category', 'brand', 'get_main_image_url'] def get_main_image_url(self, obj): return obj.main_image.url if obj.main_image else '-' get_main_image_url.short_description = '主图'
  • 检查ProductVariantInline.fk_name是否为'product'(必须与ProductVariant.product字段名一致)。

6. 二次开发实战:给商品详情页加“相似推荐”模块

现在你已跑通项目,下一步往往是加功能。我以“在商品详情页下方加相似推荐”为例,带你走一遍从需求分析到上线的完整链路。这不是教你怎么写代码,而是告诉你一个合格开发者会怎么思考、怎么验证、怎么避免返工。

6.1 需求拆解:什么是“相似”?数据从哪来?

“相似推荐”不是玄学。在这个商城里,合理定义是:同一品牌 + 同一分类 + 价格区间在 ±20% 内的其他 SPU。这样既保证相关性,又避免推荐太贵或太便宜的竞品。数据源就是Product模型本身,无需额外爬虫或 AI。

6.2 后端逻辑:在ProductDetailView中注入推荐数据

apps/products/views.py的ProductDetailView是详情页视图。我们不改原逻辑,而是用get_context_data()注入:

# apps/products/views.py from django.db.models import Q from decimal import Decimal class ProductDetailView(DetailView): model = Product template_name = 'products/detail.html' context_object_name = 'product' def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) # 获取当前商品的品牌、分类、价格(取第一个 SKU 价格) current_product = self.object if current_product.variants.exists(): base_price = current_product.variants.first().price else: base_price = Decimal('0.00') # 构建相似查询:同品牌、同分类、价格在 ±20% min_price = base_price * Decimal('0.8') max_price = base_price * Decimal('1.2') similar_products = Product.objects.filter( Q(brand=current_product.brand) & Q(category=current_product.category) & Q(variants__price__gte=min_price) & Q(variants__price__lte=max_price) ).distinct().exclude(id=current_product.id)[:4] context['similar_products'] = similar_products return context

注意:distinct()防止同一 SPU 因多个 SKU 匹配而重复出现;exclude(id=...)排除自身;[:4]限制数量,避免模板渲染过慢。

6.3 前端渲染:复用现有组件,不写新 CSS

templates/products/detail.html里已有商品卡片组件product_card.html。我们直接复用:

<!-- 在 detail.html 底部添加 --> {% if similar_products %} <div class="mt-5"> <h3 class="h4 mb-4">相似推荐</h3> <div class="row"> {% for p in similar_products %} {% include "products/product_card.html" with product=p show_price=True %} {% endfor %} </div> </div> {% endif %}

关键点:with product=p show_price=True传参,确保product_card.html能正确渲染价格。检查该模板是否支持show_price变量(通常有{% if show_price %}...{% endif %}判断)。

6.4 验证与压测:三步确认改动可靠

改完代码别急着提交,按顺序验证:

  1. 单点验证:

    • 启动服务,打开一个商品详情页(如 iPhone 15 Pro),确认“相似推荐”区块出现,且显示的是 Apple 品牌的其他手机(如 iPhone 14、iPhone SE);
    • 检查浏览器 Network 面板,确认详情页请求耗时未明显增加(应 < 200ms);
  2. 边界验证:

    • 找一个无 SKU 的商品(手动删掉其所有ProductVariant),访问其详情页,确认不报错,similar_products为空列表;
    • 找一个价格极低的商品(如 99 元手机壳),确认推荐列表为空(因无同品牌同分类的低价手机);
  3. 并发验证(可选但强烈建议):

    • 用ab命令压测详情页:ab -n 100 -c 10 http://127.0.0.1:8000/products/1/;
    • 观察manage.py runserver终端日志,确认无OperationalError: database is locked(SQLite 并发瓶颈);若有,说明查询太重,需加数据库索引:
      python manage.py dbshell CREATE INDEX idx_product_brand_category ON products_product (brand_id, category_id);

从那以后我每次加推荐逻辑,都强制走一遍这三步验证——哪怕只是改一行模板。因为线上用户不会给你“刷新重试”的机会,他们只会默默关掉页面,然后去隔壁商城下单。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进