IT策士 10余年一线大厂经验,专注 IT 思维、架构、职场进阶。我会在公众号、今日头条持续发布最新文章,助你少走弯路。
上一篇我们把购物车的增删改查全部搞定,用户现在可以自由挑选商品并放入购物车,勾选准备结算。但"结算"按钮点下去,后端要做的事情非常多:校验库存、生成订单、记录订单商品快照、扣减库存......这些操作都围绕着订单模型展开。
今天,我们不急于写视图,而是先把订单相关的模型重新审视一遍,补全之前设计中的疏漏,并加入关键业务逻辑------订单号生成规则 和状态流转。这是下单前的"地基",地基打牢,明天写确认订单页和提交订单才能一气呵成。
一、订单模型回顾
在第 2 篇中,我们已经定义了 Order 和 OrderItem 两个核心模型。先回顾一下当前的结构(apps/orders/models.py):
Order(订单主表)字段摘要:
-
order_no:订单编号,唯一 -
user:外键关联用户 -
address_snapshot:JSON 字段,收货地址快照 -
total_amount:订单总金额 -
freight:运费 -
pay_method:支付方式 -
status:订单状态(0待支付、1待发货、2待收货、3已完成、4已取消) -
remark:用户备注 -
create_time、update_time、pay_time
OrderItem(订单商品条目)字段:
-
order:外键关联 Order -
sku:外键关联 SKU,on_delete=PROTECT -
sku_name、sku_specs:快照字段 -
price:成交单价 -
quantity:购买数量
这些字段基本够用,但有几个地方需要增强:
-
订单状态流转需要更清晰的状态定义和转换规则。
-
地址快照的具体存储格式需要统一约定。
-
订单号生成需要一套可靠、防重复的算法。
-
下单时的库存扣减策略需要在模型中体现(乐观锁或 F 表达式)。
-
订单总额校验:订单金额应由后端根据商品单价和数量重新计算,而非直接信任前端传值。
二、订单号生成策略
订单号是订单的唯一标识,需要满足以下要求:
-
全局唯一(至少在很长一段时间内不重复)
-
有一定的可读性(包含时间信息便于追踪)
-
长度适中,不宜过长
常见方案:时间戳 + 随机数 + 用户ID 后缀 ,格式例如 20260525143015A3B2C1。我们采用 年月日时分秒 + 6位随机大写字母数字 的组合,极低概率冲突,无需加锁。
在 apps/orders/models.py 中新增一个静态方法(可放在 Order 模型内或单独的工具模块,放在模型内便于调用):
bash
import random
import string
from django.utils import timezone
class Order(models.Model):
# ... 字段定义 ...
@staticmethod
def generate_order_no():
"""生成订单号:时间戳 + 6位随机字符"""
now = timezone.now()
timestamp = now.strftime('%Y%m%d%H%M%S')
random_str = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
return f"{timestamp}{random_str}"
使用示例:
bash
>>> Order.generate_order_no()
'20260525143015X7K9M2'
为了保证唯一性,order_no 字段我们已设置了 unique=True,如果极端情况撞车(概率极低),数据库会报 IntegrityError,我们在提交订单的视图中捕获该异常并重新生成即可。
三、订单状态流转设计
当前模型定义了 5 个状态,但它们的转换并不是任意的。我们需要设计一个状态机,明确哪些状态之间可以转换。
在模型中,我们可以添加一个方法来检查状态转换是否合法:
bash
STATUS_TRANSITIONS = {
0: [1, 4], # 待支付 -> 待发货、已取消
1: [2, 4], # 待发货 -> 待收货、已取消
2: [3], # 待收货 -> 已完成
# 3和4为终态,不允许转换
}
def can_transition_to(self, new_status):
"""检查是否允许转换到目标状态"""
return new_status in self.STATUS_TRANSITIONS.get(self.status, [])
def set_status(self, new_status):
"""安全地修改状态"""
if not self.can_transition_to(new_status):
raise ValueError(f'不允许从状态 {self.status} 转换到 {new_status}')
self.status = new_status
self.save(update_fields=['status', 'update_time'])
虽然我们目前只在后端逻辑中控制状态流转,但模型中加上这些方法可以防止误操作。
四、地址快照格式规范
下单时,用户的收货地址可能会在之后被修改。为了保证历史订单的收货地址不变,我们在 address_snapshot JSON 字段中存储下单时的完整地址信息。
从 Address 模型中取出必要字段,组装成一个字典:
bash
{
"receiver": "张三",
"phone": "13800000001",
"province": "广东省",
"city": "深圳市",
"district": "南山区",
"detail": "科技园路1号"
}
在视图中创建订单时,我们会根据用户选择的地址 ID 查询 Address,然后将其序列化为上述格式存入 address_snapshot。这部分逻辑在第 19 篇实现。
五、订单金额与库存扣减设计
下单时需要保证:
-
金额一致性 :订单总金额应等于
sum(OrderItem.price * OrderItem.quantity),且OrderItem.price应是下单时 SKU 的当前价格(快照),而非前端传来的价格,防止用户篡改。 -
库存扣减 :下单时扣减对应 SKU 的库存,使用
F表达式避免超卖。
扣减库存的时机通常有两种策略:
-
下单即扣减:提交订单时立刻扣库存(可能未支付,占用库存)
-
支付后扣减:支付成功再扣库存(可能超卖)
为了简单且保证用户体验,我们采用下单即扣减 + 超时未支付自动取消并回滚库存(后续用 Celery 实现)。第 19 篇提交订单时会用 F('stock') - quantity 来更新库存,并检查库存是否充足。
六、完善模型代码
我们将上述所有增强集成到 apps/orders/models.py 中。完整代码如下(合并原有字段和新增方法):
bash
import random
import string
from django.db import models
from django.conf import settings
from django.utils import timezone
class Order(models.Model):
STATUS_CHOICES = (
(0, '待支付'),
(1, '待发货'),
(2, '待收货'),
(3, '已完成'),
(4, '已取消'),
)
STATUS_TRANSITIONS = {
0: [1, 4],
1: [2, 4],
2: [3],
}
order_no = models.CharField(max_length=64, unique=True, verbose_name='订单编号')
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name='orders',
verbose_name='下单用户'
)
address_snapshot = models.JSONField(verbose_name='收货地址快照')
total_amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='订单总金额')
freight = models.DecimalField(max_digits=8, decimal_places=2, default=0, verbose_name='运费')
pay_method = models.CharField(max_length=20, default='alipay', verbose_name='支付方式')
status = models.SmallIntegerField(choices=STATUS_CHOICES, default=0, verbose_name='订单状态')
remark = models.TextField(null=True, blank=True, verbose_name='用户备注')
create_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
update_time = models.DateTimeField(auto_now=True, verbose_name='更新时间')
pay_time = models.DateTimeField(null=True, blank=True, verbose_name='支付时间')
class Meta:
db_table = 'tb_order'
verbose_name = '订单'
verbose_name_plural = verbose_name
ordering = ['-create_time']
indexes = [
models.Index(fields=['user', '-create_time'], name='idx_user_time'),
models.Index(fields=['status'], name='idx_status'),
]
def __str__(self):
return self.order_no
@staticmethod
def generate_order_no():
now = timezone.now()
timestamp = now.strftime('%Y%m%d%H%M%S')
random_str = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
return f"{timestamp}{random_str}"
def can_transition_to(self, new_status):
return new_status in self.STATUS_TRANSITIONS.get(self.status, [])
def set_status(self, new_status):
if not self.can_transition_to(new_status):
raise ValueError(f'不允许从状态 {self.status} 转换到 {new_status}')
self.status = new_status
if new_status == 1: # 待发货表示已支付
self.pay_time = timezone.now()
self.save(update_fields=['status', 'update_time', 'pay_time'])
class OrderItem(models.Model):
order = models.ForeignKey(
Order,
on_delete=models.CASCADE,
related_name='items',
verbose_name='所属订单'
)
sku = models.ForeignKey(
'products.SKU',
on_delete=models.PROTECT,
verbose_name='商品 SKU'
)
sku_name = models.CharField(max_length=200, verbose_name='商品名称')
sku_specs = models.JSONField(verbose_name='规格快照')
price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='成交单价')
quantity = models.PositiveIntegerField(verbose_name='购买数量')
class Meta:
db_table = 'tb_order_item'
verbose_name = '订单商品'
verbose_name_plural = verbose_name
def __str__(self):
return f"{self.sku_name} x {self.quantity}"
如果模型中增加了新字段或修改了选项,执行迁移:
bash
python manage.py makemigrations orders
python manage.py migrate
如果未改变数据库结构(本次未改字段),迁移将提示 No changes detected。
七、模型方法单元测试
测试订单号生成和状态流转,编写 apps/orders/tests.py:
bash
from django.test import TestCase
from django.contrib.auth import get_user_model
from products.models import SPU, SKU, Category
from .models import Order, OrderItem
User = get_user_model()
class OrderModelTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='buyer', password='testpass')
self.category = Category.objects.create(name='测试', level=1)
self.spu = SPU.objects.create(name='测试SPU', category=self.category)
self.sku = SKU.objects.create(spu=self.spu, name='测试SKU', price=100, stock=10, is_active=True)
def test_generate_order_no(self):
order_no = Order.generate_order_no()
self.assertEqual(len(order_no), 20) # 14位时间戳 + 6位随机
self.assertTrue(order_no.isalnum())
def test_order_creation(self):
order = Order.objects.create(
order_no=Order.generate_order_no(),
user=self.user,
address_snapshot={'receiver': '张三', 'phone': '138'},
total_amount=200,
)
OrderItem.objects.create(order=order, sku=self.sku, sku_name='测试SKU', sku_specs={}, price=100, quantity=2)
self.assertEqual(order.status, 0)
self.assertEqual(order.items.count(), 1)
def test_status_transition_valid(self):
order = Order.objects.create(
order_no=Order.generate_order_no(),
user=self.user,
address_snapshot={},
total_amount=100,
)
order.set_status(1)
self.assertEqual(order.status, 1)
self.assertIsNotNone(order.pay_time)
def test_status_transition_invalid(self):
order = Order.objects.create(
order_no=Order.generate_order_no(),
user=self.user,
address_snapshot={},
total_amount=100,
)
# 待支付不能直接跳到已完成
with self.assertRaises(ValueError):
order.set_status(3)
运行测试:
bash
python manage.py test orders
输出:
bash
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 4 tests in 0.156s
OK
Destroying test database for alias 'default'...
八、总结与下集预告
今天我们把订单模型从"初步可用"打磨到了"业务就绪":
-
设计了可靠的订单号生成算法;
-
定义了完整的状态机及转换规则,并封装了校验方法;
-
明确了地址快照格式和金额计算、库存扣减策略;
-
补充了单元测试,确保核心逻辑正确。
现在,数据库里的 tb_order 和 tb_order_item 已经准备好承载真实订单数据。下一篇(第 19 篇),我们将迎来电商最关键的环节------确认订单页面与提交订单:从购物车勾选商品,选择地址,确认金额,到生成订单并扣减库存,全程事务控制。
想了解更多还可以去公众号、今日头条搜索「IT策士」,一起升级 IT 思维 !
本文为《Django 从 0 到 1 打造完整电商平台》系列第 18 篇,作者:IT策士,未经授权禁止转载。