Розробка ядра онлайн-казино — це складна інженерна задача, яка вимагає глибокого розуміння фінансових транзакцій, регуляторних вимог та високого навантаження. У цій статті розглянемо ключові компоненти, які складають серце будь-якого сучасного iGaming платформи.
Загальна архітектура
Ядро казино складається з декількох критичних сервісів, які працюють разом для забезпечення безперервної роботи:
┌─────────────────────────────────────────────────────┐
│ API Gateway │
│ (Rate Limiting, Auth) │
└─────────────────┬───────────────────────────────────┘
│
┌─────────┴─────────┬──────────────┬──────────┐
│ │ │ │
┌────▼────┐ ┌──────▼─────┐ ┌───▼────┐ ┌──▼──────┐
│ Game │ │ Wallet │ │Session │ │ User │
│ Engine │◄────►│ Service │ │Service │ │ Service │
└────┬────┘ └──────┬─────┘ └────────┘ └─────────┘
│ │
│ ┌──────▼─────┐
└──────────►│Transaction │
│ Pipeline │
└──────┬─────┘
│
┌──────▼─────┐
│ Journal │
│ Service │
└────────────┘
1. Game Engine — мозок казино
Game Engine відповідає за логіку ігор, комунікацію з провайдерами та обробку результатів.
Основні відповідальності:
Управління провайдерами ігор:
type GameProvider interface {
GetGameList() ([]Game, error)
LaunchGame(ctx context.Context, req LaunchRequest) (*LaunchResponse, error)
ProcessCallback(ctx context.Context, callback ProviderCallback) error
ValidateSignature(payload []byte, signature string) bool
}
Каталог ігор:
CREATE TABLE games (
id UUID PRIMARY KEY,
provider_id VARCHAR(50) NOT NULL,
game_code VARCHAR(100) NOT NULL,
name VARCHAR(255) NOT NULL,
category VARCHAR(50),
rtp DECIMAL(5,2),
volatility VARCHAR(20),
min_bet DECIMAL(10,2),
max_bet DECIMAL(10,2),
is_active BOOLEAN DEFAULT true,
metadata JSONB,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(provider_id, game_code)
);
CREATE INDEX idx_games_provider ON games(provider_id);
CREATE INDEX idx_games_category ON games(category) WHERE is_active = true;
Життєвий цикл гри:
type GameSession struct {
ID string
UserID string
GameID string
ProviderID string
Currency string
SessionURL string
StartedAt time.Time
ExpiresAt time.Time
Status SessionStatus
}
func (ge *GameEngine) LaunchGame(ctx context.Context, userID, gameID string) (*GameSession, error) {
// 1. Валідація користувача та гри
user, err := ge.userService.GetUser(ctx, userID)
if err != nil {
return nil, err
}
game, err := ge.getGame(ctx, gameID)
if err != nil {
return nil, err
}
// 2. Перевірка балансу
balance, err := ge.walletService.GetBalance(ctx, userID, user.Currency)
if err != nil {
return nil, err
}
if balance.Available < game.MinBet {
return nil, ErrInsufficientBalance
}
// 3. Створення сесії
session := &GameSession{
ID: generateUUID(),
UserID: userID,
GameID: gameID,
ProviderID: game.ProviderID,
Currency: user.Currency,
StartedAt: time.Now(),
ExpiresAt: time.Now().Add(4 * time.Hour),
Status: SessionStatusActive,
}
// 4. Запит до провайдера
provider := ge.getProvider(game.ProviderID)
launchResp, err := provider.LaunchGame(ctx, LaunchRequest{
UserID: userID,
GameCode: game.GameCode,
Currency: user.Currency,
SessionID: session.ID,
ReturnURL: ge.config.ReturnURL,
Language: user.Language,
})
if err != nil {
return nil, err
}
session.SessionURL = launchResp.URL
// 5. Збереження сесії
if err := ge.sessionRepo.Save(ctx, session); err != nil {
return nil, err
}
return session, nil
}
2. RGS (Remote Game Server) — віддалений сервер ігор
RGS — це сервер, який обробляє callback'и від провайдерів ігор (ставки, виграші, скасування).
Endpoint структура:
type RGSHandler struct {
transactionPipeline *TransactionPipeline
journalService *JournalService
walletService *WalletService
}
// POST /rgs/provider/{provider_id}/callback
func (h *RGSHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
providerID := chi.URLParam(r, "provider_id")
// 1. Валідація підпису
if !h.validateSignature(r, providerID) {
respondError(w, http.StatusUnauthorized, "Invalid signature")
return
}
// 2. Парсинг callback
var callback ProviderCallback
if err := json.NewDecoder(r.Body).Decode(&callback); err != nil {
respondError(w, http.StatusBadRequest, "Invalid payload")
return
}
// 3. Обробка транзакції
result, err := h.processTransaction(r.Context(), providerID, &callback)
if err != nil {
// Повертаємо специфічну помилку провайдеру
respondProviderError(w, err)
return
}
// 4. Відповідь провайдеру
respondJSON(w, http.StatusOK, result)
}
Типи callback'ів:
type CallbackType string
const (
CallbackTypeBet CallbackType = "bet"
CallbackTypeWin CallbackType = "win"
CallbackTypeRefund CallbackType = "refund"
CallbackTypeRollback CallbackType = "rollback"
)
type ProviderCallback struct {
TransactionID string `json:"transaction_id"`
RoundID string `json:"round_id"`
UserID string `json:"user_id"`
GameID string `json:"game_id"`
Type CallbackType `json:"type"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Timestamp time.Time `json:"timestamp"`
Reference string `json:"reference"`
Metadata interface{} `json:"metadata"`
}
3. Wallet Service — управління балансом
Wallet Service — це критичний компонент, який управляє грошима користувачів.
Структура балансу:
CREATE TABLE wallets (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
currency VARCHAR(3) NOT NULL,
balance DECIMAL(20,8) NOT NULL DEFAULT 0,
locked_balance DECIMAL(20,8) NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0,
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, currency),
CONSTRAINT balance_positive CHECK (balance >= 0),
CONSTRAINT locked_positive CHECK (locked_balance >= 0)
);
CREATE INDEX idx_wallets_user ON wallets(user_id);
Optimistic locking для конкурентності:
type WalletService struct {
db *sql.DB
}
func (ws *WalletService) UpdateBalance(
ctx context.Context,
userID string,
currency string,
amount decimal.Decimal,
txType TransactionType,
) error {
maxRetries := 3
for attempt := 0; attempt < maxRetries; attempt++ {
// 1. Читаємо поточний баланс з версією
wallet, err := ws.getWalletForUpdate(ctx, userID, currency)
if err != nil {
return err
}
// 2. Обчислюємо новий баланс
newBalance := wallet.Balance.Add(amount)
if newBalance.IsNegative() {
return ErrInsufficientBalance
}
// 3. Оновлюємо з перевіркою версії
result, err := ws.db.ExecContext(ctx, `
UPDATE wallets
SET balance = $1,
version = version + 1,
updated_at = NOW()
WHERE user_id = $2
AND currency = $3
AND version = $4
`, newBalance, userID, currency, wallet.Version)
if err != nil {
return err
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 1 {
return nil // Успіх!
}
// Version conflict - retry
time.Sleep(time.Millisecond * time.Duration(10*(attempt+1)))
}
return ErrVersionConflict
}
4. Transaction Pipeline — конвеєр транзакцій
Transaction Pipeline забезпечує атомарну обробку всіх фінансових операцій.
Етапи обробки:
type TransactionPipeline struct {
journalService *JournalService
walletService *WalletService
eventBus *EventBus
}
func (tp *TransactionPipeline) Process(
ctx context.Context,
tx *Transaction,
) (*TransactionResult, error) {
// 1. Перевірка дубліката (idempotency)
if existing, err := tp.journalService.GetByIdempotencyKey(
ctx, tx.IdempotencyKey,
); err == nil {
return existing.Result, nil // Вже оброблено
}
// 2. Валідація
if err := tp.validate(ctx, tx); err != nil {
return nil, err
}
// 3. Запис у журнал (pending)
journal := &JournalEntry{
ID: generateUUID(),
IdempotencyKey: tx.IdempotencyKey,
TransactionType: tx.Type,
UserID: tx.UserID,
Amount: tx.Amount,
Currency: tx.Currency,
Status: StatusPending,
CreatedAt: time.Now(),
}
if err := tp.journalService.Create(ctx, journal); err != nil {
return nil, err
}
// 4. Оновлення балансу
err := tp.walletService.UpdateBalance(
ctx,
tx.UserID,
tx.Currency,
tp.getAmountWithSign(tx),
tx.Type,
)
// 5. Оновлення статусу журналу
if err != nil {
journal.Status = StatusFailed
journal.Error = err.Error()
} else {
journal.Status = StatusCompleted
}
if err := tp.journalService.Update(ctx, journal); err != nil {
return nil, err
}
// 6. Публікація події
tp.eventBus.Publish(TransactionCompletedEvent{
JournalID: journal.ID,
UserID: tx.UserID,
Type: tx.Type,
Amount: tx.Amount,
Status: journal.Status,
})
return &TransactionResult{
Success: err == nil,
Balance: journal.BalanceAfter,
JournalID: journal.ID,
}, err
}
5. RTP та математика ігор
RTP (Return to Player) — це відсоток ставок, який повертається гравцям у вигляді виграшів.
Моніторинг RTP:
CREATE TABLE rtp_tracking (
game_id UUID NOT NULL,
provider_id VARCHAR(50) NOT NULL,
period_start TIMESTAMP NOT NULL,
period_end TIMESTAMP NOT NULL,
total_bets DECIMAL(20,2) NOT NULL,
total_wins DECIMAL(20,2) NOT NULL,
rounds_count BIGINT NOT NULL,
actual_rtp DECIMAL(5,2) GENERATED ALWAYS AS (
CASE
WHEN total_bets > 0
THEN (total_wins / total_bets * 100)
ELSE 0
END
) STORED,
PRIMARY KEY (game_id, period_start)
);
CREATE INDEX idx_rtp_tracking_period ON rtp_tracking(period_start, period_end);
Агрегація метрик:
type RTPCalculator struct {
db *sql.DB
}
func (rc *RTPCalculator) CalculateRTP(
ctx context.Context,
gameID string,
period time.Duration,
) (*RTPMetrics, error) {
var metrics RTPMetrics
err := rc.db.QueryRowContext(ctx, `
SELECT
COALESCE(SUM(amount), 0) as total_bets,
COALESCE(SUM(CASE WHEN type = 'win' THEN amount ELSE 0 END), 0) as total_wins,
COUNT(DISTINCT round_id) as rounds_count,
COUNT(*) as transactions_count
FROM transactions
WHERE game_id = $1
AND created_at > $2
AND type IN ('bet', 'win')
`, gameID, time.Now().Add(-period)).Scan(
&metrics.TotalBets,
&metrics.TotalWins,
&metrics.RoundsCount,
&metrics.TransactionsCount,
)
if err != nil {
return nil, err
}
if metrics.TotalBets > 0 {
metrics.ActualRTP = (metrics.TotalWins / metrics.TotalBets) * 100
}
return &metrics, nil
}
6. Journal Service — журнал подій та idempotency
Journal Service зберігає всі транзакції та забезпечує idempotency.
Структура журналу:
CREATE TABLE transaction_journal (
id UUID PRIMARY KEY,
idempotency_key VARCHAR(255) UNIQUE NOT NULL,
user_id UUID NOT NULL,
transaction_type VARCHAR(50) NOT NULL,
amount DECIMAL(20,8) NOT NULL,
currency VARCHAR(3) NOT NULL,
balance_before DECIMAL(20,8),
balance_after DECIMAL(20,8),
status VARCHAR(20) NOT NULL,
error_message TEXT,
provider_id VARCHAR(50),
game_id UUID,
round_id VARCHAR(255),
reference VARCHAR(255),
metadata JSONB,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_journal_idempotency ON transaction_journal(idempotency_key);
CREATE INDEX idx_journal_user ON transaction_journal(user_id, created_at DESC);
CREATE INDEX idx_journal_round ON transaction_journal(round_id) WHERE round_id IS NOT NULL;
Реалізація idempotency:
type JournalService struct {
db *sql.DB
}
func (js *JournalService) GetByIdempotencyKey(
ctx context.Context,
key string,
) (*JournalEntry, error) {
var entry JournalEntry
err := js.db.QueryRowContext(ctx, `
SELECT id, idempotency_key, user_id, transaction_type,
amount, currency, balance_after, status, error_message,
created_at
FROM transaction_journal
WHERE idempotency_key = $1
`, key).Scan(
&entry.ID,
&entry.IdempotencyKey,
&entry.UserID,
&entry.TransactionType,
&entry.Amount,
&entry.Currency,
&entry.BalanceAfter,
&entry.Status,
&entry.Error,
&entry.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
return &entry, err
}
Інтеграції з провайдерами
Адаптер для провайдера:
type ProviderAdapter interface {
Name() string
LaunchGame(ctx context.Context, req LaunchRequest) (*LaunchResponse, error)
ProcessCallback(ctx context.Context, callback ProviderCallback) (*CallbackResponse, error)
ValidateSignature(payload []byte, signature string) bool
}
type PragmaticPlayAdapter struct {
config PragmaticConfig
client *http.Client
}
func (pp *PragmaticPlayAdapter) ProcessCallback(
ctx context.Context,
callback ProviderCallback,
) (*CallbackResponse, error) {
// Специфічна логіка для Pragmatic Play
switch callback.Type {
case CallbackTypeBet:
return pp.processBet(ctx, callback)
case CallbackTypeWin:
return pp.processWin(ctx, callback)
case CallbackTypeRefund:
return pp.processRefund(ctx, callback)
default:
return nil, ErrUnsupportedCallbackType
}
}
Моніторинг та алерти
type MetricsCollector struct {
prometheus *prometheus.Registry
}
var (
transactionDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "casino_transaction_duration_seconds",
Help: "Transaction processing duration",
Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1},
},
[]string{"type", "status"},
)
activeGameSessions = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "casino_active_game_sessions",
Help: "Number of active game sessions",
},
)
walletBalance = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "casino_wallet_balance_total",
Help: "Total wallet balance by currency",
},
[]string{"currency"},
)
)
Висновок
Побудова ядра онлайн-казино вимагає:
- Надійної архітектури з чітким розділенням відповідальності
- Atomic транзакцій з гарантією idempotency
- Масштабованості для high-load сценаріїв
- Моніторингу всіх критичних метрик
- Аудиту кожної транзакції через журнал
У наступних статтях детально розглянемо:
- Як уникнути подвійних ставок та виплат
- Побудову session service для high-load
Питання? Пишіть у коментарях! 🎰
Top comments (0)