IT策士 10余年一线大厂经验,专注 IT 思维、架构、职场进阶。我会在公众号、今日头条持续发布最新文章,助你少走弯路。
前面几篇我们让用户能按分类浏览商品、用关键词搜索商品,商品发现的能力已经基本齐了。但还有一个关键体验没做:排序。用户在列表页不能只按“最新发布”看,还需要能按“价格从低到高”、“销量最高”、“最多人在看”来筛选,这样才像一个成熟的电商平台。
今天我们就一口气完成两件事:
-
商品排序功能:价格升降、销量、浏览量、综合排序;
-
浏览量统计:每次进入商品详情页,该 SKU 的浏览量自动 +1。
这两块结合,既能让用户灵活筛选,也能为“热门商品”提供数据基础。全程用 Django ORM 和 F 表达式,零第三方依赖。
一、需求分析
1.1 排序需求
在商品列表页增加一个排序下拉菜单,选项包括:
排序可以和分类、搜索叠加,分页链接也要保留排序参数。
1.2 浏览量统计需求
-
用户每次访问 SKU 详情页(通过 SPU 详情页切换到某个 SKU 暂不算,我们统计的是进入 SPU 详情页即算 SPU 下所有 SKU?通常统计 SPU 详情页的浏览量,或 SKU 维度。我们选择在 SPU 详情页视图被调用时,给该 SPU 下的所有活动 SKU 的浏览量 +1。这样能反映产品的热度,又不至于太细粒度。
-
或者更合理的是,SPU 详情页进入时,默认展示的 SKU(第一个)浏览量 +1。但我们可以在访问时给当前 spu 的所有 skus 的 views 都加 1,简单粗暴,后续可改成针对具体被展示的 SKU。考虑到产品级热度,我们给 SPU 下所有 is_active 的 SKU 都加 1,便于排序时按 SPU 热度排。
-
使用
F('views') + 1原子更新,避免竞态条件。
二、模型回顾(views 字段已就位)
在第 11 篇我们已经给 SKU 模型添加了 views 字段:
class SKU(models.Model):
# ...
views = models.PositiveIntegerField(default=0, verbose_name='浏览量')
确保该字段存在且迁移已完成。如果没有,请执行 makemigrations 和 migrate。
三、改造商品列表视图,支持排序
打开 apps/products/views.py,找到 sku_list 视图。在已有的搜索和分类过滤之后、分页之前,插入排序逻辑。
def sku_list(request):
skus = SKU.objects.filter(is_active=True).select_related('spu__category').prefetch_related('images')
# 1. 搜索过滤(保持不变)
query = request.GET.get('q', '').strip()
if query:
skus = skus.filter(
Q(name__icontains=query) |
Q(spu__name__icontains=query) |
Q(spu__brand__icontains=query) |
Q(spu__desc__icontains=query)
)
# 2. 分类过滤(保持不变)
category_id = request.GET.get('category_id')
current_category = None
if category_id:
try:
category = Category.objects.get(pk=category_id, is_active=True)
category_ids = [category.id]
children = Category.objects.filter(parent=category, is_active=True)
category_ids.extend(children.values_list('id', flat=True))
skus = skus.filter(spu__category_id__in=category_ids)
current_category = category
except Category.DoesNotExist:
skus = skus.none()
# 3. 排序(新增)
sort_option = request.GET.get('sort', '-create_time') # 默认按发布时间降序
valid_sorts = {
'price_asc': 'price',
'price_desc': '-price',
'sales_desc': '-sales',
'views_desc': '-views',
'-create_time': '-create_time', # 默认
}
if sort_option in valid_sorts:
skus = skus.order_by(valid_sorts[sort_option])
else:
sort_option = '-create_time'
skus = skus.order_by('-create_time')
# 4. 分页(保持不变)
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
paginator = Paginator(skus, 12)
page_number = request.GET.get('page', 1)
try:
page_obj = paginator.page(page_number)
except PageNotAnInteger:
page_obj = paginator.page(1)
except EmptyPage:
page_obj = paginator.page(paginator.num_pages)
# 5. 顶级分类
top_categories = Category.objects.filter(parent__isnull=True, is_active=True).order_by('sort')
context = {
'page_obj': page_obj,
'current_category': current_category,
'top_categories': top_categories,
'query': query,
'sort_option': sort_option, # 传递给模板
}
return render(request, 'products/sku_list.html', context)
四、改造商品列表模板,加入排序选择
编辑 apps/products/templates/products/sku_list.html。
在“共 X 件商品”的右侧或下方添加排序下拉菜单。修改顶部标题栏部分:
<div class="d-flex justify-content-between align-items-center mb-3">
<h3>
{% if query %}
🔎 搜索 “{{ query }}”
{% elif current_category %}
📦 {{ current_category.name }}
{% else %}
🛍️ 全部商品
{% endif %}
</h3>
<div class="d-flex align-items-center">
<span class="text-muted me-3">共 {{ page_obj.paginator.count }} 件</span>
<!-- 排序下拉 -->
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" id="sortDropdown" data-bs-toggle="dropdown">
排序:
{% if sort_option == 'price_asc' %}价格 ↑
{% elif sort_option == 'price_desc' %}价格 ↓
{% elif sort_option == 'sales_desc' %}销量 ↓
{% elif sort_option == 'views_desc' %}浏览量 ↓
{% else %}最新
{% endif %}
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item {% if sort_option == '-create_time' %}active{% endif %}"
href="?sort=-create_time{% if query %}&q={{ query }}{% endif %}{% if current_category %}&category_id={{ current_category.id }}{% endif %}">最新</a></li>
<li><a class="dropdown-item {% if sort_option == 'price_asc' %}active{% endif %}"
href="?sort=price_asc{% if query %}&q={{ query }}{% endif %}{% if current_category %}&category_id={{ current_category.id }}{% endif %}">价格从低到高</a></li>
<li><a class="dropdown-item {% if sort_option == 'price_desc' %}active{% endif %}"
href="?sort=price_desc{% if query %}&q={{ query }}{% endif %}{% if current_category %}&category_id={{ current_category.id }}{% endif %}">价格从高到低</a></li>
<li><a class="dropdown-item {% if sort_option == 'sales_desc' %}active{% endif %}"
href="?sort=sales_desc{% if query %}&q={{ query }}{% endif %}{% if current_category %}&category_id={{ current_category.id }}{% endif %}">销量从高到低</a></li>
<li><a class="dropdown-item {% if sort_option == 'views_desc' %}active{% endif %}"
href="?sort=views_desc{% if query %}&q={{ query }}{% endif %}{% if current_category %}&category_id={{ current_category.id }}{% endif %}">浏览量从高到低</a></li>
</ul>
</div>
</div>
</div>
同时,分页链接也需要保留 sort 参数。将分页中所有的 href 修改为携带当前 sort、query 和 category_id。例如:
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if sort_option %}&sort={{ sort_option }}{% endif %}{% if query %}&q={{ query }}{% endif %}{% if current_category %}&category_id={{ current_category.id }}{% endif %}">« 上一页</a>
其他页码链接同理。
五、实现浏览量统计
打开 apps/products/views.py,找到 spu_detail 视图。进入该页时,将该 SPU 下所有上架 SKU 的 views 字段原子加 1。
from django.db.models import F
def spu_detail(request, spu_id):
spu = get_object_or_404(SPU.objects.prefetch_related('skus__images'), pk=spu_id)
skus = spu.skus.filter(is_active=True)
# 浏览量统计:所有上架的 SKU 浏览量 +1
skus.update(views=F('views') + 1)
# 聚合所有规格维度
specs_data = {}
for sku in skus:
for key, value in sku.specs.items():
if key not in specs_data:
specs_data[key] = set()
specs_data[key].add(value)
specs_list = {k: list(v) for k, v in specs_data.items()}
default_sku = skus.first()
return render(request, 'products/spu_detail.html', {
'spu': spu,
'skus': skus,
'specs': specs_list,
'default_sku': default_sku,
})
skus.update(views=F('views') + 1) 会生成一条 SQL:UPDATE tb_sku SET views = views + 1 WHERE ...,原子操作,不会产生竞态。
注意:在
update之后,skus的 QuerySet 内部缓存已经过期,但我们后面仍遍历skus,因此需要重新获取?实际上update执行后,之前的skus查询集的_result_cache会失效,Django 在遍历时会重新查询,所以没问题。但为了保险,可以在update后重新取一次,比如skus = spu.skus.filter(is_active=True),然后在重新取后再构建 specs 数据。调整如下:
def spu_detail(request, spu_id):
spu = get_object_or_404(SPU.objects.prefetch_related('skus__images'), pk=spu_id)
# 浏览量 +1
SPU.objects.filter(pk=spu_id).update(views=F('views')) # 不,views在SKU上
# 正确写法:
skus_queryset = spu.skus.filter(is_active=True)
skus_queryset.update(views=F('views') + 1)
# 重新获取,因为 update 后之前加载的 skus 对象不带更新后的 views
skus = list(spu.skus.filter(is_active=True).prefetch_related('images'))
# ... 后续 specs 聚合和默认 sku 使用 skus
为避免混淆,我们直接在 update 后重新查询 skus,然后进行 specs 聚合:
def spu_detail(request, spu_id):
spu = get_object_or_404(SPU.objects.prefetch_related('skus__images'), pk=spu_id)
# 浏览量 +1(对所有上架 SKU)
active_skus = spu.skus.filter(is_active=True)
active_skus.update(views=F('views') + 1)
# 重新获取更新后的 SKU 列表
skus = spu.skus.filter(is_active=True).prefetch_related('images')
specs_data = {}
for sku in skus:
for key, value in sku.specs.items():
if key not in specs_data:
specs_data[key] = set()
specs_data[key].add(value)
specs_list = {k: list(v) for k, v in specs_data.items()}
default_sku = skus.first()
# ... render
这样能保证模板中显示的浏览量是最新的。
六、测试流程与输出
启动服务器:
python manage.py runserver
6.1 排序功能测试
访问 http://127.0.0.1:8000/products/list/,默认按最新排序。点击排序下拉,选择“价格从低到高”,URL 变为 /products/list/?sort=price_asc,页面重新加载,商品按价格升序排列。
终端输出:
[25/May/2026 10:05:00] "GET /products/list/?sort=price_asc HTTP/1.1" 200 9123
再选“销量从高到低”,URL 变为 ?sort=sales_desc,列表顺序变化。
测试组合搜索与排序:搜索“Phone”,然后选“价格从高到低”,URL 类似 /products/list/?q=Phone&sort=price_desc。结果正确。
测试分页携带排序参数:点击第2页,URL 包含 &sort=price_desc&page=2,排序保持。
6.2 浏览量统计测试
-
进入某个 SPU 详情页,例如 iPhone 15:
/products/spu/1/ -
在浏览器中查看该页,记录当前显示的销量和库存不变。
-
通过命令行查看数据库,确认浏览量增加:
python manage.py dbshell
sqlite> SELECT id, name, views FROM tb_sku WHERE spu_id = 1;
每次刷新详情页,views 字段递增 1。
刷新 3 次后结果示例:
1|iPhone 15 128GB 午夜色|3
2|iPhone 15 256GB 午夜色|3
5|iPhone 15 128GB 星光色|3
6|iPhone 15 256GB 星光色|3
- 现在返回商品列表页,选择“浏览量从高到低”排序,
?sort=views_desc,多次刷新的商品会排在前面。
终端输出(多次访问):
[25/May/2026 10:10:12] "GET /products/spu/1/ HTTP/1.1" 200 14256
[25/May/2026 10:10:18] "GET /products/spu/1/ HTTP/1.1" 200 14256
[25/May/2026 10:10:22] "GET /products/spu/1/ HTTP/1.1" 200 14256
每次请求都成功,数据库更新无异常。
6.3 高并发下的原子性验证(可选)
可以通过 ab 或简单多线程脚本模拟多个并发请求,但这里不展开。F 表达式保证了原子性,不会有计数丢失。
七、细节优化与注意事项
-
去重统计:当前简单地对每次进入详情页都 +1,同一个用户反复刷会虚增浏览量。如果需要更精确的统计,可以记录用户 cookie 或 session 去重,但会引入额外复杂度。本系列作为入门项目,先采用简单方案,后续优化篇(第 24 篇)可结合 Redis 做去重计数。
-
排序性能:所有排序字段(price, sales, views, create_time)都有索引吗?
sales和views我们没有建索引,但数据量小的时候没问题。第 26 篇会讲解如何为经常排序的字段加索引。 -
默认排序:我们保留了
-create_time作为缺省排序,这是电商常用做法。
八、总结与下集预告
今天我们为商品列表加上了灵活动态的排序功能,并实现了浏览量统计,让商品数据维度更加丰富。主要收获:
-
使用 Django ORM 的
order_by结合 URL 参数实现多维排序; -
使用
F表达式原子更新浏览量,避免竞态; -
排序参数通过模板标签在各链接间正确传递。
现在,用户不仅能搜、能分类,还能按价格、销量、热度排序,商品模块的功能已经基本完善。下一步我们将迎来电商的核心——购物车。下一篇(第 16 篇)我会带大家分析购物车的实现方式、设计模型,并在第 17 篇实现完整的购物车增删改查。!
想了解更多还可以去公众号、今日头条搜索「IT策士」,一起升级 IT 思维 !
本文为《Django 从 0 到 1 打造完整电商平台》系列第 15 篇。