做游泳用品商城这个项目,说实话一开始是有点"被需求推着走"的。朋友开了家线下游泳用品专卖店,夏天生意不错,但一到淡季就只能靠老客带新客,他一直想搞个线上商城,把泳衣、泳镜、泳帽、泳圈这些SKU挂到网上去。我接手时的第一反应不是"这项目多简单",而是——这类商品属性太碎了,泳镜有度数、泳衣有尺码和花色、泳帽有材质之分,和卖标准化的3C数码完全不是一个建模思路。所以这篇文章不打算给你堆一堆高深理论,就实实在在复盘一下,我用Python基于Django框架从零搭建这套游泳用品专卖店系统的全过程,包括业务模型怎么拆、下单库存怎么处理并发、后台怎么管理,以及最后如何用waitress + nginx把它部署到生产环境。如果你正准备用Django做类似的中小型商城系统,这篇应该能让你少踩不少坑。
1. 先想清楚再动手:游泳用品商城的业务模型与技术选型
1.1 游泳用品品类的特殊性在哪里
很多初学者拿到"游泳用品专卖店"这个需求,第一反应就是照搬通用的电商Demo,把商品、分类、购物车、订单一摆就完事。但真正做过垂直品类商城的人会告诉你,垂直品类的核心价值恰恰在于"垂直"二字。
游泳用品的商品属性非常不规整:
- 泳衣:分男士、女士、儿童,每个性别下面又分连体、分体、平角、三角,尺码从XS到XXL,还有花色和款式的区分。
- 泳镜:有平光、近视度数之分,近视度数从150度到800度不等,还有成人款和儿童款。
- 泳帽:硅胶、布料、PU涂层,材质直接影响价格和使用场景。
- 泳圈与浮板:要看适用年龄段、承重、直径尺寸,属于"规格差异大"的商品。
这意味着商品表如果只设计一个简单的Product模型,后面做购物车、订单的时候会非常痛苦。用户在详情页选的不是"一件泳衣",而是"这件泳衣的藏蓝色M码"——这个"具体到某个属性组合"的库存单位,就是SKU(Stock Keeping Unit)。所以我在建模时,商品(Product)和规格(SKU)是严格分离的两张表,这也是后面整个交易链路能够跑通的基础。
1.2 为什么选择Django而不是Flask或Spring Boot
技术选型没有绝对的对错,只有适不适合当前场景。这个项目我选Django,理由其实很朴素。
先说MTV模式。Django的MTV(Model-Template-View)经常被拿来和MVC做对比,很多人纠结两者区别,实际开发中你只需要理解一句话:Model管数据,Template管展示,View管业务逻辑。Template作为一个独立的层,可以让前端模板和后端业务解耦,商品详情页、购物车页、结算页各用各的模板,互不干扰。对于商城这种页面结构相对标准化的项目,Django的模板系统开箱即用,比Flask需要自己选Jinja2搭配方案要省心。
再说它的"全家桶"特性。做商城绕不开用户认证、后台管理、表单校验、CSRF防护、Session管理等。这些Django全部自带,尤其是Admin后台——虽然生产环境不建议直接用默认Admin给运营用,但它对于快速搭建管理端原型、给店主先看效果,价值太大了。我第一版后台就是基于Django Admin二次开发的,商品上架、订单查看、库存修改几乎零成本实现。
另外Django的ORM非常成熟,对于后续要讲的事务处理、行锁(select_for_update)、预取关联数据(prefetch_related),写法都比裸SQL更安全、更不容易出错。如果换成Spring Boot,从Java环境搭建到MyBatis或JPA配置,起步成本高一大截,对这样一个中小型垂直商城来说属于杀鸡用牛刀。
1.3 项目功能边界的划分
一个完整的游泳用品商城系统,我把它拆成两部分:用户端和管理端。
用户端需要做到:
- 用户注册、登录、个人资料管理
- 商品分类浏览、关键词搜索、按销量/价格/新品排序
- 商品详情页展示多图、SKU规格选择、库存显示
- 购物车添加/修改/删除,支持未登录状态下使用
- 订单创建、模拟支付、订单状态查看、确认收货
- 收货地址管理
管理端则围绕运营需求:
- 商品管理:上架、下架、修改价格/库存、设置主图和详情图
- 订单管理:查看订单、发货、处理退款
- 类目管理
- 用户管理:查看注册用户、禁用恶意账号
功能边界想清楚以后,我不会一上来就写代码,而是先把数据库模型设计出来。这一步省不了,商城系统的数据模型一旦建错,后期改起来是牵一发动全身。
2. 环境配置到工程初始化:每一步都值得认真对待
2.1 开发环境版本的选择逻辑
Django项目最怕的就是版本隐患。我本地用的是Python 3.10,Django选择了4.2 LTS版本。为什么不追新用Django 5.x?因为4.2是长期支持版本,第三方库兼容性更好,尤其是MySQL驱动、图片处理库这些,在LTS版本上踩坑的概率低很多。
数据库方面,开发环境用了SQLite,生产环境切成MySQL 8.0。为什么开发和生产用不同的库?SQLite是文件型数据库,零配置,本地调试非常方便;但生产环境并发一上来,SQLite的写锁问题就会暴露。所以我会在开发阶段就尽量用ORM的标准写法,避免用到SQLite特有语法,切换数据库时只需要改settings.py里的配置和安装对应的驱动。
依赖管理我建议从一开始就用虚拟环境:
python -m venv venv source venv/bin/activate # Windows环境执行 venv\Scripts\activate pip install django==4.2.* mysqlclient # 如果连MySQL pip freeze > requirements.txt有个小习惯值得养成:每次安装新依赖后第一时间更新requirements.txt,并且把虚拟环境目录加入.gitignore。项目开发到一半换机器、加人的时候,你会感谢这个习惯。
2.2 创建Django工程与应用
创建工程和应用是再基础不过的操作,但应用拆分这事很多人没想清楚就动手了。
django-admin startproject swim_shop cd swim_shop python manage.py startapp goods python manage.py startapp users python manage.py startapp cart python manage.py startapp orders我个人按业务域拆分应用,而不是按"前端、后端"这种技术层拆分:
users:用户模型、注册登录、收货地址goods:商品、分类、SKU、商品图片cart:购物车逻辑orders:订单、订单明细、支付状态
拆分的好处是每个应用的内聚性高,职责清楚。商城项目后期一定会加功能,比如优惠券、评论系统,那时候再加独立应用即可,不会把某个应用撑成几千行的"上帝模块"。
应用创建后别忘了两件事:一是在settings.py的INSTALLED_APPS里注册,二是把应用的urls.py用include挂到主路由。初次接触Django的人经常在这两个地方漏掉配置,结果页面404了还不知道原因。
2.3 VSCode中的Django调试配置
日常开发我习惯用VSCode,但要打断点调试Django请求,必须手动配置一下调试器。很多人不知道Django在VSCode里可以直接用"Python Debugger"附加到开发服务器上。
推荐在项目根目录建.vscode/launch.json:
{ "version": "0.2.0", "configurations": [ { "name": "Django Server", "type": "debugpy", "request": "launch", "program": "${workspaceFolder}/manage.py", "args": ["runserver", "127.0.0.1:8000"], "django": true, "justMyCode": false } ] }这里django: true是关键,它让调试器识别Django的模板和ORM上下文,遇到异常时可以直接在"调试控制台"里查看SQL语句。justMyCode: false表示可以进入第三方库内部代码调试,排查Django源码问题时非常有用。
另外,如果你的VSCode里Python解释器选错了,会直接导致"cannot be resolved against python helper roots"这类报错。处理方法很简单:Ctrl+Shift+P,输入"Python: Select Interpreter",选择虚拟环境里的python.exe,问题立刻消失。
3. 数据库建模:游泳用品商城的关键是SKU与状态管理
3.1 商品模型设计:从类目到SKU
这是整个项目的核心,我花了最多时间设计。先看最终落地的模型代码,再逐个解释为什么这么建:
from django.db import models class Category(models.Model): name = models.CharField(max_length=50, verbose_name="类目名称") parent = models.ForeignKey( "self", null=True, blank=True, on_delete=models.CASCADE, verbose_name="父级类目" ) sort_order = models.IntegerField(default=0, verbose_name="排序权重") class Meta: verbose_name = "商品类目" verbose_name_plural = verbose_name def __str__(self): return self.name class Product(models.Model): category = models.ForeignKey( Category, on_delete=models.PROTECT, verbose_name="所属类目" ) title = models.CharField(max_length=200, verbose_name="商品标题") subtitle = models.CharField(max_length=255, blank=True, verbose_name="副标题") main_image = models.ImageField(upload_to="products/%Y/%m/", verbose_name="主图") detail = models.TextField(verbose_name="商品详情") is_active = models.BooleanField(default=True, verbose_name="是否上架") created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") class Meta: verbose_name = "商品" verbose_name_plural = verbose_name indexes = [ models.Index(fields=["category", "is_active"]), ] class SKU(models.Model): product = models.ForeignKey( Product, related_name="skus", on_delete=models.CASCADE, verbose_name="所属商品" ) spec_values = models.CharField(max_length=255, verbose_name="规格值,如:藏蓝色/M") price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="售价") original_price = models.DecimalField( max_digits=10, decimal_places=2, null=True, blank=True, verbose_name="原价" ) stock = models.PositiveIntegerField(default=0, verbose_name="库存") sku_code = models.CharField(max_length=64, unique=True, verbose_name="SKU编码") is_active = models.BooleanField(default=True, verbose_name="是否启用") class Meta: verbose_name = "SKU" verbose_name_plural = verbose_name这里面有几个关键设计决策:
第一个是Category的自关联。游泳用品可以先分"泳衣、泳镜、泳帽、配件"几大类,每个大类下还能再分小类,比如配件下面有泳圈、浮板、耳塞鼻夹。自关联字段用一个parent指向自己,就能实现无限级类目,前端展示树形结构也很方便。
第二个是商品和SKU分离。Product管商品的公共信息,比如标题、主图、详情;SKU管具体可下单的规格组合,比如"藏蓝色/M码"。每个SKU有自己的价格和独立库存。为什么不把价格直接放在Product上?因为泳衣不同尺码价格一样,但像泳镜带度数和不带度数价格就差很多,如果价格绑在商品上,就无法支持这种差异化定价。
第三个是on_delete=models.PROTECT。当我们尝试删除一个还被商品引用的类目时,数据库会拒绝,防止误操作导致商品变成无类目的"孤儿数据"。
3.2 用户、地址、购物车与订单模型
用户模型这里我推荐用一个实际开发中很常见的做法——扩展Django自带的User,而不是自己另建一张用户表:
from django.contrib.auth.models import AbstractUser class User(AbstractUser): phone = models.CharField(max_length=20, blank=True, verbose_name="手机号") avatar = models.ImageField(upload_to="avatars/%Y/%m/", blank=True, verbose_name="头像") class Meta: verbose_name = "用户" verbose_name_plural = verbose_name扩展AbstractUser的好处是,直接继承Django完整的认证体系,登录、会话、权限这些不用重写,只需要加你自己的业务字段。记得在settings.py里设置:
AUTH_USER_MODEL = "users.User"这一行必须在第一次执行migrate之前配置好,否则后面换用户模型会非常麻烦。
收货地址、购物车、订单这三张表的设计逻辑:
class Address(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="addresses", verbose_name="所属用户") receiver = models.CharField(max_length=50, verbose_name="收货人") phone = models.CharField(max_length=20, verbose_name="联系电话") province = models.CharField(max_length=50, verbose_name="省") city = models.CharField(max_length=50, verbose_name="市") district = models.CharField(max_length=50, verbose_name="区") detail = models.CharField(max_length=255, verbose_name="详细地址") is_default = models.BooleanField(default=False, verbose_name="是否默认") class CartItem(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="cart_items", verbose_name="所属用户") sku = models.ForeignKey("goods.SKU", on_delete=models.CASCADE, verbose_name="SKU") quantity = models.PositiveIntegerField(default=1, verbose_name="数量") created_at = models.DateTimeField(auto_now_add=True, verbose_name="加入时间") class Order(models.Model): STATUS_PENDING = "pending" STATUS_PAID = "paid" STATUS_SHIPPED = "shipped" STATUS_COMPLETED = "completed" STATUS_CANCELLED = "cancelled" STATUS_CHOICES = [ (STATUS_PENDING, "待支付"), (STATUS_PAID, "已支付"), (STATUS_SHIPPED, "已发货"), (STATUS_COMPLETED, "已完成"), (STATUS_CANCELLED, "已取消"), ] order_no = models.CharField(max_length=64, unique=True, verbose_name="订单编号") user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="orders", verbose_name="下单用户") address_snapshot = models.TextField(verbose_name="地址快照") total_amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="订单总额") status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING, verbose_name="订单状态") created_at = models.DateTimeField(auto_now_add=True, verbose_name="下单时间") paid_at = models.DateTimeField(null=True, blank=True, verbose_name="支付时间") class OrderItem(models.Model): order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items", verbose_name="所属订单") sku = models.ForeignKey("goods.SKU", on_delete=models.PROTECT, verbose_name="SKU") product_title = models.CharField(max_length=200, verbose_name="商品标题快照") spec_values = models.CharField(max_length=255, verbose_name="规格快照") price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="成交单价") quantity = models.PositiveIntegerField(default=1, verbose_name="成交数量")订单表一定要加"快照"字段:address_snapshot、product_title、spec_values。为什么要冗余这些数据?因为下单之后用户可能改了收货地址,商品可能下架或改标题,但历史订单必须保留下单那一刻的信息。这种"以空间换正确性"的设计在交易系统里是必须的,绝对不要通过关联查询去动态获取下单时的商品标题和价格。
3.3 逻辑删除:为什么我不轻易物理删除商品
Django的ORM执行delete()时默认是物理删除,就是真的从数据库里把这条记录删掉。在商城系统中这很危险。
想象一个场景:用户A在5月买了一件泳衣,6月管理员觉得这款泳衣不想卖了,直接在后台删除了商品记录。这时候用户A的个人中心再查看历史订单,关联查询商品就会报错——即使订单表里存了标题快照,商品详情页也无法访问。更严重的是,如果删除操作没有处理好关联数据,可能连带把订单明细、购物车记录一起删掉。
我的方案是给Product和SKU增加is_active布尔字段,下架操作只是把is_active设为False,而不是调用delete()。查询商品列表时统一过滤:
products = Product.objects.filter(is_active=True)如果确实需要物理删除,我会重写模型的delete方法:
class Product(models.Model): # ... def delete(self, *args, **kwargs): self.is_active = False self.save(update_fields=["is_active"])这样外部调用product.delete()时,实际执行的是逻辑删除,防止管理层误操作。
4. 核心交易链路实现:购物车、下单并发与库存扣减
4.1 购物车方案选型:Session还是数据库
购物车是商城绕不开的模块,设计上有两种主流方案:
- 纯Session购物车:用户未登录也能加购,数据存在服务端Session里,实现简单。缺点是用户换设备购物车就丢了,而且Session存在服务端内存/数据库,量大了有压力。
- 纯数据库购物车:数据落库,用户登录后任何设备都能看到购物车。缺点是未登录用户没法用,或需要先强制登录。
我实际用的是数据库优先 + Session兜底的混合方案。用户在未登录状态下加购,购物车数据写入Session;登录后,如果Session里有购物车数据,就合并到数据库的CartItem表,然后清空Session。这样既照顾了用户体验,又保证了购物车的持久性。
合并购物车是很容易出错的地方,核心逻辑如下:
def merge_cart(request, user): session_cart = request.session.get("cart", {}) if not session_cart: return for sku_id, quantity in session_cart.items(): cart_item, created = CartItem.objects.get_or_create( user=user, sku_id=sku_id, defaults={"quantity": quantity} ) if not created: cart_item.quantity += quantity cart_item.save() request.session["cart"] = {}4.2 下单流程的事务控制:阻止库存超卖
商城系统最怕的Bug就是超卖——页面显示库存10件,结果卖出去了15单。Django的ORM要解决这个问题,必须正确使用事务和行锁。
看这段创建订单的核心代码:
from django.db import transaction from django.db.models import F def create_order(user, address, sku_items): # sku_items: [{"sku_id": 1, "quantity": 2}, ...] with transaction.atomic(): order = Order.objects.create( order_no=generate_order_no(), user=user, address_snapshot=format_address(address), total_amount=Decimal("0"), status=Order.STATUS_PENDING ) total = Decimal("0") order_items = [] for item in sku_items: sku = SKU.objects.select_for_update().get(id=item["sku_id"]) if sku.stock < item["quantity"]: raise StockNotEnough(f"{sku.sku_code} 库存不足") sku.stock = F("stock") - item["quantity"] sku.save(update_fields=["stock"]) order_items.append(OrderItem( order=order, sku=sku, product_title=sku.product.title, spec_values=sku.spec_values, price=sku.price, quantity=item["quantity"] )) total += sku.price * item["quantity"] OrderItem.objects.bulk_create(order_items) order.total_amount = total order.save(update_fields=["total_amount"]) return order这里的灵魂是select_for_update()。它在数据库层面给选中的SKU行加了排他锁,事务提交前其他事务无法修改同一行,只能在锁释放后继续执行。两个用户同时购买同一件库存仅剩1件的泳镜时,第一个事务锁住SKU行并扣减库存,第二个事务会等待锁释放,然后重新读取到库存为0,触发StockNotEnough异常,从源头杜绝了超卖。
用F("stock") - item["quantity"]而不是sku.stock - item["quantity"]也很关键。F表达式把"读值-计算-写回"操作转换成数据库端的原子操作,避免并发时读到脏数据。
另外注意,我是在事务内先创建Order,再创建OrderItem,最后回填total_amount。初始订单总金额为0,等明细都算清楚了再更新,这样即使中间出错回滚,也不会出现订单金额为Null或0的不一致状态。
4.3 订单状态的流转与超时关闭
订单状态我用的是字符串常量加choices,而不是用状态机库,因为5种状态足够简单,不需要引入额外依赖。状态流转规则:
- 待支付 → 用户点击支付 → 已支付
- 待支付 → 超过30分钟系统自动关闭 → 已取消
- 已支付 → 商家后台发货 → 已发货
- 已发货 → 用户确认收货 → 已完成
超时关闭订单我用Django的manage.py自定义命令实现,配合cron定时执行:
# orders/management/commands/close_expired_orders.py python manage.py close_expired_orders命令里查询待支付且创建时间早于当前时间30分钟以上的订单,批量改为已取消,同时把锁定的库存释放回SKU。这类后台任务用Django自定义命令比Celery更轻量,适合中小型项目。
4.4 商品查询视图:过滤、分页、排序
用户端的商品列表页,我实现了按类目浏览、关键词搜索、按价格/销量/新品排序。这里要提醒一个ORM性能问题:列表页使用select_related("category")避免每行商品都查询一次类目名称。
Django内置分页器Paginator已经够用,但要注意传给模板的每页数据最好是Page对象而不是整个Page.object_list,这样才能在模板里正确展示页码。实际项目中我还会记录request.GET的原始参数,保证翻页时筛选条件不丢失。
5. 后台管理:基于Django Admin的二次开发与权限控制
5.1 让Admin后台真正可用:列表定制与搜索
默认的Django Admin注册一个模型之后虽然能用,但体验很粗糙。商品管理列表我会在admin.py里定制:
from django.contrib import admin from .models import Product, SKU, Category @admin.register(Product) class ProductAdmin(admin.ModelAdmin): list_display = ["id", "title", "category", "is_active", "created_at"] list_filter = ["category", "is_active"] search_fields = ["title", "subtitle"] list_per_page = 20 ordering = ["-created_at"] actions = ["batch_on_shelf", "batch_off_shelf"] def batch_on_shelf(self, request, queryset): queryset.update(is_active=True) batch_on_shelf.short_description = "批量上架" def batch_off_shelf(self, request, queryset): queryset.update(is_active=False) batch_off_shelf.short_description = "批量下架"list_display控制列,list_filter提供侧边栏筛选,search_fields支持关键词搜索,actions提供批量操作。这三板斧下来,运营日常维护商品的工作效率会高很多。
5.2 RBAC权限:用Django自带的Group和Permission
后台不能所有登录用户都有全部操作权限。比如运营只能管商品,客服只能看订单,财务只能看订单金额。这就是RBAC(基于角色的访问控制)的典型需求。
Django自带的权限体系包含User、Group、Permission三个核心模型。我创建了两个Group:
- 运营组:赋予商品和类目的增加、修改、删除权限
- 客服组:赋予订单的查看和发货权限
在Admin后台,Django会为每个注册的模型自动生成 add/change/delete/view 四种权限。给组分配权限后,再把用户加入对应的组即可。对于视图中需要额外控制的按钮,可以用装饰器:
from django.contrib.auth.decorators import permission_required @permission_required("orders.change_order") def ship_order(request, order_id): ...这样权限控制的粒度更细,后端接口和Admin操作都能覆盖。
5.3 库存预警功能
游泳用品有季节性,旺季库存消耗特别快,所以我在Admin后台加了一个自定义页面,显示库存低于安全阈值(比如10件)的SKU列表:
@admin.register(SKU) class SKUAdmin(admin.ModelAdmin): list_display = ["sku_code", "product", "spec_values", "price", "stock"] list_filter = ["is_active"] def changelist_view(self, request, extra_context=None): low_stock_skus = SKU.objects.filter(stock__lte=10, is_active=True).select_related("product") extra_context = extra_context or {} extra_context["low_stock_skus"] = low_stock_skus return super().changelist_view(request, extra_context=extra_context)列表页顶部渲染一个低库存提醒卡片,让管理员一进后台就知道哪些商品需要补货。这种做法比做复杂的可观测系统成本低得多,但对小团队实际帮助极大。
6. 生产部署:waitress + nginx 的完整配置实践
6.1 为什么生产环境不能继续用runserver
很多新手把项目跑起来之后,直接在服务器上用python manage.py runserver对外提供服务。这在开发阶段没毛病,生产环境绝对不行。Django的runserver是开发服务器,单进程、单线程,性能差,而且没有经过安全加固,不适合暴露在公网。
我选择waitress作为WSGI服务器。它是纯Python实现的,跨平台,Windows和Linux都能跑,不像gunicorn在Windows上支持不好。对于中小型商城,waitress的性能足够稳定,配置也简单。
安装和启动命令:
pip install waitress waitress-serve --listen=127.0.0.1:8000 swim_shop.wsgi:application生产环境我建议用进程管理工具托管,Linux用supervisor,Windows可以用NSSM或者任务计划程序,保证进程挂了能自动重启。
6.2 收集静态文件与媒体文件配置
部署中最高频的坑就是静态文件404。开发环境下Django自动处理静态文件,生产环境必须自己配。
先在settings.py里明确几个路径:
DEBUG = False ALLOWED_HOSTS = ["www.yourdomain.com", "yourdomain.com"] STATIC_URL = "/static/" STATIC_ROOT = BASE_DIR / "staticfiles" MEDIA_URL = "/media/" MEDIA_ROOT = BASE_DIR / "media"执行:
python manage.py collectstatic这个命令会把所有应用里的静态资源复制到STATIC_ROOT目录,交给nginx统一托管。图片上传的媒体文件则放在MEDIA_ROOT,也需要配置到nginx。
之前有朋友在VSCode里写img标签,发现Django的static目录下图片显示不出来,多半是STATICFILES_DIRS没配置。开发环境下除了每个应用自带的static目录,还可以在项目根目录建一个全局static目录,并在settings.py里声明:
STATICFILES_DIRS = [BASE_DIR / "static"]然后在模板里用模板标签引用:
{% load static %} <img src="{% static 'images/banner.jpg' %}" alt="banner">不要把路径硬编码写死,这是静态文件引用最基本也最容易忽略的规则。
6.3 nginx反向代理配置
nginx在这里承担两个任务:一是监听80/443端口,将请求转发给waitress;二是托管静态文件和媒体文件。
一个精简但完整的server配置:
server { listen 80; server_name yourdomain.com; client_max_body_size 10m; location /static/ { alias /path/to/swim_shop/staticfiles/; expires 7d; } location /media/ { alias /path/to/swim_shop/media/; expires 7d; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }X-Forwarded-Proto头很重要。如果Django启用HTTPS,而用户实际通过HTTP访问,缺少这个头会导致Django在生成绝对URL时误判协议,产生安全警告。严格来说还应该在settings.py里加SECURE_PROXY_SSL_HEADER,让Django正确识别经过代理的HTTPS请求。
6.4 部署阶段容易踩的坑
部署过程中我踩过几个印象深刻的坑,单独列出来:
坑一:ALLOWED_HOSTS没配好。部署后访问页面提示Invalid HTTP_HOST header。这其实是Django的安全机制在起作用,它不接受未知域名的请求。解决方案就是在ALLOWED_HOSTS里把你实际访问的域名写进去。
坑二:DEBUG=False后静态文件丢失。开发时DEBUG=True一切正常,一改成False样式就全崩了。原因就是生产环境Django默认不再处理静态文件,必须靠collectstatic+ nginx。如果临时不想配nginx,可以通过django.contrib.staticfiles的runserver --insecure应急,但这个不能用于真实生产。
坑三:数据库连接报错。从SQLite切换MySQL时,如果之前模型里有某些字段类型依赖SQLite的特性,迁移可能失败。所以还是那句话,开发阶段就用ORM标准字段,不要用方言特性。
7. 开发复盘:几个值得记住的实操经验
7.1 时间处理:时区问题的隐形坑
Django项目默认开启USE_TZ = True,数据库存储的是UTC时间,模板渲染时自动转换成本地时间。这个机制本身没问题,但如果你用datetime.datetime.now()而不是django.utils.timezone.now()存时间,就会产生时区错乱——后台看到的下单时间比实际差了8个小时。
我的习惯是:
- 代码中一律用
timezone.now()获取当前时间 - 创建时间、更新时间字段全部使用
auto_now_add和auto_now - 计算订单超时时间时,用UTC时间做比较,展示层才转本地时区
7.2 ORM的N+1查询问题
商城商品列表页有个经典性能问题:查询了10个商品,然后每个商品又要查一次类目名称,总共执行11条SQL。这就是N+1查询。
解决办法是使用select_related和prefetch_related。对于外键这种单值关系,用select_related,它会用JOIN一次性查出来:
products = Product.objects.select_related("category").filter(is_active=True)对于多值关系,比如一个商品的多个SKU,用prefetch_related,它会先查商品列表再批量查SKU,总共2条SQL:
products = Product.objects.prefetch_related("skus").filter(is_active=True)在实际项目里,这个优化能把列表页的响应时间从几百毫秒降到几十毫秒。写完代码之后,建议用django-debug-toolbar观察SQL执行次数,凡是看到"1+N"模式,一律用预取解决。
7.3 图片上传与MEDIA路径的细节
商品主图上传后马上在页面显示不出来,是很常见的现象。检查三件事:MEDIA_ROOT是否正确、MEDIA_URL是否和nginx配置一致、模板里是否用了{{ product.main_image.url }}而不是拼接字符串。
有一点容易被忽略:开发环境下Django不处理MEDIA文件的URL映射,除非在urls.py里手动加:
from django.conf import settings from django.conf.urls.static import static urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)这段代码只在settings.DEBUG为True时生效,生产环境交给nginx。很多新手在本地能显示图片,一部署就白屏,多半就是这个原因。
7.4 版本管理与数据备份意识
代码这块我强烈建议项目第一天就初始化Git仓库,每完成一个功能模块提交一次commit。数据库备份则用定时任务导出SQL文件,商城系统的订单数据是资产,丢了恢复成本极高。我在部署服务器上写了一个简单的shell脚本,每天凌晨用mysqldump备份数据库,保留最近30天的备份文件,曾不止一次在测试环境误操作后靠备份恢复数据。
说实话,做完这个游泳用品商城项目,我最深的体会是:垂直品类商城真正的工作量不在展示层,而在商品模型的灵活性和交易链路的可靠性。SKU设计够不够好,决定了后面所有功能的开发效率;事务和锁用对了,才能保证库存和订单数据不出大问题。Django这个框架在快速搭建这类系统时确实省力,它把用户认证、Admin后台、ORM这些基础设施都给你备好了,省下的时间足够你好好打磨业务逻辑。现在这套系统已经跑起来了,商品可以上架下架、用户在手机上能下单购物、后台订单和库存都管得明明白白。如果后面要继续扩展,直播带货、优惠券、多门店库存这些方向都能基于现在的模型往上加。我的建议是,如果你也想做类似的商城系统,不妨先从商品建模和订单状态这两块入手,想清楚再动手,后面会顺很多。