1. 从零搭建生鲜电商后台:为什么我选择Django而不是Flask
做生鲜电商后台这件事,我前前后后折腾过三套技术栈。最早用Flask裸写,路由、ORM、模板、表单验证全靠自己拼装,项目跑到第三周就开始失控——光是用户认证和权限管理就写了上千行胶水代码,改一个字段要动五六个文件。后来换过一阵子前后端分离的方案,前端用Vue,后端用FastAPI,结果发现生鲜这类业务的管理后台页面极其琐碎,商品列表、订单详情、库存流水、促销配置,每个页面都要单独写接口和前端组件,开发效率反而更低。
最终我回到Django,用它的MTV模式配合Admin后台,两周就把核心骨架跑通了。这篇文章就是把这套从零搭建的过程完整记录下来,包括框架选型的思考、数据表怎么设计、项目目录怎么组织。如果你也正在做类似生鲜电商、社区团购或者任何带SKU和订单的后台系统,这篇内容可以直接拿去参考。
Django在这个场景下的优势非常具体:自带ORM省去手写SQL的麻烦,Admin后台几乎零成本生成管理界面,内置的认证系统直接满足多角色需求,模板系统对于管理后台这种"页面多但交互不复杂"的场景恰到好处。生鲜电商的数据模型天然复杂——商品有分类、有规格、有库存、有促销价,订单有状态流转、有支付记录、有物流信息,这些用Django的模型层来表达非常自然。
提示:如果你的项目是纯API给移动端用,Django REST Framework是更好的选择;但如果是带管理后台的完整Web应用,原生Django的Admin能帮你省掉大量重复劳动。
2. 生鲜电商的数据表设计:从业务需求倒推模型结构
2.1 先理清业务实体之间的关系
数据表设计最忌讳上来就写代码。我习惯先拿一张纸,把业务里所有的"名词"列出来,然后画它们之间的关系。生鲜电商的核心实体其实就那么几个:用户、商品分类、商品、商品规格、购物车、订单、订单详情、收货地址、支付记录。
这些实体之间的关系需要想清楚。一个用户可以有多个收货地址,一个地址属于一个用户,这是一对多。一个商品属于一个分类,一个分类下可以有多个商品,这也是一对多。但商品和订单之间是多对多——一个订单包含多个商品,一个商品可以出现在多个订单里,所以需要一张订单详情表来拆解这个多对多关系。
生鲜品类还有个特殊之处:同一个商品可能有不同的规格。比如"苹果"这个商品,可以按"500g装""1kg装""2kg装"来卖,每个规格的价格和库存都不一样。这就需要在商品和规格之间再做一层拆分。我见过不少新手直接把规格做成商品表里的几个字段,结果后期加规格就要改表结构,非常痛苦。
2.2 用户模块的表结构设计
Django自带User模型,但生鲜电商通常需要扩展字段。我的做法是新建一个UserProfile模型,用OneToOneField关联到Django的User,把手机号、头像、生日、性别这些额外信息放在Profile里。这样既保留了Django认证系统的完整性,又能灵活扩展。
from django.db import models from django.contrib.auth.models import User class UserProfile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') phone = models.CharField(max_length=11, unique=True, verbose_name='手机号') avatar = models.ImageField(upload_to='avatars/', default='avatars/default.png', verbose_name='头像') gender = models.CharField(max_length=6, choices=(('male','男'),('female','女')), default='male', verbose_name='性别') birth = models.DateField(null=True, blank=True, verbose_name='生日') class Meta: db_table = 'user_profile' verbose_name = '用户信息' verbose_name_plural = verbose_name这里有个细节值得说:related_name='profile'这个参数很关键。设置之后,你可以通过user.profile.phone直接访问扩展信息,不用再写UserProfile.objects.get(user=user).phone这种啰嗦的查询。另外db_table显式指定表名,避免Django自动生成appname_userprofile这种带前缀的名字,后期维护更清晰。
收货地址表的设计要注意一个业务约束:每个用户只能有一个默认地址。实现方式有两种,一种是在保存时把该用户其他地址的is_default置为False,另一种是用数据库层面的唯一约束。我倾向于在模型层用save方法处理,因为数据库约束在并发场景下容易报错,而业务层处理更可控。
class Address(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='所属用户') receiver = models.CharField(max_length=20, verbose_name='收货人') detail = models.CharField(max_length=200, verbose_name='详细地址') zip_code = models.CharField(max_length=6, verbose_name='邮编') phone = models.CharField(max_length=11, verbose_name='联系电话') is_default = models.BooleanField(default=False, verbose_name='是否默认') def save(self, *args, **kwargs): if self.is_default: Address.objects.filter(user=self.user).update(is_default=False) super().save(*args, **kwargs) class Meta: db_table = 'address' verbose_name = '收货地址'2.3 商品模块的层级结构
商品模块是整个系统里最复杂的部分。我的设计思路是三层结构:商品分类、商品SPU、商品SKU。SPU是标准产品单位,比如"红富士苹果";SKU是库存量单位,比如"红富士苹果 500g装"。用户下单时选的是SKU,但浏览时看的是SPU。
分类表支持两级分类就够了,生鲜电商不需要像综合电商那样搞三四级。一级分类如"新鲜水果""时令蔬菜""海鲜水产",二级分类如"苹果""香蕉""橙子"。用parent字段自关联实现层级。
class Category(models.Model): name = models.CharField(max_length=20, verbose_name='分类名称') parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE, verbose_name='父级分类') logo = models.CharField(max_length=100, verbose_name='分类图标') is_active = models.BooleanField(default=True, verbose_name='是否启用') class Meta: db_table = 'category' verbose_name = '商品分类'商品SPU表存放所有规格共享的信息:名称、描述、详情图、所属分类。商品SKU表存放规格特有的信息:规格名、价格、库存、销量。
class Goods(models.Model): name = models.CharField(max_length=100, verbose_name='商品名称') category = models.ForeignKey(Category, on_delete=models.CASCADE, verbose_name='所属分类') desc = models.CharField(max_length=200, verbose_name='商品简介') detail = models.TextField(verbose_name='商品详情') is_on_sale = models.BooleanField(default=True, verbose_name='是否上架') class Meta: db_table = 'goods' verbose_name = '商品SPU' class GoodsSKU(models.Model): goods = models.ForeignKey(Goods, on_delete=models.CASCADE, verbose_name='所属商品') name = models.CharField(max_length=100, verbose_name='SKU名称') 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='销量') image = models.ImageField(upload_to='goods/', verbose_name='商品图片') class Meta: db_table = 'goods_sku' verbose_name = '商品SKU'价格字段用DecimalField而不是FloatField,这是做电商的铁律。浮点数在计算金额时会出现精度丢失,比如0.1 + 0.2在Python里等于0.30000000000000004,这种误差在订单结算时是致命的。DecimalField指定max_digits=10, decimal_places=2,表示最多10位数、保留2位小数,足够覆盖绝大多数商品价格。
2.4 订单模块的状态流转设计
订单表的设计难点不在字段,而在状态管理。生鲜电商的订单状态通常有:待支付、待发货、待收货、已完成、已取消。状态之间的流转有严格的规则,比如只有待支付的订单能取消,只有待发货的订单能发货。
我见过有人用多个布尔字段来表示状态,比如is_paid、is_shipped、is_received,这种设计后期查询极其痛苦。正确做法是用一个status字段配合枚举值。
class Order(models.Model): STATUS_CHOICES = ( (1, '待支付'), (2, '待发货'), (3, '待收货'), (4, '已完成'), (5, '已取消'), ) order_id = models.CharField(max_length=30, unique=True, verbose_name='订单编号') user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='下单用户') address = models.ForeignKey(Address, on_delete=models.CASCADE, verbose_name='收货地址') total_amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='订单总额') status = models.IntegerField(choices=STATUS_CHOICES, default=1, verbose_name='订单状态') create_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') pay_time = models.DateTimeField(null=True, blank=True, verbose_name='支付时间') class Meta: db_table = 'order' verbose_name = '订单'订单编号的生成也有讲究。直接用自增ID会暴露业务量,用UUID又太长不方便用户查询。我的做法是"日期+用户ID后四位+随机数"的组合,比如2024011512345678,既保证唯一性又便于人工识别。
订单详情表记录每个订单里买了什么,这里要冗余存储商品名称和价格。为什么冗余?因为商品价格会变,如果只存SKU的ID,用户三个月后查看历史订单,看到的是当前价格而不是下单时的价格,这是业务上不允许的。
class OrderDetail(models.Model): order = models.ForeignKey(Order, on_delete=models.CASCADE, verbose_name='所属订单') sku = models.ForeignKey(GoodsSKU, on_delete=models.CASCADE, verbose_name='商品SKU') count = models.IntegerField(verbose_name='购买数量') price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='下单时单价') class Meta: db_table = 'order_detail' verbose_name = '订单详情'3. 项目目录结构:让代码在三个月后还能看懂
3.1 为什么我不把所有代码塞进一个app
Django新手最容易犯的错误是只建一个app,把所有模型、视图、模板都塞进去。项目小的时候没问题,一旦超过20个模型、50个视图,这个app就变成了一个巨大的垃圾场,找个函数要翻半天。
我的做法是按业务模块拆分app:用户模块一个app,商品模块一个app,订单模块一个app,购物车模块一个app。每个app内部再按MVC的思路组织文件。这样做的好处是职责清晰,改用户相关的东西只动user这个app,不会误伤其他模块。
dailyfresh/ ├── dailyfresh/ # 项目配置目录 │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── apps/ │ ├── user/ # 用户模块 │ │ ├── models.py │ │ ├── views.py │ │ ├── urls.py │ │ └── admin.py │ ├── goods/ # 商品模块 │ ├── order/ # 订单模块 │ └── cart/ # 购物车模块 ├── static/ # 静态文件 ├── templates/ # 模板文件 └── manage.py把所有app放在apps目录下,需要在settings.py里把apps加入Python路径,否则Django找不到这些app。
import sys sys.path.insert(0, os.path.join(BASE_DIR, 'apps'))然后在INSTALLED_APPS里直接写app名,不用写apps.user这种前缀。
3.2 settings.py里必须改的几个配置
Django默认的配置是给快速原型用的,生产环境必须调整。第一个是数据库,默认用SQLite,生鲜电商的订单量上来之后SQLite根本扛不住,换成MySQL。
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'dailyfresh', 'USER': 'root', 'PASSWORD': 'your_password', 'HOST': 'localhost', 'PORT': '3306', } }第二个是时区。Django默认用UTC,生鲜电商的订单时间必须用本地时间,否则用户看到的"下单时间"会差8小时。
LANGUAGE_CODE = 'zh-hans' TIME_ZONE = 'Asia/Shanghai' USE_TZ = FalseUSE_TZ = False这个设置很关键。如果设为True,Django会把时间存成UTC,读取时再转换,虽然理论上更规范,但在实际开发中经常导致时间显示混乱。生鲜电商这种单一时区的业务,直接关掉时区转换更省心。
第三个是静态文件和模板路径。Django的Admin后台需要静态文件才能正常显示样式,模板目录也要显式配置。
STATIC_URL = '/static/' STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')] TEMPLATES[0]['DIRS'] = [os.path.join(BASE_DIR, 'templates')]3.3 URL路由的分层管理
把所有URL写在一个urls.py里是另一个常见错误。我的做法是项目级的urls.py只负责分发,每个app有自己的urls.py。
# dailyfresh/urls.py from django.contrib import admin from django.urls import path, include urlpatterns = [ path('admin/', admin.site.urls), path('user/', include('user.urls')), path('goods/', include('goods.urls')), path('order/', include('order.urls')), path('cart/', include('cart.urls')), ]# apps/user/urls.py from django.urls import path from . import views app_name = 'user' urlpatterns = [ path('register/', views.register, name='register'), path('login/', views.login, name='login'), path('logout/', views.logout, name='logout'), ]app_name这个变量是命名空间,配合name参数使用。在模板里可以用{% url 'user:login' %}来生成URL,这样即使以后改了路径,模板里的链接也不会失效。
4. 模型层的进阶技巧:那些文档里不会写的经验
4.1 用Meta类统一管理表名和排序
每个模型都写一遍db_table和ordering很烦,但这是值得的。db_table让数据库表名可控,ordering让查询结果默认有序。生鲜电商的商品列表通常按销量倒序排列,订单列表按创建时间倒序排列,在Meta里设置好,视图里就不用每次都写order_by。
class GoodsSKU(models.Model): # ... 字段定义 ... class Meta: db_table = 'goods_sku' verbose_name = '商品SKU' verbose_name_plural = verbose_name ordering = ['-sales']verbose_name_plural如果不设置,Django会在Admin后台显示"商品SKUs"这种奇怪的复数形式,设置成和verbose_name一样就能显示正常的中文。
4.2 外键的on_delete参数怎么选
Django 2.0之后on_delete变成必填参数,很多人随手写CASCADE,但这不一定对。生鲜电商里,如果删除了一个商品分类,下面的商品怎么办?用CASCADE会连带删除所有商品,这显然不合理。用PROTECT会阻止删除,提示"该分类下还有商品",这才是正确的业务逻辑。
我的选择原则是:用户删除地址,订单里的地址记录不能删,用PROTECT;订单删除,订单详情跟着删,用CASCADE;商品删除,购物车里的记录跟着删,用CASCADE。每个外键都要想清楚删除时的业务影响,不能无脑用CASCADE。
4.3 用related_name解决反向查询的命名冲突
一个模型如果有多个外键指向同一个模型,必须用related_name区分。比如订单表里既有"下单用户"又有"处理订单的客服",两个外键都指向User,如果不设置related_name,Django会报错。
class Order(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='orders', verbose_name='下单用户') handler = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='handled_orders', verbose_name='处理人')设置之后,user.orders.all()拿到该用户的所有订单,user.handled_orders.all()拿到该客服处理过的订单,清晰明了。
4.4 数据库迁移的实战注意事项
Django的迁移系统很强大,但有几个坑我踩过。第一,模型改字段类型时,如果表里已有数据,迁移可能失败。比如把CharField改成IntegerField,如果原数据里有非数字字符,迁移会报错。正确做法是先加一个新字段,写数据迁移脚本把老数据转换过去,再删掉老字段。
第二,迁移文件要纳入版本控制。我见过有人把migrations目录加到.gitignore里,结果团队协作时每个人的数据库结构都不一样,合并代码后各种报错。迁移文件是项目的一部分,必须提交。
第三,生产环境执行迁移前先备份数据库。python manage.py migrate这个命令看起来人畜无害,但如果迁移脚本里有数据转换逻辑,执行失败可能导致数据损坏。我习惯在执行前先mysqldump一份。
mysqldump -u root -p dailyfresh > backup_$(date +%Y%m%d_%H%M%S).sql python manage.py migrate5. Admin后台的定制:让运营人员能真正用起来
5.1 默认Admin不够用,必须定制
Django的Admin开箱即用,但默认界面对于生鲜电商的运营人员来说太简陋了。商品列表只显示__str__返回的字符串,看不到价格、库存、销量这些关键信息。运营要改一个商品的价格,得点进去才能看到,效率极低。
我的做法是为每个模型写ModelAdmin类,用list_display控制列表页显示的字段,用list_filter加筛选器,用search_fields加搜索框。
from django.contrib import admin from .models import Goods, GoodsSKU @admin.register(Goods) class GoodsAdmin(admin.ModelAdmin): list_display = ['name', 'category', 'is_on_sale', 'create_time'] list_filter = ['category', 'is_on_sale'] search_fields = ['name'] list_per_page = 20 @admin.register(GoodsSKU) class GoodsSKUAdmin(admin.ModelAdmin): list_display = ['name', 'goods', 'price', 'stock', 'sales'] list_filter = ['goods__category'] search_fields = ['name'] list_editable = ['price', 'stock']list_editable这个属性特别实用,它让列表页的字段可以直接编辑,运营改价格不用点进详情页,在列表页就能批量修改。但要注意,list_editable里的字段不能同时出现在list_display的第一个位置,因为第一个位置是链接,点进去是详情页。
5.2 用inlines实现关联数据的批量编辑
商品和SKU是一对多的关系,编辑商品时如果能顺便管理它的SKU,体验会好很多。TabularInline和StackedInline就是干这个的。
class GoodsSKUInline(admin.TabularInline): model = GoodsSKU extra = 1 @admin.register(Goods) class GoodsAdmin(admin.ModelAdmin): inlines = [GoodsSKUInline]这样在编辑商品页面下方,会直接显示该商品的所有SKU,可以增删改。extra = 1表示默认显示一个空白行,方便快速添加。
5.3 Admin的权限控制
生鲜电商后台通常有多种角色:超级管理员、商品运营、订单客服。不同角色能看到的菜单和能操作的数据应该不同。Django的Admin自带权限系统,基于用户组和权限点。
我的做法是创建三个组:商品组只有商品相关模型的增删改查权限,订单组只有订单相关权限,客服组只有查看权限。然后在创建用户时把用户加入对应的组。
from django.contrib.auth.models import Group, Permission goods_group, _ = Group.objects.get_or_create(name='商品运营') goods_perms = Permission.objects.filter(codename__in=[ 'add_goods', 'change_goods', 'delete_goods', 'view_goods', 'add_goodssku', 'change_goodssku', 'delete_goodssku', 'view_goodssku', ]) goods_group.permissions.set(goods_perms)权限点的codename格式是动作_模型名,比如add_goods、change_goods。可以在Django shell里用Permission.objects.all()查看所有权限点。
6. 开发过程中踩过的坑和对应的解决方案
6.1 图片上传路径冲突
商品图片上传时,如果两个商品都上传了名为1.jpg的图片,后上传的会覆盖先上传的。Django的ImageField默认会在文件名后加随机字符串,但如果你自己处理了文件名,就要注意这个问题。
我的做法是在upload_to里用日期分目录,再加上UUID前缀。
import uuid from datetime import datetime def goods_image_path(instance, filename): ext = filename.split('.')[-1] filename = f'{uuid.uuid4().hex}.{ext}' return f'goods/{datetime.now().strftime("%Y%m")}/{filename}' class GoodsSKU(models.Model): image = models.ImageField(upload_to=goods_image_path, verbose_name='商品图片')这样图片会存到goods/202401/这样的目录下,文件名是UUID,绝对不会冲突。
6.2 查询N+1问题
商品列表页要显示每个商品的分类名称,如果写成for sku in skus: print(sku.goods.category.name),Django会对每个SKU都查一次数据库,100个商品就是100次查询。这就是典型的N+1问题。
解决方案是用select_related预加载关联对象。
skus = GoodsSKU.objects.select_related('goods__category').all()select_related适用于外键和一对一关系,它会在一次查询里用JOIN把关联数据一起取出来。对于多对多关系,用prefetch_related。
6.3 事务处理保证订单数据一致性
创建订单涉及多张表的写入:插入订单记录、插入订单详情、扣减库存、清空购物车。这些操作必须在一个事务里,任何一步失败都要回滚。
from django.db import transaction @transaction.atomic def create_order(user, address, cart_items): order = Order.objects.create(user=user, address=address, ...) for item in cart_items: OrderDetail.objects.create(order=order, sku=item.sku, count=item.count, price=item.sku.price) # 扣减库存 item.sku.stock -= item.count item.sku.save() # 清空购物车 Cart.objects.filter(user=user).delete() return order@transaction.atomic装饰器保证函数内的所有数据库操作要么全部成功,要么全部回滚。这里有个细节:扣减库存时应该用F表达式而不是先读后写,避免并发问题。
from django.db.models import F GoodsSKU.objects.filter(id=sku_id).update(stock=F('stock') - count)F表达式直接在数据库层面做减法,不会出现两个请求同时读到相同库存然后都减1的情况。
6.4 静态文件在开发和生产环境的差异
开发时Django的runserver会自动处理静态文件,但生产环境用Nginx或Apache时,静态文件需要单独配置。我见过有人部署后Admin后台样式全丢,就是因为静态文件没配好。
生产环境的正确做法是运行python manage.py collectstatic,把所有静态文件收集到STATIC_ROOT目录,然后让Nginx直接服务这个目录。
STATIC_ROOT = os.path.join(BASE_DIR, 'static_collected')location /static/ { alias /path/to/static_collected/; }6.5 时区问题导致的时间显示错误
前面提到USE_TZ = False,但还有一个地方要注意:auto_now_add和auto_now。这两个参数在USE_TZ = False时使用本地时间,但如果数据库里存的是UTC时间,读出来就会差8小时。
我的建议是统一用default=datetime.now而不是auto_now_add,这样时间完全由Python控制,不受Django时区设置影响。
from datetime import datetime class Order(models.Model): create_time = models.DateTimeField(default=datetime.now, verbose_name='创建时间')7. 项目骨架搭建完成后的验证清单
7.1 数据库表是否全部正确创建
运行python manage.py makemigrations和python manage.py migrate之后,进MySQL用show tables;查看所有表是否都创建了。重点检查表名是否和db_table设置的一致,外键约束是否正确。
SHOW TABLES; DESC goods_sku; SHOW CREATE TABLE order_detail\GSHOW CREATE TABLE能看到完整的建表语句,包括外键、索引、字符集。字符集必须是utf8mb4,否则生鲜商品名称里的特殊字符(比如某些水果的emoji)会存不进去。
7.2 Admin后台能否正常登录和操作
创建一个超级用户python manage.py createsuperuser,然后访问/admin/,检查所有注册的模型是否显示正常,列表页的字段、筛选器、搜索框是否工作。重点测试list_editable的字段能否直接编辑保存。
7.3 静态文件和模板是否正常加载
访问一个需要模板渲染的页面,检查CSS和JS是否加载成功。打开浏览器的开发者工具,看Network面板有没有404。如果有,检查STATICFILES_DIRS和TEMPLATES的DIRS配置。
7.4 数据迁移脚本是否可重复执行
把数据库删掉,重新执行migrate,看是否能从零建起完整的表结构。这一步能验证迁移文件是否完整,有没有遗漏的依赖。
mysql -u root -p -e "DROP DATABASE dailyfresh; CREATE DATABASE dailyfresh CHARACTER SET utf8mb4;" python manage.py migrate字符集指定utf8mb4很重要,MySQL默认的latin1或utf8都不够用。utf8在MySQL里最多存3字节,存不了emoji和某些生僻字,utf8mb4才是真正的UTF-8。
这套骨架搭好之后,接下来就是往里面填充具体的业务逻辑:用户注册登录、商品列表和详情、购物车增删改、订单创建和支付。每个模块都可以独立开发,互不干扰。我在实际项目里用这套结构跑了半年多,加了十几个功能模块,代码依然清晰可控,没有出现"改一处崩三处"的情况。关键就在于前期把数据表设计和目录结构想清楚了,后面就是按部就班地填代码。