aslain.dev
0%
01 Hizmetler 02 Hakkımda 03 Projeler 04 Stack 05 Blog 06 İletişim
← Tüm makaleler Metin2

Metin2 Market Sistemi: Cash Shop ve Web Ödeme Entegrasyonu

Bir metin2 market sistemi, oyuncunun gerçek para (ya da bağış puanı) ile satın aldığı premium para birimini oyun içinde harcayabildiği cash shop / item mall mekanizmasıdır. İşin zor kısmı tek bir özellik değil, üç ayrı dünyanın güvenli biçimde birbirine bağlanmasıdır: web sitesindeki ödeme sağlayıcısı, MySQL veritabanı ve game core. Bu rehberde coin'in cüzdandan oyuncunun envanterine kadar nasıl aktığını ve hangi noktalarda işin nasıl ters gidebileceğini kendi sunucumda kullandığım mimariyle anlatıyorum.

Sistemin üç katmanı

Market sistemini tek bir betik gibi düşünmeyin; birbiriyle konuşan üç katman vardır:

  • Web ödeme katmanı: Oyuncu siteden paket alır, ödeme sağlayıcısı (PayPal, Stripe, PaySafeCard, mobil ödeme) sonucu bir callback ile bildirir. Bu katman hesaba coin yükler.
  • Veritabanı katmanı: Coin bakiyesi account veritabanında tutulur. Satın almalar ve coin hareketleri ayrı log tablolarına yazılır.
  • Oyun içi item mall: Oyuncu mağazayı açar, coin'iyle eşya alır ve eşya envanterine güvenli şekilde teslim edilir.

Bu üç katmanı doğrudan birbirine yazdırmaya çalışmak (örneğin web'in player.item tablosuna eşya basması) Metin2'de en sık yapılan ölümcül hatadır — nedenine birazdan geleceğiz.

Veritabanı şeması

Önce kalıcılık katmanını kuralım. Coin bakiyesini hesap tablosuna ekliyoruz, ayrıca her hareketi denetlenebilir kılmak için bir işlem günlüğü oluşturuyoruz. account veritabanında:

ALTER TABLE account.account
  ADD COLUMN `coins` BIGINT UNSIGNED NOT NULL DEFAULT 0;

CREATE TABLE account.coin_log (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `account_id` INT UNSIGNED NOT NULL,
  `delta` BIGINT NOT NULL,           -- + yükleme, - harcama
  `balance_after` BIGINT UNSIGNED NOT NULL,
  `reason` VARCHAR(32) NOT NULL,      -- 'payment','mall_buy','refund'
  `ref` VARCHAR(64) NOT NULL,         -- sağlayıcı işlem no / eşya vnum
  `created_at` DATETIME NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_payment` (`reason`,`ref`),
  KEY `account_id` (`account_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;

uniq_payment bileşik tekil anahtarı kritik: aynı ödeme bildirimi iki kez gelse bile coin iki kez yüklenemez. Buna idempotency denir ve ödeme entegrasyonunun belkemiğidir.

Web ödeme entegrasyonu

Ödeme sağlayıcısı, alışveriş tamamlanınca sunucunuza bir HTTP isteği (webhook/callback) gönderir. Kuralları nettir: asla tarayıcıdan gelen "başarılı" parametresine güvenmeyin; her zaman sağlayıcının imzasını/IPN'sini sunucu tarafında doğrulayın. Tipik bir PHP callback işleyicisi şöyle görünür:

<?php
// 1) Sağlayıcının imzasını doğrula (örnek: HMAC)
$payload = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $payload, PROVIDER_SECRET);
if (!hash_equals($expected, $sig)) {
    http_response_code(403);
    exit('invalid signature');
}

$data = json_decode($payload, true);
$txid   = $data['transaction_id'];
$status = $data['status'];
$amount = (int) $data['coins'];        // pakete göre coin
$login  = $data['account_login'];

if ($status !== 'completed' || $amount <= 0) {
    exit('ignored');
}

// 2) İdempotent yükleme — tek transaction içinde
$pdo->beginTransaction();
try {
    $acc = $pdo->prepare('SELECT id FROM account WHERE login = ? FOR UPDATE');
    $acc->execute([$login]);
    $accountId = $acc->fetchColumn();
    if (!$accountId) throw new Exception('account not found');

    // UNIQUE(reason,ref) sayesinde tekrar gelen aynı txid hata verir
    $log = $pdo->prepare(
      'INSERT INTO coin_log (account_id, delta, balance_after, reason, ref, created_at)
       SELECT ?, ?, coins + ?, "payment", ?, NOW() FROM account WHERE id = ?');
    $log->execute([$accountId, $amount, $amount, $txid, $accountId]);

    $pdo->prepare('UPDATE account SET coins = coins + ? WHERE id = ?')
        ->execute([$amount, $accountId]);

    $pdo->commit();
} catch (PDOException $e) {
    $pdo->rollBack();
    // 23000 = duplicate key → ödeme zaten işlenmiş, sorun değil
    if ($e->getCode() !== '23000') { http_response_code(500); }
}
echo 'OK';

FOR UPDATE satır kilidi ile aynı hesaba gelen iki eşzamanlı bildirim sıraya girer; UNIQUE kısıt çift yüklemeyi keser. Bu iki mekanizma birlikte yarış koşulu (race condition) ve tekrar saldırılarını engeller.

Oyun içi item mall ve "DB cache" tuzağı

Şimdi en kritik kısım. Metin2 game core, oyuncu online iken envanterini bellekte (DB cache katmanında) tutar. Web tarafından doğrudan player.item tablosuna eşya yazarsanız, oyuncu çıkış yaptığında game core kendi bellek kopyasını üzerine yazar ve eşyanız kaybolur. Bu yüzden coin/eşya teslimi mutlaka game core üzerinden yapılmalıdır.

Güvenli desen bir teslim kuyruğudur: web sadece "şu hesaba şu eşyayı ver" kaydını bir tabloya atar; oyun, oyuncu online iken bu kaydı işler ve eşyayı resmi API ile teslim eder.

CREATE TABLE player.item_award (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `account_id` INT UNSIGNED NOT NULL,
  `item_vnum` INT UNSIGNED NOT NULL,
  `count` INT UNSIGNED NOT NULL DEFAULT 1,
  `claimed` TINYINT NOT NULL DEFAULT 0,
  PRIMARY KEY (`id`),
  KEY `claimed` (`account_id`, `claimed`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;

Oyuncu mağazada coin harcadığında (bunu ister source seviyesinde bir item shop penceresi, ister bir quest NPC'si yönetebilir) iki şey olur: account.coins azaltılır ve item_award'a bir satır eklenir. Teslimi ise quest tarafında periyodik bir kontrol veya giriş olayı çözer:

quest cash_delivery begin
    state start begin
        when login begin
            -- her giriş ödülleri kontrol et (örnek mantık)
            local rows = mysql_direct_query(
              "SELECT id, item_vnum, count FROM player.item_award "..
              "WHERE account_id = "..pc.get_account_id()..
              " AND claimed = 0 LIMIT 10")
            for i, row in ipairs(rows) do
                pc.give_item2(tonumber(row.item_vnum), tonumber(row.count))
                mysql_direct_query(
                  "UPDATE player.item_award SET claimed = 1 WHERE id = "..row.id)
            end
        end
    end
end

Burada pc.give_item2 eşyayı oyuncunun gerçek (bellek + DB ile senkron) envanterine koyar; envanter doluysa standart mantıkla mektup/posta kutusuna düşürebilirsiniz. Coin'i düşme ve item_award satırını yazma işlemini de tek MySQL transaction'ında yapın ki yarım kalan bir satın alma coin'i hem almasın hem eşyayı vermesin.

Güvenlik ve dolandırıcılık önleme

Para söz konusu olunca market sistemi saldırı yüzeyinizdir. Şu kalemleri pazarlık konusu yapmayın:

  • Sunucu tarafı doğrulama: Coin'i her zaman sağlayıcının doğrulanmış callback'i tetikler — istemciden gelen hiçbir veri tek başına yetki vermez.
  • İdempotency: Tekrarlı bildirimler (sağlayıcılar ağ hatasında aynı webhook'u birden çok kez yollar) UNIQUE anahtarla emilir.
  • Negatif/taşma kontrolü: Coin miktarı ve fiyat sunucu tarafında doğrulanır; istemcinin gönderdiği fiyata asla güvenilmez.
  • Geri ödeme (chargeback): PayPal/kredi kartı iadelerinde coin'i geri alacak bir reason='refund' akışı kurun; aksi halde dolandırıcılar bedava coin basar.
  • Denetim izi: coin_log her hareketin kanıtıdır; bir anlaşmazlıkta hesabın bütün geçmişini buradan okursunuz.

Sık Sorulan Sorular

Coin'i doğrudan oyuncunun envanterine basamaz mıyım?

Oyuncu online iken hayır. Game core envanteri bellekte tutar; doğrudan MySQL yazımı çıkışta üzerine yazılır ve kaybolur. Teslim kuyruğu + pc.give_item2 kombinasyonu tek güvenli yoldur.

Ödeme sağlayıcısı webhook'u iki kez gönderirse ne olur?

Hiçbir şey — yani tam olarak istediğiniz şey. coin_log tablosundaki UNIQUE(reason, ref) kısıtı ikinci kaydı reddeder, böylece coin yalnızca bir kez yüklenir. Buna idempotency denir.

Item mall'ı quest ile mi yoksa source ile mi yapmalıyım?

Küçük/orta sunucularda quest tabanlı bir item shop hızlı ve bakımı kolaydır. Yüzlerce eşyalı, sayfalı ve filtreli bir vitrin istiyorsanız source seviyesinde özel bir mall penceresi daha akıcı bir deneyim sunar; ama mimari (coin → kuyruk → teslim) ikisinde de aynıdır.

Market sisteminizi güvenli kurmak mı istiyorsunuz? Web ödeme entegrasyonundan oyun içi item mall'a kadar tüm zinciri sunucunuza özel kurarım. Projenizi benimle iletişime geçerek konuşalım.

Bu kategorideki tüm yazılar →

Devamı için