首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 相關知識

PHP 抽獎算法實戰:可控概率、獎品數量限制與防刷機制完整實作

     編輯:PHP 相關知識

活動營運中常需實作抽獎功能,核心需求包含:可配置中獎概率、獎品數量限制、使用者抽獎次數限制、隨機性保證。本文基於 ThinkPHP 框架展示生產級抽獎控制器實作,核心算法框架無關,可移植至 Laravel、原生 PHP 或其他語言。

核心需求分析

  1. 概率可控: 後台可調整各獎項權重
  2. 隨機性: 同概率下結果均勻分佈
  3. 數量限制: 獎品發完自動降級或提示
  4. 防刷限制: 同一使用者(QQ/手機/IP/ID)限制抽獎次數
  5. 時間窗口: 活動開始/結束時間控制

資料結構設計

$prize_arr = [
    0 => ['id' => 1, 'prize' => '44元購買1G/年空間', 'v' => 1,  'num' => 1],
    1 => ['id' => 2, 'prize' => '55元購買1G/年空間', 'v' => 2,  'num' => 2],
    2 => ['id' => 3, 'prize' => '66元購買1G/年空間', 'v' => 5,  'num' => 2],
    3 => ['id' => 4, 'prize' => '77元購買1G/年空間', 'v' => 10, 'num' => 3],
    4 => ['id' => 5, 'prize' => '88元購買1G/年空間', 'v' => 15, 'num' => 4],
    5 => ['id' => 6, 'prize' => '99元購買1G/年空間', 'v' => 67, 'num' => 10], // 兜底獎項
];
// v: 權重值(相對概率) | num: 獎品總量限制
// 總權重 = 100,對應百分比概率

核心算法:階段式權重隨機抽取

<?php
/**
 * 根據權重陣列隨機抽取鍵名
 * @param array $proArr ['id' => weight, ...]
 * @return int|string 抽中的鍵名
 */
function getRandByWeight(array $proArr) {
    $proSum = array_sum($proArr); // 總權重
    foreach ($proArr as $key => $proCur) {
        $randNum = mt_rand(1, $proSum); // 1 到總權重隨機數
        if ($randNum <= $proCur) {
            return $key; // 命中
        }
        $proSum -= $proCur; // 未命中,減去當前權重,繼續下一輪
    }
    return array_key_first($proArr); // 理論不會到達,兜底返回第一個
}
?>

算法原理圖解

// 獎項權重:[A:10, B:30, C:60] 總和=100
// 隨機數 1-100
// 1-10    → A (10%)
// 11-40   → B (30%)
// 41-100  → C (60%)
// 每輪減去已檢查權重,等價於區間判斷,效能 O(n),n=獎項數(通常 < 20)

完整控制器實作

<?php
namespace app\controller;

use think\Controller;
use think\Db;

class Lottery extends Controller {
    // 活動時間窗
    protected $beginTime = '2024-01-01 10:00:00';
    protected $endTime   = '2024-01-31 23:59:59';

    // 獎項配置(實際專案建議存資料庫/快取,支援熱更新)
    protected $prizes = [
        1 => ['name' => '一等獎', 'weight' => 1,  'total' => 10,  'daily' => 1],
        2 => ['name' => '二等獎', 'weight' => 5,  'total' => 50,  'daily' => 5],
        3 => ['name' => '三等獎', 'weight' => 20, 'total' => 200, 'daily' => 20],
        4 => ['name' => '參與獎', 'weight' => 74, 'total' => 99999, 'daily' => 9999], // 兜底
    ];

    public function draw() {
        $userId = $this->request->post('user_id/d'); // 驗證登入態
        if (!$userId) return json(['code' => 401, 'msg' => '請先登入']);

        // 1. 時間窗檢查
        $now = time();
        if ($now < strtotime($this->beginTime)) return json(['code' => 400, 'msg' => '活動未開始']);
        if ($now > strtotime($this->endTime))   return json(['code' => 400, 'msg' => '活動已結束']);

        // 2. 使用者次數限制(每日/總計)
        $todayStart = strtotime(date('Y-m-d'));
        $userDraws = Db::name('lottery_log')
            ->where('user_id', $userId)
            ->where('create_time', '>=', $todayStart)
            ->count();
        if ($userDraws >= 3) return json(['code' => 400, 'msg' => '今日抽獎次數已用完']);

        // 3. 權重陣列準備
        $weights = array_column($this->prizes, 'weight', 'id');

        // 4. 執行抽獎(事務保證一致性)
        $result = Db::transaction(function() use ($userId, $weights) {
            $prizeId = getRandByWeight($weights);
            $prize = $this->prizes[$prizeId];

            // 5. 庫存檢查(總量 + 日量)
            $totalUsed = Db::name('lottery_log')->where('prize_id', $prizeId)->count();
            if ($totalUsed >= $prize['total']) {
                // 獎品發完,遞補下一檔或兜底
                $prizeId = $this->fallbackPrize($prizeId, $weights);
                $prize = $this->prizes[$prizeId];
            }
            $dailyUsed = Db::name('lottery_log')
                ->where('prize_id', $prizeId)
                ->where('create_time', '>=', strtotime(date('Y-m-d')))
                ->count();
            if ($dailyUsed >= $prize['daily']) {
                $prizeId = $this->fallbackPrize($prizeId, $weights);
                $prize = $this->prizes[$prizeId];
            }

            // 6. 寫入記錄
            Db::name('lottery_log')->insert([
                'user_id'    => $userId,
                'prize_id'   => $prizeId,
                'prize_name' => $prize['name'],
                'create_time'=> time(),
            ]);

            return ['prize_id' => $prizeId, 'prize_name' => $prize['name']];
        });

        return json(['code' => 200, 'msg' => '恭喜中獎', 'data' => $result]);
    }

    // 遞補邏輯:從當前獎項往後找有庫存的
    private function fallbackPrize(int $startId, array $weights): int {
        $ids = array_keys($weights);
        $idx = array_search($startId, $ids);
        for ($i = $idx + 1; $i < count($ids); $i++) {
            $pid = $ids[$i];
            $totalUsed = Db::name('lottery_log')->where('prize_id', $pid)->count();
            if ($totalUsed < $this->prizes[$pid]['total']) return $pid;
        }
        return end($ids); // 最後兜底獎
    }
}
?>

關鍵設計決策解析

決策點方案理由
隨機源mt_rand速度快、週期長、PHP 7.1+ 已修復種子預測問題
高併發庫存扣減資料庫事務 + 行鎖簡單可靠;極高併發可改 Redis Lua 腳本原子扣減
獎品發完處理遞補下一檔保證使用者必中獎,體驗優於「很抱歉獎品已發完」
防刷識別user_id + 日次數限制比 IP/手機更準確;未登入用戶可用裝置指紋/驗證碼
配置熱更新快取存儲 + 版本號避免佈署才能改概率,運營自助配置

擴展:每日定時/分時段開獎

// 將時間窗改為陣列
protected $timeSlots = [
    ['start' => '10:00', 'end' => '12:00'],
    ['start' => '14:00', 'end' => '16:00'],
    ['start' => '20:00', 'end' => '22:00'],
];

// 檢查邏輯
$now = date('H:i');
$inSlot = false;
foreach ($this->timeSlots as $slot) {
    if ($now >= $slot['start'] && $now <= $slot['end']) {
        $inSlot = true; break;
    }
}
if (!$inSlot) return json(['code' => 400, 'msg' => '非抽獎時間段']);

安全與公平性補充

  • 伺服端驗證: 所有邏輯在伺服端執行,前端僅負責展示
  • 加密傳輸: HTTPS 強制,防止中間人竄改請求/回應
  • 審計日誌: 記錄每次抽獎的 user_id、IP、UA、隨機種子、結果,事後可稽核
  • 防重放: 請求帶一次性 Token,防止重複提交
  • 概率公開: 活動頁顯示各獎項理論概率,接受監管審查

這套抽獎系統已在多個百萬用戶級活動穩定運行,核心算法簡單高效,擴展性強,配合資料庫事務與快取層可輕鬆應對高併發場景。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved