Django+Vue电商比价系统实战:从数据模型到价格快照与接口设计
2026/9/17 12:58:28 网站建设 项目流程

简介:这是一份面向高校期末课程设计的电商比价系统完整源码,基于 Django 后端与 Vue 前端实现,适合正在完成 Web 开发类大作业、希望快速搭建可用系统并理解前后端协作流程的学生。资源共 660 个文件,压缩包仅 6.66MB,包含 265 个 JS、115 个 HTML、90 个 CSS 前端资源以及 25 个 Python 后端模块,另有 JSON 配置、图片与字体文件,目录层次清晰,便于按功能模块对照学习。已有 1368 人浏览学习。源码内附详细操作说明与代码注释,按照文档配置 MySQL 数据库连接即可运行,降低了新手搭建环境的上手门槛。尤其值得关注的是,资源提供了完整的页面模板、业务逻辑与数据交互示例,覆盖商品信息展示、价格比较与搜索等典型场景。无论是用于期末答辩、作为课程设计参考,还是学习 Django 与 Vue 的整合方式,这套源码都能提供直接可用的框架与排错思路,是一份高性价比的参考工程。

1. 期末大作业不是跑通 demo,是讲清楚比价系统的数据链路

期末大作业选择基于 django 后端 + vue 前端的电商比价系统,眼光不错。这个项目最大的价值在于它同时覆盖了后端爬虫采集、关系型数据建模、API 接口设计、前端组件通信和部署上线,几乎是一个微型商业系统该有的骨架都有了。但说实话,从网上下载的“期末大作业基于django后端+vue前端的电商比价系统源码.zip”往往要么跑不起来,要么跑起来以后换个商品搜索就报错,原因大多出在数据模型没有为“比价”这个核心诉求设计好,比如价格字段类型选错、商品唯一键缺失、采集频率过高被反爬拦截。本文不讨论 demo 怎么渲染,而是拆解一个能稳定对比多个平台价格数据的系统该怎样搭建,让这份源码在你的机器上不只属于压缩包,而是真正变成一台自己的比价引擎。

2. 比价系统的数据模型与采集层设计:先定快照,再写爬虫

2.1 商品表与价格快照表为什么要拆分

很多人写电商比价系统时,第一版会把商品信息和价格字段放在同一张表里,比如 product_id、title、price、shop_name 全塞一起。这在表里只有一条商品记录时看不出问题,但一旦加入“价格历史”或者“多个平台对比”需求,改动就要推翻重来。比价系统最核心的设计原则是:商品基础属性与价格快照分离

具体来说,商品表只保存不会频繁变化的信息,比如平台内部编码(如淘宝的 num_iid)、商品标题、主图 URL、品牌、规格参数。而每次采集到的价格、优惠价、销量、库存状态则单独存放为一条条快照记录,带时间戳和来源平台标识。这样做的直接收益有两点。第一,能回答“这个商品上周三多少钱”,从而画出价格趋势折线图,这是电商比价系统的卖点之一,也是答辩时最容易加分的功能模块。第二,当多个平台抓取统一商品时,不同平台的名称、货号可能不一致,你需要靠商品表里的一个“业务唯一键”来归并,而不是靠价格字段来猜测。

下面是一个在 django 中推荐的 model 设计示例,这份结构是拿到源码后第一个要核对的地方。如果源码里 product 表带 price 字段,那说明它还停留在演示级别。

from django.db import models class Product(models.Model): """商品主表:一个商品在平台上的唯一身份""" # platform_code 与 platform_item_id 联合唯一,防止重复入库 platform_code = models.CharField(max_length=20, db_index=True, help_text="平台标识,如 taobao/jd/pdd") platform_item_id = models.CharField(max_length=64, help_text="平台上的商品ID") title = models.CharField(max_length=512) main_image_url = models.URLField(max_length=1024, blank=True) brand = models.CharField(max_length=128, blank=True, default="") class Meta: # 这是去重的兜底约束,很多源码漏了这行导致数据翻倍 unique_together = (("platform_code", "platform_item_id"),) class PriceSnapshot(models.Model): """每一次抓取动作的结果快照,只追加不更新""" product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="prices") price = models.DecimalField(max_digits=10, decimal_places=2, help_text="实际到手价") original_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) sale_count = models.IntegerField(default=0, help_text="30天付款人数或销量,各平台含义不同") stock_status = models.BooleanField(default=True, help_text="是否有货") captured_at = models.DateTimeField(auto_now_add=True, db_index=True) class Meta: ordering = ("-captured_at",)

说明一下参数设计的理由。platform_codeplatform_item_idunique_together,是为了防止同一个商品被多个采集任务重复写入。如果没有这个约束,你跑两次爬虫,数据库里就会出现两条同平台同 ID 的商品,比价列表里同一个商品重复展示,前端v-for渲染时 key 就很难选。DecimalField而不是FloatField是为了价格精度,浮点数存 19.99 可能出现 19.9900001 的情况,前端展示时要转字符串处理,多一步麻烦。

2.2 采集任务的执行策略与反爬规避

数据模型定好以后,接下来要处理的就是“数据从哪里来”。期末项目的常见做法是直接用requests请求各平台页面然后用BeautifulSoup解析,这在开发环境没问题,但要考虑两点。其一是目标平台结构变动频繁,写死 CSS 选择器的话,代码可用寿命很短。其二是请求频率,如果用一个for循环在 10 秒内请求 30 个商品,IP 基本会被暂时限制。

合理的设计是把采集任务做成可配置的队列,而非硬编码在视图函数里。给出一段采集调度的伪代码思路:

# tasks.py 假设项目使用 django-celery-beat 做周期调度 import requests from bs4 import BeautifulSoup from .models import Product, PriceSnapshot def crawl_single_product(platform_code: str, item_id: str): """采集单商品,失败时抛出异常由 Celery 记录日志""" url = build_platform_url(platform_code, item_id) headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://www.baidu.com/", } resp = requests.get(url, headers=headers, timeout=10) resp.raise_for_status() # 这里换成对应平台的解析逻辑 soup = BeautifulSoup(resp.text, "html.parser") price_text = soup.select_one(".price").text title_text = soup.select_one(".title").text.strip() product, _ = Product.objects.get_or_create( platform_code=platform_code, platform_item_id=item_id, defaults={"title": title_text}, ) PriceSnapshot.objects.create( product=product, price=float(price_text.replace("¥", "")), original_price=None, sale_count=0, stock_status=True, )

这段代码里有几个参数值得逐一说明。timeout=10是必须设置的,比价系统挂着跑的时候,如果某个平台响应超时,不设置 timeout 会让爬虫卡死,整个 Celery worker 资源被占满。headersReferer设置成百度首页是惯用技巧,因为部分电商平台对空 Referer 有校验,而对搜索引擎来源相对宽松。get_or_createdefaults参数只在创建时生效,已存在的商品不会覆盖标题,这就保证了多次采集后,商品标题不会因为页面文案微调而频繁更新。

提示:不要在这个阶段去研究破解验证码或模拟浏览器指纹。期末项目的时间成本有限,能在robots.txt允许范围内完成 2 到 3 个平台的比价展示就已经超过绝大多数作业了。后面提到的模拟登录和复杂反爬属于加分项,不属于必选项。

3. Django REST Framework 接口层:比价系统的查询与排序

3.1 用 DRF 视图集快速搭建只读接口

采集到的数据最终要提供给 Vue 前端调用,Django 端需要暴露两个核心接口:商品列表接口(带搜索和排序)和商品详情接口(带历史价格)。如果你的源码还在用JsonResponse手动拼数据,建议改成django-rest-frameworkReadOnlyModelViewSet,它免费给你了分页、过滤、限流和可浏览 API 文档,答辩演示时也比较好看。

一个典型实现如下:

# views.py from rest_framework import viewsets, filters from django_filters.rest_framework import DjangoFilterBackend from .models import Product from .serializers import ProductListSerializer, ProductDetailSerializer class ProductViewSet(viewsets.ReadOnlyModelViewSet): """只读视图集,防止有人通过 POST 接口篡改比价数据""" queryset = Product.objects.prefetch_related("prices").all() filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] search_fields = ["title", "brand"] ordering_fields = ["latest_price"] ordering = ["-id"] def get_serializer_class(self): if self.action == "list": return ProductListSerializer return ProductDetailSerializer def get_queryset(self): # 每个商品只取最新一条价格快照,避免列表页查询过慢 from django.db.models import Subquery, OuterRef latest = PriceSnapshot.objects.filter( product_id=OuterRef("pk") ).order_by("-captured_at").values("price")[:1] return super().get_queryset().annotate(latest_price=Subquery(latest))

这里有个容易忽略的点:列表页只需要展示商品名称和最低/最新价格,没必要把PriceSnapshot全量序列化出去。上面代码用Subqueryannotate,每个商品只关联一条最新价格数值,这在列表数据量大时能显著减少 JSON 体积。很多源码把列表页和详情页用同一个序列化器,导致列表页返回一两百条价格历史,前端渲染卡顿,这往往不是 Vue 代码的问题,而是接口给了太多数据。

3.2 按价格排序时遇到空值和类型错误的处理

比价系统里有一个高频需求:价格从低到高排序。前端浏览器地址可能长这样:/products?ordering=price。但需要注意,如果某些商品存在缺货或价格未抓到的情况,latest_price会为NULL,按它排序时,数据库默认把空值排在最前或最后,不同数据库行为不一致。SQLite 里NULL默认排在最前面,PostgreSQL 则相反,这会让前端展示的“最低价”列表出现异常。

推荐的做法是在序列化器层面做一层加工,给前端永远返回一个可排序的数值字段:

# serializers.py from rest_framework import serializers from .models import Product, PriceSnapshot class ProductListSerializer(serializers.ModelSerializer): price = serializers.SerializerMethodField() class Meta: model = Product fields = ["id", "title", "main_image_url", "brand", "price"] def get_price(self, obj): # obj.latest_price 是上一个视图集里 annotate 出来的字段 if getattr(obj, "latest_price", None) is None: return None return float(obj.latest_price)

注意到这里没有把price声明为DecimalField,而是用SerializerMethodField手动转成float。原因是前端 JavaScript 处理大数字时可能丢失精度,但价格最多到百万级,float 足够。真正的坑在于 Django 的DecimalField序列化后是一个字符串,比如"19.99",Vue 拿到后如果直接parseFloat处理还好,如果直接用===比较两个价格,字符串和数字就会出问题。统一由后端转成float,前端就少一个类型判断分支。

3.3 缓存比价结果,不要让数据库被重复查询打爆

比价系统的数据天然有“时延容忍”特性。同一个商品,5 分钟前的价格和现在的价格,在用户看来几乎没有区别,因此非常适合加缓存。常见的做法是使用 Django 内置的cache_page装饰器,按 URL 维度缓存接口响应:

from django.views.decorators.cache import cache_page from django.utils.decorators import method_decorator from rest_framework.viewsets import ReadOnlyModelViewSet @method_decorator(cache_page(60 * 5), name="dispatch") class ProductViewSet(ReadOnlyModelViewSet): pass

这里cache_page(60 * 5)是 5 分钟过期。参数不必设得太长,因为比价系统的核心价值在于“新”。假设 5 分钟内平台大促价格变了,用户看到旧价格可能不会生气,但看到一天前的价格就会质疑数据可靠性。需要注意加了cache_page之后,Django 会缓存整个 HTTP 响应,包括Set-Cookie之类的头。如果后面接入用户登录和收藏功能,就不能对整个视图缓存,而应该改为只缓存查询集结果,示例代码如下:

from django.core.cache import cache def get_latest_prices(product_id: int): cache_key = f"product_latest_price_{product_id}" cached = cache.get(cache_key) if cached is not None: return cached try: price = PriceSnapshot.objects.filter(product_id=product_id).latest("captured_at").price except PriceSnapshot.DoesNotExist: price = None cache.set(cache_key, price, timeout=300) return price

4. Vue 前端核心页面:从商品列表到比价详情的实现路径

4.1 项目基础结构与路由设计

拿到了一个大作业源码,第一步看前端目录,确认用的是 Vue 3 还是 Vue 2。两者在生命周期、v-model绑定和数据响应式原理上有本质区别,如果你在 Vue 3 项目里写 Vue 2 的created()钩子和this.$emit,代码会静默失败。这年头新项目建议直接用 Vue 3 + Vite + Element Plus,组件库生态对中后台系统覆盖最全,尤其是表格和表单组件能省不少事。

前端路由设计一般是两级结构。第 1 级是站点头部和搜索框,第 2 级是页面切换。用 Vue Router 的createWebHistory模式时要注意,生产环境下需要后端配合做 history fallback,否则刷新一个子路由页面会 404。Django 这边的兜底方案是在urls.py最后加一个通配路由:

from django.contrib import admin from django.urls import path, re_path from django.views.generic import TemplateView urlpatterns = [ path("admin/", admin.site.urls), path("api/", include("price_api.urls")), # 前端 history 路由刷新兜底,必须放在最后 re_path(r"^.*$", TemplateView.as_view(template_name="index.html")), ]

前端路由参数有个容易踩的坑。从商品列表页跳详情页,你可能会想这样带参:

// router/index.js const router = createRouter({ history: createWebHistory(), routes: [ { path: "/product/:id", name: "ProductDetail", component: ProductDetail }, ], });

跳转时可以写router.push({ name: "ProductDetail", params: { id: item.id } })。这里要注意params只在name方式跳转时有效,如果用path: "/product/" + item.id这种写法就无所谓。更需要注意的场景是:用户从详情页复制链接发给别人,新用户打开时没有携带任何搜索状态,所以详情页的数据请求不要依赖前端 store 里的临时状态,必须在onMounted里通过route.params.id重新调接口。

4.2 搜索防抖与比对表格

Vue 前端的搜索框是比价系统的脸面。如果监听input事件每次都发请求,用户输入一个 10 个字符的型号,后端可能收到 10 次请求,其中 9 次是无意义的。正确的做法是加lodash.debounce或者自己写防抖函数,等待时间设在 300 毫秒:

// components/SearchBar.vue import { ref, watch } from "vue"; import { useRouter } from "vue-router"; const keyword = ref(""); const router = useRouter(); let timer = null; watch(keyword, (val) => { clearTimeout(timer); timer = setTimeout(() => { if (val.trim()) { router.push({ path: "/products", query: { search: val.trim() } }); } }, 300); });

这段代码的核心逻辑是watch监听输入变化,clearTimeout取消上一次的定时器,保证只有用户停顿 300 毫秒后才会触发跳转。请求参数用query携带,这样刷新页面后浏览器 URL 里仍有搜索词,刷新不会丢失结果。后端 Django 的SearchFilter会自动读取?search=参数,不需要自己手动解析。

价格对比表格推荐用 Element Plus 的el-table,这个组件自带排序、多列固定和空数据处理。比价核心列设计建议如下:

列名字段来源后端字段名备注
商品名称Product.titletitle超长省略显示
平台Product.platform_codeplatform_code前端映射成中文
当前价格最新快照price按价格排序的主要字段
历史最低价快照查询取 minmin_price后端单独计算
更新时间最新快照captured_at展示“x 分钟前”

平台名映射不要在后端硬编码,前端拿platform_code后做一次本地字典替换即可。比如{ taobao: "淘宝", jd: "京东", pdd: "拼多多" }。这样做的好处是后端接口更通用,以后接入新平台时前端改一行代码就行。

提示:如果你拿到的源码前后端没有分离,也就是 Django 直接渲染index.html模板,那么在改造时要关注静态资源路径。Vite 默认配置base: "/",部署到 Django 的static/目录下需要改成base: "/static/",否则 CSS 和 JS 全走 404。

5. 比价系统的价格历史、缓存与埋点统计进阶

比价系统做完基本查询和对比功能,还能往深了做三个方向,这三个方向投入产出比最高:价格历史曲线、缓存过期策略、点击跳转埋点。

价格历史曲线是真正体现“比价”两个字的功能。前端用 ECharts 展示PriceSnapshot序列,接口设计时可以按商品 ID 返回最近 30 天的价格变化数组。需要注意数据库查询如果直接filter(product_id=xxx)然后全量返回,数据量大时 JSON 里会有大量重复的时间戳,合理的做法是采样后返回:

# 按天聚合最低价,减少点位数 from django.db.models import Min from django.db.models.functions import TruncDate daily_prices = ( PriceSnapshot.objects .filter(product_id=product_id, captured_at__gte=timezone.now() - timedelta(days=30)) .annotate(day=TruncDate("captured_at")) .values("day") .annotate(min_price=Min("price")) .order_by("day") )

这样前端折线图点位最多 30 个,渲染流畅且能看出降价趋势。

缓存策略方面,除了上一章提到的接口缓存,还要注意一个边界场景:当爬虫更新了价格后,缓存不会立即失效。解决思路是主动删除缓存 key,在创建PriceSnapshot时顺手清掉相关商品的价格缓存:

# signals.py from django.db.models.signals import post_save from django.dispatch import receiver from .models import PriceSnapshot from django.core.cache import cache @receiver(post_save, sender=PriceSnapshot) def clear_product_price_cache(sender, instance, **kwargs): cache.delete(f"product_latest_price_{instance.product_id}")

这个 signal 的作用很直接:每次有新快照写入,就清除旧缓存,下一次查询会强制回源数据库获取最新价。

最后是点击跳转埋点。电商比价系统明面上是比价,实际商业模式大概率是靠导购佣金获利。前端在“去购买”按钮点击时,不要直接window.open(item.buy_url),而是先请求一次后端 API,后端记录下点击的商品 ID、平台、时间、用户 IP,然后返回真实的跳转地址。这样写的好处有两个,一是以后换推广链接不需要重新发版,二是有数据能证明这个系统给平台带去了多少流量。

埋点接口代码:

# views.py from rest_framework.decorators import api_view from rest_framework.response import Response @api_view(["POST"]) def track_click(request): product_id = request.data.get("product_id") platform = request.data.get("platform") # 记录到日志:ts, product_id, platform, user_ip logger.info("click product=%s platform=%s ip=%s", product_id, platform, request.META.get("REMOTE_ADDR")) # 从数据库取真实跳转地址,这里省略具体实现 return Response({"redirect_url": "/go/" + str(product_id)})

这个接口不需要返回完整商品信息,只返回一个内部短链。前端拿到redirect_url后再window.open,整个过程对用户无感知。如果你不想引入额外的短链表,也可以直接返回外部 URL,但那样埋点就没法记录到完整的中间状态了。逐层权衡下来,短链方式比直连多一些代码量,但给后续运营留了口子,是值得做的。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询