Django 从 0 到 1 打造完整电商平台:商品排序与浏览量统计

0 阅读9分钟

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


前面几篇我们让用户能按分类浏览商品、用关键词搜索商品,商品发现的能力已经基本齐了。但还有一个关键体验没做:排序。用户在列表页不能只按“最新发布”看,还需要能按“价格从低到高”、“销量最高”、“最多人在看”来筛选,这样才像一个成熟的电商平台。

今天我们就一口气完成两件事:

  1. 商品排序功能:价格升降、销量、浏览量、综合排序;

  2. 浏览量统计:每次进入商品详情页,该 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='浏览量')

确保该字段存在且迁移已完成。如果没有,请执行 makemigrationsmigrate


三、改造商品列表视图,支持排序

打开 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 修改为携带当前 sortquerycategory_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 浏览量统计测试

  1. 进入某个 SPU 详情页,例如 iPhone 15:/products/spu/1/

  2. 在浏览器中查看该页,记录当前显示的销量和库存不变。

  3. 通过命令行查看数据库,确认浏览量增加:

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
  1. 现在返回商品列表页,选择“浏览量从高到低”排序,?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. 去重统计:当前简单地对每次进入详情页都 +1,同一个用户反复刷会虚增浏览量。如果需要更精确的统计,可以记录用户 cookie 或 session 去重,但会引入额外复杂度。本系列作为入门项目,先采用简单方案,后续优化篇(第 24 篇)可结合 Redis 做去重计数。

  2. 排序性能:所有排序字段(price, sales, views, create_time)都有索引吗?salesviews 我们没有建索引,但数据量小的时候没问题。第 26 篇会讲解如何为经常排序的字段加索引。

  3. 默认排序:我们保留了 -create_time 作为缺省排序,这是电商常用做法。


八、总结与下集预告

今天我们为商品列表加上了灵活动态的排序功能,并实现了浏览量统计,让商品数据维度更加丰富。主要收获:

  • 使用 Django ORM 的 order_by 结合 URL 参数实现多维排序;

  • 使用 F 表达式原子更新浏览量,避免竞态;

  • 排序参数通过模板标签在各链接间正确传递。

现在,用户不仅能搜、能分类,还能按价格、销量、热度排序,商品模块的功能已经基本完善。下一步我们将迎来电商的核心——购物车。下一篇(第 16 篇)我会带大家分析购物车的实现方式、设计模型,并在第 17 篇实现完整的购物车增删改查。!

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


本文为《Django 从 0 到 1 打造完整电商平台》系列第 15 篇。