我从前端菜鸟到全栈:年会抽奖系统实战(钉钉免登 + 并发解法)

35 阅读4分钟

我从前端菜鸟到全栈:年会抽奖系统实战(钉钉免登 + 并发解法)

年会抽奖看起来是小功能,实际上是并发和事务的考场——本篇把我在钉钉免登、抽奖并发处理与云效部署上的实战经验,拆成你能立刻复制的步骤。

一、系统概览

前端: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框架
ORMSequelizeMySQL 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/getuserinfouser/get

实战要点:

  • 确认 HTTP 方法(钉钉某些接口要求 GET/POST 必须严格遵守);
  • 检查开发者后台权限(如 qyapi_get_member),没有权限会报“缺少权限”的错误;
  • 钉钉的 authCode 是一次性且短时效,测试调试时要注意时效和重复使用问题。

2. 并发抽奖的问题与解法

问题:多人同时点击“抽奖”会把后端拉成一锅乱炖——可能出现重复中奖、计数错位等数据不一致问题。

解法概述:把“计数 + 判断 + 写入”放在同一事务内,并对关键记录加行锁(SELECT ... FOR UPDATE),由数据库保证并发安全。

核心流程:

  1. lottery_state 表里预先设定 winning_index(比如随机 100–300)。
  2. 每次抽奖在事务中对 lottery_stateSELECT ... FOR UPDATE(Sequelize 的 lock: transaction.LOCK.UPDATE)。
  3. 在事务里将 global_counter++,如果等于 winning_index 则标记中奖并 lock 状态;否则返回普通文案并更新用户为已抽。
  4. 提交事务。

这样就保证了“计数+判断+更新”在数据库层面是原子的,避免并发冲突。

deepseek_mermaid_20260311_d65a72.png


3. 部署实务:CI 构建前端、目标机安装后端依赖

要点:

  • 后端只打源码包 backend-src.tgz(不包含 node_modules),打包时把 backend/.env 一起或另行在目标机配置。
  • 目标机解压:前端放到 /var/www/lottery/frontend/dist(Nginx root 指向),后端放到 /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 })
  • 后端:
    1. GET gettoken -> corpToken
    2. GET user/getuserinfo?authCode=xxx -> userid
    3. GET user/get?userid=xxx -> 详细信息

  • 监控:pm2 monit + Nginx 访问日志 + MySQL 慢查询

  • 限流:Nginx 层对 /api/lottery/draw 做简单限流,防止刷接口

  • 备份:lottery_stateusers 表定期备份

  • 灰度:如果要改中奖逻辑,先在测试环境跑并发用例

  • 抽奖高峰用 Redis 做缓存热点数据,但计数/中奖仍需回落到关系型数据库以保证一致性。

  • 想要更高可用:把后端容器化(Docker),用 Nginx + 多实例做负载均衡;数据库读写分离按需扩展。


五、经验教训

  • 环境配置会拖你后腿:先搞定证书、钉钉权限、CI 变量。
  • 并发不是日常的“小概率错误”,抽奖这种场景必须从一开始把并发和事务设计好。
  • 文档好、例子多:把关键流程写到 README.md/技术设计文档,能救你一命。

六、结束语

做全栈像是去健身房练新肌肉:开始会酸,但你能在年会现场看别人“中大奖”的那一刻,感受到极大的成就感——以及大家的掌声。下次我可能做个“抽奖后康复指南”,告诉你如何优雅地接受夸奖。