
年底接了个老客户的单子给他们的客户答谢年会做一个抽奖程序。对方开口第一句就是“这年头还搞年会抽奖简直良心企业”言下之意反正都是自家花钱办活动不如把互动玩得专业点。甲方点名要我“弄个年会抽奖程序”但当时需求就一句话剩下的全靠我现场聊。这篇东西就是把我做这个年会抽奖程序的全过程写下来从需求拆解、技术选型、抽奖算法、大屏动效到现场运维包括那些临时爆出来、文档里不会写的坑。如果你也是接私活的开发者或者公司内部想自己搞一套简单可靠的活动抽奖系统那这篇应该能帮你省掉不少试错时间。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打开新窗口时指定fullscreenyes同时提醒现场运维人员提前禁用系统屏保和锁屏。否则抽奖抽到一半屏幕黑了那真是灾难。建议在操作系统电源设置里把“接通电源后永不关闭屏幕”打开。声音方面我用了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花了两个晚上写完主要功能剩下一天全在测试异常场景。最后活动现场一晚上顺顺利利抽完五轮甲方很开心我也松了一口气。如果你也准备接这种活动项目我的建议很简单把现场当成一场直播所有数据都要能恢复所有操作都要有确认任何花哨功能都要能在两分钟内关闭或绕过。至于程序员最爱纠结的随机算法、技术栈新不新在年会灯光亮起来的那一刻真的没那么重要。