Django+Vue电商比价系统:价格聚合、数据库设计与可视化实战 简介这是一套面向电商系统课程设计与期末大作业场景的完整源码工程采用 Django 后端与 Vue 前端分离架构适合具备 Python/Web 基础、希望快速完成高完成度比价系统的学生参考。压缩包共 660 个文件大小约 6.66MB其中 265 个 js、115 个 html、90 个 css 等前端文件覆盖页面布局、交互逻辑与后台管理界面25 个 py 文件对应 Django 工程核心代码另有图片、字体、配置文件及操作说明文档目录结构清晰方便按模块定位学习。目前已有 1368 人查看说明其在同类作业场景中有一定参考价值。源码内置详细操作说明与代码注释并附有数据库绑定参数修改位置可帮助使用者快速跑通前后端联调同时保留了用户、商品、比价等模块的典型实现思路适合作为课程设计答辩、期末项目演示或入门实战练手的蓝本。1. 期末项目里的DjangoVue比价系统到底在比什么手头这个期末大作业源码是一个前后端分离的电商比价系统后端用 Django 提供商品、价格和搜索的 REST API前端用 Vue 渲染搜索页、价格对比卡片和历史折线图。它解决的并不是“爬多少商品”而是“同一件商品在不同平台卖多少钱、价格怎么变”——这类数据天然适合课程设计因为业务逻辑清晰、数据模型好论证、页面效果也直观。源码里已经带了 Django 的settings.py、PriceCompare项目结构、前端静态资源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_length255) platform models.CharField(平台, max_length50) # 京东、淘宝、天猫等 product_url models.URLField(商品链接, max_length500) sku_code models.CharField(SKU编号, max_length100, blankTrue) created_at models.DateTimeField(创建时间, auto_now_addTrue) 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_deletemodels.CASCADE, related_nameprices) price models.DecimalField(价格, max_digits10, decimal_places2) recorded_at models.DateTimeField(采集时间, defaulttimezone.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( productOuterRef(pk) ).order_by(-recorded_at).values(price)[:1] products Product.objects.annotate( current_priceSubquery(latest_price) ).filter(current_price__isnullFalse).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(manyTrue, read_onlyTrue) 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__icontainskeyword) return qsSerializerMethodField用来动态生成当前最低价prefetch_related(prices)是关键它会把一个商品的所有价格记录一次性查出来避免每查一个商品就发一条 SQL。接口路径可以使用 DRF 的DefaultRouter注册from rest_framework.routers import DefaultRouter router DefaultRouter() router.register(rproducts, ProductViewSet) urlpatterns [] router.urls这样/api/products/?q手机就能返回包含标题、平台、最新价格和历史价格的 JSON。表里的参数含义如下。参数类型说明qstring标题模糊搜索关键词pageintDRF 默认分页页数需在 settings 中配置默认分页orderingstring按指定字段排序如orderingcurrent_lowestcurrent_lowest只读不允许前端写入所以实现里放到了SerializerMethodField。分页参数如果没配置DRF 会直接返回全量列表数据量大时前端渲染会卡顿建议在REST_FRAMEWORK配置里加上PAGE_SIZE 20。3. Vue前端如何展示价格差与历史趋势3.1 项目结构与依赖从 npm install 到 vue.config.js 代理打开前端目录常见结构是src/views、src/api、src/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-modelkeyword placeholder输入商品关键词 keyup.entersearch / div classproduct-card v-foritem in list :keyitem.id h3{{ item.title }}/h3 span classplatform{{ item.platform }}/span span classprice¥{{ item.current_lowest }}/span a :hrefitem.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 } } } /scriptres.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 refchart styleheight: 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 }] }) } } } /scriptxAxis.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的版本组合。我的建议是Django3.2.18 djangorestframework3.14.0 mysqlclient2.1.1 django-cors-headers3.14.0Django 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_APPS和MIDDLEWARE里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 最直观。把Product和PriceRecord注册到admin.pyfrom 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_deletemodels.CASCADE, related_nameprices) price models.DecimalField(max_digits10, decimal_places2) recorded_at models.DateTimeField(defaulttimezone.now) class Meta: constraints [ models.UniqueConstraint( fields[product, recorded_at], nameunique_product_price_per_time ) ]UniqueConstraint会让同一商品在同一秒只能有一笔价格记录。搭配recorded_at改成按天归一化可以避免因多次采集导致折线图上的横坐标过于拥挤。归一化方法很简单from django.utils import timezone recorded_at timezone.now().replace(minute0, second0, microsecond0)这样每小时只留一个点折线图看起来更平稳。如果你想把粒度改成天就把hour0也 replace 掉。定时刷新是比价系统的进阶功能。Django 里不推荐用apscheduler而是用原生的 management command 配合系统 crontab。在应用下建management/commands/update_prices.pyfrom 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_pricesLinux 上配合 crontab 每天凌晨跑一次0 3 * * * cd /path/to/project /usr/bin/python3 manage.py update_prices /tmp/price_update.log 21crawl_and_save里面最需要处理的是价格变化幅度。某些平台会把“京豆抵扣”或“领券立减”后的金额也写入价格导致折线图出现十几元到几元的跳变。我的做法是记录原始价格的同时从标题或降价文案里提取券后价存到PriceRecord的discounted_price字段页面默认显示券后价但历史曲线用原始价这样既能展示优惠力度又不会让曲线出现难以解释的毛刺。最后再提一个验证口径答辩时如果被问“你的比价结果准不准”不要只说“我们和后端数据库核对过”而是准备一个商品样本手动去京东和淘宝页面查当前价再对照系统里current_lowest字段确认时间戳差异和价格来源。把这段核对过程截图放进演示 PPT比任何用词都更有说服力。本文还有配套的精品资源点击获取