Django+Vue电商比价系统:价格聚合、数据库设计与可视化实战
2026/9/17 1:24:57 网站建设 项目流程

简介:这是一套面向电商系统课程设计与期末大作业场景的完整源码工程,采用 Django 后端与 Vue 前端分离架构,适合具备 Python/Web 基础、希望快速完成高完成度比价系统的学生参考。压缩包共 660 个文件,大小约 6.66MB,其中 265 个 js、115 个 html、90 个 css 等前端文件覆盖页面布局、交互逻辑与后台管理界面,25 个 py 文件对应 Django 工程核心代码,另有图片、字体、配置文件及操作说明文档,目录结构清晰,方便按模块定位学习。目前已有 1368 人查看,说明其在同类作业场景中有一定参考价值。源码内置详细操作说明与代码注释,并附有数据库绑定参数修改位置,可帮助使用者快速跑通前后端联调;同时保留了用户、商品、比价等模块的典型实现思路,适合作为课程设计答辩、期末项目演示或入门实战练手的蓝本。

1. 期末项目里的Django+Vue比价系统到底在比什么

手头这个期末大作业源码是一个前后端分离的电商比价系统:后端用 Django 提供商品、价格和搜索的 REST API,前端用 Vue 渲染搜索页、价格对比卡片和历史折线图。它解决的并不是“爬多少商品”,而是“同一件商品在不同平台卖多少钱、价格怎么变”——这类数据天然适合课程设计,因为业务逻辑清晰、数据模型好论证、页面效果也直观。源码里已经带了 Django 的settings.pyPriceCompare项目结构、前端静态资源(bootstrap、layui、style.css 等)以及注释,适合想快速跑通一个完整前后端项目的人。下面我会按“数据模型怎么建→接口怎么出→前端怎么画→跑通后怎么维护”的顺序拆开讲,重点放在比价逻辑和数据库绑定这两个容易踩坑的地方。

2. Django后端比价模型与价格聚合接口设计

比价系统的核心不是页面,而是商品与价格的关系。如果只在商品表里存一个“最低价”字段,那每次价格变动都得更新整行,且无法回答“一个月前这个商品多少钱”。所以常见做法是把商品信息和价格快照拆成两张表,用外键关联。

2.1 商品与价格表结构:为什么价格单独建表

PriceCompare应用下,我建议沿用 Django 的 model 设计思路,把商品基础信息和价格历史分开,类似这样:

from django.db import models from django.utils import timezone class Product(models.Model): title = models.CharField('商品标题', max_length=255) platform = models.CharField('平台', max_length=50) # 京东、淘宝、天猫等 product_url = models.URLField('商品链接', max_length=500) sku_code = models.CharField('SKU编号', max_length=100, blank=True) created_at = models.DateTimeField('创建时间', auto_now_add=True) class Meta: db_table = 'product' ordering = ['-created_at'] def __str__(self): return f'{self.platform}: {self.title[:30]}' class PriceRecord(models.Model): product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='prices') price = models.DecimalField('价格', max_digits=10, decimal_places=2) recorded_at = models.DateTimeField('采集时间', default=timezone.now) class Meta: db_table = 'price_record' ordering = ['recorded_at']

PriceRecord通过外键指向Product,这样同一个商品会有多条价格记录,查询时按recorded_at排序,就能得到历史价格序列。DecimalField保存价格用了两位小数,后端计算时不会出现浮点数精度问题。db_table显式指定表名,避免 Django 自动加的app_label前缀让 SQL 看不懂。

这里需要注意一个点:同一个商品如果在多个平台都抓到了,是建模成Product里的多条记录,还是用Product+平台字段表示同一商品的不同渠道?期末作业一般选后者,因为比价通常指“同一个 SKU 在不同平台的价格差异”,而不是“不同商品的价格差异”。如果你想做更严格的同款匹配,可以给Product增加一个group_id字段,把不同平台的同款归到一组。

2.2 比价逻辑:同款商品的匹配与最低价计算

拿到原始数据后,第一个要处理的问题是“怎么知道京东上的 A 和淘宝上的 B 是同一款”。对于课程设计,不引入复杂的 NLP 算法,直接用标题关键词 + SKU 编号匹配就够用。比如取商品标题里的一组核心词做去空格、去品牌词后的哈希值作为match_key

import re import hashlib def gen_match_key(title, brand='', model=''): # 去掉标点和空格,只保留中英文和数字 clean = re.sub(r'[^\w\u4e00-\u9fa5]', '', f'{brand}{model}{title}') # 品牌和型号都放进 key,避免不同型号被误判为同款 return hashlib.md5(clean.encode('utf-8')).hexdigest()

生成match_key后,在Product模型里加一个字段保存它。计算某一组同款商品的最低价时,先按match_key分组,再取每个PriceRecord的最新价做聚合:

from django.db.models import OuterRef, Subquery latest_price = PriceRecord.objects.filter( product=OuterRef('pk') ).order_by('-recorded_at').values('price')[:1] products = Product.objects.annotate( current_price=Subquery(latest_price) ).filter(current_price__isnull=False).order_by('current_price')

这段 SQL 对应的逻辑是:对每个商品找出它最新一条价格记录,再把所有商品按最新价升序排列,排在第一位的就是当前最低价渠道。Subquery配合OuterRef是 Django 里做“取每组最新一条”的经典写法,比在 Python 循环里逐条查询效率高得多,商品数量到几百条时体感差异非常明显。

2.3 接口层:ViewSet与序列化器

前端要和后端打交道,需要把查询结果序列化成 JSON。用 Django REST Framework 的ModelViewSet能少写很多重复代码。

from rest_framework import viewsets, serializers from .models import Product, PriceRecord class PriceRecordSerializer(serializers.ModelSerializer): recorded_at = serializers.DateTimeField(format='%Y-%m-%d %H:%M:%S') class Meta: model = PriceRecord fields = ['price', 'recorded_at'] class ProductSerializer(serializers.ModelSerializer): prices = PriceRecordSerializer(many=True, read_only=True) current_lowest = serializers.SerializerMethodField() class Meta: model = Product fields = ['id', 'title', 'platform', 'product_url', 'current_lowest', 'prices'] def get_current_lowest(self, obj): latest = obj.prices.order_by('-recorded_at').first() return latest.price if latest else None class ProductViewSet(viewsets.ModelViewSet): serializer_class = ProductSerializer def get_queryset(self): qs = Product.objects.prefetch_related('prices') keyword = self.request.query_params.get('q') if keyword: qs = qs.filter(title__icontains=keyword) return qs

SerializerMethodField用来动态生成当前最低价,prefetch_related('prices')是关键,它会把一个商品的所有价格记录一次性查出来,避免每查一个商品就发一条 SQL。接口路径可以使用 DRF 的DefaultRouter注册:

from rest_framework.routers import DefaultRouter router = DefaultRouter() router.register(r'products', ProductViewSet) urlpatterns = [] + router.urls

这样/api/products/?q=手机就能返回包含标题、平台、最新价格和历史价格的 JSON。表里的参数含义如下。

参数类型说明
qstring标题模糊搜索关键词
pageintDRF 默认分页页数,需在 settings 中配置默认分页
orderingstring按指定字段排序,如ordering=current_lowest

current_lowest只读,不允许前端写入,所以实现里放到了SerializerMethodField。分页参数如果没配置,DRF 会直接返回全量列表,数据量大时前端渲染会卡顿,建议在REST_FRAMEWORK配置里加上PAGE_SIZE = 20

3. Vue前端如何展示价格差与历史趋势

3.1 项目结构与依赖:从 npm install 到 vue.config.js 代理

打开前端目录,常见结构是src/viewssrc/apisrc/router。后端运行在 8000 端口,前端开发服务器运行在 8080 端口,直接让 Vue 去请求 Django 接口会碰见跨域问题。最省事的方法是在vue.config.js里配置代理,把/api转发到后端:

module.exports = { devServer: { port: 8080, proxy: { '/api': { target: 'http://127.0.0.1:8000', changeOrigin: true, pathRewrite: { '^/api': '/api' } } } }, lintOnSave: false }

changeOrigin: true会修改请求头中的Host,避免后端校验来源时拒绝请求;pathRewrite在这里不需要重写路径,因为后面 DRF 路由本身就挂在/api/products下。运行npm install之前,建议先确认 Node 版本不低于 16,否则部分依赖(比如sass-loader)会报Cannot find module的错误。

3.2 商品搜索页与价格对比卡片的实现

搜索页的逻辑很直接:输入关键词,调用接口,把返回的商品列表渲染成对比卡片。卡片上展示平台、当前最低价和历史价格条目数。这里我一般把接口请求封装到api/product.js里:

import axios from 'axios' export function fetchProducts(params) { return axios.get('/api/products/', { params }) }

组件里这样使用:

<template> <div> <input v-model="keyword" placeholder="输入商品关键词" @keyup.enter="search" /> <div class="product-card" v-for="item in list" :key="item.id"> <h3>{{ item.title }}</h3> <span class="platform">{{ item.platform }}</span> <span class="price">¥{{ item.current_lowest }}</span> <a :href="item.product_url" target="_blank">查看链接</a> </div> </div> </template> <script> import { fetchProducts } from '../api/product' export default { data() { return { keyword: '', list: [] } }, methods: { async search() { if (!this.keyword) return const res = await fetchProducts({ q: this.keyword }) this.list = res.data.results || res.data } } } </script>

res.data.results对应 DRF 启用分页后的返回结构,没启用分页时后端会直接返回数组,所以这里做了兜底。课程演示时建议在REST_FRAMEWORK里设置PAGE_SIZE = 50,避免搜索结果过多页面闪烁。卡片样式直接用 bootstrap 或 layui 的栅格类就行,源码里已经带了现成 CSS。

3.3 历史价格折线图:用 ECharts 绑定后端数据

比价系统比“商品列表”更有价值的就是历史价格曲线。前端拿到item.prices数组后,把recorded_at作为 X 轴,price作为 Y 轴,交给 ECharts 绘制。

先安装依赖:

npm install echarts --save

然后在组件里引入并按需初始化:

<template> <div ref="chart" style="height: 300px;"></div> </template> <script> import * as echarts from 'echarts' export default { props: { priceRecords: { type: Array, default: () => [] } }, mounted() { this.initChart() }, methods: { initChart() { const chart = echarts.init(this.$refs.chart) const times = this.priceRecords.map(r => r.recorded_at) const prices = this.priceRecords.map(r => Number(r.price)) chart.setOption({ xAxis: { type: 'time', name: '采集时间' }, yAxis: { type: 'value', name: '价格' }, tooltip: { trigger: 'axis' }, series: [{ type: 'line', data: times.map((t, i) => [t, prices[i]]), smooth: true }] }) } } } </script>

xAxis.type: 'time'会让 ECharts 自动识别时间字符串并做刻度优化,比用category轴干净很多。如果后端返回的recorded_at已经是YYYY-MM-DD HH:mm:ss格式,可以直接传给 ECharts,不需要额外Date.parse。调试时遇到折线图只显示一个点,多半是价格记录只有一条,或者recorded_at字段因为序列化格式不对被 ECharts 判定成了无效时间。检查这两点基本能解决 90% 的图表为空白问题。

4. 数据库绑定、跨域调试与依赖安装的常见坑

4.1 MySQL 配置与建库命令

源码里的settings.py已经给出了 MySQL 连接参数,但直接运行前要先建库。MySQL 命令行里执行:

CREATE DATABASE pricecompare DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

utf8mb4必须指定,否则商品标题里有 Emoji 或生僻字时,Django 写库会报Incorrect string value错误。然后修改PriceCompare/settings.py里的DATABASES字典:

DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'pricecompare', 'HOST': '127.0.0.1', 'PORT': 3306, 'USER': 'root', 'PASSWORD': '142857', 'OPTIONS': { 'charset': 'utf8mb4', } } }

注意PORT是整数,不写引号;OPTIONS里的charset保证连接串也是 utf8mb4。改完后执行:

python manage.py makemigrations python manage.py migrate

如果系统里没有mysqlclientmigrate会直接报ModuleNotFoundError: No module named 'MySQLdb'。解决方式是装pymysql并在项目的__init__.py里加上pymysql.install_as_MySQLdb(),或者在虚拟环境里编译安装mysqlclient。更省事的办法是直接改用 SQLite 做本地调试,只需要把ENGINE改成django.db.backends.sqlite3,再把NAME指向db.sqlite3,这样期末答辩演示时不会受 MySQL 服务启动状态影响。

4.2 requirements.txt 中关键依赖的版本陷阱

pip install -r requirements.txt常见卡壳在 Django、DRF 和mysqlclient的版本组合。我的建议是:

Django==3.2.18 djangorestframework==3.14.0 mysqlclient==2.1.1 django-cors-headers==3.14.0

Django 3.2 是 LTS 版本,对 Python 3.7 到 3.10 都友好;DRF 3.14 与 Django 3.2 兼容性最好。如果你本机是 Python 3.11 以上,Django 3.2 可能会出现asyncio相关的弃用警告,但不影响跑通。真要避免一切兼容性争议,可以换成 Django 4.2 + DRF 3.15,但mysqlclient必须用 2.1.1 之后的版本,否则编译报错。

django-cors-headers只在你不打算走vue.config.js代理而选择前后端直连时才有用。它需要加到INSTALLED_APPSMIDDLEWARE里:

INSTALLED_APPS = [ ... 'corsheaders', ] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', ... ] CORS_ALLOWED_ORIGINS = [ 'http://localhost:8080', 'http://127.0.0.1:8080', ]

这里有个容易混淆的地方:如果你已经在前端配置了proxy,同时后端又开了CORS_ALLOWED_ORIGINS,两者叠加并不会出错,但调试时出现“请求成功但浏览器拦截”的情况,优先 Check 是不是代理没生效,而不是急着开 CORS。

4.3 前端 devServer 代理和后端 CORS 二选一

实际开发时我一般只用一种跨域方案。用 Vue devServer 代理,浏览器请求的是http://localhost:8080/api/products/,代理服务器再去请求后端,同源策略被绕过,后端也不需要做任何 CORS 配置。用 CORS 方案,浏览器直接请求http://127.0.0.1:8000/api/products/,后端通过响应头允许前端访问。

两种方案的关键区别在于生产环境:打包后的静态文件放在 Nginx 里,如果 Nginx 配置了转发/api到 Django,那么 devServer 代理在生产环境中不生效,只能靠 Nginx 转发。所以课程设计里最稳的路径是:开发时用代理,部署时用 Nginx。前端打包命令:

npm run build

生成的dist/目录可以交给 Django 托管。在 Django 的urls.py里加一个静态文件兜底路由,把 Vue 打包出的index.html返回给非/api的请求,这样才能实现“一个服务跑完整套系统”。

4.4 Django Admin 数据核对流程

比价数据对不对,直接看 Django Admin 最直观。把ProductPriceRecord注册到admin.py

from django.contrib import admin from .models import Product, PriceRecord @admin.register(Product) class ProductAdmin(admin.ModelAdmin): list_display = ['id', 'title', 'platform', 'product_url'] search_fields = ['title'] list_filter = ['platform'] @admin.register(PriceRecord) class PriceRecordAdmin(admin.ModelAdmin): list_display = ['product', 'price', 'recorded_at'] ordering = ['-recorded_at']

要使用 Admin,需要先创建超级管理员:

python manage.py createsuperuser

登录/admin后,你可以手动加一条商品和几笔价格记录,再切回前端页面看折线图有没有变化。这个操作在答辩前特别有用——不用依赖爬虫,就能给评委展示完整的数据流。注意 Admin 默认的列表页每页只有 10 条,判重一定用list_filter按平台过滤,别在一页里翻。

5. 让比价更可信:价格去重与定时刷新技巧

比价系统的数据可信度取决于两点:价格记录不冗余、价格采样频率合理。期末作业能做到“同一天同一商品只保留一条价格记录”,就已经比很多随手爬到的数据专业了。

去重逻辑可以在PriceRecord.save()里做,也可以建 unique 约束。我比较推荐后者,因为约束比业务代码可靠:

class PriceRecord(models.Model): ... product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='prices') price = models.DecimalField(max_digits=10, decimal_places=2) recorded_at = models.DateTimeField(default=timezone.now) class Meta: constraints = [ models.UniqueConstraint( fields=['product', 'recorded_at'], name='unique_product_price_per_time' ) ]

UniqueConstraint会让同一商品在同一秒只能有一笔价格记录。搭配recorded_at改成按天归一化,可以避免因多次采集导致折线图上的横坐标过于拥挤。归一化方法很简单:

from django.utils import timezone recorded_at = timezone.now().replace(minute=0, second=0, microsecond=0)

这样每小时只留一个点,折线图看起来更平稳。如果你想把粒度改成天,就把hour=0也 replace 掉。

定时刷新是比价系统的进阶功能。Django 里不推荐用apscheduler,而是用原生的 management command 配合系统 crontab。在应用下建management/commands/update_prices.py

from django.core.management.base import BaseCommand from price_app.services import crawl_and_save class Command(BaseCommand): help = '抓取最新价格并写入价格记录' def handle(self, *args, **options): product_count, record_count = crawl_and_save() self.stdout.write( self.style.SUCCESS( f'更新 {product_count} 个商品,写入 {record_count} 条价格记录' ) )

然后执行:

python manage.py update_prices

Linux 上配合 crontab 每天凌晨跑一次:

0 3 * * * cd /path/to/project && /usr/bin/python3 manage.py update_prices >> /tmp/price_update.log 2>&1

crawl_and_save里面最需要处理的是价格变化幅度。某些平台会把“京豆抵扣”或“领券立减”后的金额也写入价格,导致折线图出现十几元到几元的跳变。我的做法是记录原始价格的同时,从标题或降价文案里提取券后价存到PriceRecorddiscounted_price字段,页面默认显示券后价,但历史曲线用原始价,这样既能展示优惠力度,又不会让曲线出现难以解释的毛刺。

最后再提一个验证口径:答辩时如果被问“你的比价结果准不准”,不要只说“我们和后端数据库核对过”,而是准备一个商品样本,手动去京东和淘宝页面查当前价,再对照系统里current_lowest字段,确认时间戳差异和价格来源。把这段核对过程截图放进演示 PPT,比任何用词都更有说服力。

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

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

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

立即咨询