我从前端菜鸟到全栈:年会抽奖系统实战(钉钉免登 + 并发解法)
年会抽奖看起来是小功能,实际上是并发和事务的考场——本篇把我在钉钉免登、抽奖并发处理与云效部署上的实战经验,拆成你能立刻复制的步骤。
一、系统概览
前端:React + Vite;后端:Node + Express + Sequelize(MySQL);部署:Nginx + PM2;CI:云效构建前端并打包产物,后端源码下发目标机安装依赖。
┌────────────────────────────────────────────────────────────────┐
│ 访问层 │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ 二维码海报 │────────▶│ 扫码H5页面 │ │
│ │ (年会现场) │ │ (React) │ │
│ └─────────────┘ └──────┬──────┘ │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ 钉钉OAuth授权 │ │
│ │ 获取用户信息 │ │
│ └─────────┬─────────┘ │
└──────────────────────────────────┼─────────────────────────────┘
│ HTTPS
▼
┌────────────────────────────────────────────────────────────────┐
│ 服务端层 │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Node.js + Express │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ │
│ │ │ 认证模块 │ │ 抽奖模块 │ │ 管理模块 │ │ │
│ │ │ 企业ID验证 │ │ 安全计数器法 │ │ 结果查询/筛选 │ │ │
│ │ │ DingTalk │ │ 限制人员 │ │ │ │ │
│ │ │ OAuth │ │ 过滤 │ │ │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ 数据层 │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ MySQL 数据库 │ │
│ │ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ users │ │lottery_state│ │ │
│ │ │ 用户/抽奖 │ │ 抽奖状态 │ │ │
│ │ │ 数据 │ │ │ │ │
│ │ └─────────────┘ └─────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
二、技术选型
| 层次 | 技术选型 | 说明 |
|---|---|---|
| 前端框架 | React 18 | 组件化开发,Hooks |
| 构建工具 | Vite | 快速构建,热更新 |
| UI组件库 | Ant Design Mobile | 移动端UI组件 |
| 后端框架 | Express.js | 轻量级Node.js框架 |
| ORM | Sequelize | MySQL ORM,支持事务 |
| 数据库 | MySQL 8.0+ | 关系型数据库,支持行锁 |
| 状态管理 | Zustand | 轻量级状态管理 |
| HTTP客户端 | Axios | 请求封装 |
| 部署 | Nginx + PM2 | 反向代理 + 进程管理 |
三、踩过的坑
1. 钉钉免登不是 OAuth2 的标准 code
钉钉的 dd.runtime.permission.requestAuthCode 返回的是 免登临时码(别被名字骗了),它不是标准 OAuth2 授权码,后端不能直接把它当成 OAuth2 的 code 来交换用户 token。
正确的 H5 微应用流程:
前端 → 获取 authCode → 后端:gettoken(GET)→ user/getuserinfo → user/get
实战要点:
- 确认 HTTP 方法(钉钉某些接口要求 GET/POST 必须严格遵守);
- 检查开发者后台权限(如
qyapi_get_member),没有权限会报“缺少权限”的错误; - 钉钉的
authCode是一次性且短时效,测试调试时要注意时效和重复使用问题。
2. 并发抽奖的问题与解法
问题:多人同时点击“抽奖”会把后端拉成一锅乱炖——可能出现重复中奖、计数错位等数据不一致问题。
解法概述:把“计数 + 判断 + 写入”放在同一事务内,并对关键记录加行锁(SELECT ... FOR UPDATE),由数据库保证并发安全。
核心流程:
- 在
lottery_state表里预先设定winning_index(比如随机 100–300)。 - 每次抽奖在事务中对
lottery_state做SELECT ... FOR UPDATE(Sequelize 的lock: transaction.LOCK.UPDATE)。 - 在事务里将
global_counter++,如果等于winning_index则标记中奖并lock状态;否则返回普通文案并更新用户为已抽。 - 提交事务。
这样就保证了“计数+判断+更新”在数据库层面是原子的,避免并发冲突。
3. 部署实务:CI 构建前端、目标机安装后端依赖
要点:
- 后端只打源码包
backend-src.tgz(不包含node_modules),打包时把backend/.env一起或另行在目标机配置。 - 目标机解压:前端放到
/var/www/lottery/frontend/dist(Nginxroot指向),后端放到/var/www/lottery/backend,执行npm ci --omit=dev,pm2 启动。 - Nginx:
/提供静态资源,/api/反向代理到127.0.0.1。
四、关键代码片段
抽奖核心(精简版)
// 事务内加锁读取 lottery_state
const t = await sequelize.transaction();
try {
const lottery = await LotteryState.findOne({ transaction: t, lock: t.LOCK.UPDATE });
if (lottery.is_locked) { /* 返回普通文案 */ }
lottery.global_counter += 1;
if (lottery.global_counter === lottery.winning_index) {
lottery.is_locked = true;
lottery.winner_id = userId;
// 更新用户为中奖
} else {
// 更新用户为未中奖的文案
}
await lottery.save({ transaction: t });
await t.commit();
} catch (e) { await t.rollback(); throw e; }
钉钉 H5 免登
- 前端:
dd.runtime.permission.requestAuthCode({ corpId }) - 后端:
GET gettoken-> corpTokenGET user/getuserinfo?authCode=xxx-> useridGET user/get?userid=xxx-> 详细信息
-
监控:
pm2 monit+ Nginx 访问日志 + MySQL 慢查询 -
限流:Nginx 层对
/api/lottery/draw做简单限流,防止刷接口 -
备份:
lottery_state和users表定期备份 -
灰度:如果要改中奖逻辑,先在测试环境跑并发用例
-
抽奖高峰用 Redis 做缓存热点数据,但计数/中奖仍需回落到关系型数据库以保证一致性。
-
想要更高可用:把后端容器化(Docker),用 Nginx + 多实例做负载均衡;数据库读写分离按需扩展。
五、经验教训
- 环境配置会拖你后腿:先搞定证书、钉钉权限、CI 变量。
- 并发不是日常的“小概率错误”,抽奖这种场景必须从一开始把并发和事务设计好。
- 文档好、例子多:把关键流程写到
README.md/技术设计文档,能救你一命。
六、结束语
做全栈像是去健身房练新肌肉:开始会酸,但你能在年会现场看别人“中大奖”的那一刻,感受到极大的成就感——以及大家的掌声。下次我可能做个“抽奖后康复指南”,告诉你如何优雅地接受夸奖。