
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_deletemodels.CASCADE, related_nameprofile) phone models.CharField(max_length11, uniqueTrue, verbose_name手机号) avatar models.ImageField(upload_toavatars/, defaultavatars/default.png, verbose_name头像) gender models.CharField(max_length6, choices((male,男),(female,女)), defaultmale, verbose_name性别) birth models.DateField(nullTrue, blankTrue, verbose_name生日) class Meta: db_table user_profile verbose_name 用户信息 verbose_name_plural verbose_name这里有个细节值得说related_nameprofile这个参数很关键。设置之后你可以通过user.profile.phone直接访问扩展信息不用再写UserProfile.objects.get(useruser).phone这种啰嗦的查询。另外db_table显式指定表名避免Django自动生成appname_userprofile这种带前缀的名字后期维护更清晰。收货地址表的设计要注意一个业务约束每个用户只能有一个默认地址。实现方式有两种一种是在保存时把该用户其他地址的is_default置为False另一种是用数据库层面的唯一约束。我倾向于在模型层用save方法处理因为数据库约束在并发场景下容易报错而业务层处理更可控。class Address(models.Model): user models.ForeignKey(User, on_deletemodels.CASCADE, verbose_name所属用户) receiver models.CharField(max_length20, verbose_name收货人) detail models.CharField(max_length200, verbose_name详细地址) zip_code models.CharField(max_length6, verbose_name邮编) phone models.CharField(max_length11, verbose_name联系电话) is_default models.BooleanField(defaultFalse, verbose_name是否默认) def save(self, *args, **kwargs): if self.is_default: Address.objects.filter(userself.user).update(is_defaultFalse) 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_length20, verbose_name分类名称) parent models.ForeignKey(self, nullTrue, blankTrue, on_deletemodels.CASCADE, verbose_name父级分类) logo models.CharField(max_length100, verbose_name分类图标) is_active models.BooleanField(defaultTrue, verbose_name是否启用) class Meta: db_table category verbose_name 商品分类商品SPU表存放所有规格共享的信息名称、描述、详情图、所属分类。商品SKU表存放规格特有的信息规格名、价格、库存、销量。class Goods(models.Model): name models.CharField(max_length100, verbose_name商品名称) category models.ForeignKey(Category, on_deletemodels.CASCADE, verbose_name所属分类) desc models.CharField(max_length200, verbose_name商品简介) detail models.TextField(verbose_name商品详情) is_on_sale models.BooleanField(defaultTrue, verbose_name是否上架) class Meta: db_table goods verbose_name 商品SPU class GoodsSKU(models.Model): goods models.ForeignKey(Goods, on_deletemodels.CASCADE, verbose_name所属商品) name models.CharField(max_length100, verbose_nameSKU名称) price models.DecimalField(max_digits10, decimal_places2, verbose_name价格) stock models.IntegerField(default0, verbose_name库存) sales models.IntegerField(default0, verbose_name销量) image models.ImageField(upload_togoods/, verbose_name商品图片) class Meta: db_table goods_sku verbose_name 商品SKU价格字段用DecimalField而不是FloatField这是做电商的铁律。浮点数在计算金额时会出现精度丢失比如0.1 0.2在Python里等于0.30000000000000004这种误差在订单结算时是致命的。DecimalField指定max_digits10, decimal_places2表示最多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_length30, uniqueTrue, verbose_name订单编号) user models.ForeignKey(User, on_deletemodels.CASCADE, verbose_name下单用户) address models.ForeignKey(Address, on_deletemodels.CASCADE, verbose_name收货地址) total_amount models.DecimalField(max_digits10, decimal_places2, verbose_name订单总额) status models.IntegerField(choicesSTATUS_CHOICES, default1, verbose_name订单状态) create_time models.DateTimeField(auto_now_addTrue, verbose_name创建时间) pay_time models.DateTimeField(nullTrue, blankTrue, verbose_name支付时间) class Meta: db_table order verbose_name 订单订单编号的生成也有讲究。直接用自增ID会暴露业务量用UUID又太长不方便用户查询。我的做法是日期用户ID后四位随机数的组合比如2024011512345678既保证唯一性又便于人工识别。订单详情表记录每个订单里买了什么这里要冗余存储商品名称和价格。为什么冗余因为商品价格会变如果只存SKU的ID用户三个月后查看历史订单看到的是当前价格而不是下单时的价格这是业务上不允许的。class OrderDetail(models.Model): order models.ForeignKey(Order, on_deletemodels.CASCADE, verbose_name所属订单) sku models.ForeignKey(GoodsSKU, on_deletemodels.CASCADE, verbose_name商品SKU) count models.IntegerField(verbose_name购买数量) price models.DecimalField(max_digits10, decimal_places2, verbose_name下单时单价) class Meta: db_table order_detail verbose_name 订单详情3. 项目目录结构让代码在三个月后还能看懂3.1 为什么我不把所有代码塞进一个appDjango新手最容易犯的错误是只建一个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这个设置很关键。如果设为TrueDjango会把时间存成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, nameregister), path(login/, views.login, namelogin), path(logout/, views.logout, namelogout), ]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_nameDjango会报错。class Order(models.Model): user models.ForeignKey(User, on_deletemodels.CASCADE, related_nameorders, verbose_name下单用户) handler models.ForeignKey(User, on_deletemodels.SET_NULL, nullTrue, related_namehandled_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 fgoods/{datetime.now().strftime(%Y%m)}/{filename} class GoodsSKU(models.Model): image models.ImageField(upload_togoods_image_path, verbose_name商品图片)这样图片会存到goods/202401/这样的目录下文件名是UUID绝对不会冲突。6.2 查询N1问题商品列表页要显示每个商品的分类名称如果写成for sku in skus: print(sku.goods.category.name)Django会对每个SKU都查一次数据库100个商品就是100次查询。这就是典型的N1问题。解决方案是用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(useruser, addressaddress, ...) for item in cart_items: OrderDetail.objects.create(orderorder, skuitem.sku, countitem.count, priceitem.sku.price) # 扣减库存 item.sku.stock - item.count item.sku.save() # 清空购物车 Cart.objects.filter(useruser).delete() return ordertransaction.atomic装饰器保证函数内的所有数据库操作要么全部成功要么全部回滚。这里有个细节扣减库存时应该用F表达式而不是先读后写避免并发问题。from django.db.models import F GoodsSKU.objects.filter(idsku_id).update(stockF(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小时。我的建议是统一用defaultdatetime.now而不是auto_now_add这样时间完全由Python控制不受Django时区设置影响。from datetime import datetime class Order(models.Model): create_time models.DateTimeField(defaultdatetime.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。这套骨架搭好之后接下来就是往里面填充具体的业务逻辑用户注册登录、商品列表和详情、购物车增删改、订单创建和支付。每个模块都可以独立开发互不干扰。我在实际项目里用这套结构跑了半年多加了十几个功能模块代码依然清晰可控没有出现改一处崩三处的情况。关键就在于前期把数据表设计和目录结构想清楚了后面就是按部就班地填代码。