抽獎系統的核心在於「加權隨機」:每個獎項擁有不同中獎機率,總和為 100%。本文提供一個經典、高效且易擴充的 PHP 實作,採用「區間映射法」,將機率轉換為整數區間,隨機生成一個數字落入哪個區間即中該獎。適合活動行銷、遊戲掉寶、優惠券發放等場景。
$prize[key] = 機率%,總和 = 100$prizeZonemt_rand(0, $max-1)<?php
/**
* 加權隨機抽獎函數
*
* @param array $prizes 獎項機率陣列 [key => 機率%],總和需為 100
* @param array $prizeList 獎項詳細資訊 [key => ['name', 'desc', ...]]
* @param int $precision 放大精度(10=小數點1位,100=小數點2位)
* @return mixed 中獎獎項的 key,若失敗回傳 false
*/
function weighted_lottery(array $prizes, array $prizeList, int $precision = 10) {
// 1. 驗證機率總和
$totalProb = array_sum($prizes);
if (abs($totalProb - 100) > 0.0001) {
trigger_error("獎項機率總和必須為 100%,目前為:$totalProb%", E_USER_WARNING);
return false;
}
// 2. 建立累積區間
$prizeZone = [];
$max = 0;
foreach ($prizes as $k => $v) {
$max += $v * $precision;
$prizeZone[] = ['max' => $max, 'key' => $k];
}
$max--; // 最大隨機值
// 3. 產生隨機數
$rand = mt_rand(0, $max);
// 4. 二分搜尋區間(效能優於線性遍歷,獎項多時顯著)
$low = 0;
$high = count($prizeZone) - 1;
while ($low <= $high) {
$mid = (($low + $high) >> 1);
if ($rand > $prizeZone[$mid]['max']) {
$low = $mid + 1;
} else {
$high = $mid - 1;
}
}
$winnerKey = $prizeZone[$low]['key'] ?? false;
return $winnerKey;
}
/* ===== 使用範例 ===== */
// 獎項機率設定(總和 100%)
$prizes = [
1 => 3.5, // 3.5%
2 => 2.5, // 2.5%
3 => 6, // 6%
4 => 2, // 2%
5 => 42, // 42%(謝謝參與)
6 => 36, // 36%
7 => 2, // 2%
8 => 4, // 4%
9 => 2, // 2%
];
// 獎項詳細資料
$prizeList = [
1 => ['name' => '小仙子', 'type' => 'virtual'],
2 => ['name' => '5 Q幣', 'type' => 'currency'],
3 => ['name' => '38,888 遊戲幣', 'type' => 'coin'],
4 => ['name' => '88,888 遊戲幣', 'type' => 'coin'],
5 => ['name' => '謝謝參與!', 'type' => 'nothing'],
6 => ['name' => '歡迎下次再來!', 'type' => 'nothing'],
7 => ['name' => '10 Q幣', 'type' => 'currency'],
8 => ['name' => '888 銀元寶(綁定)', 'type' => 'item'],
9 => ['name' => '隨機高級坐騎(綁定)', 'type' => 'item'],
];
// 執行抽獎
$winnerKey = weighted_lottery($prizes, $prizeList, 10); // 精度 10(支援小數點 1 位)
if ($winnerKey !== false) {
$prize = $prizeList[$winnerKey];
echo "🎉 恭喜中獎:{$prize['name']} (Key: $winnerKey)";
// 這裡可接入資料庫寫入中獎紀錄、扣除庫存、發送通知等邏輯
} else {
echo "抽獎失敗,請檢查機率設定。";
}
?>
// 從資料庫讀取獎項設定
$prizes = [];
$prizeList = [];
$stmt = $pdo->query("SELECT `id`, `probability`, `name`, `type`, `stock` FROM `lottery_prizes` WHERE `status`=1");
while ($row = $stmt->fetch()) {
$prizes[$row['id']] = (float)$row['probability'];
$prizeList[$row['id']] = $row;
}
function weighted_lottery_with_stock(array $prizes, array $prizeList, int $precision = 10) {
// 過濾掉庫存為 0 的獎項
$available = [];
foreach ($prizes as $k => $v) {
if (($prizeList[$k]['stock'] ?? 1) > 0) {
$available[$k] = $v;
}
}
// 重新正規化機率(使總和仍為 100%)
$sum = array_sum($available);
if ($sum === 0) return false; // 全部送完
foreach ($available as $k => $v) {
$available[$k] = round($v * 100 / $sum, 4);
}
// 微調最後一項確保總和精確 100
$keys = array_keys($available);
$lastKey = end($keys);
$available[$lastKey] = 100 - array_sum(array_slice($available, 0, -1));
return weighted_lottery($available, $prizeList, $precision);
}
// Redis 實作:每日限抽 3 次
$redisKey = "lottery:limit:{$userId}:".date('Ymd');
$todayCount = $redis->incr($redisKey);
if ($todayCount === 1) $redis->expire($redisKey, 86400);
if ($todayCount > 3) {
die(json_encode(['code' => 429, 'msg' => '今日抽獎次數已用完']));
}
$pdo->beginTransaction();
try {
// 1. 扣減庫存
$pdo->prepare("UPDATE `lottery_prizes` SET `stock`=`stock`-1 WHERE `id`=? AND `stock`>0")->execute([$winnerKey]);
// 2. 寫入中獎記錄
$pdo->prepare("INSERT INTO `lottery_log` (`user_id`, `prize_id`, `prize_name`, `created_at`) VALUES (?, ?, ?, NOW())")
->execute([$userId, $winnerKey, $prizeList[$winnerKey]['name']]);
$pdo->commit();
} catch (Exception $e) {
$pdo->rollBack();
// 記錄錯誤日誌
}
| 精度 | 最大區間值 | 適用場景 | 記憶體佔用 |
|---|---|---|---|
| 10 (0.1%) | 1,000 | 一般活動、遊戲掉寶 | 極低 |
| 100 (0.01%) | 10,000 | 精細機率控制 | 低 |
| 1000 (0.001%) | 100,000 | 極高精度需求 | 中 |
建議:一般商業活動 precision = 100 即足夠(支援 0.01% 機率),且 mt_rand 最大值 2^31-1 遠大於所需區間。
UPDATE ... WHERE stock > 0 + 事務,或引入 Redis Lua 腳本原子扣減。function test_lottery_distribution() {
$prizes = ['A' => 70, 'B' => 20, 'C' => 10];
$prizeList = ['A' => ['name' => 'A'], 'B' => ['name' => 'B'], 'C' => ['name' => 'C']];
$counts = ['A' => 0, 'B' => 0, 'C' => 0];
$trials = 100000;
for ($i = 0; $i < $trials; $i++) {
$k = weighted_lottery($prizes, $prizeList, 10);
$counts[$k]++;
}
foreach ($counts as $k => $v) {
$actual = round($v / $trials * 100, 2);
$expected = $prizes[$k];
echo "$k: 期望 $expected%, 實際 $actual% (誤差 " . abs($actual - $expected) . "%)\n";
}
}
// 執行測試:誤差通常在 ±0.2% 內,符合統計學預期