简介:EcommerceWbsite项目压缩包面向希望用Django完成电商实战的Python Web学习者与初中级开发者,围绕商品展示、购物车、用户账户和订单结账等流程,提供了一套可运行的HTML前端与Django后端示例。资源共101个文件,以商品及页面图片(jpg、webp、png)、py后端代码、html模板和pyc编译文件为主,另含sqlite3数据库,整体约3.89MB,目录紧凑,便于按模块对照阅读。已有91人学习下载,适合作为课程设计、毕业项目原型或Django入门后的综合练习。内容覆盖MVT分层、Django模板语言动态渲染、ORM数据模型、用户认证与权限、购物车状态维护、URL路由与视图函数,并涉及结账支付集成、缓存及CSRF/XSS防护、Nginx/Gunicorn部署等实践点,读者可据此理解电商网站从页面布局到后端数据处理的完整链路,并改造成自己的小型商城。
1. 一个能跑通的 Django 电商网站:从商品列表到下单的全链路
打开这份源码,第一眼看到的是典型的 Django 工程结构,manage.py、settings.py、几个平级的 app 目录,以及一堆放在templates/下的 HTML 模板。它不是那种只做了个首页的作业,商品分类、商品列表、商品详情、加入购物车、结算下单、订单记录这条主线是通的,后台还能靠 Django 自带的 Admin 直接维护数据。整个站点的渲染方式走的是服务端模板:HTML 由 Django 的模板引擎拼好再吐给浏览器,前端只负责样式和少量交互。
适合谁?适合已经会一点 Python、想找个完整电商场景练手的人,也适合想快速给一个小店做个展示加下单页面、不打算上微服务那套的开发者。它的价值不在技术多新,而在于把电商最核心的那几个环节用最直白的方式写了一遍,改起来门槛低。
2. 技术栈与目录结构:Django MTV 分层在这份项目里怎么落地
2.1 技术栈拆解:为什么是 Django 加服务端模板
这份项目的技术选型非常克制:后端 Django,数据库默认用项目自带的 SQLite 起步,图片字段靠 Pillow 支撑,前端就是纯 HTML + CSS,模板里混着 Django 模板标签。没有前后端分离,没有 REST 框架,没有 Node 构建链。这种选的不是偷懒,是匹配场景——一个商品量不大、以展示和下单为主的小电商,服务端渲染的开发和部署成本比 SPA 低一个量级。
从 Django 的 MTV 视角看,这个项目把职责切得很清楚:
| 层 | 对应文件 | 在这个项目里干什么 |
|---|---|---|
| Model | shop/models.py、orders/models.py | 商品、分类、订单、订单项的表结构与业务方法 |
| Template | templates/shop/*.html | 商品列表、详情、购物车、结算页的 HTML 骨架 |
| View | shop/views.py、cart/views.py、orders/views.py | 取数据、算购物车、落订单,再挑选模板返回 |
要注意,很多人把 MTV 里的 View 当成 MVC 的 Controller,其实 Django 的 URLconf 才是路由分发的入口,View 更像「组装数据然后交给模板」的一层。理解这一点,后面改页面的时候就不会满项目找不到「控制器」。
提示:如果你接手时发现项目里
views.py特别长,几百行堆在一起,那多半是把业务逻辑和取数逻辑混写了。这份源码还算克制,但二次开发时建议按「列表/详情/购物车/订单」拆成模块文件,再做from .views.shop import *这种聚合。
2.2 目录结构对照:每个 app 负责哪一段业务
项目按业务切成几个平级 app,这是 Django 项目里最常见也最好维护的切法。下面是按这类电商源码的常规组织还原出来的结构,你下载后对号入座即可:
EcommerceWbsite/ ├── manage.py ├── requirements.txt ├── config/ # 项目配置包,settings / urls / wsgi │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── shop/ # 商品域:分类、商品、上下架 │ ├── models.py │ ├── views.py │ ├── urls.py │ └── admin.py ├── cart/ # 购物车域:session 存储、增删改 │ ├── cart.py # 核心类,不依赖 ORM │ └── views.py ├── orders/ # 订单域:创建订单、扣库存、订单列表 │ ├── models.py │ └── views.py ├── templates/ │ ├── base.html # 全站骨架,导航和页脚 │ └── shop/ │ ├── product/list.html │ ├── product/detail.html │ ├── cart/detail.html │ └── orders/create.html ├── static/ # css / js / 占位图 └── media/ # 用户上传的商品图,随 ImageField 写入这个划分有个明显好处:cart和orders之间只通过「商品 id + 数量 + 单价快照」通信,不互相 import model。后续想把购物车换成 Redis 存、把订单拆成独立服务,改动面被压在两个目录里。
media/和static/是两个必须分清的概念,也是新手最容易搞混的地方。static/放的是随代码走的资源,CSS、JS、logo、占位图,部署时用collectstatic收集;media/放的是运行时用户上传的东西,商品主图就属于这一类,它不随代码打包,需要单独备份和配置 Web 服务器托管。
2.3 一次商品详情页请求的完整链路
把一次点击拆开看,比读十遍文档管用。用户点列表页里的商品卡片,浏览器发出GET /product/12/iphone-15/,接下来的路径是:
config/urls.py里include("shop.urls")把product/前缀的请求转给 shop 应用;shop/urls.py里的path("<int:id>/<slug:slug>/", views.product_detail, name="product_detail")命中;- View 里用
get_object_or_404取商品,顺手.select_related("category")避免多一次查询; - 模板
product/detail.html渲染,把{{ product.price }}、{{ product.description }}塞进 HTML; - 响应返回,浏览器再并发请求
/static/css/xxx.css和/media/products/2024/05/xxx.jpg。
第 5 步是排查问题的关键分界点。页面结构对但样式全丢,问题在 static;页面正常但商品图裂了,问题在 media 的 URL 挂载。这两类故障现象高度相似,但排查路径完全不同,后面第 5 章会专门说。
3. 核心模块拆解:商品、购物车、订单三条主线的代码实现
3.1 数据建模:Category / Product / Order 三张核心表
电商系统的地基是表结构,尤其是金额和库存这两类字段,选错类型后面全是坑。这是项目里商品模型的核心部分,我已经把常见的补充字段一起写进来了:
from django.db import models from django.urls import reverse from django.utils.text import slugify class Category(models.Model): name = models.CharField("分类名", max_length=100) slug = models.SlugField("URL 标识", unique=True, blank=True) class Meta: verbose_name_plural = "分类" def save(self, *args, **kwargs): # 没填 slug 时用分类名自动生成,保证 URL 可读 if not self.slug: self.slug = slugify(self.name) super().save(*args, **kwargs) def __str__(self): return self.name class Product(models.Model): category = models.ForeignKey( Category, verbose_name="所属分类", related_name="products", on_delete=models.CASCADE ) name = models.CharField("商品名", max_length=200) slug = models.SlugField("URL 标识", max_length=200) price = models.DecimalField("单价", max_digits=10, decimal_places=2) stock = models.PositiveIntegerField("库存", default=0) image = models.ImageField("主图", upload_to="products/%Y/%m", blank=True) description = models.TextField("描述", blank=True) available = models.BooleanField("上架", default=True) created_at = models.DateTimeField("创建时间", auto_now_add=True) class Meta: ordering = ["-created_at"] indexes = [models.Index(fields=["slug"])] def __str__(self): return self.name def get_absolute_url(self): return reverse("shop:product_detail", args=[self.id, self.slug])逐项说下为什么这么定。price用DecimalField而不是FloatField,是因为浮点数做金额运算会出现0.1 + 0.2 = 0.30000000000000004这类结果,购物车累加几次之后总额就对不上账,这是电商项目里最经典的血泪经验。stock用PositiveIntegerField把负数挡在数据库层,比在视图里到处写if stock < 0靠谱。
upload_to="products/%Y/%m"让上传的图片按年月分目录,商品上千张图的时候,单目录几万文件在文件系统上列目录会明显变慢。get_absolute_url是给模板和redirect()用的,有了它,模板里直接写{{ product.get_absolute_url }},不用把 URL 规则硬编码进 HTML。
订单侧的表结构建议这样,注意OrderItem里存的是下单那一刻的价格快照:
class Order(models.Model): STATUS_CHOICES = [ ("pending", "待支付"), ("paid", "已支付"), ("shipped", "已发货"), ("done", "已完成"), ("canceled", "已取消"), ] user = models.ForeignKey("auth.User", verbose_name="下单人", related_name="orders", on_delete=models.PROTECT) status = models.CharField("状态", max_length=10, choices=STATUS_CHOICES, default="pending") total = models.DecimalField("总额", max_digits=10, decimal_places=2, default=0) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["-created_at"] class OrderItem(models.Model): order = models.ForeignKey(Order, related_name="items", on_delete=models.CASCADE) product = models.ForeignKey("shop.Product", on_delete=models.PROTECT) # 价格快照:商品后来调价,历史订单金额不能被改 price = models.DecimalField("成交单价", max_digits=10, decimal_places=2) quantity = models.PositiveIntegerField("数量", default=1) def get_cost(self): return self.price * self.quantityon_delete=PROTECT是个容易被忽略的细节。如果商品用CASCADE,运营在后台删掉一个下架商品,历史订单里的条目会跟着消失,对账时直接变成黑匣子。用PROTECT挡一层,删商品前必须先处理关联订单,数据就留住了。
3.2 商品列表与详情:views 到 HTML 模板的渲染
列表页要处理分类筛选、只显示上架商品、分页这三件事。这是shop/views.py里的实现:
from django.shortcuts import render, get_object_or_404 from django.core.paginator import Paginator from .models import Category, Product def product_list(request, category_slug=None): category = None # 只查上架商品,下架商品不出现在任何前台列表 products = Product.objects.filter(available=True) if category_slug: category = get_object_or_404(Category, slug=category_slug) products = products.filter(category=category) # select_related 用 JOIN 一次取出分类,避免模板里循环触发 N+1 查询 products = products.select_related("category") paginator = Paginator(products, 12) page = paginator.get_page(request.GET.get("page")) return render(request, "shop/product/list.html", { "category": category, "page": page, })几个参数值得单独说。Paginator(products, 12)的 12 是每页条数,配合 12 栅格刚好三行四列;paginator.get_page()比page()好在页码越界或传了非数字时不会抛PageNotAnInteger/EmptyPage,它会自动回落到第一页或最后一页,少写两个 try。
select_related是最容易见效的优化。如果模板里要显示product.category.name,不加这一句,12 个商品就是 1 次列表查询加 12 次分类查询,一个接口 13 条 SQL;加上之后压到 1 条。
模板这边是纯 HTML 加 Django 模板标签,{% for %}循环出商品卡片:
{% extends "shop/base.html" %} {% load static %} {% block content %} <div class="row"> {% for product in page.object_list %} <div class="col-md-3"> <a href="{{ product.get_absolute_url }}"> {% if product.image %} <img src="{{ product.image.url }}" alt="{{ product.name }}"> {% else %} <img src="{% static 'img/placeholder.png' %}" alt="暂无图片"> {% endif %} <h5>{{ product.name }}</h5> <p>¥{{ product.price }}</p> <p>{{ product.category.name }}</p> </a> </div> {% empty %} <p>该分类下还没有商品</p> {% endfor %} </div> {% include "shop/pagination.html" with page=page %} {% endblock %}{% extends %}必须放在模板第一行,前面哪怕有个空行也会导致继承失效,报TemplateDoesNotExist或者整个骨架不生效,这个坑几乎人人踩过一次。{{ product.image.url }}只有在ImageField有值时才有效,空值直接取.url会抛ValueError,所以必须套{% if product.image %}。
商品详情页的 View 更短:
def product_detail(request, id, slug): product = get_object_or_404( Product.objects.select_related("category"), id=id, slug=slug, available=True ) return render(request, "shop/product/detail.html", {"product": product})URL 里同时带id和slug是个好习惯,id保证唯一命中,slug保证 URL 对人类可读、对搜索友好。用户手改 slug 也不影响查询正确性——当然,如果你想让错误 slug 跳转到正确地址,得自己加一层 301 重定向。
3.3 购物车:session 存储与落库两种写法
购物车是这个项目里最能体现设计取舍的模块。源码这类项目通常走 session 方案:不建表,购物车数据塞在request.session里,序列化成{商品id: {数量, 单价}}的字典。好处是匿名用户也能加购,不用强制登录,没有数据库写入压力;代价是换设备就丢,且 session 存太多数据会拖慢每次请求。
核心类长这样,我把关键边界条件都补上了:
from decimal import Decimal from django.conf import settings class Cart: CART_KEY = "cart" def __init__(self, request): self.session = request.session cart = self.session.get(self.CART_KEY) if cart is None: cart = self.session[self.CART_KEY] = {} self.cart = cart def add(self, product, quantity=1, override=False): pid = str(product.id) if pid not in self.cart: self.cart[pid] = {"quantity": 0, "price": str(product.price)} if override: self.cart[pid]["quantity"] = quantity else: self.cart[pid]["quantity"] += quantity # 不能超过库存,也不能小于 1 self.cart[pid]["quantity"] = max(1, min(self.cart[pid]["quantity"], product.stock)) self.save() def remove(self, product): pid = str(product.id) if pid in self.cart: del self.cart[pid] self.save() def __iter__(self): # 遍历时回查商品对象,cart 里只存 id 和快照价 from shop.models import Product ids = self.cart.keys() products = Product.objects.filter(id__in=ids) for product in products: item = self.cart[str(product.id)].copy() item["product"] = product item["price"] = Decimal(item["price"]) item["total_price"] = item["price"] * item["quantity"] yield item def __len__(self): return sum(item["quantity"] for item in self.cart.values()) def get_total_price(self): return sum(Decimal(i["price"]) * i["quantity"] for i in self.cart.values()) def clear(self): self.session[self.CART_KEY] = {} self.session.modified = True def save(self): # 显式标记 session 已修改,否则嵌套字典的变更不会被写回 self.session.modified = True几个非写不可的地方。str(product.id)把 key 转成字符串,因为 session 序列化成 JSON 后整型 key 会变成字符串,混用整型和字符串会出现「加了两次却只显示一件」的玄学。self.session.modified = True必须在每次改动后置位,Django 只对顶层赋值自动检测修改,往嵌套字典里塞数据它感知不到。price存成字符串而不是 Decimal,是因为 Decimal 不能被 JSON 序列化。
如果要做登录用户跨设备同步的购物车,就得建表,用Cart(user, product, quantity)三字段结构,再把__iter__里的回查换成select_related("product")。两种方案可以共存:session 管匿名加购,登录时把 session 里的数据合并进数据库,合并完清空 session。这条合并逻辑是最容易漏写的,漏了就会出现「登录后购物车空了」的用户投诉。
3.4 结算下单:事务、库存与订单状态
下单是整个项目唯一必须保证原子性的地方:创建订单、创建订单项、扣减库存,任何一步失败都必须全部回滚,否则会留下扣了库存却没订单、或者有订单没扣库存的脏数据。
from django.db import transaction from django.contrib import messages from django.contrib.auth.decorators import login_required from django.shortcuts import redirect, render from cart.cart import Cart from .models import Order, OrderItem @login_required @transaction.atomic def order_create(request): cart = Cart(request) if len(cart) == 0: messages.warning(request, "购物车是空的") return redirect("shop:product_list") order = Order.objects.create( user=request.user, total=cart.get_total_price(), status="pending", ) for item in cart: # select_for_update 行级锁,防止并发下单把库存扣成负数 product = item["product"].__class__.objects.select_for_update().get(id=item["product"].id) if product.stock < item["quantity"]: # 抛异常让整个事务回滚,前面创建的订单也不会留下 raise ValueError(f"《{product.name}》库存不足") OrderItem.objects.create( order=order, product=product, price=item["price"], quantity=item["quantity"], ) product.stock -= item["quantity"] product.save(update_fields=["stock"]) cart.clear() return render(request, "orders/create.html", {"order": order})@transaction.atomic保证整段视图在一个事务里,select_for_update()在数据库层加行锁,两个用户同时买最后一件时,后到的那个会等前一个事务结束再读库存,读到 0 就直接失败。这两个必须配对使用,把select_for_update写在事务外面会报TransactionManagementError,或者根本不生效。
product.save(update_fields=["stock"])只更新库存一个字段,避免auto_now字段被无意义刷新,也减少 SQL 里带的字段数。price=item["price"]存的是购物车里的快照价,不是product.price,这样商品今天调价,昨天放进购物车今天才付款的用户还是按老价格成交——这是对用户的保护,也是账目可追溯的前提。
订单状态按pending → paid → shipped → done单向流转,取消只能从pending走到canceled并把库存加回去。这个加回库存的动作最好也放在事务里,别在管理员后台手工改,否则后悔药是没得吃的。
4. 环境搭建与跑通:从零到能下单的完整步骤
4.1 装依赖与改 settings:能跑起来的最小配置
拿到源码第一件事是建虚拟环境,别往系统 Python 里装东西。下面这套命令在 Linux、macOS 和 Windows 的 PowerShell 里都成立,只是激活脚本路径不同:
python -m venv .venv # Linux / macOS source .venv/bin/activate # Windows PowerShell # .venv\Scripts\Activate.ps1 pip install django pillow pip freeze > requirements.txtpillow是硬依赖,ImageField没有它直接报Cannot use ImageField because Pillow is not installed,这个错很多人第一次见会以为是模型写错了。
接着改settings.py,这几项是跑通的关键:
import os from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", # 项目自己的 app "shop", "cart", "orders", ] TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [BASE_DIR / "templates"], # 项目级模板目录 "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.debug", "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", ], }, }, ] LANGUAGE_CODE = "zh-hans" TIME_ZONE = "Asia/Shanghai" USE_I18N = True USE_TZ = False # 本地小站图省事;跨国业务必须开 True STATIC_URL = "/static/" STATICFILES_DIRS = [BASE_DIR / "static"] MEDIA_URL = "/media/" MEDIA_ROOT = BASE_DIR / "media"USE_TZ = False是个有争议的选择。本地单机小站关掉时区转换,存进去的时间就是本地时间,Admin 里看得明白;一旦要跨时区或者上云,必须改成True并统一按 UTC 存,展示时再转,否则订单时间会对不上。
媒体文件还得在开发环境挂一条路由,否则商品图必然 404:
# config/urls.py from django.contrib import admin from django.urls import path, include from django.conf import settings from django.conf.urls.static import static urlpatterns = [ path("admin/", admin.site.urls), path("cart/", include("cart.urls")), path("orders/", include("orders.urls")), path("", include("shop.urls")), ] if settings.DEBUG: # 只在开发环境由 Django 托管上传文件,线上交给 Nginx urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)4.2 迁移、建管理员、灌种子数据
配置完就是建表。Django 的迁移机制会记住每次模型变更,注意别删migrations/目录,删了之后数据库和模型就脱节了:
python manage.py makemigrations shop cart orders python manage.py migrate python manage.py createsuperusermakemigrations生成迁移脚本,要提交进版本库;migrate真正建表。如果makemigrations输出No changes detected,先确认 app 在INSTALLED_APPS里,再确认migrations/__init__.py文件存在。这两个点覆盖了九成的「迁移不生成」。
空数据库跑起来只有个空列表页,得灌点数据。两种方式,手工在 Admin 里点,或者写个一次性脚本批量造:
# python manage.py shell 里直接粘 from shop.models import Category, Product phone = Category.objects.create(name="手机") for i in range(1, 13): Product.objects.create( category=phone, name=f"测试机型 {i}", slug=f"test-phone-{i}", price=1999 + i * 100, stock=20, description="用于验证列表、分页和下单流程的假数据", ) print(Product.objects.count())批量造数据别在Product.objects.create上循环写几千次,那是一次一条 INSERT。数据量大的话用bulk_create(objs, batch_size=500),几千条从几十秒压到一两秒。灌完数据刷新首页,能分页、能点进详情、能加购,主流程就算通了。
4.3 静态文件与图片:先让商品图显示出来
开发模式下runserver会自动帮你找出静态文件,因为django.contrib.staticfiles在INSTALLED_APPS里。但一旦DEBUG = False,Django 就不再托管静态文件了,页面会变成一堆无样式 HTML,这是上线时最常见的翻车点。上线前的固定动作是:
python manage.py collectstatic --noinput它会把各个 app 下的static/目录以及STATICFILES_DIRS里的内容,全部拷贝到STATIC_ROOT指定的目录,再由 Nginx 把这个目录映射到/static/。settings.py里必须补上STATIC_ROOT = BASE_DIR / "staticfiles",否则collectstatic直接报错。
图片走的是另一条路。MEDIA_ROOT是磁盘路径,MEDIA_URL是浏览器访问路径,两者靠 4.1 里那条static()路由连起来。上传的商品图海报图多了之后,磁盘占用会涨得很快,二次开发时建议加个清理未关联图片的管理命令,或者干脆把ImageField换成对象存储的 URL 字段。
4.4 用 Admin 把后台运营能力补齐
这个项目最省事的地方是后台不用从零写。admin.py注册一下,分类、商品、订单就都有了增删改查界面:
from django.contrib import admin from .models import Category, Product @admin.register(Category) class CategoryAdmin(admin.ModelAdmin): list_display = ["name", "slug"] prepopulated_fields = {"slug": ("name",)} @admin.register(Product) class ProductAdmin(admin.ModelAdmin): list_display = ["name", "category", "price", "stock", "available", "created_at"] list_filter = ["available", "category"] list_editable = ["price", "stock", "available"] search_fields = ["name", "description"] prepopulated_fields = {"slug": ("name",)}list_editable让列表页可以直接改价格和库存,运营调价不用点进详情,效率提升肉眼可见;prepopulated_fields在输入商品名时自动填 slug,避免中文 slug 和一堆重复值。注意list_editable里的字段不能同时出现在list_display的第一列,因为第一列默认是跳转链接。
订单这块建议把OrderItem做成内联表,一次页面看到订单和它的所有条目:
class OrderItemInline(admin.TabularInline): model = OrderItem extra = 0 can_delete = False readonly_fields = ["product", "price", "quantity"] @admin.register(Order) class OrderAdmin(admin.ModelAdmin): list_display = ["id", "user", "status", "total", "created_at"] list_filter = ["status", "created_at"] inlines = [OrderItemInline]can_delete = False和readonly_fields是刻意的,订单项一旦生成就不该被后台随手改,否则金额对不上技术那边根本查不出来。
5. 避坑与常见问题排查:跑不起来、图片不显示、下单报错
5.1 启动与依赖类问题
现象:执行runserver或makemigrations时报Cannot use ImageField because Pillow is not installed。原因:ImageField依赖 Pillow 做图片校验和尺寸读取,requirements.txt里漏了这个包。 解决:pip install pillow后重新执行命令;顺手把它写进requirements.txt,别指望下一个人自己发现。
现象:页面能打开,但所有样式丢失,标题变成巨大的黑字。原因:DEBUG = False之后 Django 不再托管静态文件,而collectstatic没跑,或者 Nginx 没映射/static/目录。 解决:本地排查先把DEBUG临时改回True确认是不是这个原因;正式环境执行python manage.py collectstatic --noinput,检查STATIC_ROOT下有没有文件,再核对 Web 服务器的 location 配置。
现象:商品图裂了,浏览器控制台显示/media/products/xxx.jpg 404。原因:urls.py里忘了加static(settings.MEDIA_URL, ...)那条路由,或者MEDIA_ROOT和图片实际存放目录不一致。 解决:确认DEBUG为 True 时路由已挂载;用python manage.py shell打印settings.MEDIA_ROOT,去磁盘上看文件是不是真在那儿。上传成功但访问 404,基本就是这两个原因之一。
现象:提交表单报 403 Forbidden,提示 CSRF verification failed。原因:模板里的<form>少了{% csrf_token %},或者用了@csrf_exempt之后又混用了 session 认证。 解决:所有 POST 表单内部第一行加上{% csrf_token %};前后端分离场景另说,但这个项目是服务端渲染,加这一行就够了。
5.2 数据与订单类问题
现象:购物车总价偶尔差一分钱,或者出现0.30000000000000004。原因:金额字段用了FloatField,或者在视图里用float()转了一次价格再做累加。 解决:模型层改DecimalField(max_digits=10, decimal_places=2),Python 侧全程用Decimal,模板里需要格式化就上{% load humanize %}配合intcomma,不要在模板里做算术。
现象:两个人同时抢最后一件商品,库存被扣成 -1。原因:读取库存和扣减库存之间没有锁,两个请求都读到了 1,都判断「够」,然后各减一次。 解决:把整个下单逻辑包进transaction.atomic,库存查询用select_for_update()加行级锁,并在模型层加CheckConstraint把stock >= 0落成数据库约束,双保险。
现象:改了模型字段,执行migrate后报no such column: shop_product.xxx。原因:SQLite 对某些变更(改字段类型、删字段)支持有限,Django 会走「建新表拷贝数据」的路径,中途失败或者迁移文件没生成全。 解决:先python manage.py makemigrations确认迁移文件已生成并提交,再看showmigrations状态;开发阶段实在乱了,删掉db.sqlite3和所有migrations/000*.py重新来过,比一行行修快得多。
5.3 上线前必须改的几个开关
现象:部署到服务器后访问报DisallowedHost,或者页面把调试信息一股脑吐给用户。原因:ALLOWED_HOSTS还是空的,DEBUG还是True。 解决:DEBUG = False,ALLOWED_HOSTS = ["你的域名"],SECRET_KEY从代码里挪到环境变量。这三件事不做完,别把端口对外开放。
还有一个隐蔽的坑:DEBUG = False之后必须配置500.html和404.html模板,放在templates/根目录,否则用户看到的是裸的服务器错误页,体验极差。这个跟业务无关,纯粹是上线礼仪。
6. 二次开发改造:把这套练手商城往能用的方向推一步
6.1 三个性价比最高的改造点
拿到这份源码,我不建议一上来就上 DRF 做前后端分离。那等于把整个项目重写一遍,收益和投入不成比例。真正能提升可用性的改造有三个,按投入从低到高排:
第一个是商品搜索。现在只能按分类点,用户想搜「耳机」没有任何入口。用Q对象做多字段模糊匹配就够,商品上到几万条之后再考虑全文索引:
from django.db.models import Q def product_list(request, category_slug=None): keyword = request.GET.get("q", "").strip() products = Product.objects.filter(available=True) if keyword: products = products.filter( Q(name__icontains=keyword) | Q(description__icontains=keyword) ) # 其余分类筛选与分页逻辑不变icontains在 SQLite 上对中文是可用的,但走不了普通索引,数据量上万后会明显变慢。到那个阶段再换 PostgreSQL 加SearchVector,或者接一个轻量搜索服务,不必提前优化。
第二个是库存并发。第 3 章那套select_for_update在单机 SQLite 上其实形同虚设,SQLite 是库级写锁,真正的行锁要上 PostgreSQL 或 MySQL。换库这件事在新项目起步时成本最低,越往后迁移数据越麻烦,我的建议是二次开发一开始就把数据库换掉,别等上线再动。
第三个是支付占位。不要把支付逻辑直接写进order_create,抽象出一个PaymentProvider接口,现在用「模拟支付」实现,把订单状态从pending推到paid,将来接真实渠道只替换实现类。这样订单域完全不用改,改动面锁死在一个文件里。
6.2 改完怎么验证:一个能跑的下单测试
改完代码靠手点页面验证,早晚会漏。这个项目最值得补的是下单链路的自动化测试,因为它是唯一涉及事务和库存的地方,回归一次成本最高:
from decimal import Decimal from django.test import TestCase from django.contrib.auth import get_user_model from django.urls import reverse from shop.models import Category, Product from orders.models import Order class OrderCreateTest(TestCase): def setUp(self): self.user = get_user_model().objects.create_user( username="tester", password="pwd123456" ) self.category = Category.objects.create(name="测试分类") self.product = Product.objects.create( category=self.category, name="测试商品", slug="test-item", price=Decimal("99.00"), stock=2, available=True, ) self.client.force_login(self.user) def test_order_deducts_stock(self): # 加购 2 件后下单,库存应归零,订单总额为 198.00 self.client.post(reverse("cart:cart_add", args=[self.product.id]), {"quantity": 2, "override": False}) response = self.client.post(reverse("orders:order_create")) self.assertEqual(response.status_code, 200) self.product.refresh_from_db() self.assertEqual(self.product.stock, 0) order = Order.objects.get(user=self.user) self.assertEqual(order.total, Decimal("198.00")) self.assertEqual(order.items.count(), 1) def test_order_fails_when_stock_short(self): # 库存 2 件却要买 3 件,必须整单失败,不能只建一半订单 self.client.post(reverse("cart:cart_add", args=[self.product.id]), {"quantity": 3, "override": True}) with self.assertRaises(ValueError): self.client.post(reverse("orders:order_create")) self.product.refresh_from_db() self.assertEqual(self.product.stock, 2) self.assertEqual(Order.objects.count(), 0)跑python manage.py test orders就能看到结果。这两个用例分别覆盖了正常扣减和库存不足回滚,是这条链路上最容易出问题的两个分支。测试里用force_login绕过登录页面,用refresh_from_db()拿数据库最新值而不是内存里的对象——这一点特别容易写错,内存对象的stock还是旧值,断言会全部通过,测试形同虚设。
注意:测试用的数据库是独立的,不会污染
db.sqlite3,但每次跑测试都会重新建表,所以setUp里的种子数据必须写全,别依赖数据库里已有的东西。
从那以后我每次接手这类电商练手项目,第一步不是急着改页面,而是先把「加购 → 下单 → 扣库存」这条链路的测试补上,让它变成一块可以反复按的试金石。后面无论换数据库、改价格策略还是接支付,跑一遍测试就知道自己有没有把地基挖塌。希望帮到你。
本文还有配套的精品资源,点击获取