本文目录导读:

在PHP项目中实现抽奖功能,通常涉及概率算法设计、数据库存储、并发控制和安全性处理,下面我会提供一个完整、可运行的实现方案。
核心概念
抽奖功能的关键要素包括:
- 奖品列表与概率:每个奖品有一个中奖概率,所有概率之和为100%
- 库存控制:奖品数量有限,需要实时扣减
- 用户限制:防止同一用户重复中奖或刷奖
- 公平随机:使用可靠的随机算法
概率算法实现
经典概率算法
<?php
/**
* 根据概率抽取奖品
*
* @param array $prizes 奖品列表,每个元素包含prize_id, name, probability, stock
* @return array 中奖结果
*/
function drawPrize(array $prizes): array {
// 1. 过滤库存为0的奖品
$availablePrizes = array_filter($prizes, function($p) {
return $p['stock'] > 0;
});
if (empty($availablePrizes)) {
return ['code' => 0, 'message' => '奖品已全部抽完'];
}
// 2. 重置索引为连续数字
$availablePrizes = array_values($availablePrizes);
// 3. 计算概率总和(应接近100%)
$totalProbability = array_sum(array_column($availablePrizes, 'probability'));
if ($totalProbability <= 0) {
return ['code' => 0, 'message' => '概率配置错误'];
}
// 4. 生成随机数
$randNum = mt_rand(1, $totalProbability * 100) / 100;
// 5. 遍历奖品,累加概率,判断落在哪个区间
$current = 0;
foreach ($availablePrizes as $prize) {
$current += $prize['probability'];
if ($randNum <= $current) {
return [
'code' => 1,
'prize' => $prize,
'message' => '恭喜您抽中了' . $prize['name']
];
}
}
// 理论上不会到这里,但安全起见
return ['code' => 0, 'message' => '未中奖'];
}
// 示例奖品配置(概率总和为100%)
$prizes = [
['prize_id' => 1, 'name' => '一等奖 iPhone', 'probability' => 1, 'stock' => 2],
['prize_id' => 2, 'name' => '二等奖 平板', 'probability' => 5, 'stock' => 10],
['prize_id' => 3, 'name' => '三等奖 耳机', 'probability' => 14, 'stock' => 50],
['prize_id' => 4, 'name' => '四等奖 优惠券', 'probability' => 30, 'stock' => 200],
['prize_id' => 5, 'name' => '谢谢参与', 'probability' => 50, 'stock' => 9999],
];
$result = drawPrize($prizes);
print_r($result);
带权重的备选实现(支持非百分比权重)
function weightedRandom(array $items): ?array {
// 计算总权重
$totalWeight = array_sum(array_column($items, 'weight'));
if ($totalWeight <= 0) return null;
$random = mt_rand(1, $totalWeight);
$current = 0;
foreach ($items as $item) {
$current += $item['weight'];
if ($random <= $current) {
return $item;
}
}
return null;
}
数据库设计与存储
SQL设计示例
CREATE TABLE `prizes` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL COMMENT '奖品名称', `probability` decimal(5,2) NOT NULL DEFAULT '0.00' COMMENT '中奖概率', `total_stock` int(11) NOT NULL DEFAULT '0' COMMENT '总库存', `remaining_stock` int(11) NOT NULL DEFAULT '0' COMMENT '剩余库存', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态 1启用 0禁用', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `user_prize_records` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL COMMENT '用户ID', `prize_id` int(11) NOT NULL COMMENT '奖品ID', `prize_name` varchar(100) NOT NULL COMMENT '奖品名称', `draw_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '抽奖时间', `ip_address` varchar(50) DEFAULT NULL COMMENT 'IP地址', `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '状态 0待领取 1已领取 2已过期', `expire_time` datetime DEFAULT NULL COMMENT '过期时间', PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_draw_time` (`draw_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
完整抽奖服务类
<?php
class LotteryService
{
private $db; // PDO实例或数据库连接
private $config;
public function __construct($db, $config = [])
{
$this->db = $db;
$this->config = array_merge([
'daily_limit' => 3, // 每日抽奖次数限制
'cooldown_seconds' => 5, // 抽奖间隔(秒)
'debug' => false
], $config);
}
/**
* 执行抽奖
*/
public function draw(int $userId): array
{
// 1. 检查用户当日抽奖次数
if (!$this->checkDailyLimit($userId)) {
return ['success' => false, 'message' => '今日抽奖次数已用完'];
}
// 2. 检查抽奖间隔
if (!$this->checkCooldown($userId)) {
return ['success' => false, 'message' => '操作太快,请稍后再试'];
}
// 3. 获取可用奖品列表(库存>0且启用)
$prizes = $this->getAvailablePrizes();
if (empty($prizes)) {
return ['success' => false, 'message' => '暂无可用奖品'];
}
// 4. 概率抽奖
$result = $this->calculatePrize($prizes);
$prize = $result['prize'] ?? null;
try {
// 5. 使用事务保证库存一致性
$this->db->beginTransaction();
if ($prize && $prize['prize_id'] > 0) {
// 检查是否有特殊限制(如一等奖限制)
if ($prize['is_rare'] ?? false) {
if (!$this->checkRarePrizeLimit($userId, $prize['prize_id'])) {
// 一等奖已被人抽中,降级处理
$prize = $this->getFallbackPrize($prizes);
}
}
// 扣减库存
$affected = $this->deductStock($prize['prize_id']);
if ($affected === 0) {
// 库存不足,回退
$this->db->rollBack();
return ['success' => false, 'message' => '奖品已被抽完'];
}
// 记录中奖记录
$this->recordWin($userId, $prize['prize_id'], $prize['name']);
} else {
// 未中奖,记录参与记录(谢谢参与)
$this->recordLose($userId);
}
$this->db->commit();
return [
'success' => true,
'is_win' => ($prize && $prize['prize_id'] > 0),
'prize_name' => $prize['name'] ?? '谢谢参与',
'prize_id' => $prize['prize_id'] ?? 0
];
} catch (\Exception $e) {
$this->db->rollBack();
if ($this->config['debug']) {
throw $e;
}
return ['success' => false, 'message' => '系统繁忙,请稍后再试'];
}
}
/**
* 检查每日抽奖次数
*/
private function checkDailyLimit(int $userId): bool
{
$today = date('Y-m-d');
$sql = "SELECT COUNT(*) FROM user_prize_records
WHERE user_id = ? AND DATE(draw_time) = ?";
$stmt = $this->db->prepare($sql);
$stmt->execute([$userId, $today]);
$count = (int) $stmt->fetchColumn();
return $count < $this->config['daily_limit'];
}
/**
* 检查抽奖间隔
*/
private function checkCooldown(int $userId): bool
{
$sql = "SELECT MAX(draw_time) FROM user_prize_records
WHERE user_id = ?";
$stmt = $this->db->prepare($sql);
$stmt->execute([$userId]);
$lastDraw = $stmt->fetchColumn();
if (!$lastDraw) return true;
$diff = time() - strtotime($lastDraw);
return $diff >= $this->config['cooldown_seconds'];
}
/**
* 获取可用奖品
*/
private function getAvailablePrizes(): array
{
$sql = "SELECT id as prize_id, name, probability, remaining_stock
FROM prizes
WHERE status = 1 AND remaining_stock > 0";
$stmt = $this->db->query($sql);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
/**
* 概率计算核心
*/
private function calculatePrize(array $prizes): array
{
// 对概率进行处理(可能需要调整使总和等于100%)
$total = array_sum(array_column($prizes, 'probability'));
if ($total <= 0) return ['prize' => null];
$rand = mt_rand(1, $total * 100) / 100;
$current = 0;
foreach ($prizes as $prize) {
$current += $prize['probability'];
if ($rand <= $current) {
return ['prize' => $prize];
}
}
return ['prize' => null];
}
/**
* 扣减库存(带行锁保护)
*/
private function deductStock(int $prizeId): int
{
$sql = "UPDATE prizes
SET remaining_stock = remaining_stock - 1
WHERE id = ? AND remaining_stock > 0";
$stmt = $this->db->prepare($sql);
$stmt->execute([$prizeId]);
return $stmt->rowCount();
}
/**
* 记录中奖
*/
private function recordWin(int $userId, int $prizeId, string $prizeName): void
{
$sql = "INSERT INTO user_prize_records
(user_id, prize_id, prize_name, status, expire_time)
VALUES (?, ?, ?, 0, DATE_ADD(NOW(), INTERVAL 7 DAY))";
$stmt = $this->db->prepare($sql);
$stmt->execute([$userId, $prizeId, $prizeName]);
}
/**
* 记录未中奖
*/
private function recordLose(int $userId): void
{
$sql = "INSERT INTO user_prize_records
(user_id, prize_id, prize_name, status)
VALUES (?, 0, '谢谢参与', 0)";
$stmt = $this->db->prepare($sql);
$stmt->execute([$userId, '谢谢参与']);
}
/**
* 检查稀有奖品限制(例如一等奖只允许一个人中)
*/
private function checkRarePrizeLimit(int $userId, int $prizeId): bool
{
$sql = "SELECT COUNT(*) FROM user_prize_records
WHERE prize_id = ? AND prize_id != 0";
$stmt = $this->db->prepare($sql);
$stmt->execute([$prizeId]);
return (int)$stmt->fetchColumn() === 0;
}
/**
* 降级奖品(一等奖已被领走时给二等奖)
*/
private function getFallbackPrize(array $prizes): array
{
// 排除一等奖,返回二等奖
foreach ($prizes as $p) {
if ($p['probability'] > 1) {
return $p;
}
}
return $prizes[array_rand($prizes)];
}
}
并发控制与安全性
使用Redis锁防止超发
// 在deductStock前检查Redis分布式锁
public function drawWithRedisLock(int $userId): array
{
$lockKey = 'lottery:draw:' . $userId;
$lock = $this->redis->setnx($lockKey, time(), 3); // 3秒超时
if (!$lock) {
return ['success' => false, 'message' => '操作太频繁'];
}
try {
return $this->draw($userId);
} finally {
$this->redis->del($lockKey);
}
}
用户抽奖限制检查
// 用Redis记录用户抽奖次数(比数据库快)
public function getUserDailyDraws(int $userId): int
{
$key = 'lottery:daily:' . $userId . ':' . date('Ymd');
$count = $this->redis->get($key);
if ($count === false) {
// 从数据库同步
$count = $this->db->getDailyDraws($userId);
$this->redis->setex($key, 86400, $count);
}
return (int)$count;
}
防止脚本刷奖
// 检查IP频率
public function checkIpFrequency(string $ip): bool
{
$key = 'lottery:ip:' . md5($ip);
$count = $this->redis->incr($key);
if ($count === 1) {
$this->redis->expire($key, 60); // 60秒内
}
return $count <= $this->config['ip_limit_per_minute'];
}
// 检查设备指纹
public function checkDeviceFingerprint(string $fingerprint): bool
{
$key = 'lottery:device:' . $fingerprint;
return (int)$this->redis->get($key) < $this->config['device_daily_limit'];
}
完整调用示例
<?php
// 1. 数据库配置
$pdo = new PDO('mysql:host=localhost;dbname=lottery;charset=utf8mb4', 'root', 'password');
// 2. 初始化服务
$lottery = new LotteryService($pdo, [
'daily_limit' => 5,
'cooldown_seconds' => 3
]);
// 3. 假设用户已登录
$userId = 123;
// 4. 检查是否还有抽奖资格
if (!$lottery->checkDailyLimit($userId)) {
die('今日抽奖次数已用完');
}
// 5. 抽奖
$result = $lottery->draw($userId);
// 6. 返回结果
if ($result['success']) {
if ($result['is_win']) {
echo "🎉 恭喜您获得:" . $result['prize_name'];
// 这里可以引导用户填写收货地址
} else {
echo "😅 谢谢参与,下次加油!";
}
} else {
echo "❌ 抽奖失败:" . $result['message'];
}
注意事项
-
概率精度:建议使用浮点数(如decimal(5,2))存储概率,计算时转换为整数处理小数误差
-
库存原子性:一定要在数据库层面使用行锁或乐观锁保证库存扣减的原子性
-
回退机制:高并发场景下,当用户抽中一等奖后发现已被领走,需要有一个降级容错机制
-
测试环境:建议在测试环境使用固定的随机种子进行概率测试
-
日志记录:记录所有抽奖日志,包括IP、设备信息、用户ID、抽奖结果等,便于事后检查和防刷
-
法律合规:确保抽奖活动符合当地法律法规关于「有奖销售」的规定
扩展优化思路
- 按固定时间段开放:用定时任务控制奖品的生效时段
- 权重动态调整:根据用户等级、活跃度调整中奖概率
- 保底机制:连续N次不中后必中一个奖品(使用计数器逻辑)
- 奖品分级放量:越早参与中奖率越高,后续递减
希望这个完整的实现方案能帮到你!如果需要针对特定场景(如微信公众号抽奖、高并发秒杀场景)的优化,可以进一步讨论。