Django 从 0 到 1 打造完整电商平台:确认订单页面与提交订单

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


上一篇我们给订单模型加上了订单号生成、状态流转等关键逻辑,地基已经打牢。今天我们要做的是整个电商流程中最激动人心的一步------把购物车里的商品变成真正的订单

业务流程是:用户在购物车页面点击"去结算" → 跳转到确认订单页面(选择收货地址、查看商品明细、填写备注) → 点击"提交订单" → 后端校验库存、计算金额、生成订单、扣减库存、清空购物车 → 跳转到支付页面(待支付状态)。

这个过程环环相扣,涉及多张表的操作,必须用数据库事务保证一致性。我会用 transaction.atomic() 包裹整个下单逻辑,确保要么全部成功,要么全部回滚。


一、需求分析与页面设计

确认订单页面需要展示以下信息:

  1. 收货地址选择:显示用户所有收货地址,默认地址优先选中。可在此页新增或修改地址(链接跳转到地址管理页)。

  2. 商品列表:从购物车带入已勾选的商品,显示图片、名称、规格、单价、数量、小计。

  3. 金额汇总:商品总价 + 运费(暂免)= 应付总额。

  4. 备注输入框:用户可填写额外要求。

  5. 提交订单按钮

页面效果类似于:

bash 复制代码
[地址卡片 1] [地址卡片 2] [+ 新增地址]
---------------------------------------------------------------------
商品图片 | 名称/规格 | 单价 | 数量 | 小计
---------------------------------------------------------------------
                         合计:¥xxxx.xx
[备注] [___________]
[提交订单]

二、配置 URL

确认订单页面和提交订单各需要一个路由。在 apps/orders/urls.py 中创建:

bash 复制代码
from django.urls import path
from . import views

app_name = 'orders'

urlpatterns = [
    path('confirm/', views.order_confirm, name='order_confirm'),
    path('submit/', views.order_submit, name='order_submit'),
]

然后在项目 django_ecommerce/urls.py 中 include:

bash 复制代码
path('orders/', include('apps.orders.urls')),

同时,我们需要修改购物车页面的"去结算"按钮,让它跳转到确认订单页。打开 apps/cart/templates/cart/cart_list.html,把"去结算"按钮的 href 改为:

bash 复制代码
<a href="{% url 'orders:order_confirm' %}" class="btn btn-danger btn-lg" id="checkout-btn">去结算</a>

如果用户没有勾选商品,点击去结算应给出提示。我们可以在前端加一个判断(后续完善),或在后端确认订单页面做校验。


三、确认订单视图

编辑 apps/orders/views.py

bash 复制代码
from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from cart.models import CartItem


@login_required(login_url='users:login')
def order_confirm(request):
    """确认订单页面"""
    user = request.user

    # 获取购物车中已勾选的商品
    cart_items = CartItem.objects.filter(
        user=user,
        is_checked=True
    ).select_related('sku__spu').prefetch_related('sku__images')

    if not cart_items.exists():
        messages.warning(request, '请先勾选要购买的商品。')
        return redirect('cart:cart_list')

    # 校验每个商品是否还有库存
    for item in cart_items:
        if item.quantity > item.sku.stock:
            messages.error(request, f'"{item.sku.name}"库存不足(仅剩 {item.sku.stock} 件),请调整数量。')
            return redirect('cart:cart_list')

    # 获取用户地址
    addresses = user.addresses.all()

    if not addresses.exists():
        messages.warning(request, '请先添加收货地址。')
        return redirect('users:address_create')

    # 计算总金额
    total_amount = sum(item.sku.price * item.quantity for item in cart_items)

    return render(request, 'orders/order_confirm.html', {
        'cart_items': cart_items,
        'addresses': addresses,
        'total_amount': total_amount,
    })

四、确认订单页面模板

创建 apps/orders/templates/orders/order_confirm.html

bash 复制代码
{% extends 'base.html' %}
{% load static %}

{% block title %}确认订单{% endblock %}

{% block content %}
<h3 class="mb-4">📋 确认订单</h3>

<form id="order-form" method="post" action="{% url 'orders:order_submit' %}">
    {% csrf_token %}

    <!-- 收货地址选择 -->
    <div class="card shadow-sm mb-4">
        <div class="card-header bg-light">
            <h5 class="mb-0">📍 收货地址</h5>
        </div>
        <div class="card-body">
            <div class="row">
                {% for addr in addresses %}
                <div class="col-md-6 mb-2">
                    <div class="card address-card {% if addr.is_default %}border-primary{% endif %}" 
                         data-address-id="{{ addr.id }}">
                        <div class="card-body">
                            <input type="radio" name="address_id" value="{{ addr.id }}" 
                                   {% if addr.is_default %}checked{% endif %} class="me-2">
                            <strong>{{ addr.receiver }}</strong> {{ addr.phone }}
                            {% if addr.is_default %}<span class="badge bg-primary ms-1">默认</span>{% endif %}
                            <p class="card-text text-muted mb-0 mt-1">
                                {{ addr.province }} {{ addr.city }} {{ addr.district }}<br>
                                {{ addr.detail }}
                            </p>
                        </div>
                    </div>
                </div>
                {% endfor %}
                <div class="col-md-6 mb-2">
                    <a href="{% url 'users:address_create' %}?next={% url 'orders:order_confirm' %}" 
                       class="card h-100 text-decoration-none">
                        <div class="card-body d-flex align-items-center justify-content-center">
                            <span class="text-muted fs-5">+ 新增地址</span>
                        </div>
                    </a>
                </div>
            </div>
        </div>
    </div>

    <!-- 商品明细 -->
    <div class="card shadow-sm mb-4">
        <div class="card-header bg-light">
            <h5 class="mb-0">🛒 商品明细</h5>
        </div>
        <div class="card-body p-0">
            <table class="table mb-0">
                <thead class="table-light">
                    <tr>
                        <th>商品信息</th>
                        <th style="width: 100px;">单价</th>
                        <th style="width: 80px;">数量</th>
                        <th style="width: 100px;">小计</th>
                    </tr>
                </thead>
                <tbody>
                    {% for item in cart_items %}
                    <tr>
                        <td>
                            <div class="d-flex align-items-center">
                                <img src="{{ item.sku.main_image_url }}" />
                                <div>
                                    <div>{{ item.sku.name }}</div>
                                </div>
                            </div>
                        </td>
                        <td>¥{{ item.sku.price }}</td>
                        <td>{{ item.quantity }}</td>
                        <td>¥{{ item.sku.price|floatformat:2 }}</td>
                    </tr>
                    {% endfor %}
                </tbody>
            </table>
        </div>
    </div>

    <!-- 备注 -->
    <div class="card shadow-sm mb-4">
        <div class="card-header bg-light">
            <h5 class="mb-0">📝 订单备注</h5>
        </div>
        <div class="card-body">
            <textarea name="remark" class="form-control" rows="3" placeholder="选填,如有特殊要求请注明"></textarea>
        </div>
    </div>

    <!-- 金额汇总与提交 -->
    <div class="card shadow-sm">
        <div class="card-body text-end">
            <p class="mb-1">商品总计:<strong>¥{{ total_amount|floatformat:2 }}</strong></p>
            <p class="mb-3">运费:<strong>¥0.00</strong>(免运费)</p>
            <h4 class="text-danger">应付总额:<strong>¥{{ total_amount|floatformat:2 }}</strong></h4>
            <button type="submit" class="btn btn-danger btn-lg mt-2" id="submit-order-btn">提交订单</button>
        </div>
    </div>
</form>
{% endblock %}

五、提交订单视图(核心)

这是最核心的逻辑,需要用 transaction.atomic() 保证原子性。

继续在 apps/orders/views.py 中添加:

bash 复制代码
from django.shortcuts import get_object_or_404
from django.db import transaction, IntegrityError
from django.db.models import F
from django.views.decorators.http import require_POST
from products.models import SKU
from users.models import Address
from .models import Order, OrderItem


@require_POST
@login_required(login_url='users:login')
@transaction.atomic
def order_submit(request):
    """
    提交订单:
    1. 验证地址
    2. 获取购物车勾选商品
    3. 校验库存并扣减
    4. 生成订单和订单商品
    5. 清空购物车勾选商品
    """
    user = request.user
    address_id = request.POST.get('address_id')
    remark = request.POST.get('remark', '')

    # 1. 验证地址
    if not address_id:
        messages.error(request, '请选择收货地址。')
        return redirect('orders:order_confirm')

    address = get_object_or_404(Address, pk=address_id, user=user)

    # 2. 获取购物车勾选商品
    cart_items = CartItem.objects.filter(
        user=user,
        is_checked=True
    ).select_related('sku')

    if not cart_items.exists():
        messages.warning(request, '没有勾选任何商品,请先去购物车选择。')
        return redirect('cart:cart_list')

    # 3. 校验库存 + 扣减库存 + 计算总金额
    # 使用 select_for_update 锁定 SKU 行,防止并发超卖
    sku_ids = [item.sku_id for item in cart_items]
    skus = SKU.objects.select_for_update().in_bulk(sku_ids)

    total_amount = 0
    order_items = []

    for item in cart_items:
        sku = skus[item.sku_id]

        # 校验库存
        if item.quantity > sku.stock:
            messages.error(request, f'"{sku.name}"库存不足(仅剩 {sku.stock} 件)。')
            return redirect('cart:cart_list')

        # 扣减库存
        sku.stock = F('stock') - item.quantity
        sku.sales = F('sales') + item.quantity
        sku.save(update_fields=['stock', 'sales'])

        # 计算金额
        price = sku.price
        subtotal = price * item.quantity
        total_amount += subtotal

        order_items.append(OrderItem(
            sku=sku,
            sku_name=sku.name,
            sku_specs=sku.specs,
            price=price,
            quantity=item.quantity,
        ))

    # 4. 生成订单
    # 地址快照
    address_snapshot = {
        'receiver': address.receiver,
        'phone': address.phone,
        'province': address.province,
        'city': address.city,
        'district': address.district,
        'detail': address.detail,
    }

    # 生成唯一订单号(碰撞时重试)
    order_no = Order.generate_order_no()
    max_retry = 3
    for _ in range(max_retry):
        try:
            order = Order.objects.create(
                order_no=order_no,
                user=user,
                address_snapshot=address_snapshot,
                total_amount=total_amount,
                status=0,
                remark=remark,
            )
            break
        except IntegrityError:
            order_no = Order.generate_order_no()
    else:
        messages.error(request, '订单生成失败,请重试。')
        return redirect('orders:order_confirm')

    # 批量创建订单商品
    for oi in order_items:
        oi.order = order
    OrderItem.objects.bulk_create(order_items)

    # 5. 清空已下单的购物车商品
    cart_items.delete()

    messages.success(request, f'订单 {order_no} 已生成,请尽快支付。')
    return redirect('orders:order_detail', pk=order.pk)  # 暂跳详情,后续改为支付页

关键点详解:

  • transaction.atomic():整个函数在一个数据库事务中执行,任何步骤失败都会自动回滚。

  • select_for_update():对 SKU 行加行级锁(在 PostgreSQL、MySQL 中有效,SQLite 不支持行锁但事务隔离也能防大部分并发问题)。

  • F('stock') - item.quantity:在数据库层面做减法,避免 Python 层面的竞态条件。

  • 批量创建 bulk_create():一次性插入所有 OrderItem,减少 SQL 次数。

  • 订单号冲突重试:万一生成的订单号撞车,重新生成并重试最多 3 次。


六、订单详情视图(临时)

提交订单后需要一个详情页来显示订单信息(后续第 22 篇会加强)。我们先创建一个临时简版:

apps/orders/views.py 中添加:

bash 复制代码
@login_required(login_url='users:login')
def order_detail(request, pk):
    order = get_object_or_404(Order.objects.prefetch_related('items__sku__images'), pk=pk, user=request.user)
    return render(request, 'orders/order_detail.html', {'order': order})

apps/orders/urls.py 中添加:

bash 复制代码
path('<int:pk>/', views.order_detail, name='order_detail'),

创建模板 apps/orders/templates/orders/order_detail.html

bash 复制代码
{% extends 'base.html' %}

{% block title %}订单详情{% endblock %}

{% block content %}
<h3 class="mb-4">📄 订单详情</h3>

<div class="card shadow-sm mb-3">
    <div class="card-header bg-light">
        <strong>订单号:{{ order.order_no }}</strong>
        <span class="badge bg-warning ms-2">{{ order.get_status_display }}</span>
    </div>
    <div class="card-body">
        <p><strong>收货人:</strong>{{ order.address_snapshot.receiver }} {{ order.address_snapshot.phone }}</p>
        <p><strong>收货地址:</strong>{{ order.address_snapshot.province }} {{ order.address_snapshot.city }} {{ order.address_snapshot.district }} {{ order.address_snapshot.detail }}</p>
        <p><strong>创建时间:</strong>{{ order.create_time|date:"Y-m-d H:i:s" }}</p>
        {% if order.remark %}
            <p><strong>备注:</strong>{{ order.remark }}</p>
        {% endif %}

        <table class="table">
            <thead>
                <tr><th>商品</th><th>单价</th><th>数量</th><th>小计</th></tr>
            </thead>
            <tbody>
                {% for item in order.items.all %}
                <tr>
                    <td>{{ item.sku_name }}</td>
                    <td>¥{{ item.price }}</td>
                    <td>{{ item.quantity }}</td>
                    <td>¥{{ item.price|floatformat:2 }}</td>
                </tr>
                {% endfor %}
            </tbody>
        </table>

        <h4 class="text-end text-danger">合计:¥{{ order.total_amount|floatformat:2 }}</h4>
    </div>
</div>

<a href="{% url 'home' %}" class="btn btn-outline-primary">返回首页</a>
{% endblock %}

七、测试完整下单流程

启动服务器:

bash 复制代码
python manage.py runserver

7.1 准备测试数据

确保有以下数据:

  • 一个已登录用户

  • 至少一个收货地址

  • 购物车中有勾选的商品

7.2 从购物车到确认订单

  1. 访问 /cart/,勾选一个商品,点击"去结算"。

  2. 跳转到 /orders/confirm/,页面显示:

    • 收货地址卡片(默认地址已选中)

    • 商品明细表格

    • 金额汇总

    • 备注输入框和提交按钮

终端输出:

bash 复制代码
[25/May/2026 14:10:22] "GET /orders/confirm/ HTTP/1.1" 200 12345

7.3 提交订单

  1. 选中地址,可填写备注(如"请尽快发货"),点击"提交订单"。

  2. 提交成功后跳转到订单详情页。

终端输出:

bash 复制代码
[25/May/2026 14:11:05] "POST /orders/submit/ HTTP/1.1" 302 0
[25/May/2026 14:11:05] "GET /orders/1/ HTTP/1.1" 200 8765

7.4 验证数据库变化

打开 dbshell

bash 复制代码
-- 查看订单
SELECT * FROM tb_order;

应出现一条新订单,状态为 0(待支付)。

bash 复制代码
-- 查看订单商品
SELECT * FROM tb_order_item WHERE order_id = 1;

显示对应商品及数量。

bash 复制代码
-- 查看库存是否扣减
SELECT id, name, stock, sales FROM tb_sku WHERE id IN (你购买的商品ID);

stock 减少对应数量,sales 增加对应数量。

bash 复制代码
-- 查看购物车是否清空
SELECT * FROM tb_cart_item WHERE user_id = <你的用户ID> AND is_checked = 1;

应为空。

7.5 测试异常情况

场景一:库存不足

在确认订单前,通过 Admin 将商品库存改为 0。回到确认页刷新,页面提示库存不足,自动跳回购物车。

bash 复制代码
[25/May/2026 14:15:00] "GET /orders/confirm/ HTTP/1.1" 302 0
[25/May/2026 14:15:00] "GET /cart/ HTTP/1.1" 200 8765

场景二:重复提交订单

提交订单后按浏览器后退,再次提交。由于购物车已清空,会提示"没有勾选任何商品"并跳回购物车。

场景三:订单号唯一性

虽然极低概率碰撞,但代码中的重试机制能处理。可以手动注释掉 cart_items.delete() 后多次提交,验证订单号不重复(每个订单都有唯一 order_no)。


八、总结与下集预告

今天我们完成了电商项目中最重要的链路------从购物车到订单的转化:

  • 确认订单页面:选择地址、查看商品明细、计算金额;

  • 提交订单核心逻辑:事务包裹、行锁防并发、F 表达式减库存、批量创建订单商品、清空购物车;

  • 订单详情页面(预览)。

现在,订单已经生成了,但用户还没有付钱!第 20 篇 ,我们将集成 支付宝沙箱支付,让用户真正扫码付款,完成交易闭环。

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


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

相关推荐
XovH16 小时前
Django 从 0 到 1 打造完整电商平台:下单前的准备,订单模型设计
后端
Cache技术分享16 小时前
418. 现代 Java IO 最佳实践 - 网络数据获取:从 HttpClient 到图片下载
前端·后端
TYKJ02316 小时前
CDN加速的原理,远不止缓存这么简单
后端·性能优化·图片资源
XovH16 小时前
Django 从 0 到 1 打造完整电商平台:我的订单列表与订单详情
后端
Nyarlathotep011316 小时前
并发集合类(4):PriorityBlockingQueue
java·后端
XovH16 小时前
Django 从 0 到 1 打造完整电商平台:集成支付宝沙箱支付
后端
国思RDIF框架16 小时前
国思 RDIF 低代码快速开发框架 v6.3 版本重磅发布!性能与体验双飞跃
前端·vue.js·后端
学以智用16 小时前
.NET Core 数据验证(最全实战指南)
后端·.net
加多16 小时前
A2A(Agent-to-Agent)协议:Agent 之间的下一个标准
后端