☰
Django+Vue实战:家具商城抽奖系统的并发控制与实现
2026/10/11 15:40:31 网站建设 项目流程

1. 选型不是抄作业:Django/Flask/Vue这套组合凭什么胜出

先交代一下背景。前阵子帮一家做中高端实木家具的商户搭线上商城,老板提了个很实际的需求:商城不能光是摆货、下单,还得有活动运营的抓手。说白了就是搞一个抽奖模块——用户下单后送抽奖次数,抽中优惠券、小件家具或者免单机会,用来拉复购、清尾货、蹭新品热度。

这种项目我建议从技术选型开始讲,因为很多人一上来就纠结"到底用Django还是Flask",实际上一旦想清楚业务画像,框架选择非常快。

1.1 家具商城这种业务,为什么我倾向Django

先说后端。家具商城不是单纯的"商品列表+下单"这种薄业务,它有几个特点:

  • 商品属性维度多,实木家具要区分材质、尺寸、颜色、是否定制,SKU结构比服装鞋帽复杂。
  • 订单状态流转长,从下单、付款、备货、发货到安装预约,每一步都要留痕。
  • 促销逻辑叠加复杂,满减、折扣、优惠券、抽奖兑换码可能同时生效。

这三个特点决定了后端需要一个"自带电池"的框架。Django自带的ORM、Admin后台、迁移机制、认证体系,对一个垂直电商项目来说,能省掉非常多的基础代码。尤其是Admin后台,商户运营人员可以直接在里面维护商品、查看订单、设置活动中奖概率,不用额外开发一个运营后台。我见过不少团队用Flask做这种项目,商品和订单模块还没写完,光是把序列化、分页、权限、迁移这些"框架本来就有"的东西补一遍就花掉了不少时间。

Flask当然也能做,但更适合那种接口逻辑非常轻、业务边界清晰、团队对Python非常熟的场景。比如只做一个抽奖接口、一个商品查询接口,Flask的灵活性和轻量会带来很大优势;但一旦牵涉到多表关联、事务回滚、后台管理、用户认证,Flask需要自己拼装的零件太多,后期的维护成本反而更高。

我的建议是:如果你需要快速交付一个包含商城+营销模块的完整系统,且运营团队需要一个可视化管理后台,直接选Django;如果团队有精力把底层架子自己搭一遍,并且后续要高度定制化,Flask也完全可行。没有"哪个更好",只有"哪个更省事"。

1.2 前端用Vue,而不是服务端渲染,核心是为了活动页面的交互体验

前端这块我选了Vue,没有用Django自带的模板渲染。原因很直接:商城首页的瀑布流商品展示、购物车数量的实时更新、抽奖转盘的旋转动画、中奖弹窗的动效反馈,这些用服务端模板做,体验非常生硬。前后端分离之后,Django只负责出JSON,Vue负责页面交互和渲染,分工清楚。

Vue相对React来说,上手曲线平滑,模板语法直观,中文资料也密集,对于中小型团队更友好。配合Vuex做全局状态管理(购物车数据、用户信息、抽奖次数),配合Vue Router做前端路由,整个商城的页面架构可以很清晰。

PyCharm在这个项目里主要承担两部分工作:Python后端的编写调试,以及Vue项目的管理。新版PyCharm Professional对Vue的支持已经很完善,可以直接识别.vue单文件组件、提供模板语法高亮和跳转。我的建议是,后端用PyCharm打开Django项目根目录,前端Vue项目作为另一个项目窗口打开,避免把两个项目的依赖混在一起。

顺带提一个关键配置:PyCharm里给Django项目配置虚拟环境时,一定要选对解释器路径,不要用系统全局Python。虚拟环境建好之后,django-admin startproject和pip install都在这个环境里操作,避免依赖冲突。

2. 先把活动玩明白:抽奖系统的表结构设计(含家具商城的业务细节)

抽奖系统表面上只是一张"中奖记录表",但实际上围绕"活动"这个核心实体,至少要拆出四张基础表:活动表、奖品表、用户抽奖记录表、中奖落库表。这四张表是抽奖功能的骨架,缺一张后面都会很难补。

2.1 从一次"家居店年中抽奖"的需求说起

假设商户要做一个年中活动:活动期30天,用户每下一笔实付金额超过500元的订单,自动获得1次抽奖机会;超过1000元获得2次。奖池里有一等奖(指定款实木椅)、二等奖(50元无门槛券)、三等奖(200元满减券)、谢谢参与,四个档位。每个档位设置库存总量和单日发放上限。

这个需求落地到表结构上就是:

  • 活动表(Activity):活动名称、开始时间、结束时间、状态、每人总抽奖次数上限、每日抽奖次数上限。
  • 奖品表(Prize):所属活动、奖品名称、奖品类型(实物/优惠券/谢谢参与)、库存总量、剩余库存、单日发放上限、概率权重、关联的优惠券模板ID或商品ID。
  • 用户抽奖记录表(LotteryRecord):用户ID、订单ID(记录是哪笔订单带来的抽奖次数)、抽奖时间、抽奖结果。
  • 中奖发放表(AwardDelivery):中奖记录ID、发放状态(待发放/已发放/已过期)、发放时间、备注。

这四个表之间的关联关系是:一个活动下有多个奖品;用户抽奖时,根据当前活动、奖品的剩余库存和权重来决定中哪个奖,并写入抽奖记录和中奖发放表。"谢谢参与"也作为一种特殊奖品存在,这能统一抽奖逻辑,不需要在代码里单独判断"没中奖"的情况。

2.2 Django模型实现:关键字段和权重设计的落点

用Django Model来表达,核心模型大概是这样的:

from django.db import models from django.contrib.auth.models import User class Activity(models.Model): name = models.CharField(max_length=128) start_at = models.DateTimeField() end_at = models.DateTimeField() per_user_daily_limit = models.IntegerField(default=3) per_user_total_limit = models.IntegerField(default=10) status = models.BooleanField(default=True) def __str__(self): return self.name class Prize(models.Model): PRIZE_TYPES = [ ('physical', '实物奖品'), ('coupon', '优惠券'), ('none', '谢谢参与'), ] activity = models.ForeignKey(Activity, on_delete=models.CASCADE, related_name='prizes') name = models.CharField(max_length=128) prize_type = models.CharField(max_length=20, choices=PRIZE_TYPES) stock_total = models.IntegerField(default=0) stock_remaining = models.IntegerField(default=0) daily_limit = models.IntegerField(default=0) daily_remaining = models.IntegerField(default=0) weight = models.IntegerField(default=1) # 概率权重 # 关联业务:如果奖品是优惠券,关联到优惠券模板;如果是实物,关联到商品 coupon_template_id = models.IntegerField(null=True, blank=True) product_id = models.IntegerField(null=True, blank=True) def __str__(self): return f'{self.activity.name}-{self.name}' class LotteryRecord(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) activity = models.ForeignKey(Activity, on_delete=models.CASCADE) order_id = models.IntegerField() # 关联来源订单 prize = models.ForeignKey(Prize, on_delete=models.CASCADE, null=True) created_at = models.DateTimeField(auto_now_add=True) ip_address = models.GenericIPAddressField(null=True) class Meta: ordering = ['-created_at'] class AwardDelivery(models.Model): STATUS_CHOICES = [ ('pending', '待发放'), ('delivered', '已发放'), ('expired', '已过期'), ] record = models.OneToOneField(LotteryRecord, on_delete=models.CASCADE) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') delivered_at = models.DateTimeField(null=True, blank=True) remark = models.CharField(max_length=255, blank=True)

关于"概率权重"要特别多说一句。常见的错误做法是给每个奖品写一个固定的"中奖率"字段,比如一等奖0.1%、二等奖0.5%。这在奖品库存是无限量时没问题,但抽奖系统里库存有限,随着一等奖被抽走,它的实时中奖概率会从0.1%降到0,而"按固定百分比抽"的逻辑不会自动感知库存变化。所以比较稳妥的做法是:只存权重(weight),每次抽奖时把当前所有还有库存的奖品按权重做随机,库存为0的奖品自动不参与。这样"谢谢参与"的权重设计一个大值,就能兜底所有未中奖的情况。

另外,daily_remaining这个字段就是用来控制"单日发放上限"的。活动每天凌晨重置,可以通过Django的定时任务(比如Celery beat或简单的cron)把每个奖品当天的daily_remaining重置为daily_limit。活动结束后,再把整个活动的status置为False,抽奖接口直接拒绝。

3. 中奖概率和库存并发:最容易翻车的核心逻辑

表结构设计好之后,真正的硬骨头在这里:抽奖接口。这块有两个核心点,一是"权重随机怎么算",二是"在高并发下奖品库存和每日限额不能超发"。我把这两个部分拆开讲。

3.1 权重随机算法:不搞玄学,用最简单的区间法

权重随机的实现并不需要复杂的数学知识,最常用的就是"区间法":把所有权重加起来,然后随机生成一个总数范围内的整数,看这个数落在哪个奖品的权重区间里,就选中哪个奖品。

import random def draw_prize(prizes): """ prizes: 包含所有可抽奖品的QuerySet,至少包含id, weight字段 返回一个奖品对象或None """ total_weight = sum(p.weight for p in prizes if p.stock_remaining > 0) if total_weight <= 0: return None random_value = random.randint(1, total_weight) current = 0 for p in prizes: if p.stock_remaining <= 0: continue current += p.weight if random_value <= current: return p return None

这个算法的好处是逻辑简单、一眼能看懂,而且"库存不足的奖品不参与"这个条件直接融进了循环里。对于家具商城这种抽奖频率(一天几千到几万次),性能完全够用。

需要注意一点:随机数的抽取用random.randint还是secrets模块要分场景。如果是运营活动、非现金对赌类的抽奖,random够用;如果涉及现金奖品或严重利益相关,建议用secrets.SystemRandom(),它能防止基于伪随机序列的预测攻击。我就是因为最初用了random,后来被安全测试提了一个Minor级问题,才改掉的。

3.2 并发超发的解决办法:数据库锁和Redis原子扣减

抽奖接口最容易出事故的地方不是随机算法,而是并发。"一等奖只剩最后1个库存,正好两个用户同时点击抽奖",如果代码是这样写的:

# 这种写法有并发问题 if prize.stock_remaining > 0: prize.stock_remaining -= 1 prize.save()

两个请求同时读到stock_remaining = 1,都满足条件,都执行了减一,库存就变成了-1。而且这两个用户都会认为自己中奖了。

解决方式有两种,我先说数据库层面的,比较简单直接:

from django.db import transaction from django.db.models import F with transaction.atomic(): # 使用 select_for_update 对奖品行加锁 prize = Prize.objects.select_for_update().get(id=prize_id) if prize.stock_remaining <= 0: return {'code': 1, 'msg': '该奖品已被抽完'} if prize.daily_remaining <= 0: return {'code': 1, 'msg': '该奖品今日已发完'} # 扣减库存 Prize.objects.filter(id=prize.id).update( stock_remaining=F('stock_remaining') - 1, daily_remaining=F('daily_remaining') - 1, ) # 写中奖记录和发放记录 record = LotteryRecord.objects.create(...) AwardDelivery.objects.create(record=record, status='pending')

select_for_update是PostgreSQL和MySQL在事务内提供的行级锁。两个并发的请求同时来,第二个请求会等第一个的事务提交后,才能读到最新库存,避免了超发。这里用F()表达式做原子更新也很关键,它能保证"读-改-写"这三步在数据库内部是一个原子操作,不会因为你代码里多了一个取值的动作而出错。

如果项目里已经用了Redis,还有一种更极致的做法:把每个奖品的库存和每日限额都放到Redis里,用DECR或者Lua脚本做原子扣减。Redis的单线程模型天然避免了并发覆盖的问题,扣减成功后再异步落库到MySQL。这种方案性能更好,但引入了缓存和数据库的一致性问题,对于家具商城的抽奖规模来说,select_for_update已经绰绰有余,没必要过度设计。

真要说踩坑,我提醒一句:select_for_update必须放在事务里,而且查询条件必须能命中索引,否则它会锁全表。用活动ID+奖品ID做联合查询条件,然后给表加联合索引,这是最基础的规避手段。

3.3 防"薅羊毛"的几条实用策略

做活动抽奖,如果不防刷,老板第二天看到中奖名单会崩溃。我在这类项目里常用的策略有:

  • 按用户维度做限制:以LotteryRecord表为基础,统计同一个用户在当前活动的抽奖次数,超过活动总次数限制就拒绝。这个直接用Django的count()就能查。
  • 按IP维度做限制:在LotteryRecord里存了ip_address,可以在同一个活动里限制单个IP每天最多抽奖次数。这个对"一个人注册多个小号刷奖"的情况很有效。
  • 抽奖机会的来源校验:抽奖次数必须绑定订单ID,每次抽奖时校验该订单是否真实存在、是否已支付、是否已经生成过抽奖记录。我在模型里把order_id加了一个唯一约束(UniqueConstraint),防止同一笔订单被用户反复提交多次抽奖请求。
  • 前端按钮置灰+后端幂等:Vue端在点击抽奖后立刻把按钮置灰,直到接口返回结果。后端层面,同一个用户并发抽奖时,可以用用户ID做一个简单的Redis分布式锁,比如SET user:123:lottery_lock NX EX 3,抢不到锁的请求直接提示"操作太快,请稍后再试"。

这些策略按成本从低到高递进,先做用户限制和订单来源校验,这两个是底线;IP限制和Redis锁视业务风险决定是否加。

4. 商城和抽奖的联动:从下单到抽奖次数到账的完整链路

抽奖系统不是孤立存在的,它要跟商城的订单体系打通。家具商城的核心场景是"下单后送抽奖次数",所以订单付款成功的那一刻,就要自动生成抽奖资格。这个链路如果设计不好,会出现用户付了款但抽不了奖的客诉。

4.1 订单支付成功后的"送次数"逻辑怎么落地

我推荐的做法是在订单支付成功的回调处理函数里,完成"计算应得抽奖次数并写入资格"的操作。Django这边的代码逻辑大概是:

from django.db import transaction def handle_order_paid(order): """ 订单支付成功后调用 """ with transaction.atomic(): # 这里判断订单是否已经处理过,避免重复发放 if LotteryCredit.objects.filter(order=order).exists(): return # 计算抽奖次数:满500送1次,满1000送2次,以此类推 times = order.paid_amount // 500 if times <= 0: return # 写入抽奖资格记录 LotteryCredit.objects.create( user=order.user, order=order, times=times, used_times=0, valid_until=activity.end_at # 活动结束时间 ) # 给用户发送站内通知,告知抽奖次数到账 notify_user(order.user)

这里引入了一张中间表LotteryCredit,专门记录"用户有哪些抽奖资格"。这张表的字段包括:用户ID、来源订单ID、获得次数、已用次数、有效期截止时间。为什么要单独一张表,而不是直接在订单表上加一个"抽奖次数"字段?因为一个用户在一个活动周期内可能有多个订单,如果每次都往订单表里塞一个lottery_credits字段,统计和核销的逻辑会变得很乱。单独一张资格表,每次抽奖时扣减used_times,逻辑非常清晰。

4.2 抽奖接口怎么消费"抽奖资格"

用户在前端点击抽奖后,后端接口的流程是:

  1. 检查活动是否在有效期、是否开启。
  2. 检查用户是否有可用的抽奖资格(used_times < times且未过期)。
  3. 检查用户今日及总抽奖次数是否超限。
  4. 调用权重随机算法选中奖品。
  5. 锁库存、扣库存、写中奖记录、扣减资格。
  6. 返回结果给前端,同时对实物奖品生成发放待办。

这里面有一个顺序问题:先扣资格还是先抽奖?我的建议是"先校验资格,然后在一个事务里完成抽奖和扣资格"——不能把资格扣减放在抽奖之前单独提交,否则万一奖品全部抽完,用户的抽奖次数就被白白扣掉了。如果放在抽奖之后、同一个事务内,抽奖失败则事务回滚,资格也跟着回滚,用户不会有损失。

@transaction.atomic def lottery_api(user, activity_id): # 取资格 credit = LotteryCredit.objects.select_for_update().filter( user=user, used_times__lt=F('times'), valid_until__gt=timezone.now() ).first() if not credit: return {'code': 403, 'msg': '暂无抽奖次数'} # 取活动下所有可抽奖品 prizes = Prize.objects.filter(activity_id=activity_id) prize = draw_prize(prizes) # 扣减奖品库存 if prize and prize.prize_type != 'none': locked = Prize.objects.select_for_update().get(id=prize.id) if locked.stock_remaining <= 0 or locked.daily_remaining <= 0: raise PrizeNotAvailableError # 触发回滚,前端可重新调用 Prize.objects.filter(id=locked.id).update( stock_remaining=F('stock_remaining') - 1, daily_remaining=F('daily_remaining') - 1, ) # 写记录 + 扣资格 record = LotteryRecord.objects.create(user=user, activity=activity, prize=prize, order_id=credit.order_id) AwardDelivery.objects.create(record=record, status='pending') LotteryCredit.objects.filter(id=credit.id).update(used_times=F('used_times') + 1) return {'code': 0, 'prize': prize.name, 'prize_type': prize.prize_type}

这段代码有一个细节点:抽奖资格用select_for_update锁住,避免用户并发请求时"两次抽奖都通过资格校验,但次数只有1次"的问题。同理,奖品库存用的是另一行锁。两个锁先后获取,事务内顺序固定,不会出现死锁。

4.3 用户抽奖次数的前端展示逻辑

用户登录后,Vue端需要实时显示"我还有几次抽奖机会"。这个数据可以通过一个简单的接口获取,比如GET /api/lottery/status,后端返回available_times和total_times。前端在进入活动页面时拉取一次,抽奖成功后再重新拉取。不要自己在前端做减法,因为后端如果因为奖品库存不足等原因拒绝了抽奖,前端已计算的剩余次数就会跟后端不一致。

5. Vue端的页面组织与接口对接:抽奖转盘不复杂,但要注意状态同步

前端这块,整体页面我分了四个核心视图:商城首页/商品列表页、商品详情页、购物车与订单结算页、活动抽奖页。活动抽奖页是这套系统的特色模块,我单独说一下实现思路。

5.1 活动抽奖页的组件拆分和接口约定

一个标准的抽奖转盘页面,在前端至少拆成三个组件:

  • LotteryWheel.vue:转盘本体,负责展示奖品名称和对应的扇区。
  • LotteryResultModal.vue:中奖结果弹窗,展示中奖内容,如果是优惠券,直接展示券码;如果是实物,展示"已生成发放待办,请联系客服兑换"。
  • LotteryHistory.vue:中奖记录列表,按时间倒序展示用户的历史中奖情况。

转盘本体不需要引入重量级Canvas库。大多数家居店活动都不会做定制动画,一个CSS样式的圆形转盘,配合Vue的transform: rotate()就足够。核心逻辑是:接口返回中奖奖品名称和ID,前端通过预先配置的"奖品ID -> 旋转角度"映射表,计算转盘需要旋转到哪个角度,加上几圈默认旋转量,然后触发CSS过渡动画。

<template> <div class="wheel" :style="{ transform: `rotate(${currentAngle}deg)`, transition: 'transform 4s cubic-bezier(0.2, 0.8, 0.2, 1)' }"> <div v-for="(item, index) in prizeList" :key="item.id" class="wheel-sector"> {{ item.name }} </div> </div> </template> <script> export default { data() { return { currentAngle: 0, prizeList: [], }; }, methods: { async startLottery() { // 调用抽奖接口 const result = await this.$http.post('/api/lottery/draw'); if (result.code !== 0) { this.$toast(result.msg); return; } // 根据中奖奖品ID计算目标角度 const targetIndex = this.prizeList.findIndex(p => p.id === result.prize_id); const sectorAngle = 360 / this.prizeList.length; const targetAngle = 360 * 5 + (targetIndex * sectorAngle + sectorAngle / 2); this.currentAngle = targetAngle; // 动画结束后弹窗展示结果 setTimeout(() => this.showModal(result), 4200); }, }, }; </script>

这里需要留意一个业务细节:前端的奖品列表必须与后端奖品表结构保持一致。如果活动期间后端调整了奖品名称或库存,前端列表要有同步机制。我建议的做法是,活动页面加载时动态拉取奖品列表,而不是把奖品写死在代码里,这样运营人员在后端Admin里改奖品名称,用户刷新页面就能看到变化。

5.2 Axios拦截器与Token处理

商城系统的接口大多需要登录态,前端用JWT或Session管理用户身份。我习惯在Vue里用Axios拦截器统一处理:

  • 请求拦截:给每个请求头加上Authorization: Bearer <token>。
  • 响应拦截:如果收到401状态码,说明Token过期,自动跳转到登录页,并清空本地用户信息。
axios.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); axios.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); router.push('/login'); } return Promise.reject(error); } );

有一点在工作里经常被忽略:抽奖动画期间,用户如果快速刷新页面,转盘的状态会丢失。解决方法是把"转盘当前角度"和"最近一次中奖结果"存到Vuex里,或者用sessionStorage缓存。刷新后优先读取Vuex中的状态,避免用户看见转盘回到初始角度。

5.3 前端和后端的中奖一致性:有一个顺序必须坚持

抽奖转盘有一个体验细节:先播放动画还是先请求后端?如果你先播放动画再请求接口,用户看到的动画结果大概率跟后端返回不一致;如果你先请求接口再播放动画,等待时间又会影响体验。

我建议的方案是:点击瞬间立刻向后端发起抽奖请求,请求返回前转盘可以先转起来(预转效果但不指向具体奖品),等接口返回中奖结果后,再从当前角度继续旋转到目标奖品。这样既能做到"结果由后端决定",又能避免用户等待时的无聊感。

这个方案在代码上的实现,就是把startLottery中的请求提前,预转一个随机角度,拿到结果后再执行一次补充旋转。小技巧,但用户体验差异很大。

6. 上线前彻查:时间边界、库存清零、重复请求这些坑

系统开发完不是终点,上线前的边界测试才是真正考验代码质量的地方。我把自己在类似项目中实际遇到并排查过的问题列一遍,这几条几乎每个抽奖系统都会碰到。

6.1 活动时间边界:提前1秒抽奖和中奖后活动结束

活动时间判断如果写得粗糙,会出现"用户23:59:59抽奖成功,00:00:00活动结束,中奖记录却正常生成"的问题。关键在于,后端判断活动时间是否有效,应该以"事务开始时间"为准,而不是以"用户请求到达时间"为准。用Django的timezone.now()在事务内取一次时间,然后全程使用这个时间,避免事务执行几秒后跨越了活动结束点。

奖品发放也要注意:活动结束后,已中奖但未发放的奖品应该仍然可以发放。我在AwardDelivery表里加了status='pending'的记录,运营人员可以在Admin后台手动确认发放,也可以写一个定时任务,在活动结束后自动处理所有待发放的实物奖品。这个逻辑应当在需求阶段就跟商户确认清楚,否则运营会天天来找你。

6.2 奖品库存清零时的用户体验

一等奖库存为0后,如果需求是"一等奖还能显示,只是抽不中",转盘上就必须保留一等奖的位置;但如果需求是"一等奖抽完就从转盘上隐藏,让用户看到剩余可中奖项",就需要另一套处理逻辑。

我比较推荐前者,即转盘保留完整的奖品格,用户抽到一等奖时接口返回"该奖品已抽完,已自动发放补偿奖品"。这个补偿逻辑可以在后端这样处理:奖品库存为0时,权重随机算法跳过该奖品,但为了让用户不觉得"被坑",可以在前端弹窗里提示"很遗憾,一等奖已被抽完"。这里的底线是:用户不能因为奖品库存不足而白白损失抽奖次数,所以我在第4节里特别强调用事务保证"抽不到奖时资格不会扣减"。

6.3 重复点击和重复请求的堵漏

很多前端会做防重复点击,但后端不能完全依赖前端。同一个用户在5秒内发来10次抽奖请求,可能有几种情况:手抖、网络重试、恶意脚本。

除了前面提到的用户维度次数限制和Redis锁,我还会在接口入口处做一个简单的频率控制:5秒内同一用户的抽奖请求超过3次,直接返回"操作过于频繁"。这个控制用Redis的滑动窗口最方便,没有Redis也可以用一张简单的RequestLog表做计数,只是性能没有Redis好。

6.4 时区和时间显示:Django的TIME_ZONE一定要显式设置

这个坑特别隐蔽,但对活动运营来说是致命的。如果Django的TIME_ZONE没设对,前端显示的抽奖活动开始时间和实际判定的开始时间会有8小时的偏差,用户凌晨发现活动已经结束,而商户后台显示还有剩余时间。

解决办法:在Django的settings.py里显式设置TIME_ZONE = 'Asia/Shanghai',并保持USE_TZ = True。所有时间字段用Django的timezone.now()写入,前端展示时再统一转换成浏览器本地时间。前后端时间传递的规范是:后端一律返回ISO8601标准格式的UTC时间字符串,前端用dayjs或moment转换成本地时间展示。

之前在这个项目里,我就因为漏了TIME_ZONE设置,测试环境里所有活动时间都偏差了几个小时,排查了半天才发现是配置问题,这种基础配置真的要在项目启动第一天就检查一遍。

7. 从开发到上线的几个额外叮嘱

补几条不在正题里、但实际做项目非常受用的经验。

7.1 数据备份与活动回滚预案

抽奖系统涉及真金白银的权益,上线前一定要做好数据备份方案。尤其是活动期间的奖品数据,每天至少备份一次。万一出现"奖品超发"或者"用户刷奖"等紧急情况,要有能力把活动数据恢复到前一天的状态。Django自带的dumpdata可以按应用备份,也可以直接用数据库层面的定时备份脚本。

活动如果出现严重问题需要临时暂停,"下架活动"这个操作要在Admin后台控制,而不是直接改数据库字段。我建议在活动表里留一个status字段,运营人员可以在Admin后台一键启停。

7.2 日志与监控:抽奖接口要有独立的日志级别

抽奖接口的日志不能跟普通接口混在一起。每次抽奖请求,除了记录用户ID、订单ID、抽奖结果、IP和User-Agent外,还要记录奖品库存扣减前后的数值。这样出了问题,可以通过日志倒推是哪一次请求导致了库存异常。

Django的logging配置里,给抽奖模块单独配一个logger和独立的日志文件。项目上线后,每天检查一次抽奖日志的关键指标:抽奖次数、中奖率、各奖品发放量。如果某个奖品的中奖率明显偏离权重设定值,优先检查是否有刷奖行为,而不是怀疑算法有问题。

7.3 关于Django Admin的后台管理配置

最后说一个提升运营效率的小技巧。Django自带的Admin对抽奖系统来说是个宝,但默认配置展示的信息不够直观。我的做法是在admin.py里给Prize和LotteryRecord配好list_display和list_filter:

@admin.register(Prize) class PrizeAdmin(admin.ModelAdmin): list_display = ('name', 'activity', 'prize_type', 'stock_remaining', 'daily_remaining', 'weight') list_filter = ('activity', 'prize_type') @admin.register(LotteryRecord) class LotteryRecordAdmin(admin.ModelAdmin): list_display = ('user', 'activity', 'prize', 'created_at', 'ip_address') list_filter = ('activity', 'created_at') search_fields = ('user__username', 'order_id')

运营人员每天打开后台,按活动筛选一眼就能看到"哪个奖品快发完了""今天抽奖频次是否异常",比看报表更直接。这类配置代码量不大,但对运营体验的提升非常明显。

整个项目做下来,我的感受是:技术本身不复杂,抽奖算法、并发控制、前后端接口都是成熟方案;真正的复杂度在业务细节里——活动和订单怎么联动、奖品库存和用户体验怎么平衡、运营人员怎么高效地管理活动。把这几个问题想透了,再用Django和Vue去落地,项目就能又快又稳地交付。

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

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

立即咨询