public class Prize
{
public int Id { get; set; }
public string Name { get; set; } // 獎品名稱
public double Probability { get; set; } // 權重/機率 (總和不需為 1)
public int Level { get; set; } // 獎等 (1=特獎 ... 7=安慰獎)
public int Stock { get; set; } // 庫存量 (-1 表無限)
public bool IsActive { get; set; } // 是否上架
}
public interface ILotteryService
{
LotteryResult Draw(int userId);
}
public class LotteryResult
{
public bool Success { get; set; }
public string ErrorMessage { get; set; }
public Prize Prize { get; set; }
public int Angle { get; set; } // 前端旋轉角度
}
public class WeightedLotteryService : ILotteryService
{
private readonly AppDbContext _db;
private static readonly Random _rng = new(); // 單例避免種子重複
public WeightedLotteryService(AppDbContext db) => _db = db;
public LotteryResult Draw(int userId)
{
// 1. 取得有效獎品池
var prizes = _db.Prizes.Where(p => p.IsActive && (p.Stock == -1 || p.Stock > 0)).ToList();
if (!prizes.Any()) return Fail("目前無可用獎品");
// 2. 計算權重總和
double totalWeight = prizes.Sum(p => p.Probability);
if (totalWeight <= 0) return Fail("獎品權重配置錯誤");
// 3. 產生隨機點
double point = _rng.NextDouble() * totalWeight;
// 4. 落點判定 (O(n) 線性掃描,獎品數少時足夠;大量獎品可用前綴和+二分搜尋 O(log n))
double accum = 0;
Prize hit = null;
foreach (var p in prizes)
{
accum += p.Probability;
if (point <= accum) { hit = p; break; }
}
// 5. 扣減庫存 (樂觀鎖/交易確保一致性)
if (hit.Stock > 0)
{
hit.Stock--;
_db.SaveChanges();
}
// 6. 映射角度 (前端扇區排序需與後端一致)
int angle = MapLevelToAngle(hit.Level);
return new LotteryResult { Success = true, Prize = hit, Angle = angle };
}
private int MapLevelToAngle(int level) => level switch
{
1 => 26, // 特獎
2 => 282, // 一等獎
3 => 185, // 二等獎
4 => 137, // 三等獎
5 => 337, // 四等獎
6 => 235, // 五等獎
7 => 88, // 安慰獎
_ => 0
};
private LotteryResult Fail(string msg) => new() { Success = false, ErrorMessage = msg };
}
[ApiController]
[Route("api/lottery")]
public class LotteryController : ControllerBase
{
private readonly ILotteryService _lottery;
private readonly IUserService _user;
public LotteryController(ILotteryService lottery, IUserService user)
{
_lottery = lottery;
_user = user;
}
[HttpPost("draw")]
public async Task Draw()
{
// 1. 身份驗證 (示範用 Session/JWT)
var userId = GetCurrentUserId();
if (userId == null) return Ok(new { code = 1, msg = "請先登入" });
// 2. 業務檢查 (抽獎券、次數限制等)
if (!await _user.CanDrawAsync(userId.Value))
return Ok(new { code = -3, msg = "抽獎券不足" });
// 3. 執行抽獎
var result = _lottery.Draw(userId.Value);
if (!result.Success) return Ok(new { code = -1, msg = result.ErrorMessage });
// 4. 記錄中獎紀錄
await _lottery.RecordAsync(userId.Value, result.Prize.Id);
// 5. 回傳前端所需資料
return Ok(new
{
code = 0,
angle = result.Angle,
prize = result.Prize.Name,
id = result.Prize.Level // 對應前端 case
});
}
}
.turntable-container { position: relative; width: 400px; height: 400px; margin: 0 auto; }
.pointer { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 10; cursor: pointer; }
.rotate { position: absolute; top: 0; left: 0; width: 100%; height: 100%; transform-origin: center center; }
/* 切圖時確保指針指向 0° 對應特獎區中心,Level→Angle 映射才會準確 */
let isRotating = false;
$('#ptr').click(function() {
if (isRotating) return;
$.ajax({
url: '/api/lottery/draw',
type: 'POST',
dataType: 'json',
success: function(res) {
if (res.code === 1) { alert(res.msg); return; } // 未登入
if (res.code === -3) { alert(res.msg); return; } // 券不足
if (res.code !== 0) { alert('系統錯誤'); return; }
// 正常中獎流程
isRotating = true;
rotateWheel(res.angle, res.prize);
},
error: () => alert('網路錯誤')
});
});
function rotateWheel(targetAngle, prizeName) {
$('#wheel').rotate({
angle: 0,
animateTo: targetAngle + 1800, // 多轉 5 圈 (1800°) 增加懸念
duration: 8000, // 8 秒動畫
easing: $.easing.easeOutCubic,
callback: function() {
$('#prize-name').text(`恭喜獲得:${prizeName}`);
isRotating = false;
// 可選:延遲刷新頁面更新餘額
setTimeout(() => location.reload(), 3000);
}
});
}
| 風險 | 對策 |
|---|---|
| 前端 JS 竄改角度/獎品 | 後端唯一權威決定結果,前端僅呈現 |
| 重放攻擊 / 多次點擊 | 前端 isRotating 鎖 + 後端冪等 Token / Redis 分散式鎖 |
| 概率配置洩露 | 不在前端輸出機率,僅回傳中獎結果 |
| 庫存超賣 | 資料庫 RowLock / 樂觀鎖 (Version) / Redis Lua 原子扣減 |
| 內部人員操控 | 抽獎日誌不可篡改 (Append-only)、定期稽核 |
轉盤抽獎系統的核心在於「後端決定命運,前端演繹過程」。加權隨機算法簡單高效,配合嚴格的身份驗證、庫存扣減原子性、前端動畫與後端角度映射同步,即可構建公平、流暢、防作弊的抽獎體驗。記住:永遠不要信任客戶端傳來的任何參數。