PHP项目中如何实现抽奖功能?

wen PHP项目 1

本文目录导读:

PHP项目中如何实现抽奖功能?

  1. 核心概念
  2. 概率算法实现
  3. 数据库设计与存储
  4. 完整抽奖服务类
  5. 并发控制与安全性
  6. 完整调用示例
  7. 注意事项
  8. 扩展优化思路

在PHP项目中实现抽奖功能,通常涉及概率算法设计数据库存储并发控制安全性处理,下面我会提供一个完整、可运行的实现方案。

核心概念

抽奖功能的关键要素包括:

  1. 奖品列表与概率:每个奖品有一个中奖概率,所有概率之和为100%
  2. 库存控制:奖品数量有限,需要实时扣减
  3. 用户限制:防止同一用户重复中奖或刷奖
  4. 公平随机:使用可靠的随机算法

概率算法实现

经典概率算法

<?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'];
}

注意事项

  1. 概率精度:建议使用浮点数(如decimal(5,2))存储概率,计算时转换为整数处理小数误差

  2. 库存原子性:一定要在数据库层面使用行锁或乐观锁保证库存扣减的原子性

  3. 回退机制:高并发场景下,当用户抽中一等奖后发现已被领走,需要有一个降级容错机制

  4. 测试环境:建议在测试环境使用固定的随机种子进行概率测试

  5. 日志记录:记录所有抽奖日志,包括IP、设备信息、用户ID、抽奖结果等,便于事后检查和防刷

  6. 法律合规:确保抽奖活动符合当地法律法规关于「有奖销售」的规定

扩展优化思路

  • 按固定时间段开放:用定时任务控制奖品的生效时段
  • 权重动态调整:根据用户等级、活跃度调整中奖概率
  • 保底机制:连续N次不中后必中一个奖品(使用计数器逻辑)
  • 奖品分级放量:越早参与中奖率越高,后续递减

希望这个完整的实现方案能帮到你!如果需要针对特定场景(如微信公众号抽奖、高并发秒杀场景)的优化,可以进一步讨论。

抱歉,评论功能暂时关闭!