年底接了个老客户的单子,给他们的客户答谢年会做一个抽奖程序。对方开口第一句就是“这年头还搞年会抽奖,简直良心企业”,言下之意,反正都是自家花钱办活动,不如把互动玩得专业点。甲方点名要我“弄个年会抽奖程序”,但当时需求就一句话,剩下的全靠我现场聊。
这篇东西就是把我做这个年会抽奖程序的全过程写下来,从需求拆解、技术选型、抽奖算法、大屏动效到现场运维,包括那些临时爆出来、文档里不会写的坑。如果你也是接私活的开发者,或者公司内部想自己搞一套简单可靠的活动抽奖系统,那这篇应该能帮你省掉不少试错时间。
1. 先搞清楚甲方要什么:需求拆解与方案选型
1.1 需求分析:不是“抽个奖”那么简单
跟甲方聊了半小时,表面上就一个需求“能抽奖就行”,实际上往深挖能挖出一箩筐东西。我问了几个关键问题,几乎每个都决定了系统长什么样:
- 抽奖名单哪来?有没有现成的Excel表,还是需要现场扫码参与?
- 奖项怎么设置?分几轮?每轮抽几个人?能不能设置“中过的人不再参与后续抽奖”?
- 大屏上要什么效果?是名单滚动,还是抽号码球,或者更花哨的动画?
- 是否需要员工手机端参与?比如扫码签到后才有资格抽奖,还是只要名单导入就行?
- 现场会不会有公证需求?抽奖的公平性怎么向台下观众证明?
- 中奖记录是否需要留存?社保、财务会不会事后要数据?
这是我做这类外包养成的一个习惯:需求必须落到具体操作场景,别只听甲方“随便搞搞”的客气话。活动当天现场几百人盯着大屏,抽奖程序一旦出bug,那不是尴尬的问题,而是信任危机。
这个项目的答案还算明确:名单用Excel导入,不需要扫码,总共5轮抽奖,其中一等奖1人,二等奖2人,三等奖5人,阳光普照奖20人,每轮奖品不同。中过奖的人不再进入后续轮次。现场需要一台Windows笔记本接投影或大屏,浏览器直接全屏跑。
基于这些,我基本确定是一个“本地Web应用”:前端负责大屏展示和滚动动画,后端负责抽奖逻辑和数据存储,部署在同一台笔记本上,通过浏览器访问。不依赖外网,不依赖服务器,现场断网也不怕。
1.2 技术方案选型:为什么最终选了Web技术栈
这个项目选择其实不少,我挨个对比过:
- C# WinForm或WPF桌面程序:做按钮和名单列表没问题,但要做华丽的滚动动画、转场特效,开发效率低,改样式也麻烦,而且一旦出问题,甲方想临时调个颜色字体还得重新打开项目改代码。
- Electron桌面应用:本质还是Web,但要额外打包,体积大,现场笔记本配置不明,可能跑不顺。除非必须做成本地双击打开,否则没必要。
- 微信小程序:对“扫码参与”场景比较合适,但这里名单已经确定,而且小程序审核、发布周期不可控,年会前赶不上,果断放弃。
- 纯网页H5应用:这是最终选择。理由很实在:开发速度快,样式方便调,浏览器全屏后直接就是一个大屏应用,投屏和HDMI输出零成本,而且本地配一个轻量后台就能支撑所有功能。
技术栈我选了“前端原生JavaScript + 后端Node.js + Express + SQLite”。可能有人会觉得,抽奖这么小的需求,直接用静态页面加本地数组不就行了?但考虑到现场需要保存多轮抽奖结果,还要防止页面刷新后中奖记录丢失,加一个轻量后端更稳。SQLite是个几十MB的库,数据存在一个文件里,备份只需要拷走这个文件,不需要部署数据库服务。
为什么不用Vue?因为这个项目页面就三个:主抽奖页、名单管理页、调试维护页。数据交互不复杂,原生JS完全能扛住,还能少引入一层构建工具。当然如果你喜欢Vue或React,也一样能做,本质差别不大。重要的是,不要让工具链干扰现场稳定性。
2. 抽奖核心逻辑:保证公平与不重复
2.1 随机算法选型与实现
这是整个系统最核心的部分。抽奖公平性是现场所有人都盯着的事,尤其是大奖环节,台下几百双眼睛看着大屏滚动的名字,这时候如果冒出个“怎么又是他”,整场气氛就崩了。
先说随机数来源。浏览器里最常用的是Math.random(),它是伪随机数生成器,但用在抽奖场景完全够用。有人担心被预测,说实话,针对一个本地局域网跑、活动时长两小时的抽奖,讨论密码学强度纯属过度设计。真正的公平来自算法逻辑,而不是随机数来源。
我的抽奖逻辑很简单:从“剩余未中奖名单”中,不重复地随机抽取指定人数。这是典型的不放回抽样。代码可以这样写:
function drawWinners(remainingList, count) { if (remainingList.length < count) { throw new Error('剩余名单不足,无法抽取'); } // 对剩余名单做一次随机排列,然后取前 count 个 const shuffled = [...remainingList]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } return shuffled.slice(0, count); }这个算法是Fisher-Yates洗牌,时间复杂度O(n),不需要额外维护“已抽过”集合,因为每轮抽之前传来的名单就已经排除了之前所有中奖者。
那什么时候排除?我的设计是:每一轮抽奖前,从数据库读取“所有中奖记录”,把中奖者ID从总名单里过滤掉,再喂给抽奖函数。简单可靠,而且每轮结果都能回溯。
有一点需要提醒:设计奖项抽奖顺序时,一定要先抽大奖、再抽小奖。因为一等奖只有一个名额,如果最后抽,剩余名单可能已经被前几轮拿掉不少,虽然不影响公平,但那种“最后大奖从一堆人中诞生”的悬念感更强。甲方最初就想把阳光普照奖放最后,被我劝回去了。这个不是技术问题,是活动节奏问题。
2.2 中奖数据的幂等与持久化
抽奖程序最怕什么?怕现场断电、浏览器误关、误刷新,然后中奖数据丢了,再打开程序发现还能重复抽取同一个人的名字。为了避免这个,我定义了两条铁律:
- 抽奖结果一旦产生,先写数据库,再返回前端。
- 前端收到结果后只负责展示,前端不能再次“抽奖”。
数据库就一张表:
CREATE TABLE draw_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, prize_id INTEGER, employee_id TEXT, employee_name TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP );后端抽奖接口设计成事务性的:插入中奖记录和更新参与状态必须同时成功。我用了Node.js里常见的better-sqlite3,它支持同步API,事务处理简洁:
const insertDraw = db.transaction((records) => { const stmt = db.prepare( 'INSERT INTO draw_records (prize_id, employee_id, employee_name) VALUES (?, ?, ?)' ); for (const record of records) { stmt.run(record.prizeId, record.employeeId, record.employeeName); } }); insertDraw(winnerList);这里有一个小坑:better-sqlite3是原生模块,安装时可能因为Node版本不对编译失败,后面部署章我会专门说。如果你不想碰原生模块,也可以改用sql.js+手动写入文件,或者干脆用JSON文件记录,但可靠性会差一些。对于这种一次性的活动,我更倾向“功能简单但不出事”。
每当抽完一轮,后台还会自动在项目目录下生成一份backup_中奖名单_时间戳.json,存的是当前全部中奖记录。这样就算SQLite文件损坏,也能从JSON里恢复大部分数据。
3. 从扫码参与到防作弊的实现(如果有互动需求)
3.1 参与者扫码签到
虽然这个项目最终没要扫码,但我在设计时留了一个模式开关,方便以后甲方加互动需求。这里也分享一下方案,万一你的客户想搞“观众手机参与”呢?
扫码参与的典型场景是:给每个员工生成一个二维码,扫码后填工号+姓名,后端校验是否在名单内。校验通过,才进入可抽奖池。如果不需要指定资格,也可以直接扫码就加入抽奖池。两种模式我都实现了一下:
本地名单校验模式:后端把Excel名单导入SQLite,每个工号对应一条记录。员工扫码打开H5页面,输入工号,系统自动带出姓名,点击“签到”即记为参与。这个设计有两个好处:第一,能防止现场亲戚朋友乱入;第二,签到数据能告诉你哪些人没到场,方便后勤确认。
不需要签到模式:直接扫二维码进入抽奖页即可,适合开放式的展会、商场活动。参与资格靠IP地址限制或入场券验证,比较复杂,一般客户也不会提。
从技术实现来看,二维码内容就是一个短链接,形如http://192.168.31.25:8000/join。局域网内手机直接访问这个IP地址即可,不需要部署公网。后端用qrcode库生成图片,连同活动规则打印出来放在签到处。
3.2 并发控制与防刷策略
如果真的上了扫码,就绕不开并发问题。想象一下,整场几百人同时签到,或者同时点抽奖按钮,后端同时收到几十个请求,如果不做控制,很可能出现两个人同时拿到同一个中奖名额。
我的方案是“后端单机加锁”。在Node.js单进程环境下,一个抽奖操作队列就能解决:
let drawLock = Promise.resolve(); function enqueueDraw(prizeId) { drawLock = drawLock.then(() => doDraw(prizeId)); return drawLock; }但这样只适用于单进程。如果你用了pm2多进程或部署在多实例环境,得改用Redis分布式锁。不过对一个现场活动的本地程序,单进程加锁足够稳妥,毕竟并发量也不会高到哪去。
至于防刷,签到时限制一个用户一分钟内最多请求5次,超过就返回“操作过于频繁”。每次请求带一个签名值,由后端生成并绑定用户,防止有人直接改接口参数。这个需求虽然这次没用到,但代码里留了口子,现场真要加也来得及。
同样重要的是幂等性。用户重复点击“签到”按钮,不会重复入池;重复点击“抽奖”,同一奖项第二次直接提示“已完成”。这些都靠后端根据prize_id和employee_id做唯一约束。
4. 大屏互动与动画效果实战
4.1 滚动抽奖动画怎么做得“高级”
甲方对大屏效果的核心要求就一个:“看着高级”。说白了,名字要快、炫、有节奏感。我实现了一个典型的“名单滚动抽奖”效果,用requestAnimationFrame驱动,而不是setInterval,因为前者在浏览器标签页不活跃时会自动暂停,后者可能积压回调导致动画卡顿。
滚动思路不复杂:页面上显示一个人员名单列表,一开始正常滚动显示所有名字,滚动速度快到人眼只能看到残影。当后台返回中奖结果后,前端不立即停下,而是模拟“减速停止”的过程:速度从每秒几百条递减到零,最终停在目标名字上。
这里最考验技巧的是“如何确保最后停到中奖者名字上”。很多人写抽奖动画会踩这个坑:动画已经停了,结果跟后台返回的人不一致,或者停到了相邻名字,特别尴尬。
我的做法是这样的:滚动列表其实是一个环形结构,先把所有未中奖者名单按顺序排好,然后根据后台返回的中奖者位置,在停止前算好目标索引。减速过程中,每次帧率更新时,判断当前是否接近目标索引,如果距离小于某个阈值,就把速度降得更慢,最终精准停在目标上。
示例核心逻辑:
let currentIndex = 0; let speed = 400; // 每帧滚动条数 let targetIndex = -1; function tick() { if (targetIndex >= 0) { const distance = (targetIndex - currentIndex + total) % total; if (distance < 20) { speed = Math.max(0, speed - 8); } } currentIndex = (currentIndex + speed) % total; renderList(currentIndex); if (speed > 0 || currentIndex !== targetIndex) { requestAnimationFrame(tick); } else { showWinner(); } }这里speed每帧减少8,控制减速过程大概在2到3秒。现场试下来,效果确实不错:观众还没反应过来,画面已经从疯狂滚动慢慢滑向结果,最后名字定住,全场目光聚焦,仪式感拉满。
但要注意,requestAnimationFrame在动画中每一帧都会更新DOM,如果名单数量很大,比如几千人,一次性把所有名字放进DOM会让页面卡顿。解决办法是“窗口渲染”,只渲染当前可视区域前后各100个名字,其余用空白占位,实现虚拟列表的效果。这样既保持滚动流畅,也不会因为名单太长导致内存膨胀。
4.2 投屏与多屏适配
大屏适配这块,甲方提供的现场是会议室,投影幕布分辨率是1920×1080,但笔记本可能是老机器。我建议甲方提前准备了VGA和HDMI线各一条,防止一边接口缺失。其实做抽奖程序,最稳妥的部署方式是把程序跑在同一台笔记本上,笔记本直接连接投影仪,同时在笔记本上打开所有页面做检查。不要试图远程控制再投屏,那样一旦网络抖动,现场就完了。
页面样式上,我做了一个1920×1080的适配主体,同时用vw/vh做单位,确保在1366×768的老笔记本上也能完整显示。背景用了深色渐变加光晕效果,比纯黑更有“年会感”。
浏览器全屏的问题也值得注意:Windows笔记本上按F11可以全屏,但有些浏览器会显示地址栏。我是给前端页面加了启动参数,用window.open打开新窗口时指定fullscreen=yes,同时提醒现场运维人员提前禁用系统屏保和锁屏。否则抽奖抽到一半,屏幕黑了,那真是灾难。建议在操作系统电源设置里,把“接通电源后永不关闭屏幕”打开。
声音方面,我用了HTML5的Audio接口,放了一段短促的鼓点音效配合抽奖,效果还行。但注意浏览器自动播放限制,用户必须首次点击页面任意位置才能解锁音频上下文。这个我提前写了一个“点击全屏开始”按钮,顺便满足用户激活要求,也避免了现场误触。
5. 现场部署与运维:那些年的“血泪教训”
5.1 环境准备:node、npm 环境变量坑
如果你的客户现场人员不是技术人员,那部署环境就是最大风险点。这里必须说说热搜词里那些“程序”相关的报错:什么“npm : 无法将‘npm’项识别为 cmdlet”、“conda’ 不是内部或外部命令,也不是可运行的程序”……这些本质都是同一个问题:环境变量没配好,或者压根没安装Node.js/Python。
所以我的建议是:现场部署时,不要指望活动当天临时装环境。提前准备一个绿色解压版的Node.js,把路径手动加到系统环境变量里,或者干脆让项目代码自带启动脚本,脚本里指定Node的绝对路径。更稳妥的做法是,我将整个项目目录打包好,里面已经包含了node_modules,这样连npm install都不用在现场执行,双击一个start.bat就能起来。
start.bat里的内容大概是这样:
@echo off cd /d %~dp0 start "" "node.exe" server.js start "" "http://localhost:8000"这个方式有两个好处:一是不会发生“npm命令找不到”这种尴尬;二是启动时间极短,一两秒就能打开页面。如果你还依赖Python,比如用Python脚本做数据处理,那最好也打包成exe,不要在现场跑源码。这个项目我后来彻底舍弃了Python依赖,所有数据处理都放Node端处理,省心很多。
5.2 数据备份与故障恢复
现场最怕的不是程序写不出来,而是运行到一半挂了。我给自己定了个规矩:抽奖数据至少要有三份备份。
第一份是SQLite数据库,它本身就是单文件;第二份是每次抽奖后自动生成的JSON备份;第三份是活动开始前手动导出的“原始名单快照”,存在U盘里。
这样万一真出了问题,恢复路径是这样的:
- 如果只是页面卡死:直接F5刷新,程序从数据库里读回所有中奖记录,继续下一轮。
- 如果是笔记本断电:重新启动
start.bat,页面自动恢复到最新状态。 - 如果数据库文件损坏:用最近一次JSON备份,写一个恢复脚本,把记录重新导入到数据库。
还有一个细节:现场每轮抽奖前,我会手动看一眼后台页面的“已中奖名单”,确认人数与轮次一致,再开始下一轮。这不是多此一举,而是最笨但最稳的校验方法。做过几场活动你就会明白,现场运维的核心原则不是“功能多”,而是“可恢复”。
5.3 彩排检查清单
活动前一天,我列了一个彩排检查清单,确保没有遗漏。这个清单我建议你也直接抄走:
- 用与现场相同分辨率的屏幕测试所有页面,确认无横向滚动条。
- 导入完整名单,测试第一轮抽奖,确认滚动动画正常、结果写入数据库。
- 模拟断电:抽完一轮后强制关掉浏览器,再启动看数据是否还在。
- 测试全屏模式与音响连接:确保点击全屏后声音正常。
- 清空浏览器缓存,避免现场因为缓存导致旧版本页面。
- 准备备用笔记本:如果现场主电脑出问题,备用机直接插U盘启动,拷贝数据库后继续抽奖。
彩排时最容易忽略的是“名单乱码”。Excel默认保存的文件是ANSI/GBK编码,而Node.js读取时默认按UTF-8处理,读出来就会变成“绉┿”之类的乱码。这个坑我在第一次做抽奖时踩过,后来解决方案是:上传Excel时自动检测编码,如果是GBK就用iconv-lite转码;同时要求甲方客户另存一份CSV文件,用UTF-8编码,双保险。
6. 常见问题与排查技巧实录
6.1 页面白屏或黑屏
现场最常见的故障就是打开浏览器一片空白。原因通常有几种:
- 端口被占用,后端没起来,前端页面请求不到数据。
- 浏览器缓存了旧版本,没有加载到最新的JS文件。
- 静态资源路径写错,把
/js/app.js写成了js/app.js,在二级目录下就找不到资源。
排查思路:先看start.bat命令行窗口有没有报错,然后看浏览器控制台F12报什么错。绝大多数白屏问题,刷新一下或者清缓存就能解决。为了避免路径问题,我在前端页面上所有资源引用都用了绝对路径/js/xxx,并且基于根路径部署,不会嵌套子目录。
6.2 抽奖结果重复或丢失
重复中奖通常是并发并发控制没做好,或者数据库写入失败没有事务。我的后端统一走事务接口,一次抽奖要么全部成功,要么全部失败,不存在“写进来一条,另一条丢了”的情况。
如果你发现实际中了两个人,但页面只显示一个,先查数据库里的draw_records表,看是不是两条记录都在。如果都在但页面没显示,多半是前端展示逻辑有bug,比如只取了结果数组的第一个元素。这个我建议调试时用一个上千人的测试名单跑多轮,不要手工点几遍就认为没问题。
6.3 大名单滚动卡顿
名单超过三千人,滚动动画如果不做虚拟列表,几乎必卡。前面提过“窗口渲染”方案,这里再说一个细节:不要用innerHTML频繁拼接几百个div,而是直接操作一个canvas画布,用fillText绘制名字。Canvas的渲染性能远高于DOM节点,每秒滚动几百条毫无压力。
如果名单是五千人,也不建议一次全部加载到前端DOM中,而是后端按需返回分页数据。我在项目里做了个接口:返回当前索引附近的100个名字,前端滚动的时候每帧请求一次?其实不用每帧,因为网络请求跟不上帧率。更好的策略是一开始就预取整份名单放内存,DOM只渲染可视区域的那部分,这样请求一次就够了。
6.4 中奖名单Excel中文乱码
乱码是活动系统最常见的幺蛾子。要解决,记住一条:后端统一用UTF-8处理所有字符串,Excel导入时做编码识别。如果甲方死活只给一个xlsx文件,就用xlsx库直接解析,它内部是XML格式,不存在编码问题。只有CSV才会因为编码不同出现乱码。
我加了一个小功能:上传名单后,在后台页面上预览前10条记录,如果出现乱码,立刻能看出来,不用等现场才发现。这个“预览”功能看似简单,却能避免活动现场最尴尬的一刻。
下面是我整理的现场问题速查表,建议打印出来放设备旁边。
| 症状 | 可能原因 | 快速处理 |
|---|---|---|
| 页面白屏 | 后端未启动 / 端口占用 | 查看命令行窗口,重启服务 |
| 点击抽奖没反应 | 本轮已完成 / 名单为空 | 检查后端日志,确认奖项状态 |
| 名单乱码 | Excel编码非UTF-8 | 用CSV UTF-8重新导入 |
| 动画卡顿 | 名单太大 / DOM太多 | 改用canvas或虚拟列表 |
| 中奖重复 | 并发未加锁 | 后端加事务与单进程锁 |
| 浏览器自动锁屏 | 未设置系统电源 | 关闭锁屏与屏保 |
| 没有声音 | 浏览器未解锁音频 | 点击页面后触发一次播放 |
最后说两句
年会抽奖程序这个需求听着小,真做起来,考点全都在“稳定”两个字上。算法本身不难,难的是把公平性、数据持久化、现场故障恢复这些东西想周全。我用原生JavaScript加Node.js加SQLite,花了两个晚上写完主要功能,剩下一天全在测试异常场景。最后活动现场一晚上顺顺利利抽完五轮,甲方很开心,我也松了一口气。
如果你也准备接这种活动项目,我的建议很简单:把现场当成一场直播,所有数据都要能恢复,所有操作都要有确认,任何花哨功能都要能在两分钟内关闭或绕过。至于程序员最爱纠结的随机算法、技术栈新不新,在年会灯光亮起来的那一刻,真的没那么重要。