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策士,未经授权禁止转载。

相关推荐
金銀銅鐵1 小时前
[Java] 用 GUI 展示 EnumSet 增减元素时的内部变化(上)
java·后端·ai编程
念何架构之路2 小时前
go-grpc设计思想
开发语言·后端·golang
system_error_x2 小时前
SpringBoot 集成 Thymeleaf 简洁入门
java·spring boot·后端
步行cgn4 小时前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc
码事漫谈5 小时前
AI 读你的提示词,其实很没耐心
后端
用户8356290780516 小时前
如何使用 Python 格式化 Excel 单元格中的长文本
后端·python
我命由我123456 小时前
Python 开发 - Python 的 pathlib
java·开发语言·后端·python·学习·java-ee·学习方法
IT_陈寒6 小时前
Java性能优化完全指南
前端·人工智能·后端
电子IT笔记6 小时前
对接群晖NAS,上传和删除踩坑记录
前端·后端
Mk598786 小时前
P4:把 Agent 的记忆搬进 Redis —— AgentScope-Java 工程化实战
后端