| Server IP : 202.61.199.114 / Your IP : 216.73.217.139 Web Server : nginx/1.22.1 System : Linux de.arni-solutions.de 6.1.0-49-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.174-1 (2026-05-26) x86_64 User : web20 ( 1018) PHP Version : 8.4.23 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : ON Directory : /var/www/clients/client2/web31/web/slots/includes/ |
Upload File : |
<?php
/**
* Reel Royale · Wettmodul – Service-Schicht (BettingService)
* ---------------------------------------------------------------------------
* - Virtuelle Coins, KEIN Echtgeld.
* - Quoten werden AUSSCHLIESSLICH serverseitig verwaltet (bet_options.odds).
* - Jede Coin-Bewegung wird in coin_transactions protokolliert (Audit).
* - Einsätze laufen atomar in einer SQL-Transaktion mit Row-Lock.
* - Schutz gegen Doppelabrechnung über bet_markets.status = 'settled'.
* - Modular: jede Logik ist turnierunabhängig (bet_tournaments).
* ---------------------------------------------------------------------------
* PHP 7.3 kompatibel.
*/
class BettingException extends RuntimeException {}
class BettingService
{
/** @var PDO */
private $pdo;
/** Markttypen, die automatisch aus einem Ergebnis abgerechnet werden können. */
const AUTO_TYPES = ['1x2', 'double_chance', 'correct_score', 'over_under', 'btts'];
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
// =======================================================================
// Lesen – Turniere / Teams / Spiele / Märkte
// =======================================================================
public function tournaments(): array
{
return $this->pdo->query(
'SELECT * FROM bet_tournaments ORDER BY FIELD(status,"active","upcoming","finished","archived"), starts_at'
)->fetchAll();
}
public function activeTournament(): ?array
{
$row = $this->pdo->query(
'SELECT * FROM bet_tournaments WHERE status IN ("active","upcoming")
ORDER BY FIELD(status,"active","upcoming"), starts_at LIMIT 1'
)->fetch();
return $row ?: null;
}
public function tournament(int $id): ?array
{
$st = $this->pdo->prepare('SELECT * FROM bet_tournaments WHERE id = ? LIMIT 1');
$st->execute([$id]);
return $st->fetch() ?: null;
}
public function teams(int $tournamentId): array
{
$st = $this->pdo->prepare('SELECT * FROM bet_teams WHERE tournament_id = ? ORDER BY group_label, name');
$st->execute([$tournamentId]);
return $st->fetchAll();
}
public function teamMap(int $tournamentId): array
{
$map = [];
foreach ($this->teams($tournamentId) as $t) {
$map[(int)$t['id']] = $t;
}
return $map;
}
/**
* Spiele eines Turniers nach Filter.
* @param string $filter upcoming | live | finished | all
*/
public function matches(int $tournamentId, string $filter = 'all'): array
{
$sql = 'SELECT * FROM bet_matches WHERE tournament_id = ?';
if ($filter === 'upcoming') { $sql .= ' AND status = "scheduled"'; }
elseif ($filter === 'live') { $sql .= ' AND status = "live"'; }
elseif ($filter === 'finished') { $sql .= ' AND status IN ("finished","cancelled")'; }
$sql .= ' ORDER BY kickoff_at';
$st = $this->pdo->prepare($sql);
$st->execute([$tournamentId]);
return $st->fetchAll();
}
public function match(int $id): ?array
{
$st = $this->pdo->prepare('SELECT * FROM bet_matches WHERE id = ? LIMIT 1');
$st->execute([$id]);
return $st->fetch() ?: null;
}
public function market(int $id): ?array
{
$st = $this->pdo->prepare('SELECT * FROM bet_markets WHERE id = ? LIMIT 1');
$st->execute([$id]);
return $st->fetch() ?: null;
}
/** Optionen eines Marktes. */
public function options(int $marketId): array
{
$st = $this->pdo->prepare('SELECT * FROM bet_options WHERE market_id = ? ORDER BY sort, id');
$st->execute([$marketId]);
return $st->fetchAll();
}
/** Alle Märkte (mit Optionen) zu einem Spiel. */
public function marketsForMatch(int $matchId): array
{
$st = $this->pdo->prepare('SELECT * FROM bet_markets WHERE match_id = ? ORDER BY sort, id');
$st->execute([$matchId]);
$markets = $st->fetchAll();
foreach ($markets as &$m) {
$m['options'] = $this->options((int)$m['id']);
}
return $markets;
}
/** Spezialwetten (match_id = NULL) eines Turniers, inkl. Optionen. */
public function specialMarkets(int $tournamentId, bool $onlyOpen = false): array
{
$sql = 'SELECT * FROM bet_markets WHERE tournament_id = ? AND match_id IS NULL';
if ($onlyOpen) { $sql .= ' AND status = "open"'; }
$sql .= ' ORDER BY sort, id';
$st = $this->pdo->prepare($sql);
$st->execute([$tournamentId]);
$markets = $st->fetchAll();
foreach ($markets as &$m) {
$m['options'] = $this->options((int)$m['id']);
}
return $markets;
}
/** Spiele eines Turniers mit eingebetteten Märkten/Optionen + Team-Daten. */
public function matchesWithMarkets(int $tournamentId, string $filter = 'all'): array
{
$teams = $this->teamMap($tournamentId);
$matches = $this->matches($tournamentId, $filter);
foreach ($matches as &$mt) {
$hid = (int)$mt['home_team_id'];
$aid = (int)$mt['away_team_id'];
$mt['home'] = $teams[$hid] ?? null;
$mt['away'] = $teams[$aid] ?? null;
$mt['markets'] = $this->marketsForMatch((int)$mt['id']);
}
return $matches;
}
// =======================================================================
// Wettschluss / Bettbarkeit
// =======================================================================
/** Effektiver Wettschluss eines Marktes (close_at, sonst Anstoß). */
public function effectiveClose(array $market, ?array $match): ?string
{
if (!empty($market['close_at'])) {
return $market['close_at'];
}
if ($match && !empty($match['kickoff_at'])) {
return $match['kickoff_at'];
}
return null;
}
/** Kann auf diesen Markt aktuell gewettet werden? */
public function isBettable(array $market, ?array $match = null): bool
{
if ($market['status'] !== 'open') {
return false;
}
if ($match && in_array($match['status'], ['live', 'finished', 'cancelled', 'postponed'], true)) {
return false;
}
$close = $this->effectiveClose($market, $match);
if ($close !== null && strtotime($close) <= time()) {
return false;
}
return true;
}
// =======================================================================
// Coin-Buchhaltung (immer innerhalb einer Transaktion + Row-Lock!)
// =======================================================================
/**
* Bucht einen vorzeichenbehafteten Betrag auf das Konto und protokolliert ihn.
* Erwartet eine offene Transaktion; die users-Zeile sollte zuvor mit
* FOR UPDATE gesperrt sein. Gibt den neuen Kontostand zurück.
*/
private function postCoins(int $userId, string $type, float $amount, ?string $refType = null, $refId = null, ?string $note = null): float
{
$amount = round($amount, 2);
$this->pdo->prepare('UPDATE users SET balance = balance + ? WHERE id = ?')
->execute([$amount, $userId]);
$st = $this->pdo->prepare('SELECT balance FROM users WHERE id = ? LIMIT 1');
$st->execute([$userId]);
$balance = round((float)$st->fetchColumn(), 2);
$this->pdo->prepare(
'INSERT INTO coin_transactions (user_id, type, amount, balance_after, ref_type, ref_id, note, created_at)
VALUES (?,?,?,?,?,?,?,NOW())'
)->execute([$userId, $type, $amount, $balance, $refType, $refId !== null ? (int)$refId : null, $note]);
return $balance;
}
public function ledger(int $userId, int $limit = 50): array
{
$st = $this->pdo->prepare(
'SELECT * FROM coin_transactions WHERE user_id = ? ORDER BY id DESC LIMIT ' . max(1, (int)$limit)
);
$st->execute([$userId]);
return $st->fetchAll();
}
// =======================================================================
// Wette platzieren (atomar, serverseitige Quote, Guthabenprüfung)
// =======================================================================
public function placeBet(int $userId, int $optionId, float $stake): array
{
$stake = round((float)$stake, 2);
if ($stake <= 0) {
throw new BettingException('Bitte einen gültigen Einsatz größer 0 angeben.');
}
if ($stake > 1000000) {
throw new BettingException('Der Einsatz ist zu hoch.');
}
$pdo = $this->pdo;
$pdo->beginTransaction();
try {
// 1) Nutzer sperren
$st = $pdo->prepare('SELECT * FROM users WHERE id = ? FOR UPDATE');
$st->execute([$userId]);
$user = $st->fetch();
if (!$user) {
throw new BettingException('Spieler nicht gefunden.');
}
if ((int)($user['banned'] ?? 0) === 1) {
throw new BettingException('Dein Konto ist gesperrt.');
}
// 2) Option + Markt frisch & gesperrt laden (Quote NUR von hier)
$st = $pdo->prepare(
'SELECT o.id AS option_id, o.label AS option_label, o.odds, o.status AS option_status,
m.id AS market_id, m.match_id, m.tournament_id, m.status AS market_status,
m.title AS market_title, m.close_at
FROM bet_options o
JOIN bet_markets m ON m.id = o.market_id
WHERE o.id = ? FOR UPDATE'
);
$st->execute([$optionId]);
$row = $st->fetch();
if (!$row) {
throw new BettingException('Diese Wettoption existiert nicht.');
}
if ($row['option_status'] !== 'open' || $row['market_status'] !== 'open') {
throw new BettingException('Diese Wette ist nicht mehr verfügbar.');
}
// 3) Wettschluss prüfen
$match = null;
if (!empty($row['match_id'])) {
$ms = $pdo->prepare('SELECT * FROM bet_matches WHERE id = ? LIMIT 1');
$ms->execute([(int)$row['match_id']]);
$match = $ms->fetch() ?: null;
}
if (!$this->isBettable([
'status' => $row['market_status'],
'close_at' => $row['close_at'],
], $match)) {
throw new BettingException('Der Wettschluss für diese Wette ist bereits erreicht.');
}
// 4) Guthaben prüfen
if ((float)$user['balance'] < $stake) {
throw new BettingException('Nicht genügend Coins für diesen Einsatz.');
}
// 5) Quote serverseitig, Einsatz buchen, Wette anlegen
$odds = round((float)$row['odds'], 2);
$potential = round($stake * $odds, 2);
$ins = $pdo->prepare(
'INSERT INTO bet_user_bets
(user_id, tournament_id, market_id, option_id, match_id, stake, odds, potential_win, status, placed_at)
VALUES (?,?,?,?,?,?,?,?,"open",NOW())'
);
$ins->execute([
$userId,
(int)$row['tournament_id'],
(int)$row['market_id'],
(int)$row['option_id'],
!empty($row['match_id']) ? (int)$row['match_id'] : null,
$stake, $odds, $potential,
]);
$betId = (int)$pdo->lastInsertId();
$balance = $this->postCoins($userId, 'bet_stake', -$stake, 'user_bet', $betId,
'Einsatz: ' . $row['market_title']);
$pdo->commit();
return [
'bet_id' => $betId,
'stake' => $stake,
'odds' => $odds,
'potential_win' => $potential,
'balance' => $balance,
'option_label' => $row['option_label'],
'market_title' => $row['market_title'],
];
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw ($e instanceof BettingException) ? $e : new BettingException($e->getMessage());
}
}
// =======================================================================
// Wetten eines Nutzers
// =======================================================================
public function userBets(int $userId, int $tournamentId = 0, int $limit = 200): array
{
$sql = 'SELECT b.*, m.title AS market_title, m.type AS market_type, o.label AS option_label,
mt.kickoff_at, mt.status AS match_status,
th.name AS home_name, th.flag AS home_flag,
ta.name AS away_name, ta.flag AS away_flag,
mt.home_score, mt.away_score
FROM bet_user_bets b
JOIN bet_markets m ON m.id = b.market_id
JOIN bet_options o ON o.id = b.option_id
LEFT JOIN bet_matches mt ON mt.id = b.match_id
LEFT JOIN bet_teams th ON th.id = mt.home_team_id
LEFT JOIN bet_teams ta ON ta.id = mt.away_team_id
WHERE b.user_id = ?';
$params = [$userId];
if ($tournamentId > 0) {
$sql .= ' AND b.tournament_id = ?';
$params[] = $tournamentId;
}
$sql .= ' ORDER BY b.id DESC LIMIT ' . max(1, (int)$limit);
$st = $this->pdo->prepare($sql);
$st->execute($params);
return $st->fetchAll();
}
public function userBetStats(int $userId): array
{
$st = $this->pdo->prepare(
'SELECT
COUNT(*) AS total,
SUM(status = "open") AS open,
SUM(status = "won") AS won,
SUM(status = "lost") AS lost,
COALESCE(SUM(stake),0) AS staked,
COALESCE(SUM(payout),0) AS payout
FROM bet_user_bets WHERE user_id = ?'
);
$st->execute([$userId]);
return $st->fetch() ?: [];
}
// =======================================================================
// Abrechnung (Admin) – mit Schutz gegen Doppelabrechnung
// =======================================================================
/**
* Rechnet einen Markt ab: markiert Gewinn-Optionen, schließt verlorene,
* zahlt Gewinne aus (stake * odds) und protokolliert alles.
* @param int[] $winnerOptionIds IDs der Gewinn-Optionen
*/
public function settleMarket(int $marketId, array $winnerOptionIds, ?int $adminId = null, ?string $adminName = null): array
{
$pdo = $this->pdo;
$pdo->beginTransaction();
try {
$st = $pdo->prepare('SELECT * FROM bet_markets WHERE id = ? FOR UPDATE');
$st->execute([$marketId]);
$market = $st->fetch();
if (!$market) {
throw new BettingException('Markt nicht gefunden.');
}
if ($market['status'] === 'settled') {
throw new BettingException('Dieser Markt wurde bereits abgerechnet.');
}
if ($market['status'] === 'cancelled') {
throw new BettingException('Dieser Markt wurde storniert.');
}
$winnerIds = array_map('intval', $winnerOptionIds);
// Optionen markieren
$options = $this->options($marketId);
foreach ($options as $o) {
$isWin = in_array((int)$o['id'], $winnerIds, true);
$pdo->prepare('UPDATE bet_options SET is_winner = ?, status = ? WHERE id = ?')
->execute([$isWin ? 1 : 0, $isWin ? 'won' : 'lost', (int)$o['id']]);
}
// Offene Wetten abrechnen
$bets = $pdo->prepare('SELECT * FROM bet_user_bets WHERE market_id = ? AND status = "open" FOR UPDATE');
$bets->execute([$marketId]);
$rows = $bets->fetchAll();
$wonCount = 0; $lostCount = 0; $paidTotal = 0.0;
foreach ($rows as $b) {
$betId = (int)$b['id'];
if (in_array((int)$b['option_id'], $winnerIds, true)) {
$payout = round((float)$b['stake'] * (float)$b['odds'], 2);
// Spieler sperren & Gewinn buchen
$pdo->prepare('SELECT id FROM users WHERE id = ? FOR UPDATE')->execute([(int)$b['user_id']]);
$this->postCoins((int)$b['user_id'], 'bet_payout', $payout, 'user_bet', $betId,
'Gewinn: ' . $market['title']);
$pdo->prepare('UPDATE bet_user_bets SET status = "won", payout = ?, settled_at = NOW() WHERE id = ?')
->execute([$payout, $betId]);
$wonCount++; $paidTotal += $payout;
} else {
$pdo->prepare('UPDATE bet_user_bets SET status = "lost", payout = 0, settled_at = NOW() WHERE id = ?')
->execute([$betId]);
$lostCount++;
}
}
$pdo->prepare('UPDATE bet_markets SET status = "settled", settled_at = NOW() WHERE id = ?')
->execute([$marketId]);
$this->logAdmin($adminId, $adminName, 'settle_market', 'market', $marketId, [
'winners' => $winnerIds, 'won' => $wonCount, 'lost' => $lostCount, 'paid' => $paidTotal,
]);
$pdo->commit();
return ['won' => $wonCount, 'lost' => $lostCount, 'paid' => round($paidTotal, 2)];
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw ($e instanceof BettingException) ? $e : new BettingException($e->getMessage());
}
}
/**
* Storniert einen Markt: alle OFFENEN Wetten werden erstattet (Einsatz zurück)
* und auf "cancelled" gesetzt, der Markt wird "cancelled".
*/
public function cancelMarket(int $marketId, ?int $adminId = null, ?string $adminName = null): array
{
$pdo = $this->pdo;
$pdo->beginTransaction();
try {
$st = $pdo->prepare('SELECT * FROM bet_markets WHERE id = ? FOR UPDATE');
$st->execute([$marketId]);
$market = $st->fetch();
if (!$market) {
throw new BettingException('Markt nicht gefunden.');
}
if ($market['status'] === 'cancelled') {
throw new BettingException('Markt ist bereits storniert.');
}
$bets = $pdo->prepare('SELECT * FROM bet_user_bets WHERE market_id = ? AND status = "open" FOR UPDATE');
$bets->execute([$marketId]);
$rows = $bets->fetchAll();
$refunded = 0.0; $count = 0;
foreach ($rows as $b) {
$betId = (int)$b['id'];
$pdo->prepare('SELECT id FROM users WHERE id = ? FOR UPDATE')->execute([(int)$b['user_id']]);
$this->postCoins((int)$b['user_id'], 'bet_refund', (float)$b['stake'], 'user_bet', $betId,
'Erstattung (Storno): ' . $market['title']);
$pdo->prepare('UPDATE bet_user_bets SET status = "cancelled", payout = ?, settled_at = NOW() WHERE id = ?')
->execute([round((float)$b['stake'], 2), $betId]);
$refunded += (float)$b['stake']; $count++;
}
$pdo->prepare('UPDATE bet_markets SET status = "cancelled", settled_at = NOW() WHERE id = ?')->execute([$marketId]);
$pdo->prepare('UPDATE bet_options SET status = "void" WHERE market_id = ?')->execute([$marketId]);
$this->logAdmin($adminId, $adminName, 'cancel_market', 'market', $marketId,
['refunded' => $refunded, 'bets' => $count]);
$pdo->commit();
return ['refunded' => round($refunded, 2), 'bets' => $count];
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw ($e instanceof BettingException) ? $e : new BettingException($e->getMessage());
}
}
/** Einzelne offene Wette stornieren + Einsatz erstatten. */
public function cancelBet(int $betId, ?int $adminId = null, ?string $adminName = null): array
{
$pdo = $this->pdo;
$pdo->beginTransaction();
try {
$st = $pdo->prepare('SELECT * FROM bet_user_bets WHERE id = ? FOR UPDATE');
$st->execute([$betId]);
$bet = $st->fetch();
if (!$bet) {
throw new BettingException('Wette nicht gefunden.');
}
if ($bet['status'] !== 'open') {
throw new BettingException('Nur offene Wetten können storniert werden.');
}
$pdo->prepare('SELECT id FROM users WHERE id = ? FOR UPDATE')->execute([(int)$bet['user_id']]);
$bal = $this->postCoins((int)$bet['user_id'], 'bet_refund', (float)$bet['stake'], 'user_bet', $betId,
'Erstattung Einzelwette');
$pdo->prepare('UPDATE bet_user_bets SET status = "cancelled", payout = ?, settled_at = NOW() WHERE id = ?')
->execute([round((float)$bet['stake'], 2), $betId]);
$this->logAdmin($adminId, $adminName, 'cancel_bet', 'user_bet', $betId, ['stake' => $bet['stake']]);
$pdo->commit();
return ['refunded' => round((float)$bet['stake'], 2), 'balance' => $bal];
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw ($e instanceof BettingException) ? $e : new BettingException($e->getMessage());
}
}
public function setMarketStatus(int $marketId, string $status, ?int $adminId = null, ?string $adminName = null): void
{
if (!in_array($status, ['open', 'suspended', 'closed'], true)) {
throw new BettingException('Ungültiger Status.');
}
$m = $this->market($marketId);
if (!$m) {
throw new BettingException('Markt nicht gefunden.');
}
if (in_array($m['status'], ['settled', 'cancelled'], true)) {
throw new BettingException('Abgerechnete oder stornierte Märkte können nicht geändert werden.');
}
$this->pdo->prepare('UPDATE bet_markets SET status = ? WHERE id = ?')->execute([$status, $marketId]);
$this->logAdmin($adminId, $adminName, 'market_status', 'market', $marketId, ['status' => $status]);
}
// =======================================================================
// Ergebnis erfassen + (optional) automatische Abrechnung
// =======================================================================
/**
* Setzt das Endergebnis eines Spiels und rechnet – falls gewünscht – alle
* automatisch abrechenbaren Märkte dieses Spiels ab.
*/
public function setMatchResult(int $matchId, int $homeScore, int $awayScore, bool $autoSettle = true, ?int $adminId = null, ?string $adminName = null): array
{
$match = $this->match($matchId);
if (!$match) {
throw new BettingException('Spiel nicht gefunden.');
}
$homeScore = max(0, $homeScore);
$awayScore = max(0, $awayScore);
$this->pdo->prepare('UPDATE bet_matches SET home_score = ?, away_score = ?, status = "finished", updated_at = NOW() WHERE id = ?')
->execute([$homeScore, $awayScore, $matchId]);
$this->logAdmin($adminId, $adminName, 'match_result', 'match', $matchId,
['home' => $homeScore, 'away' => $awayScore, 'auto' => $autoSettle]);
$settled = [];
if ($autoSettle) {
foreach ($this->marketsForMatch($matchId) as $m) {
if ($m['status'] === 'settled' || $m['status'] === 'cancelled') {
continue;
}
if (!in_array($m['type'], self::AUTO_TYPES, true)) {
continue;
}
$winners = $this->resolveMatchMarketWinners($m, $homeScore, $awayScore);
if ($winners === null) {
continue; // Typ nicht auflösbar -> manuell
}
$res = $this->settleMarket((int)$m['id'], $winners, $adminId, $adminName);
$settled[] = ['market_id' => (int)$m['id'], 'title' => $m['title']] + $res;
}
}
return ['match_id' => $matchId, 'settled' => $settled];
}
/**
* Ermittelt die Gewinn-Options-IDs eines Spielmarktes aus dem Ergebnis.
* Liefert null, wenn der Typ nicht automatisch auflösbar ist.
* @return int[]|null
*/
public function resolveMatchMarketWinners(array $market, int $home, int $away): ?array
{
$options = $market['options'] ?? $this->options((int)$market['id']);
$total = $home + $away;
$byCode = [];
foreach ($options as $o) {
$byCode[strtolower((string)$o['code'])] = (int)$o['id'];
}
$pick = function (array $codes) use ($byCode) {
$ids = [];
foreach ($codes as $c) {
if (isset($byCode[strtolower($c)])) {
$ids[] = $byCode[strtolower($c)];
}
}
return $ids;
};
switch ($market['type']) {
case '1x2':
$code = $home > $away ? '1' : ($home < $away ? '2' : 'x');
return $pick([$code]);
case 'double_chance':
if ($home > $away) { return $pick(['1x', '12']); }
elseif ($home < $away) { return $pick(['12', 'x2']); }
else { return $pick(['1x', 'x2']); }
case 'btts':
return $pick([($home > 0 && $away > 0) ? 'yes' : 'no']);
case 'over_under':
$line = (float)($market['param'] ?? 2.5);
return $pick([$total > $line ? 'over' : 'under']);
case 'correct_score':
$code = $home . '-' . $away;
$ids = $pick([$code]);
if (!$ids && isset($byCode['other'])) {
$ids = [$byCode['other']]; // "Anderes Ergebnis"
}
return $ids;
}
return null;
}
// =======================================================================
// Admin-Protokoll
// =======================================================================
public function logAdmin(?int $adminId, ?string $adminName, string $action, ?string $refType = null, $refId = null, array $detail = []): void
{
$ip = $_SERVER['REMOTE_ADDR'] ?? null;
$this->pdo->prepare(
'INSERT INTO bet_admin_log (admin_id, admin_name, action, ref_type, ref_id, detail, ip, created_at)
VALUES (?,?,?,?,?,?,?,NOW())'
)->execute([
$adminId, $adminName, $action, $refType,
$refId !== null ? (int)$refId : null,
$detail ? json_encode($detail, JSON_UNESCAPED_UNICODE) : null,
$ip,
]);
}
public function adminLog(int $limit = 100): array
{
return $this->pdo->query('SELECT * FROM bet_admin_log ORDER BY id DESC LIMIT ' . max(1, (int)$limit))->fetchAll();
}
}
/** Singleton-Zugriff auf den Wett-Service. */
function betting(): BettingService
{
static $svc = null;
if ($svc === null) {
$svc = new BettingService(db());
}
return $svc;
}