2. 数据模型设计:把书刊商城的所有业务塞进数据库
2.1 模型字段到底怎么定?从订单和书籍入手
书刊商城和普通电商有一个明显区别:图书的属性比衣服鞋子复杂一点。衣服可能只有尺码和颜色,一本书至少有书名、作者、出版社、ISBN、定价、封面图、库存、分类、上架状态。做模型设计的时候,如果一开始就把所有字段一股脑写进一张表里,后面改需求的时候会非常痛苦。我的做法是先理业务对象,再定字段。
以最核心的Book模型为例,我实际项目里是这么设计的:
from django.db import models from django.utils import timezone class Category(models.Model): name = models.CharField('分类名称', max_length=50, unique=True) parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE) sort_order = models.IntegerField('排序', default=0) class Meta: ordering = ['sort_order', 'id'] def __str__(self): return self.name class Book(models.Model): title = models.CharField('书名', max_length=200) author = models.CharField('作者', max_length=100, blank=True) isbn = models.CharField('ISBN', max_length=20, unique=True) publisher = models.CharField('出版社', max_length=100, blank=True) publish_date = models.DateField('出版日期', null=True, blank=True) price = models.DecimalField('定价', max_digits=8, decimal_places=2) stock = models.IntegerField('库存', default=0) sales = models.IntegerField('销量', default=0) cover = models.ImageField('封面', upload_to='covers/%Y/%m/', blank=True) description = models.TextField('简介', blank=True) category = models.ForeignKey(Category, on_delete=models.PROTECT, verbose_name='分类') is_active = models.BooleanField('上架', default=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ['-created_at'] indexes = [ models.Index(fields=['isbn']), models.Index(fields=['category', 'is_active']), ] def __str__(self): return self.title这里有几个值得说的设计细节。价格字段用DecimalField而不用FloatField,是因为浮点数在二进制下无法精确表示,做金额运算时会冒出0.1+0.2不等于0.3这种问题;商城系统涉及真金白银,精度是第一优先级。库存字段我加了销量字段,排序的时候直接按销量排就能做“热卖榜”,不用每次去count订单表,节省不少查询成本。分类表用了自关联外键,是为了支持二级分类——比如“计算机”下面分“编程语言”和“数据库”,这种层级结构在书城很常见。
订单这块,我拆成了订单主表和订单明细表。主表存收货人、联系电话、订单状态、总金额、下单时间;明细表存每一本书的标题、单价、数量、小计。为什么拆两张表?因为一个订单里可能有多本书,如果都塞在一行里,后续想统计某本书卖了多少、某段时间的销售额,SQL写起来会非常别扭。这种主从表结构是电商系统里最经典最稳妥的设计,没有之一。
2.2 订单状态流转:状态机的思路比if-else好用一百倍
订单是书刊商城最复杂的部分,不是因为代码难写,而是因为状态实在太多——用户下单、支付、备货、发货、确认收货、取消、退款,每一个动作都会改变订单的归属状态。如果全用if-else硬判断,写着写着就会出现“这个状态到底能不能取消?”这种灵魂拷问。我建议直接在模型里定义状态常量,再把状态迁移规则单独写成一个模块。
class Order(models.Model): STATUS_CREATED = 'created' # 已下单未支付 STATUS_PAID = 'paid' # 已支付待发货 STATUS_SHIPPED = 'shipped' # 已发货 STATUS_COMPLETED = 'completed' # 已完成 STATUS_CANCELLED = 'cancelled' # 已取消 STATUS_REFUNDING = 'refunding' # 退款中 STATUS_CHOICES = [ (STATUS_CREATED, '待支付'), (STATUS_PAID, '待发货'), (STATUS_SHIPPED, '已发货'), (STATUS_COMPLETED, '已完成'), (STATUS_CANCELLED, '已取消'), (STATUS_REFUNDING, '退款中'), ] # 定义允许的状态流转路径 STATUS_TRANSITIONS = { STATUS_CREATED: [STATUS_PAID, STATUS_CANCELLED], STATUS_PAID: [STATUS_SHIPPED, STATUS_REFUNDING], STATUS_SHIPPED: [STATUS_COMPLETED, STATUS_REFUNDING], STATUS_REFUNDING: [STATUS_CANCELLED], STATUS_COMPLETED: [], STATUS_CANCELLED: [], } def change_status(self, new_status): if new_status not in self.STATUS_TRANSITIONS.get(self.status, []): raise ValueError(f'非法状态流转: {self.get_status_display()} -> {new_status}') self.status = new_status self.save()这样做的好处是,所有状态迁移的合法性都集中在同一个地方管理,页面上能显示哪些操作按钮、接口能不能执行某个动作,全部参考这个mapping就行,不会出现状态A跳到状态Z的乌龙。后面接支付回调、后台发货操作,逻辑都会清爽很多。这也是我在实际项目里反复重构后总结的教训:状态字段加注释确实有用,但把流转规则写出来,才是真正防呆。
2.3 数据库选型:Django不替你决定,但默认选择往往够用
做书刊商城的数据存储,有人纠结该用MySQL还是PostgreSQL。我自己的经验是:如果团队里没人对PostgreSQL有特别的偏好,直接MySQL就行,Django的ORM把差异基本抹平了——配置好连接,改个ENGINE,模型代码不用动,QuerySet的写法也几乎一样。对书刊商城这个量级的应用,两者的性能差异根本体现不出来,选自己最熟的那个,才是最优解。唯一要注意的是连接参数,建议在settings里加上:
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'bookshop', 'USER': 'bookshop_user', 'PASSWORD': '这里别用弱密码', 'HOST': '127.0.0.1', 'PORT': '3306', 'OPTIONS': { 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'", }, } }STRICT_TRANS_TABLES是MySQL的严格模式,开着能避免字段超长被静默截断的问题。这类配置看起来不起眼,但数据库层面出怪问题,多半都是这些细节没补上。
3. 核心功能实现:从商品展示到支付回调一条龙
3.1 商品列表页的缓存策略:别让数据库扛下所有
书刊商城的商品列表页,是访问量最高的页面,同时也是最容易写烂的页面。如果每次请求都去数据库把所有书查一遍,赶上促销活动,数据库连接池很快就被打满了。我的方案分两层:第一层用Django的cache框架缓存热门分类的书籍列表,第二层在数据库查询层面加上select_related和prefetch_related,把外键关联的查询合并成一条SQL。
from django.core.cache import cache from .models import Book def get_books_by_category(category_id, page=1, page_size=20): cache_key = f'books_category_{category_id}_page_{page}' books = cache.get(cache_key) if books is not None: return books book_list = Book.objects.filter( category_id=category_id, is_active=True ).select_related('category').order_by('-sales') # 这里省略分页逻辑… cache.set(cache_key, book_list, 60 * 5) return book_list缓存时间我设置的是5分钟。书城不像股票行情,短时间内数据变化不大,5分钟既能保证用户看到的数据不算陈旧,又能明显降低数据库压力。后台管理里如果改了图书价格或者库存,记得在保存逻辑里主动删除相关缓存key,否则前台会一直显示旧数据。我一开始就是这么干的,运营改了价格用户看不到,后来才在保存模型时加了个信号来处理。
3.2 购物车:用户未登录时怎么办?存session还是存数据库?
购物车的核心矛盾在于:用户未登录时加的图书,登录之后不能丢。Django的session默认是存数据库的,但session里的数据是序列化的,结构复杂了维护起来很麻烦。我的实现思路比较直接:session只存图书ID和数量的字典,用redis或者数据库session都可以,登录后把session里的数据merge到数据库的购物车表里。这两段逻辑别看简单,实际做好“合并”这个动作很关键:同一本书session里有1本,数据库里有2本,合并之后应该是3本,而不是互相覆盖。
def merge_cart(request, user): session_cart = request.session.get('cart', {}) db_cart = Cart.objects.filter(user=user).select_related('book') db_map = {item.book_id: item for item in db_cart} for book_id, session_qty in session_cart.items(): if book_id in db_map: item = db_map[book_id] item.quantity = min(item.quantity + session_qty, item.book.stock) item.save() else: Cart.objects.create(user=user, book_id=book_id, quantity=session_qty) request.session['cart'] = {}这里有个细节需要注意:合并的时候要校验库存上限。用户session里莫名其妙有了100本,数据库里已有20本,合并出来120本,但库存只有50本,提交订单时就会出现超卖。所以在加购物车的入口、合并购物车、提交订单三个位置,都要做库存校验。三层防线下来,超卖这种事基本就堵死了。
3.3 订单提交与库存扣减:先锁库存还是先减库存?
订单提交是一个典型的并发场景。两个用户同时买最后一本库存剩余1本书,如果代码写成先查库存再减库存,几乎必然出现超卖。Django的ORM里常规的update写法,需要换成原子操作:
from django.db import transaction from django.db.models import F def create_order(request, cart_items, address_data): with transaction.atomic(): # 循环校验并扣减库存 for item in cart_items: # 用update + F表达式防超卖 affected = Book.objects.filter( id=item.book_id, stock__gte=item.quantity ).update(stock=F('stock') - item.quantity) if affected == 0: raise ValueError(f'《{item.book.title}》库存不足') # 这里拿到最新库存,剩余量为0的话可以自动下架 book = Book.objects.get(id=item.book_id) if book.stock - item.quantity <= 0: Book.objects.filter(id=item.book_id).update(is_active=False)update()配合F表达式,是把“查库存”和“减库存”合并成一条SQL执行,数据库层面天然保证原子性。stock__gte=item.quantity这个条件如果没满足,update返回0,说明有并发竞争,直接拒绝下单,不会出现两个订单都扣减成功的情况。transaction.atomic()保证库存扣减、订单创建、购物车清空三步是一整套操作,任何一步失败都全部回滚。这里面的顺序也很讲究——先扣库存,再创建订单记录,绝对不能反过来。如果先建订单再扣库存,万一扣减失败,订单记录虽然回滚没了,但操作顺序的语义会让排查问题的人一脸懵。
3.4 支付回调和幂等:微信支付宝回调真的好容易重复
支付接入这块,每个平台的回调机制大同小异,但都有一个共同点:回调通知可能会发送多次。如果代码没有做幂等处理,用户付了一笔钱,系统里生成了三个订单记录,那就真的翻车了。我的做法是给订单表加一个唯一的外来交易号字段,回调时先按交易号查是否已经处理过,处理过就直接返回成功,不再重复处理。
def payment_callback(request): # 验签逻辑省略,这一步绝不能少 notify_data = parse_callback(request) trade_no = notify_data.get('out_trade_no') payment = Payment.objects.filter(trade_no=trade_no).first() if payment and payment.status == 'success': return JsonResponse({'code': 'success'}) with transaction.atomic(): payment, created = Payment.objects.get_or_create( trade_no=trade_no, defaults={ 'order_id': notify_data.get('order_id'), 'amount': notify_data.get('total_fee'), 'status': 'success', } ) if created or payment.status != 'success': payment.status = 'success' payment.save() # 更新订单状态 payment.order.change_status(Order.STATUS_PAID) return JsonResponse({'code': 'success'})这里的get_or_create就是天然防重复的利器。第一次回调创建payment记录,第二次回调拿到created=False,直接就不再往下执行了。另一个容易踩的坑是回调里的金额要和订单金额比对,不一致直接拒绝——虽然理论上支付平台不会回调错金额,但作为防御性编程,这个校验写了不亏。接入支付时记得先去平台文档确认回调是GET还是POST、验签参数顺序是什么,每个平台的细节都不一样,别想当然。
4. 后台管理、权限控制与安全加固
4.1 Admin后台:Django自带后台不是玩具,定制一下真香
书刊商城必须有一个给运营人员用的后台,用来上架图书、修改库存、查看订单、处理发货。Django自带的Admin在这一点上能省掉半条命。不要觉得Admin只能作为开发调试用,稍微定制一下它完全能当正式后台使。我在项目里做了几件事:注册模型的时候配置list_display,把书名、作者、定价、库存、上架状态都列出来;加上list_filter,按分类和上架状态筛选;search_fields设置成书名和作者,方便运营搜索。
@admin.register(Book) class BookAdmin(admin.ModelAdmin): list_display = ['title', 'author', 'price', 'stock', 'sales', 'is_active', 'created_at'] list_filter = ['is_active', 'category'] search_fields = ['title', 'author', 'isbn'] list_editable = ['price', 'stock', 'is_active'] list_per_page = 20 readonly_fields = ['created_at', 'updated_at']list_editable这个配置特别适合运营场景:在列表页直接改库存、改价格、上下架,不用每次点进编辑页再保存。这里有个Django的限制——list_editable的字段不能和list_display_links的字段重复,如果你想把书名的链接也作为编辑入口,这两个配置就得避开冲突。Admin还有一个容易被忽略的能力,是actions,可以实现批量上下架。在action函数里传入request和queryset,处理完后返回一个提示消息即可。运营人员用起来非常顺手。
4.2 用户权限:普通用户、运营、管理员三级划分
Django自带的用户系统只提供了is_staff和is_superuser两大类,但一个真实的书刊商城往往有三种角色:普通购买用户、运营人员(能管图书和订单但不能碰系统设置)、管理员(全权限)。我的做法是自建一个UserProfile关联到Django的User,用一个role字段来区分:
class UserProfile(models.Model): ROLE_USER = 'user' ROLE_OPERATOR = 'operator' ROLE_ADMIN = 'admin' ROLE_CHOICES = [ (ROLE_USER, '普通用户'), (ROLE_OPERATOR, '运营人员'), (ROLE_ADMIN, '管理员'), ] user = models.OneToOneField(User, on_delete=models.CASCADE) role = models.CharField('角色', max_length=20, choices=ROLE_CHOICES, default=ROLE_USER)角色区分好了之后,Django的装饰器就可以派上用场。自定义一个 @require_role 装饰器,直接作用在视图函数上:
from functools import wraps def require_role(*allowed_roles): def decorator(view_func): @wraps(view_func) def _wrapped_view(request, *args, **kwargs): profile = request.user.userprofile if profile.role not in allowed_roles: raise PermissionDenied return view_func(request, *args, **kwargs) return _wrapped_view return decorator比如运营人员能访问订单管理视图,但操作后台系统设置的视图只允许管理员访问。用装饰器来控制权限,代码复用性极高,不用在每个视图函数开头重复写一大段if判断。这里有一个坑:如果用户用Admin登录,request.user.userprofile访问可能因为profile不存在而报错,所以注册用户的时候要保证UserProfile的创建逻辑一定是联动自动生成的,比如接Django自带的post_save信号。
4.3 安全基线:CSRF、XSS、SQL注入一个都不能放
很多初学者觉得安全是很遥远的事情,但商城系统直接和用户的钱挂钩,安全就是第一生产力。Django在这一块其实已经做了大量工作:所有模板渲染默认转义,XSS大部分被自动防住了;ORM用参数化查询,SQL注入基本不存在;表单提交默认带CSRF token,只要不关掉csrf中间件就问题不大。真正容易出问题的反而是开发者自己放松警惕的地方。
常见的一个问题是模板里用户上传的HTML想原样展示,于是加了|safe过滤器。这个操作一旦遇到用户提交的文本里包含script标签,就是妥妥的XSS漏洞。书刊系统的简介字段建议一律不用safe,而是用markdown或者其他富文本规范之后再渲染。另一个问题是表单里如果用户输入了书籍标题,后面展示如果没转义,用户在标题里写一段脚本,前台浏览列表就会执行。安全这部分我的原则是:能用框架的默认配置就不用自定义,能不开safe就不开safe。
5. 部署上线:从本地跑到服务器这一步必须稳住
5.1 环境配置和Django部署的关键参数
本地开发时DEBUG=True用runserver跑得很开心,真到部署上线的时候,才知道坑全在看不见的地方。部署前一定要把settings里的DEBUG改成False,ALLOWED_HOSTS要明确指定你的域名,否则Django会拒绝访问。同时所有静态文件和媒体文件的处理逻辑也要重新梳理:开发阶段Django能自己处理,上线后必须交给服务器。
我的部署方案用的是最常见稳定的组合:Nginx + uWSGI + Django + MySQL,服务器选的是Linux发行版。部署的核心流程是先把项目代码同步到服务器,创建虚拟环境,安装依赖,跑migrate,收集静态文件到STATIC_ROOT,然后配置uWSGI。settings里的静态文件配置:
STATIC_URL = '/static/' STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') MEDIA_URL = '/media/' MEDIA_ROOT = os.path.join(BASE_DIR, 'media/')数据库迁移这里有个小窍门:上线前先在本地把所有migration测试一遍,包括新建空表、给表加字段、修改字段类型这些场景都过一遍。有些复杂的迁移操作,比如修改一个带等价交换的外键字段,可能会因为数据库约束问题失败。如果migration出了意外,先用migrate --plan看Django的执行计划,确定每一步要干嘛,再加--fake之类参数。真到线上表数据很多的时候,迁移耗时和锁表风险都要考虑进去,尽量拆分成多个小迁移来执行。
5.2 常见问题与排查技巧实录
部署和运维阶段,我整理一份自己实际踩过的问题清单,按出现频率排序:
| 症状 | 可能原因 | 排查命令/方法 |
|---|---|---|
| 页面显示 404 | Nginx配置的root目录不对 | 查看Nginx error.log,确认root路径和static文件位置 |
| 后台图片不显示 | MEDIA_URL和Nginx的media location不匹配 | 确认MEDIA_ROOT生成的文件结构,Nginx里要加location /media/ { alias ...; } |
| 提交表单报500 | ALLOWED_HOSTS没配好 | 看django日志,确认请求的Host是否在ALLOWED_HOSTS里 |
| CSRF验证失败 | 前端表单缺csrf_token | 使用Django的{% csrf_token %}模板标签,或者在JS请求里加X-CSRFToken头 |
| 数据库连接耗尽 | 连接池配置不足或慢查询太多 | 在MySQL里执行SHOW PROCESSLIST查看连接数,优化慢查询SQL |
| 迁移文件异常 | 多人协作时改了同一个模型 | 用python manage.py makemigrations --check检查,无依赖冲突再合并 |
| 时区错乱 | USE_TZ=True但服务器时区不是UTC | 设置TIME_ZONE='Asia/Shanghai',同时保持USE_TZ=True,存储用UTC展示再转换 |
这几个问题里面,CSRF和时区的坑最隐蔽。CSRF报错的时候很多人第一反应是关掉中间件,但这是负优化,正确办法是在模板里加上token标签。时区问题如果不设置好,订单的写入时间差8小时,不仅运营看得懵,统计报表也会偏一天。我建议在项目一开始就把时区和中文语言配好,不用等上线再补救:
LANGUAGE_CODE = 'zh-hans' TIME_ZONE = 'Asia/Shanghai' USE_I18N = True USE_TZ = True5.3 uWSGI和Nginx的配合
uWSGI配置本身不复杂,但有几个参数会影响稳定性。我常用的一个配置骨架是这样的:
[uwsgi] chdir = /srv/bookshop module = bookshop.wsgi:application master = true processes = 4 threads = 2 socket = 127.0.0.1:8001 vacuum = true max-requests = 5000 daemonize = /var/log/bookshop/uwsgi.logprocesses和threads要根据服务器CPU来定,不要硬调大。设置max-requests是为了防止内存泄漏——Django长驻进程跑久了内存会涨,5000个请求强制回收一次是比较稳妥的做法。Nginx那边对应的配置只要把请求转发到127.0.0.1:8001就行,静态文件用alias指向STATIC_ROOT和MEDIA_ROOT。uWSGI日志是排查线上问题的第一帮手,页面报错时去uwsgi.log里找Traceback,比反复看浏览器上的错误页快得多。
Django项目部署这块,我个人的感受是:只要DEBUG=False之后能正常跑通第一遍,后面基本都是一马平川。第一次部署通常会在静态文件、权限路径、配置环境上卡一段时间,但把流程跑通一次之后,就会觉得整个套路非常固定,完全可以做成脚本自动化,再部署新环境时几分钟就能搞定。
6. 项目过程中的管理小技巧
6.1 路由和视图:一个功能一个文件,别堆成一团
不少人写Django喜欢把所有视图都塞在views.py里,几百行代码写完,后面对照着改一个功能要找半天。为了不让这个项目变成意大利面条,我把视图按业务模块拆成了多个文件,比如views_cart.py、views_order.py、views_payment.py,然后在views/init.py里统一导出。Django的urls.py里照常写from views import cart_list, create_order,这样导入路径不变,代码却清爽很多。
URL设计上,我遵循RESTful的直觉风格,不用纯动词:/books/表示列表、/books/1/表示详情、/cart/表示购物车、/orders/表示订单列表。Django的path和re_path都支持这个方式,参数尽量用尖括号类型转换器,比如 int:book_id ,它能自动做类型校验,不会因为输入了非数字而报500错误。
6.2 Git提交和版本备份的管理习惯
单人开发项目时的Git管理是最容易被忽视的,但后患最大。我现在习惯是固定几个分支:master放稳定的可部署版本,dev放日常开发,feature分支做新功能。每个功能开发完,本地跑一遍测试,确认无误再merge到dev。项目里不同阶段会产生很多临时配置,比如调试用的一些打印日志和测试代码,这些不要一股脑提交上去。
书刊商城这种项目,数据库结构变更还会涉及到生产数据,所以网上很多团队会用django-migrations来管理数据库结构版本,但是别忘了把新版本的migration文件完整提交到仓库,放漏一个文件线上跑makemigrations --check就会提示有变更未生成迁移。对于代码量不是很大的项目来说,把这些版本管理习惯建立好,后面维护起来会轻松非常多。
7. 避坑指南和性能优化建议
7.1 查询优化的三板斧:select_related、prefetch_related、values
书刊商城的数据库查询,最怕的就是“N+1”问题。比如在列表页展示每本书的分类名称,如果代码是:
# 每次循环取book.category.name都会发一次SQL for book in books: print(book.category.name)图书100本就会发100次分类查询。加上select_related('category')之后,Django会把书籍和分类用一次JOIN查出来,N+1问题直接消失。对于多对多关系或者反向关联,要用的是prefetch_related,它先把主表查出来,再额外发另一条查询按关联ID批量取回数据,然后Django在内存里做匹配。
values和values_list也是很有用的方法。比如要批量取所有在售图书的价格和书名,用values('title', 'price')能只查这两列,不会把description这种很大段的文本也拖回来,带宽和内存都省。
7.2 N+1之外容易忽视的性能细节
分页查询的时候,很多人喜欢直接用QuerySet的切片操作,比如books[:50]。这样确实能控制返回的行数,但千万不要误以为这很高效——Django的ORM切片是生成了LIMIT语句没错,但如果order_by的字段没加索引,数据库还是要全表排序。所以排序字段也要设计好索引,别只在筛选字段上建索引。
书刊商城有一个非常特殊的性能问题:封面上传的图片。用户上传的封面如果是几MB的照片,每次列表页都去加载原图,页面会非常慢。建议在保存模型时用Pillow把图片压缩成适合Web展示的尺寸,比如最大宽度800px,质量压缩到70%左右。Django的ImageField可以设置upload_to,配合信号处理来进行图片缩略和优化,能做到无侵入式。我处理过的一个项目里,封面的平均体积从4MB降到了150KB,页面加载速度肉眼可见地提升。
最终一点经验
做完这个Django书刊商城项目,我最深的感受是:实现功能本身不难——Django的文档和社区资料已经把路铺得非常平了,真正的分水岭在于细节。比如数据库模型设计时有没有想到并发扣库存的问题,写视图时有没有意识到N+1查询让接口变慢,部署上线时有没有提前把时区和静态文件配置好。这些细节单独拿出来都是很小的事,但堆在一起就是项目开发和后期维护体验的巨大差异。
如果你正打算做一个Django书刊商城,我的建议是:先把模型层设计透——在写任何视图和模板之前,把Category、Book、Order、OrderItem这几张表的关系和字段都捋清楚。模型设计对了,后面每一步开发速度都很稳。如果你做完基础版还有余力,可以再把搜索替换成全文搜索引擎,或者用Celery做订单超时未支付自动关闭,扩展空间很大。这套项目练完,Django的MTV模式、ORM、Admin、中间件、部署这些知识点基本能形成完整的实战认知,对你吃透整个Web开发方向都有很直接的帮助。