Награды за голосование

Награды за голосование используют Webhook-события server.vote и project.vote: обработчик получает событие, запрашивает данные голоса через API, находит пользователя в вашей системе и выдает награду только один раз.

Сначала настройте Webhook проекта и проверку signature. Данные голоса запрашиваются через GET /votes/:vote_id.

Как работает сценарий

  1. Примите событие Webhook и проверьте signature. Если is_test равен true, верните 204 без запроса голоса и без выдачи награды.
  2. Убедитесь, что event_type равен server.vote или project.vote.
  3. Используйте event_id как ID голоса.
  4. Получите данные голоса через GET /votes/:vote_id и найдите игрока в своей системе.
  5. В одной транзакции примените защиту от повторной обработки по event_type + event_id и выдайте награду только для нового события.
  6. Если награду нельзя выдать безопасно, верните ответ с ошибкой. После исправления причины повторите доставку из интерфейса.

Пример выдачи награды

Допустим, игрок PlayerName проголосовал за сервер с ID 1, а ваша система должна начислить ему 100 монет.

  1. GAMEMONITORING отправляет Webhook с event_type: server.vote и event_id: 9824cabb-2203-437e-9b6c-aba43dde3e4b.
  2. Обработчик проверяет signature. Если подпись неверная, он возвращает 401 и останавливается.
  3. Обработчик запрашивает GET /votes/9824cabb-2203-437e-9b6c-aba43dde3e4b, получает никнейм, сервер и пользователя, затем находит локальный аккаунт.
  4. В транзакции обработчик сохраняет event_type + event_id для защиты от повторной обработки.
  5. Для нового события обработчик начисляет 100 монет в той же транзакции.
  6. При повторной доставке обработчик находит уже сохраненное событие, не начисляет награду повторно и возвращает 204.

Такой сценарий подходит не только для монет. Вместо баланса можно выдать предмет, роль, VIP-время, промокод или поставить задачу во внутреннюю очередь.

Событие голосования

Для голоса за сервер GAMEMONITORING отправляет server.vote, а для голоса за проект — project.vote. В теле события есть только данные доставки: event_type, event_id, is_test и signature. Полные данные голоса нужно запросить отдельно.

Пример события
{
  "event_id": "9824cabb-2203-437e-9b6c-aba43dde3e4b",
  "event_type": "server.vote",
  "is_test": false,
  "signature": "ae83b8aba88a3a9ab3b97b1f6d65664da5628a9cb64d56d5132807bca5472e4f"
}

В обоих событиях event_id является ID голоса. Не используйте тело Webhook как источник никнейма, сущности или пользователя: эти данные приходят из API.

Получение данных голоса

Используйте event_id как vote_id и запросите данные голоса через GET /votes/:vote_id:

Запрос данных голоса
curl -sS "https://api.gamemonitoring.ru/votes/9824cabb-2203-437e-9b6c-aba43dde3e4b"

Для выдачи награды используйте response.entity_type и response.entity_id, чтобы определить цель голоса. Голос за сервер также содержит response.server, а голос за проект — response.project. Оба типа содержат response.nickname и публичные данные response.user.

response.nickname помогает найти аккаунт в вашей базе, response.entity_type и response.entity_id выбирают правило награды, а response.user.id можно сохранить в журнале выдач. Всегда проверяйте, что для server.vote API вернул entity_type: server, а для project.voteentity_type: project.

Если API временно недоступен или вернул неожиданный ответ, не выдавайте награду без проверки. Верните код ошибки, исправьте причину и повторите доставку из интерфейса.

Шаг 3. Обработчик награды за голос

Пример продолжает базовый обработчик: он проверяет подпись, получает данные голоса, защищает событие от повторной обработки и начисляет награду в одной транзакции. Название таблицы пользователей, поле баланса и правило поиска игрока замените на структуру вашей системы.

Перед запуском примера настройте Webhook проекта, проверьте GET /votes/:vote_id и замените SQL-запросы обновления пользователя на вашу модель аккаунтов.

php
<?php
// Replace this token with the signing token from your GAMEMONITORING webhook settings.
$secret = 'paste-webhook-token-here';

// Add the GAMEMONITORING API URL and reward settings for vote events.
$apiUrl = 'https://api.gamemonitoring.ru';
$rewardAmount = '1.00';

// Read and decode the JSON body sent by GAMEMONITORING.
$event = json_decode(file_get_contents('php://input'), true) ?: [];

// Test deliveries are signed too. Normalize the boolean value to the lowercase
// string used by GAMEMONITORING when the signature is calculated.
$isTest = ($event['is_test'] ?? false) === true;
$signingData = array_replace($event, ['is_test' => $isTest ? 'true' : 'false']);

// Build the exact signing string: all body fields except signature,
// sorted by key and joined as key=value pairs with &.
$fields = array_values(array_filter(array_keys($event), fn($field) => $field !== 'signature'));
sort($fields, SORT_STRING);

// Calculate HMAC-SHA256 with the webhook token from your settings.
$signing = implode('&', array_map(fn($field) => $field . '=' . (string) ($signingData[$field] ?? ''), $fields));
$expected = hash_hmac('sha256', $signing, $secret);
$actual = (string) ($event['signature'] ?? '');

// Reject the request before doing any work when the signature is invalid.
if (!hash_equals($expected, $actual)) {
    http_response_code(401);
    exit;
}

// Test deliveries must not change balance, inventory, roles, or production data.
if ($isTest) {
    http_response_code(204);
    exit;
}

// Real deliveries must include an event type and a stable event id.
$eventType = (string) ($event['event_type'] ?? '');
$eventId = (string) ($event['event_id'] ?? '');

if ($eventType === '' || $eventId === '') {
    http_response_code(400);
    exit;
}

// This reward handler processes server and project vote events.
if (!in_array($eventType, ['server.vote', 'project.vote'], true)) {
    http_response_code(204);
    exit;
}

// At this point the webhook is trusted. Load vote data before opening a database transaction.
$pdo = null;

try {
    // Load full vote data by event_id. Nickname, entity, and user data are not
    // in the webhook body. Return 500 if the API cannot confirm the vote.
    $voteUrl = $apiUrl . '/votes/' . rawurlencode($eventId);
    $voteContext = stream_context_create(['http' => ['timeout' => 5]]);
    $voteBody = @file_get_contents($voteUrl, false, $voteContext);

    if ($voteBody === false) {
        throw new RuntimeException('Vote API request failed');
    }

    $voteResponse = json_decode($voteBody, true) ?: [];
    $vote = $voteResponse['response'] ?? null;

    // Do not issue a reward when the vote response is missing a concrete nickname.
    if (!is_array($vote) || !isset($vote['nickname']) || !is_string($vote['nickname'])) {
        throw new RuntimeException('Vote API response does not include nickname');
    }

    // Verify that the API entity matches the event before changing the account.
    $expectedEntityType = $eventType === 'project.vote' ? 'project' : 'server';
    if (($vote['entity_type'] ?? '') !== $expectedEntityType) {
        throw new RuntimeException('Vote entity type does not match event type');
    }

    // Use vote nickname to update the local account. The entity id is available in
    // vote.entity_id and in either vote.server.id or vote.project.id.
    $nickname = trim($vote['nickname']);

    if ($nickname === '') {
        throw new RuntimeException('Vote nickname is empty');
    }

    // Add your local database connection for deduplication and event-specific work.
    $pdo = new PDO('mysql:host=127.0.0.1;dbname=game;charset=utf8mb4', 'game', 'password', [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    ]);

    // Keep deduplication and the real state change in one transaction.
    // If any step fails, return 500 so the delivery can be retried.
    $pdo->beginTransaction();

    // Store the event once. This requires the table to have a unique key on
    // (event_type, event_id). Duplicate deliveries affect zero rows.
    $deduplicate = $pdo->prepare('INSERT IGNORE INTO gamemonitoring_webhooks (event_type, event_id) VALUES (?, ?)');
    $deduplicate->execute([$eventType, $eventId]);

    // The event was already processed earlier. Return success without changing
    // state again, because duplicate delivery is expected.
    if ($deduplicate->rowCount() === 0) {
        $pdo->commit();
        http_response_code(204);
        exit;
    }

    // Add event-specific database changes here. Keep them after the
    // deduplication insert and inside this same transaction.
    $balance = $pdo->prepare('UPDATE users SET balance = balance + ? WHERE nickname = ?');
    $balance->execute([$rewardAmount, $nickname]);

    // Commit only after deduplication and event-specific work both succeed.
    $pdo->commit();

    // Log only newly processed real events after the transaction succeeds.
    syslog(LOG_INFO, 'Accepted webhook event ' . $eventType . ' #' . $eventId);

    http_response_code(204);
} catch (Throwable $error) {
    // Roll back partial database work so the event can be retried safely.
    if ($pdo instanceof PDO && $pdo->inTransaction()) {
        $pdo->rollBack();
    }

    // 500 keeps the delivery failed instead of marking unfinished work as done.
    http_response_code(500);
}