☰
Vue+Django实战:家具商城与活动抽奖系统开发全解析
2026/10/4 13:40:04 网站建设 项目流程

这段时间刚把一个家居商城的全栈项目收尾,主技术栈定的是 Vue 3 + Django,中间为了对比轻量方案,又用 Flask 单独写了抽奖接口的参考实现。整套开发都在 PyCharm 里完成,从 Python 环境搭建到 Vue 工程初始化,再到抽奖并发控制,踩坑记录攒了不少。这篇文章就把这个“家具商城 + 家居店活动抽奖系统”完整拆一遍,把技术选型、商城模块、抽奖算法、常见坑位都讲清楚。如果你准备做电商类毕设,或者想给实体家居店做一个集商品浏览、下单、会员抽奖于一体的前后端分离项目,这篇可以直接当实战笔记来用。

1. 项目定位与整体拆解

1.1 标题里说的“家具商城 + 家居店活动抽奖系统”,本质上是什么

很多刚接触 Python 的初学者看到“基于 Vue 的家具商城”这种标题,会以为要同时精通前端 Vue、后端 Python、数据库、部署,好像是个大工程。其实剥开来看,这个项目就是两个相互独立又能连通的业务场景嵌套:

  • 商城侧:提供家居商品的分类浏览、详情展示、搜索、加购、下单。这类功能是所有电商系统的地基,不管你是卖家具、卖农产品还是卖虚拟商品,骨架都一样。
  • 活动侧:以“家居店周年庆抽奖”为入口,用户在商城消费后或每日签到获得抽奖次数,点击抽奖后,后端根据奖品库存和权重概率返回中奖结果。抽奖活动要和商城用户体系打通,同时必须满足并发下不超卖、不重复抽奖。

换句话说,项目的核心不是“怎么写一个好看的家具页面”,而是“如何用 Python 后端把商品订单和营销活动这两类业务安全、稳定地串起来”。商城负责商品数据流和交易流程,抽奖负责概率玩法和库存控制,两边通过同一个用户身份做关联。

1.2 为什么技术栈是 Vue + Django/Flask + PyCharm 的组合

先讲选型。前端选 Vue,最现实的理由有两个:一是 Vue 的生态和上手曲线对 Python 开发者比较友好,组件化和 SPA 开发模式能在一个页面里承载商品列表、购物车弹窗、抽奖转盘这些复杂交互;二是现在前后端分离已经是常态,前端只需要通过接口拿数据,不需要再纠结 Django 模板语法。

后端在 Django 和 Flask 之间,我是这样分配的:整套商城系统用 Django,抽奖接口单独写一份 Flask 参考实现。为什么这么拆?

  • Django 自带 ORM、Admin 后台、Session、权限体系,做商城这种实体关系复杂、后台管理需求多的项目,能省掉大量重复开发。商品分类、购物车、订单、奖品表之间的关联,Django ORM 的 ForeignKey 能直接支撑。
  • Flask 更轻,适合做单一职责的抽奖微服务。如果团队已经有商城系统,不想动核心代码,完全可以独立部署一个 Flask 抽奖服务,前端单独调用它。Flask 没有“全家桶”约束,启动速度快,路由灵活。
  • 开发工具统一用 PyCharm。PyCharm 对 Django 有成熟的工程支持,对 Vue 也有对应插件和 Node 集成,一个 IDE 同时管理前后端项目,联调时不用来回切换软件。

当然,实际生产环境里你不想维护两套后端框架的话,抽奖接口也完全可以放进 Django 的 lottery app 里。这篇文章会把两种写法都写出来,你看完就能判断哪种更适合自己的场景。

1.3 三端功能模块怎么划分

整个系统我拆成三个部分:

模块前端(Vue)后端(Django)后端(Flask 参考实现)
商城商品模块商品列表、详情、分类筛选商品/分类模型、商品接口不参与
购物车与订单购物车页面、订单确认页购物车模型、订单创建、库存扣减不参与
抽奖活动抽奖转盘、中奖记录页活动表、奖品表、抽奖记录表独立抽奖服务:权重概率、库存扣减

模块划分的原则只有一个:每个后端接口职责单一,前端页面只关心数据展示,不关心业务规则。这样后续做促销、改装抽奖规则时,只需要改后端逻辑,前端页面基本不动。比如临时要把一等奖概率从 1% 调到 3%,前端一行代码都不用改,后台改一下权重字段就行。

2. 开发环境配置:PyCharm 里管理 Python 与 Vue 双端工程

2.1 Python 环境安装与虚拟环境

先说环境。很多人卡在第一步:Python 官网下载安装包时忘记勾选 “Add Python to PATH”,导致后续在命令行敲python没反应。我建议 Windows 上安装时直接勾选 PATH,可以省掉后续手动配置环境变量的麻烦。更稳妥的做法是,在 PyCharm 里新建项目时直接选择 “New environment using Virtualenv”,让 PyCharm 自动创建虚拟环境。

虚拟环境解决什么问题?不同项目依赖不同版本的 Django,如果不隔离,A 项目升级了 Django,B 项目可能直接跑不起来。虚拟环境给每个项目分配独立的依赖目录,互不污染。

我这里的后端工程名叫furnishop,终端按顺序操作:

# 进入虚拟环境后 pip install django django-admin startproject furnishop cd furnishop python manage.py startapp mall python manage.py startapp lottery

mall应用负责商城商品、购物车、订单;lottery应用负责活动抽奖。按应用拆业务是 Django 官方推荐的做法,也是后期维护的关键:一个应用只做一类事。

2.2 Vue 项目初始化与依赖安装

Vue 这边需要 Node.js 环境。装完 Node 后,npm命令就跟着有了。我用 Vue CLI 初始化了一个叫furnishop-web的前端工程:

npm install -g @vue/cli vue create furnishop-web cd furnishop-web npm install axios vue-router@4 npm run dev

创建过程中会让你选 preset,建议选 Vue 3。新手先用 JavaScript 版本,不用 TypeScript,减少心智负担。vue-router用来管理路由,项目里主要用到这几个页面:首页/、商品列表/products/:id?、购物车/cart、订单确认/order、抽奖页/lottery。

2.3 PyCharm 运行配置与联调小技巧

PyCharm 里要同时跑两个进程。右上角打开运行配置,添加两个配置项:

  • Django 配置:Module name 填furnishop,Parameters 填runserver 127.0.0.1:8000,Python interpreter 选刚才创建的虚拟环境。
  • Vue 配置:新建一个 npm 配置,working directory 指到furnishop-web,scripts 选dev。

实际开发时有个非常实用的联调方案:在furnishop-web/vite.config.js里做代理转发,让前端请求/api自动打到后端 8000 端口,而不是直接让前端访问跨域地址:

import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { proxy: { '/api': { target: 'http://127.0.0.1:8000', changeOrigin: true } } } })

这样做的好处是,前端页面访问http://localhost:5173,但/api下的请求都被代理到 Django,浏览器不会出现跨域预检问题。部署阶段通过 Nginx 对/api再做一次反向代理,路径保持统一,切换环境时只需要改配置文件。

3. 家具商城核心模块:商品、购物车、订单一步到位

3.1 商品分类、商品和购物车模型的字段设计

商城模块的表结构,我用了最经典的三张表:分类表Category、商品表Product、购物车表Cart。放进mall/models.py里大概是这样:

from django.db import models class Category(models.Model): name = models.CharField('分类名称', max_length=50, unique=True) sort = models.IntegerField('排序', default=0) class Meta: db_table = 'mall_category' class Product(models.Model): category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name='products') name = models.CharField('商品名称', max_length=120) cover = models.CharField('封面图', max_length=255) price = models.DecimalField('价格', max_digits=10, decimal_places=2) stock = models.IntegerField('库存', default=0) detail_pdf = models.CharField('说明书PDF', max_length=255, blank=True) created_at = models.DateTimeField('创建时间', auto_now_add=True) class Meta: db_table = 'mall_product' class Cart(models.Model): user_id = models.IntegerField('用户ID', db_index=True) product_id = models.IntegerField('商品ID') quantity = models.IntegerField('数量', default=1) updated_at = models.DateTimeField(auto_now=True) class Meta: db_table = 'mall_cart'

字段设计核心原则:只存真正需要的信息。拿价格举例,为什么用DecimalField而不是FloatField?因为浮点数做金额计算会出现 0.1 + 0.2 这种精度问题,电商金额必须精确,一旦涉及优惠券抵扣、订单分账,浮点数误差会被放大。DecimalField把金额当定点数处理,正确性优先级最高。

删除数据时也值得注意:如果不想要某个分类及其下面的商品,直接写Product.objects.filter(category_id=5).delete()可以批量删除商品,然后再处理分类。外键用on_delete=models.CASCADE以后,删除分类时关联商品也会被自动清理,但业务上要谨慎,不要把重要的商品数据误删。

3.2 商品接口与 Vue 页面绑定

后端接口我用了纯 Django 的JsonResponse,没有额外引入 Django REST Framework。这样自由度更高,也方便新手看清“接口返回值”到底是怎么拼出来的。商品列表接口大概长这样:

from django.http import JsonResponse def product_list(request, cid=0): qs = Product.objects.select_related('category').order_by('-id') if cid: qs = qs.filter(category_id=cid) data = [{ 'id': p.id, 'name': p.name, 'cover': p.cover, 'price': str(p.price), 'category': p.category.name, } for p in qs] return JsonResponse({'code': 0, 'data': data})

在config/urls.py里注册路由,把mall应用地址映射进来。返回格式统一用{"code": 0, "data": ...},前端axios就能统一做拦截处理:

import axios from 'axios' const request = axios.create({ baseURL: '/api', timeout: 5000 }) request.interceptors.response.use( res => { if (res.data.code !== 0) { return Promise.reject(new Error(res.data.msg || '请求失败')) } return res.data.data }, err => Promise.reject(err) ) export default request

Vue 页面里调用就非常简单了。商品列表页结构大致是:顶部是分类 tab,下面是一个v-for渲染卡片列表。路由用了:cid?可选参数,点击分类后更新 URL,同时触发重新请求。商品详情页如果需要展示 PDF 说明书,直接用<iframe :src="pdfUrl">就能在页面里显示,浏览器原生支持 PDF 预览,前提是后端用FileResponse返回 PDF 并保证响应头是application/pdf。这个能力和 Vue 本身关系不大,本质是浏览器对 PDF 类型文件的处理逻辑。

3.3 购物车、订单与库存扣减的“原子性”

商城模块里最关键的操作是下单扣库存。这里最容易犯的错是“先检查库存,再修改库存”——两个操作之间,别的用户可能已经抢走了库存。用一个生活类比:电影院只剩一张票,你和朋友同时点购买,如果系统先各自查一遍余票都发现剩 1 张,然后各自扣减,结果就是超卖 1 张。

正确做法是让库存扣减变成一个原子操作。Django 里最简单的方式是使用事务配合F表达式:

from django.db import transaction from django.db.models import F from django.http import JsonResponse @transaction.atomic def create_order(request): product_id = request.POST.get('product_id') quantity = int(request.POST.get('quantity', 1)) try: product = Product.objects.get(id=product_id) except Product.DoesNotExist: return JsonResponse({'code': 1, 'msg': '商品不存在'}) if product.stock < quantity: return JsonResponse({'code': 1, 'msg': '库存不足'}) # 库存扣减放到一条 SQL 里完成,避免并发覆盖 Product.objects.filter(id=product_id, stock__gte=quantity).update(stock=F('stock') - quantity) order_no = generate_order_no(request.user.id) # 创建订单记录... return JsonResponse({'code': 0, 'data': {'order_no': order_no}})

第二个容易被忽略的点是订单号生成。不要用数据库自增 ID 直接当订单号,很容易被猜出业务量,而且分布式部署时自增 ID 会冲突。我习惯用时间戳加用户 ID 加随机数拼一个 32 位以内字符串,规则可控,也不会暴露真实订单量。

4. 家居店抽奖活动系统:加权概率、库存锁与防刷设计

4.1 抽奖活动、奖品、抽奖记录三张表

抽奖模块的数据结构比商城模块稍复杂,核心是三张表:

  • Activity:定义活动起止时间、每人每日抽奖次数、总抽奖次数上限。
  • Prize:属于某个活动,包含奖品名称、等级、权重、总库存、剩余库存、是否兜底奖品。
  • DrawRecord:记录谁在哪个活动里抽到了什么奖品,也是防刷校验的数据基础。
# lottery/models.py from django.db import models from django.utils import timezone class Activity(models.Model): name = models.CharField('活动名称', max_length=64) start_at = models.DateTimeField('开始时间') end_at = models.DateTimeField('结束时间') daily_limit = models.IntegerField('每日抽奖次数', default=1) total_limit = models.IntegerField('活动总抽奖次数', default=5) is_active = models.BooleanField('启用', default=True) class Prize(models.Model): activity = models.ForeignKey(Activity, on_delete=models.CASCADE, related_name='prizes') name = models.CharField('奖品名', max_length=64) level = models.CharField('等级', max_length=20, default='normal') weight = models.IntegerField('权重', default=1) total_count = models.IntegerField('总库存', default=0) remaining_count = models.IntegerField('剩余库存', default=0) is_default = models.BooleanField('兜底奖品', default=False) class DrawRecord(models.Model): user_id = models.IntegerField('用户ID', db_index=True) activity = models.ForeignKey(Activity, on_delete=models.CASCADE) prize = models.ForeignKey(Prize, null=True, on_delete=models.SET_NULL) created_at = models.DateTimeField('抽奖时间', default=timezone.now)

这里要解释“权重”的作用。很多抽奖系统直接把概率写死在代码里,一旦运营想调整奖品概率,就得重新发版,非常麻烦。把权重放到数据库字段里,运营在后台改数值,接口概率立即变化。比如一等奖权重 1、二等奖权重 3、谢谢参与权重 6,概率池就是 10,某个奖品的中奖概率等于它的权重除以总权重。

4.2 加权随机算法与限量奖品处理

核心算法其实是一段很短的代码:

import random def pick_prize(prizes): total_weight = sum(p.weight for p in prizes) if total_weight <= 0: return None seed = random.randint(1, total_weight) for prize in prizes: seed -= prize.weight if seed <= 0: return prize return prizes[-1]

逻辑很简单:假设一等奖权重 1、二等奖权重 2,总权重 3。系统随机生成 1 到 3 的整数,落在 1,一等奖;落在 2 或 3,二等奖。权重越大,占据的随机区间越长,中奖概率自然就高。

但真实项目不能只写这一段,还有一个关键动作:限量奖品剩余库存为 0 时,必须从候选奖池中剔除。如果奖品发完还占着权重,用户会不断抽到已空的奖品,然后永远落在空挡上。正确处理是先查剩余数大于 0 的奖品,再进入权重计算。另外,如果所有非兜底奖品都被抽完,接口要能正常返回“未中奖”或者直接给兜底奖品,而不是抛异常。

4.3 Django 抽奖接口:事务、行锁与防刷

下面是抽奖接口最核心的一段。完整逻辑是:先做活动状态和用户次数校验,再用事务包裹“锁奖品、扣库存、写记录”三步。重点看select_for_update:

from datetime import date from django.db import transaction from django.http import JsonResponse from django.utils import timezone def draw(request): user_id = request.session.get('user_id') if not user_id: return JsonResponse({'code': 401, 'msg': '请先登录'}) activity_id = request.POST.get('activity_id') activity = Activity.objects.filter(id=activity_id, is_active=True).first() now = timezone.now() if not activity or not (activity.start_at <= now <= activity.end_at): return JsonResponse({'code': 1, 'msg': '活动未开始或已结束'}) # 防刷校验:每天一次 used_today = DrawRecord.objects.filter( user_id=user_id, activity_id=activity_id, created_at__date=date.today() ).count() if used_today >= activity.daily_limit: return JsonResponse({'code': 1, 'msg': '今天的抽奖次数用完了'}) with transaction.atomic(): # 锁住所有非空奖品行,避免并发超发 prize_rows = list( Prize.objects.select_for_update() .filter(activity_id=activity_id, remaining_count__gt=0) .order_by('id') ) # 过滤掉兜底奖品,正常奖品优先参与概率计算 normal_prizes = [p for p in prize_rows if not p.is_default] target = pick_prize(normal_prizes) if target is None: # 所有非兜底奖品都被抽完,直接未中奖 DrawRecord.objects.create(user_id=user_id, activity_id=activity_id, prize=None) return JsonResponse({'code': 0, 'data': {'prize': None, 'msg': '未中奖'}}) # 扣减奖品库存并写入抽奖记录 target.remaining_count -= 1 target.save(update_fields=['remaining_count']) DrawRecord.objects.create(user_id=user_id, activity_id=activity_id, prize=target) return JsonResponse({'code': 0, 'data': {'prize': target.name, 'level': target.level}})

为什么必须用select_for_update?如果不锁行,两个并发请求可能同时读到remaining_count=1,然后都执行remaining_count - 1,库存变成 -1,也就是奖品超发。select_for_update会在数据库层面对读到的奖品行加排他锁,第二个请求必须等第一个事务提交后才能读同一行,扣减操作就串行化了。

还要注意两点:select_for_update必须在事务里使用,锁的粒度越细越好。千万别给整个 Activity 表加锁,那会让一个活动所有用户的请求都排队等待,严重影响体验。生产环境流量特别大时,更常用的是把奖品剩余数放到 Redis 里用 INCR/DECR 做原子扣减,但数据库行锁在小流量到中等流量下已经足够可靠,实现也更简单。

4.4 Flask 抽奖参考实现:更轻的另一种写法

如果抽奖模块不想和 Django 商城绑在一起,用 Flask 独立写一个服务是更轻的选择。Flask 的数据库操作可以用 SQLAlchemy,下面是一个精简版演示:

from flask import Flask, jsonify, request from flask_sqlalchemy import SQLAlchemy import random from datetime import date app = Flask(__name__) app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///draw.db' db = SQLAlchemy(app) class Prize(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(64)) weight = db.Column(db.Integer, default=1) remaining_count = db.Column(db.Integer, default=0) class DrawRecord(db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, index=True) prize_id = db.Column(db.Integer) created_at = db.Column(db.DateTime, default=date.today) @app.post('/api/draw') def draw(): user_id = request.headers.get('X-User-Id') if not user_id: return jsonify(code=401, msg='未登录') used = DrawRecord.query.filter( DrawRecord.user_id == int(user_id), DrawRecord.created_at >= date.today() ).count() if used >= 1: return jsonify(code=1, msg='今天已经抽过') prizes = Prize.query.filter(Prize.remaining_count > 0).all() total_weight = sum(p.weight for p in prizes) if total_weight <= 0: return jsonify(code=0, data={'prize': None}) seed = random.randint(1, total_weight) target = None for p in prizes: seed -= p.weight if seed <= 0: target = p break if target is None: return jsonify(code=0, data={'prize': None}) target.remaining_count -= 1 db.session.add(DrawRecord(user_id=int(user_id), prize_id=target.id)) db.session.commit() return jsonify(code=0, data={'prize': target.name})

这个简化版为了演示省略了并发控制,生产环境同样需要把“查奖品、扣库存、写记录”放进事务,并使用SELECT ... FOR UPDATE。Flask 和 Django 的核心差别不在业务代码写法,而在于配套:Django 自带 Admin、Migration、Session,Flask 要自己组装,换来的是极简和灵活。如果公司已有现成用户体系,只想快速上线一个营销接口,Flask 更合适;如果从零做一个商城加活动,老老实实用 Django,能少操心很多基建问题。

5. 高发问题排查与上线前检查清单

5.1 跨域、Token 校验、Vue 路由刷新 404

前后端分离项目里,这三个问题是新手高频踩坑点。

跨域方面,开发环境最推荐用 Vite proxy,前文已经写过配置,好处是不用在后端开跨域,浏览器看到的请求路径还是同源。但如果你是 Vue 和后端分属两个域名,比如后端只做 API、前端独立部署,就必须在后端开 CORS。Django 装django-cors-headers,在settings.py里配置CORS_ALLOWED_ORIGINS;Flask 用 Flask-CORS 的CORS(app)即可。

登录状态校验上,这里的商城接口为了演示用了request.session,抽奖接口要求请求头带用户 ID。真实项目建议用 JWT:前端登录拿到 token,之后每个请求放进Authorization头,后端统一校验。Vue 的axios拦截器非常适合处理这个场景,请求前自动追加 token,响应 401 时统一跳转登录页。

Vue 路由刷新 404 是另一个经典问题。前端用 history 模式时,URL 没有#,刷新页面浏览器会向后端发起真实请求,如果后端没有对应路由就会 404。解决方法:本地开发靠 Vite 开发服务器自动回退到 index.html;生产环境在 Nginx 配置location / { try_files $uri $uri/ /index.html; },让所有前端路由都回到 SPA 入口。

5.2 PyCharm 调试:断点打在前后端哪一侧

我在 PyCharm 里调试这个项目的习惯是“后端打主战场”。商城和抽奖的大部分核心逻辑都在 Python 侧,比如抽奖接口的权重计算、库存扣减、记录写入。遇到问题,直接在后端接口函数里打断点,然后从前端页面触发一次请求,浏览器 Network 面板找到失败请求,PyCharm 的 Debugger 停在断点处,逐步查看变量值,很快就能定位问题。

Vue 侧调试更多是检查页面数据和接口返回是否对应。展示不对,先看浏览器 Network 面板里的 JSON 是否符合预期,再回 Vue 组件排查渲染逻辑。PyCharm 新版自带 Vue 文件高亮和语法提示,插件方面建议装 Vue.js 插件。至于 AI 辅助插件,可以有,但别指望它直接纠正复杂业务逻辑,最终还是要靠读代码和调试理解问题。

5.3 上线前必须做的一串检查

项目跑起来不等于能上线,以下几个点我每次都会过一遍:

  • 后端settings.py里DEBUG必须改为False,ALLOWED_HOSTS写清楚域名,不要用*。
  • 数据库迁移是否完整:依次执行python manage.py makemigrations和migrate,确认mall_category、mall_product、lottery_prize等表都创建成功。
  • 抽奖活动时间边界:结束时间过了以后,接口要能正确拒绝,不能只靠前端隐藏按钮,因为抽奖接口可以直接被外部调用。
  • 奖品库存和权重字段要提前做好备份,运营改配置时要有据可查。
  • 抽奖记录和订单表要考虑重复提交问题,必要时加唯一索引或做幂等键,避免同一用户并发点击生成多条异常数据。

最后分享一个实际开发中的体会:这个项目的抽奖模块完成后,运营最常操作的不是抽奖本身,而是改奖品权重和补库存。所以像权重、库存这种字段一定不要写死在代码里,全部做成数据库可配置。你在后台改一条权重,运营立刻能看到概率变化,这种细节比炫酷的转盘动画更影响用户体验。希望这篇实战笔记能帮你少踩几个坑,顺利把家具商城和活动抽奖系统跑起来。

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

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

立即咨询