Django 从 0 到 1 打造完整电商平台:下单前的准备,订单模型设计

IT策士 10余年一线大厂经验,专注 IT 思维、架构、职场进阶。我会在公众号、今日头条持续发布最新文章,助你少走弯路。


上一篇我们把购物车的增删改查全部搞定,用户现在可以自由挑选商品并放入购物车,勾选准备结算。但"结算"按钮点下去,后端要做的事情非常多:校验库存、生成订单、记录订单商品快照、扣减库存......这些操作都围绕着订单模型展开。

今天,我们不急于写视图,而是先把订单相关的模型重新审视一遍,补全之前设计中的疏漏,并加入关键业务逻辑------订单号生成规则状态流转。这是下单前的"地基",地基打牢,明天写确认订单页和提交订单才能一气呵成。


一、订单模型回顾

在第 2 篇中,我们已经定义了 OrderOrderItem 两个核心模型。先回顾一下当前的结构(apps/orders/models.py):

Order(订单主表)字段摘要:

  • order_no:订单编号,唯一

  • user:外键关联用户

  • address_snapshot:JSON 字段,收货地址快照

  • total_amount:订单总金额

  • freight:运费

  • pay_method:支付方式

  • status:订单状态(0待支付、1待发货、2待收货、3已完成、4已取消)

  • remark:用户备注

  • create_timeupdate_timepay_time

OrderItem(订单商品条目)字段:

  • order:外键关联 Order

  • sku:外键关联 SKU,on_delete=PROTECT

  • sku_namesku_specs:快照字段

  • price:成交单价

  • quantity:购买数量

这些字段基本够用,但有几个地方需要增强:

  1. 订单状态流转需要更清晰的状态定义和转换规则。

  2. 地址快照的具体存储格式需要统一约定。

  3. 订单号生成需要一套可靠、防重复的算法。

  4. 下单时的库存扣减策略需要在模型中体现(乐观锁或 F 表达式)。

  5. 订单总额校验:订单金额应由后端根据商品单价和数量重新计算,而非直接信任前端传值。


二、订单号生成策略

订单号是订单的唯一标识,需要满足以下要求:

  • 全局唯一(至少在很长一段时间内不重复)

  • 有一定的可读性(包含时间信息便于追踪)

  • 长度适中,不宜过长

常见方案:时间戳 + 随机数 + 用户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 篇实现。


五、订单金额与库存扣减设计

下单时需要保证:

  1. 金额一致性 :订单总金额应等于 sum(OrderItem.price * OrderItem.quantity),且 OrderItem.price 应是下单时 SKU 的当前价格(快照),而非前端传来的价格,防止用户篡改。

  2. 库存扣减 :下单时扣减对应 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_ordertb_order_item 已经准备好承载真实订单数据。下一篇(第 19 篇),我们将迎来电商最关键的环节------确认订单页面与提交订单:从购物车勾选商品,选择地址,确认金额,到生成订单并扣减库存,全程事务控制。

想了解更多还可以去公众号、今日头条搜索「IT策士」,一起升级 IT 思维 !


本文为《Django 从 0 到 1 打造完整电商平台》系列第 18 篇,作者:IT策士,未经授权禁止转载。

相关推荐
Java编程爱好者18 小时前
Java后端硬核实战:用Spring AI Alibaba+Redis给LLM装上“超强记忆中枢”
后端
XovH18 小时前
Django 从 0 到 1 打造完整电商平台:确认订单页面与提交订单
后端
Cache技术分享18 小时前
418. 现代 Java IO 最佳实践 - 网络数据获取:从 HttpClient 到图片下载
前端·后端
TYKJ02318 小时前
CDN加速的原理,远不止缓存这么简单
后端·性能优化·图片资源
XovH18 小时前
Django 从 0 到 1 打造完整电商平台:我的订单列表与订单详情
后端
Nyarlathotep011318 小时前
并发集合类(4):PriorityBlockingQueue
java·后端
XovH18 小时前
Django 从 0 到 1 打造完整电商平台:集成支付宝沙箱支付
后端
国思RDIF框架18 小时前
国思 RDIF 低代码快速开发框架 v6.3 版本重磅发布!性能与体验双飞跃
前端·vue.js·后端
学以智用18 小时前
.NET Core 数据验证(最全实战指南)
后端·.net