IT策士 10余年一线大厂经验,专注 IT 思维、架构、职场进阶。我会在公众号、今日头条持续发布最新文章,助你少走弯路。
上一篇我们给订单模型加上了订单号生成、状态流转等关键逻辑,地基已经打牢。今天我们要做的是整个电商流程中最激动人心的一步------把购物车里的商品变成真正的订单。
业务流程是:用户在购物车页面点击"去结算" → 跳转到确认订单页面(选择收货地址、查看商品明细、填写备注) → 点击"提交订单" → 后端校验库存、计算金额、生成订单、扣减库存、清空购物车 → 跳转到支付页面(待支付状态)。
这个过程环环相扣,涉及多张表的操作,必须用数据库事务保证一致性。我会用 transaction.atomic() 包裹整个下单逻辑,确保要么全部成功,要么全部回滚。
一、需求分析与页面设计
确认订单页面需要展示以下信息:
-
收货地址选择:显示用户所有收货地址,默认地址优先选中。可在此页新增或修改地址(链接跳转到地址管理页)。
-
商品列表:从购物车带入已勾选的商品,显示图片、名称、规格、单价、数量、小计。
-
金额汇总:商品总价 + 运费(暂免)= 应付总额。
-
备注输入框:用户可填写额外要求。
-
提交订单按钮。
页面效果类似于:
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 从购物车到确认订单
-
访问
/cart/,勾选一个商品,点击"去结算"。 -
跳转到
/orders/confirm/,页面显示:-
收货地址卡片(默认地址已选中)
-
商品明细表格
-
金额汇总
-
备注输入框和提交按钮
-
终端输出:
bash
[25/May/2026 14:10:22] "GET /orders/confirm/ HTTP/1.1" 200 12345
7.3 提交订单
-
选中地址,可填写备注(如"请尽快发货"),点击"提交订单"。
-
提交成功后跳转到订单详情页。
终端输出:
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策士,未经授权禁止转载。