- 拆分 HomeController → TransactionController, ReportWebController, FollowPlanController - 新增 Service 层: TransactionService, ReportService, FollowPlanService - pk10.php JS 抽离为 4 个独立文件 (sound/race/bet/poll) - 前台新增报表查询页面 (/report) + 跟单计划页面 (/follow-plan) - 后台新增跟单计划管理 + 用户输赢明细统计 - 封盘状态显示倒计时 (x:xx) - 音效仅在开奖弹窗打开时播放 - 路由按模块分组整理 - autoload 支持 App\Services 命名空间
1352 lines
56 KiB
PHP
1352 lines
56 KiB
PHP
<?php
|
|
namespace App\Controllers\Api;
|
|
|
|
use App\Core\BotApiBaseController;
|
|
use App\Core\GameFactory;
|
|
|
|
class BotController extends BotApiBaseController
|
|
{
|
|
private function getRuntimeSecret(): string
|
|
{
|
|
$envSecret = trim((string)($_ENV['BOT_RUNTIME_SECRET'] ?? getenv('BOT_RUNTIME_SECRET') ?: ''));
|
|
if ($envSecret !== '') {
|
|
return $envSecret;
|
|
}
|
|
|
|
$botEnvPath = dirname(__DIR__, 3) . '/bot-runtime/.env';
|
|
if (!is_file($botEnvPath) || !is_readable($botEnvPath)) {
|
|
return '';
|
|
}
|
|
|
|
$lines = @file($botEnvPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
|
foreach ($lines as $line) {
|
|
$trimmed = trim((string)$line);
|
|
if ($trimmed === '' || str_starts_with($trimmed, '#')) {
|
|
continue;
|
|
}
|
|
if (!str_starts_with($trimmed, 'BOT_RUNTIME_SECRET=')) {
|
|
continue;
|
|
}
|
|
return trim((string)substr($trimmed, strlen('BOT_RUNTIME_SECRET=')));
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
public function getRuntimeInstances(): void
|
|
{
|
|
$runtimeSecret = trim((string)($_SERVER['HTTP_X_RUNTIME_SECRET'] ?? ''));
|
|
$expectedSecret = $this->getRuntimeSecret();
|
|
|
|
if ($expectedSecret === '' || !hash_equals($expectedSecret, $runtimeSecret)) {
|
|
$this->jsonResponse(false, 'Runtime auth failed', [], 401);
|
|
}
|
|
|
|
try {
|
|
$bots = $this->db->select('bot_instances', '*', [
|
|
'status' => 1,
|
|
'ORDER' => ['id' => 'ASC'],
|
|
]) ?: [];
|
|
|
|
$instances = [];
|
|
foreach ($bots as $bot) {
|
|
$botId = (int)($bot['id'] ?? 0);
|
|
if ($botId <= 0 || empty($bot['bot_token']) || empty($bot['bot_key']) || empty($bot['bot_secret'])) {
|
|
continue;
|
|
}
|
|
|
|
$groups = $this->db->select('bot_groups', [
|
|
'id',
|
|
'tg_group_id',
|
|
'group_name',
|
|
'status',
|
|
'bet_enabled',
|
|
], [
|
|
'bot_id' => $botId,
|
|
'status' => 1,
|
|
'ORDER' => ['id' => 'ASC'],
|
|
]) ?: [];
|
|
|
|
if (empty($groups)) {
|
|
continue;
|
|
}
|
|
|
|
$instances[] = [
|
|
'id' => $botId,
|
|
'name' => (string)($bot['name'] ?? ''),
|
|
'bot_token' => (string)$bot['bot_token'],
|
|
'bot_username' => (string)($bot['bot_username'] ?? ''),
|
|
'bot_key' => (string)$bot['bot_key'],
|
|
'bot_secret' => (string)$bot['bot_secret'],
|
|
'run_mode' => (string)($bot['run_mode'] ?? 'polling'),
|
|
'webhook_url' => (string)($bot['webhook_url'] ?? ''),
|
|
'groups' => array_map(static function (array $group): array {
|
|
return [
|
|
'id' => (int)($group['id'] ?? 0),
|
|
'tg_group_id' => (string)($group['tg_group_id'] ?? ''),
|
|
'group_name' => (string)($group['group_name'] ?? ''),
|
|
'status' => (int)($group['status'] ?? 0),
|
|
'bet_enabled' => (int)($group['bet_enabled'] ?? 0) === 1,
|
|
];
|
|
}, $groups),
|
|
];
|
|
}
|
|
|
|
$this->jsonResponse(true, 'ok', [
|
|
'instances' => $instances,
|
|
'count' => count($instances),
|
|
'server_time' => date('Y-m-d H:i:s'),
|
|
], 200);
|
|
} catch (\Throwable $e) {
|
|
$this->jsonResponse(false, 'Failed to load runtime instances', [], 500);
|
|
}
|
|
}
|
|
|
|
private function buildPeriodSnapshot(array $group): array
|
|
{
|
|
$gameId = (int)$group['game_id'];
|
|
$game = $this->db->get('games', ['id', 'type', 'period_duration', 'lock_before_end'], ['id' => $gameId]);
|
|
if (!$game) {
|
|
throw new \RuntimeException('Game not found');
|
|
}
|
|
|
|
$period = $this->db->get('periods', '*', [
|
|
'game_id' => $gameId,
|
|
'ORDER' => ['id' => 'DESC']
|
|
]);
|
|
|
|
$now = time();
|
|
$countdown = 0;
|
|
$lockCountdown = 0;
|
|
if ($period && in_array($period['status'], ['pending', 'locked'], true)) {
|
|
$start = strtotime($period['start_time'] ?? $period['created_at']);
|
|
$countdown = max(0, !empty($period['end_time']) ? strtotime($period['end_time']) - $now : ((int)($game['period_duration'] ?? 300) - ($now - $start)));
|
|
$periodDuration = (int)($game['period_duration'] ?? 300);
|
|
$lockBefore = (int)($game['lock_before_end'] ?? 30);
|
|
$lockCountdown = max(0, ($periodDuration - $lockBefore) - ($now - $start));
|
|
}
|
|
|
|
return [
|
|
'game_id' => $gameId,
|
|
'game' => $game,
|
|
'period' => $period,
|
|
'countdown' => $countdown,
|
|
'lock_countdown' => $lockCountdown,
|
|
'server_time' => date('Y-m-d H:i:s'),
|
|
];
|
|
}
|
|
|
|
private function getResultPayload(array $game, ?array $periodRow): ?array
|
|
{
|
|
if (!$periodRow) {
|
|
return null;
|
|
}
|
|
|
|
$algoClass = GameFactory::getAlgorithm($game['type'] ?? 'pk10');
|
|
$resultTable = $algoClass::getResultTable();
|
|
if ($resultTable) {
|
|
$result = $this->db->get($resultTable, '*', ['period_id' => (int)$periodRow['id']]);
|
|
if (!$result) {
|
|
return null;
|
|
}
|
|
$result['period_number'] = $periodRow['period_number'];
|
|
$result['status'] = $periodRow['status'];
|
|
return $result;
|
|
}
|
|
|
|
$result = $periodRow;
|
|
$result['period_number'] = $periodRow['period_number'];
|
|
return $result;
|
|
}
|
|
|
|
private function applyFundChange(array $group, array $wallet, string $requestType): void
|
|
{
|
|
$idempotencyKey = $this->buildIdempotencyKey($requestType);
|
|
$amount = (float)($this->requestJson['amount'] ?? 0);
|
|
$reason = trim((string)($this->requestJson['reason'] ?? ''));
|
|
$tgUserId = (string)($this->requestJson['tg_user_id'] ?? '');
|
|
$operator = trim((string)($this->requestJson['operator'] ?? 'bot'));
|
|
$platformUserId = (int)($this->requestJson['platform_user_id'] ?? $wallet['platform_user_id']);
|
|
|
|
if ($amount <= 0) {
|
|
$this->auditAndRespond(false, 'Amount must be greater than 0', [], 422, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
|
|
$existing = $this->db->get('bot_fund_requests', '*', ['idempotency_key' => $idempotencyKey]);
|
|
if ($existing) {
|
|
$this->auditAndRespond(true, 'Duplicate request ignored', [
|
|
'fund_request_id' => (int)$existing['id'],
|
|
'transaction_id' => (int)($existing['transaction_id'] ?? 0),
|
|
'status' => $existing['status'],
|
|
'duplicate' => true,
|
|
], 200, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
|
|
try {
|
|
$this->db->medoo->pdo->beginTransaction();
|
|
|
|
$stmt = $this->db->medoo->pdo->prepare('SELECT balance, is_virtual FROM users WHERE id = :id FOR UPDATE');
|
|
$stmt->execute([':id' => $platformUserId]);
|
|
$user = $stmt->fetch(\PDO::FETCH_ASSOC);
|
|
if (!$user) {
|
|
throw new \RuntimeException('Platform wallet user not found');
|
|
}
|
|
|
|
$balanceBefore = (float)$user['balance'];
|
|
$delta = $requestType === 'credit' ? $amount : -$amount;
|
|
if ($requestType === 'debit' && $balanceBefore < $amount) {
|
|
$this->db->medoo->pdo->rollBack();
|
|
$this->auditAndRespond(false, 'Insufficient balance', [], 422, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
|
|
$balanceAfter = $balanceBefore + $delta;
|
|
$this->db->update('users', [
|
|
'balance' => $balanceAfter,
|
|
'updated_at' => date('Y-m-d H:i:s')
|
|
], ['id' => $platformUserId]);
|
|
|
|
$transactionId = $this->db->insert('transactions', [
|
|
'user_id' => $platformUserId,
|
|
'type' => $requestType,
|
|
'amount' => $delta,
|
|
'balance_before' => $balanceBefore,
|
|
'balance_after' => $balanceAfter,
|
|
'description' => $reason !== '' ? $reason : ('TG Bot ' . $requestType),
|
|
'is_virtual' => (int)($user['is_virtual'] ?? 0),
|
|
'source' => 'bot',
|
|
'source_ref' => $idempotencyKey,
|
|
'operator_type' => 'system',
|
|
'created_at' => date('Y-m-d H:i:s')
|
|
]);
|
|
|
|
$fundRequestId = $this->db->insert('bot_fund_requests', [
|
|
'group_id' => (int)$group['id'],
|
|
'tg_user_id' => $tgUserId !== '' ? $tgUserId : null,
|
|
'platform_user_id' => $platformUserId,
|
|
'request_type' => $requestType,
|
|
'amount' => $amount,
|
|
'reason' => $reason !== '' ? $reason : ('TG Bot ' . $requestType),
|
|
'idempotency_key' => $idempotencyKey,
|
|
'transaction_id' => $transactionId,
|
|
'status' => 'approved',
|
|
'operator_type' => 'system',
|
|
'created_at' => date('Y-m-d H:i:s'),
|
|
'updated_at' => date('Y-m-d H:i:s')
|
|
]);
|
|
|
|
$this->db->insert('bot_push_logs', [
|
|
'group_id' => (int)$group['id'],
|
|
'push_type' => $requestType,
|
|
'payload_json' => json_encode([
|
|
'operator' => $operator,
|
|
'platform_user_id' => $platformUserId,
|
|
'amount' => $amount,
|
|
'balance_after' => $balanceAfter,
|
|
], JSON_UNESCAPED_UNICODE),
|
|
'status' => 'success',
|
|
'created_at' => date('Y-m-d H:i:s'),
|
|
'updated_at' => date('Y-m-d H:i:s')
|
|
]);
|
|
|
|
$this->db->medoo->pdo->commit();
|
|
|
|
$this->auditAndRespond(true, ucfirst($requestType) . ' success', [
|
|
'fund_request_id' => (int)$fundRequestId,
|
|
'transaction_id' => (int)$transactionId,
|
|
'platform_user_id' => $platformUserId,
|
|
'balance_before' => $balanceBefore,
|
|
'balance_after' => $balanceAfter,
|
|
'amount' => $amount,
|
|
], 200, (int)$group['id'], $idempotencyKey);
|
|
} catch (\Throwable $e) {
|
|
if ($this->db->medoo && $this->db->medoo->pdo->inTransaction()) {
|
|
$this->db->medoo->pdo->rollBack();
|
|
}
|
|
|
|
try {
|
|
$this->db->insert('bot_fund_requests', [
|
|
'group_id' => (int)$group['id'],
|
|
'tg_user_id' => $tgUserId !== '' ? $tgUserId : null,
|
|
'platform_user_id' => $platformUserId,
|
|
'request_type' => $requestType,
|
|
'amount' => $amount,
|
|
'reason' => $reason !== '' ? $reason : ('TG Bot ' . $requestType),
|
|
'idempotency_key' => $idempotencyKey,
|
|
'status' => 'failed',
|
|
'operator_type' => 'system',
|
|
'created_at' => date('Y-m-d H:i:s'),
|
|
'updated_at' => date('Y-m-d H:i:s')
|
|
]);
|
|
} catch (\Throwable $ignore) {
|
|
}
|
|
|
|
$this->auditAndRespond(false, 'System error', [], 500, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
}
|
|
|
|
public function ping(): void
|
|
{
|
|
$this->authenticateBotRequest();
|
|
|
|
$this->auditAndRespond(true, 'pong', [
|
|
'bot_id' => (int)$this->botInstance['id'],
|
|
'bot_name' => $this->botInstance['name'],
|
|
'server_time' => date('Y-m-d H:i:s'),
|
|
], 200, null, null);
|
|
}
|
|
|
|
public function getGroupConfig(string $tgGroupId): void
|
|
{
|
|
$this->authenticateBotRequest();
|
|
$group = $this->requireGroupByTelegramId($tgGroupId);
|
|
$wallet = $this->getGroupWallet((int)$group['id']);
|
|
|
|
$rule = null;
|
|
if (!empty($group['bet_format_rule_id'])) {
|
|
$rule = $this->db->get('bot_bet_format_rules', '*', ['id' => (int)$group['bet_format_rule_id']]);
|
|
}
|
|
|
|
$members = $this->db->count('bot_group_members', ['group_id' => (int)$group['id']]);
|
|
|
|
$this->auditAndRespond(true, 'ok', [
|
|
'group_id' => (int)$group['id'],
|
|
'tg_group_id' => $group['tg_group_id'],
|
|
'group_name' => $group['group_name'],
|
|
'game_id' => (int)$group['game_id'],
|
|
'bet_enabled' => (int)$group['bet_enabled'] === 1,
|
|
'wallet_mode' => $wallet['wallet_mode'],
|
|
'master_platform_user_id' => (int)$wallet['platform_user_id'],
|
|
'reminders' => [
|
|
'bet_success' => (int)$group['remind_bet_success'] === 1,
|
|
'draw_result' => (int)$group['remind_draw_result'] === 1,
|
|
'close_countdown' => (int)$group['remind_close_countdown'] === 1,
|
|
],
|
|
'countdown_config' => $group['countdown_config'] ? json_decode($group['countdown_config'], true) : [],
|
|
'animation_enabled' => (int)$group['animation_enabled'] === 1,
|
|
'member_count' => (int)$members,
|
|
'bet_format_rule' => $rule ? [
|
|
'id' => (int)$rule['id'],
|
|
'rule_code' => $rule['rule_code'],
|
|
'name' => $rule['name'],
|
|
'parser_type' => $rule['parser_type'],
|
|
'rule_config' => $rule['rule_config'] ? json_decode($rule['rule_config'], true) : null,
|
|
'example_text' => $rule['example_text'],
|
|
] : null,
|
|
], 200, (int)$group['id']);
|
|
}
|
|
|
|
private function updateGroupSwitches(array $group, array $allowedFields): void
|
|
{
|
|
$this->authenticateBotRequest();
|
|
$payload = $this->requestJson;
|
|
$actorTgUserId = trim((string)($payload['actor_tg_user_id'] ?? ''));
|
|
$field = trim((string)($payload['field'] ?? ''));
|
|
$enabledRaw = $payload['enabled'] ?? null;
|
|
$idempotencyKey = $this->buildIdempotencyKey('group-action');
|
|
|
|
if ($actorTgUserId === '' || $field === '' || !array_key_exists($field, $allowedFields) || !in_array($enabledRaw, [0, 1, '0', '1', true, false], true)) {
|
|
$this->auditAndRespond(false, 'Invalid group action payload', [], 422, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
|
|
$member = $this->db->get('bot_group_members', ['id', 'role'], [
|
|
'group_id' => (int)$group['id'],
|
|
'tg_user_id' => $actorTgUserId,
|
|
]);
|
|
$isConfiguredAdmin = $member && in_array((string)($member['role'] ?? ''), ['admin', 'owner'], true);
|
|
$isRuntimeAdmin = !empty($payload['is_runtime_admin']);
|
|
if (!$isConfiguredAdmin && !$isRuntimeAdmin) {
|
|
$this->auditAndRespond(false, 'Permission denied', [], 403, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
|
|
$enabled = in_array($enabledRaw, [1, '1', true], true) ? 1 : 0;
|
|
$dbField = $allowedFields[$field];
|
|
|
|
try {
|
|
$this->db->update('bot_groups', [
|
|
$dbField => $enabled,
|
|
'updated_at' => date('Y-m-d H:i:s'),
|
|
], ['id' => (int)$group['id']]);
|
|
|
|
$group[$dbField] = $enabled;
|
|
$this->auditAndRespond(true, 'Group action updated', [
|
|
'field' => $field,
|
|
'db_field' => $dbField,
|
|
'enabled' => $enabled === 1,
|
|
'group_id' => (int)$group['id'],
|
|
'tg_group_id' => (string)$group['tg_group_id'],
|
|
], 200, (int)$group['id'], $idempotencyKey);
|
|
} catch (\Throwable $e) {
|
|
$this->auditAndRespond(false, 'Failed to update group action', [], 500, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
}
|
|
|
|
public function getDrawLatest(string $tgGroupId): void
|
|
{
|
|
$this->authenticateBotRequest();
|
|
$group = $this->requireGroupByTelegramId($tgGroupId);
|
|
|
|
try {
|
|
$snapshot = $this->buildPeriodSnapshot($group);
|
|
$game = $snapshot['game'];
|
|
$lastDrawn = $this->db->get('periods', '*', [
|
|
'game_id' => (int)$snapshot['game_id'],
|
|
'status' => ['drawn', 'settled'],
|
|
'ORDER' => ['id' => 'DESC']
|
|
]);
|
|
$currentLocked = null;
|
|
if (!empty($snapshot['period']) && ($snapshot['period']['status'] ?? '') === 'locked' && !empty($snapshot['period']['result'])) {
|
|
$currentLocked = $this->getResultPayload($game, $snapshot['period']);
|
|
}
|
|
|
|
$latest = $this->getResultPayload($game, $lastDrawn);
|
|
$this->auditAndRespond(true, 'ok', [
|
|
'latest_result' => $latest,
|
|
'current_locked_result' => $currentLocked,
|
|
'server_time' => $snapshot['server_time'],
|
|
], 200, (int)$group['id']);
|
|
} catch (\Throwable $e) {
|
|
$this->auditAndRespond(false, 'Failed to load latest draw', [], 500, (int)$group['id']);
|
|
}
|
|
}
|
|
|
|
public function toggleGroupSetting(string $tgGroupId): void
|
|
{
|
|
$this->authenticateBotRequest();
|
|
$group = $this->requireGroupByTelegramId($tgGroupId);
|
|
$this->updateGroupSwitches($group, [
|
|
'bet_enabled' => 'bet_enabled',
|
|
'bet_success' => 'remind_bet_success',
|
|
'draw_result' => 'remind_draw_result',
|
|
'close_countdown' => 'remind_close_countdown',
|
|
'animation' => 'animation_enabled',
|
|
]);
|
|
}
|
|
|
|
public function getOdds(string $tgGroupId): void
|
|
{
|
|
$this->authenticateBotRequest();
|
|
$group = $this->requireGroupByTelegramId($tgGroupId);
|
|
|
|
try {
|
|
$gameId = (int)$group['game_id'];
|
|
$game = $this->db->get('games', ['id', 'name', 'type'], ['id' => $gameId]);
|
|
if (!$game) {
|
|
$this->auditAndRespond(false, 'Game not found', [], 404, (int)$group['id']);
|
|
}
|
|
|
|
$oddsRows = $this->db->select('game_odds', '*', ['game_id' => $gameId]);
|
|
$grouped = [];
|
|
foreach ($oddsRows as $row) {
|
|
$type = (string)($row['type'] ?? '');
|
|
if (!isset($grouped[$type])) {
|
|
$grouped[$type] = [];
|
|
}
|
|
$grouped[$type][] = [
|
|
'target' => (string)($row['target'] ?? ''),
|
|
'odds' => (float)($row['odds'] ?? 0),
|
|
];
|
|
}
|
|
|
|
$this->auditAndRespond(true, 'ok', [
|
|
'game_id' => $gameId,
|
|
'game_name' => $game['name'],
|
|
'game_type' => $game['type'],
|
|
'odds' => $grouped,
|
|
'total' => count($oddsRows),
|
|
], 200, (int)$group['id']);
|
|
} catch (\Throwable $e) {
|
|
$this->auditAndRespond(false, 'Failed to load odds', [], 500, (int)$group['id']);
|
|
}
|
|
}
|
|
|
|
public function getBetFormatRules(string $tgGroupId): void
|
|
{
|
|
$this->authenticateBotRequest();
|
|
$group = $this->requireGroupByTelegramId($tgGroupId);
|
|
|
|
try {
|
|
$rule = null;
|
|
if (!empty($group['bet_format_rule_id'])) {
|
|
$rule = $this->db->get('bot_bet_format_rules', '*', [
|
|
'id' => (int)$group['bet_format_rule_id'],
|
|
'status' => 1,
|
|
]);
|
|
}
|
|
|
|
if (!$rule) {
|
|
$rules = $this->db->select('bot_bet_format_rules', '*', [
|
|
'game_id' => (int)$group['game_id'],
|
|
'status' => 1,
|
|
'ORDER' => ['id' => 'ASC'],
|
|
'LIMIT' => 10,
|
|
]);
|
|
|
|
$this->auditAndRespond(true, 'ok', [
|
|
'active_rule' => null,
|
|
'available_rules' => array_map(function ($r) {
|
|
return [
|
|
'id' => (int)$r['id'],
|
|
'rule_code' => $r['rule_code'],
|
|
'name' => $r['name'],
|
|
'parser_type' => $r['parser_type'],
|
|
'rule_config' => $r['rule_config'] ? json_decode($r['rule_config'], true) : null,
|
|
'example_text' => $r['example_text'],
|
|
];
|
|
}, $rules ?: []),
|
|
], 200, (int)$group['id']);
|
|
return;
|
|
}
|
|
|
|
$this->auditAndRespond(true, 'ok', [
|
|
'active_rule' => [
|
|
'id' => (int)$rule['id'],
|
|
'rule_code' => $rule['rule_code'],
|
|
'name' => $rule['name'],
|
|
'parser_type' => $rule['parser_type'],
|
|
'rule_config' => $rule['rule_config'] ? json_decode($rule['rule_config'], true) : null,
|
|
'example_text' => $rule['example_text'],
|
|
],
|
|
'available_rules' => [],
|
|
], 200, (int)$group['id']);
|
|
} catch (\Throwable $e) {
|
|
$this->auditAndRespond(false, 'Failed to load bet format rules', [], 500, (int)$group['id']);
|
|
}
|
|
}
|
|
|
|
public function getHistory(string $tgGroupId): void
|
|
{
|
|
$this->authenticateBotRequest();
|
|
$group = $this->requireGroupByTelegramId($tgGroupId);
|
|
|
|
try {
|
|
$snapshot = $this->buildPeriodSnapshot($group);
|
|
$game = $snapshot['game'];
|
|
$limit = (int)($_GET['limit'] ?? 10);
|
|
if ($limit <= 0) {
|
|
$limit = 10;
|
|
}
|
|
$limit = min($limit, 50);
|
|
|
|
$periods = $this->db->select('periods', '*', [
|
|
'game_id' => (int)$snapshot['game_id'],
|
|
'status' => ['drawn', 'settled'],
|
|
'ORDER' => ['id' => 'DESC'],
|
|
'LIMIT' => $limit,
|
|
]);
|
|
|
|
$history = [];
|
|
foreach ($periods as $period) {
|
|
$payload = $this->getResultPayload($game, $period);
|
|
if ($payload) {
|
|
$history[] = $payload;
|
|
}
|
|
}
|
|
|
|
$this->auditAndRespond(true, 'ok', [
|
|
'items' => $history,
|
|
'count' => count($history),
|
|
'server_time' => $snapshot['server_time'],
|
|
], 200, (int)$group['id']);
|
|
} catch (\Throwable $e) {
|
|
$this->auditAndRespond(false, 'Failed to load history', [], 500, (int)$group['id']);
|
|
}
|
|
}
|
|
|
|
public function touchMember(string $tgGroupId): void
|
|
{
|
|
$this->authenticateBotRequest();
|
|
$group = $this->requireGroupByTelegramId($tgGroupId);
|
|
|
|
$tgUserId = trim((string)($this->requestJson['tg_user_id'] ?? ''));
|
|
$tgUsername = trim((string)($this->requestJson['tg_username'] ?? ''));
|
|
$tgNickname = trim((string)($this->requestJson['tg_nickname'] ?? ''));
|
|
|
|
if ($tgUserId === '') {
|
|
$this->auditAndRespond(false, 'Missing tg_user_id', [], 422, (int)$group['id']);
|
|
}
|
|
|
|
try {
|
|
$existing = $this->db->get('bot_group_members', ['id', 'tg_username', 'tg_nickname'], [
|
|
'group_id' => (int)$group['id'],
|
|
'tg_user_id' => $tgUserId,
|
|
]);
|
|
|
|
$now = date('Y-m-d H:i:s');
|
|
if ($existing) {
|
|
$update = ['last_seen_at' => $now, 'updated_at' => $now];
|
|
if ($tgUsername !== '' && $tgUsername !== ($existing['tg_username'] ?? '')) {
|
|
$update['tg_username'] = $tgUsername;
|
|
}
|
|
if ($tgNickname !== '' && $tgNickname !== ($existing['tg_nickname'] ?? '')) {
|
|
$update['tg_nickname'] = $tgNickname;
|
|
}
|
|
$this->db->update('bot_group_members', $update, ['id' => (int)$existing['id']]);
|
|
$this->auditAndRespond(true, 'ok', ['action' => 'updated', 'member_id' => (int)$existing['id']], 200, (int)$group['id']);
|
|
}
|
|
|
|
$this->db->insert('bot_group_members', [
|
|
'group_id' => (int)$group['id'],
|
|
'tg_user_id' => $tgUserId,
|
|
'tg_username' => $tgUsername ?: null,
|
|
'tg_nickname' => $tgNickname ?: null,
|
|
'role' => 'member',
|
|
'bet_enabled' => 1,
|
|
'last_seen_at' => $now,
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
]);
|
|
$memberId = (int)$this->db->id();
|
|
$this->auditAndRespond(true, 'ok', ['action' => 'created', 'member_id' => $memberId], 200, (int)$group['id']);
|
|
} catch (\Throwable $e) {
|
|
$this->auditAndRespond(true, 'ok', ['action' => 'skipped'], 200, (int)$group['id']);
|
|
}
|
|
}
|
|
|
|
public function bindMember(string $tgGroupId): void
|
|
{
|
|
$this->authenticateBotRequest();
|
|
$group = $this->requireGroupByTelegramId($tgGroupId);
|
|
|
|
$tgUserId = trim((string)($this->requestJson['tg_user_id'] ?? ''));
|
|
$platformUsername = trim((string)($this->requestJson['platform_username'] ?? ''));
|
|
|
|
if ($tgUserId === '' || $platformUsername === '') {
|
|
$this->auditAndRespond(false, 'Missing tg_user_id or platform_username', [], 422, (int)$group['id']);
|
|
}
|
|
|
|
try {
|
|
$user = $this->db->get('users', ['id', 'username', 'balance', 'status'], [
|
|
'username' => $platformUsername,
|
|
]);
|
|
if (!$user) {
|
|
$this->auditAndRespond(false, 'Platform user not found', [], 404, (int)$group['id']);
|
|
}
|
|
|
|
$existing = $this->db->get('bot_group_members', ['id'], [
|
|
'group_id' => (int)$group['id'],
|
|
'tg_user_id' => $tgUserId,
|
|
]);
|
|
|
|
$now = date('Y-m-d H:i:s');
|
|
if ($existing) {
|
|
$this->db->update('bot_group_members', [
|
|
'platform_user_id' => (int)$user['id'],
|
|
'updated_at' => $now,
|
|
], ['id' => (int)$existing['id']]);
|
|
} else {
|
|
$this->db->insert('bot_group_members', [
|
|
'group_id' => (int)$group['id'],
|
|
'tg_user_id' => $tgUserId,
|
|
'tg_username' => trim((string)($this->requestJson['tg_username'] ?? '')) ?: null,
|
|
'tg_nickname' => trim((string)($this->requestJson['tg_nickname'] ?? '')) ?: null,
|
|
'platform_user_id' => (int)$user['id'],
|
|
'role' => 'member',
|
|
'bet_enabled' => 1,
|
|
'last_seen_at' => $now,
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
]);
|
|
}
|
|
|
|
$this->auditAndRespond(true, 'Bind success', [
|
|
'platform_user_id' => (int)$user['id'],
|
|
'platform_username' => $user['username'],
|
|
'platform_balance' => (float)$user['balance'],
|
|
], 200, (int)$group['id']);
|
|
} catch (\Throwable $e) {
|
|
$this->auditAndRespond(false, 'Bind failed', [], 500, (int)$group['id']);
|
|
}
|
|
}
|
|
|
|
public function getGroupMembers(string $tgGroupId): void
|
|
{
|
|
$this->authenticateBotRequest();
|
|
$group = $this->requireGroupByTelegramId($tgGroupId);
|
|
|
|
try {
|
|
$members = $this->db->select('bot_group_members', [
|
|
'[>]users' => ['platform_user_id' => 'id'],
|
|
], [
|
|
'bot_group_members.id',
|
|
'bot_group_members.tg_user_id',
|
|
'bot_group_members.tg_username',
|
|
'bot_group_members.tg_nickname',
|
|
'bot_group_members.platform_user_id',
|
|
'bot_group_members.role',
|
|
'bot_group_members.bet_enabled',
|
|
'bot_group_members.last_seen_at',
|
|
'bot_group_members.created_at',
|
|
'bot_group_members.updated_at',
|
|
'users.username(platform_username)',
|
|
'users.balance(platform_balance)',
|
|
'users.status(platform_status)',
|
|
], [
|
|
'bot_group_members.group_id' => (int)$group['id'],
|
|
'ORDER' => ['bot_group_members.id' => 'ASC'],
|
|
]);
|
|
|
|
$shillUserIds = $this->db->select('bot_shills', ['tg_user_id'], [
|
|
'group_id' => (int)$group['id'],
|
|
'enabled' => 1,
|
|
]);
|
|
$shillMap = array_fill_keys($shillUserIds ?: [], true);
|
|
|
|
$items = [];
|
|
foreach ($members as $member) {
|
|
$tgUserId = (string)$member['tg_user_id'];
|
|
$items[] = [
|
|
'id' => (int)$member['id'],
|
|
'tg_user_id' => $tgUserId,
|
|
'tg_username' => $member['tg_username'],
|
|
'tg_nickname' => $member['tg_nickname'],
|
|
'platform_user_id' => $member['platform_user_id'] !== null ? (int)$member['platform_user_id'] : null,
|
|
'platform_username' => $member['platform_username'] ?? null,
|
|
'platform_balance' => $member['platform_balance'] !== null ? (float)$member['platform_balance'] : null,
|
|
'platform_status' => $member['platform_status'] !== null ? (int)$member['platform_status'] : null,
|
|
'role' => $member['role'],
|
|
'bet_enabled' => (int)$member['bet_enabled'] === 1,
|
|
'is_shill' => isset($shillMap[$tgUserId]) || $member['role'] === 'shill',
|
|
'last_seen_at' => $member['last_seen_at'],
|
|
'created_at' => $member['created_at'],
|
|
'updated_at' => $member['updated_at'],
|
|
];
|
|
}
|
|
|
|
$this->auditAndRespond(true, 'ok', [
|
|
'group_id' => (int)$group['id'],
|
|
'items' => $items,
|
|
'count' => count($items),
|
|
], 200, (int)$group['id']);
|
|
} catch (\Throwable $e) {
|
|
$this->auditAndRespond(false, 'Failed to load group members', [], 500, (int)$group['id']);
|
|
}
|
|
}
|
|
|
|
public function getMemberStats(string $tgGroupId): void
|
|
{
|
|
$this->authenticateBotRequest();
|
|
$group = $this->requireGroupByTelegramId($tgGroupId);
|
|
|
|
try {
|
|
$from = trim((string)($_GET['date'] ?? date('Y-m-d')));
|
|
$startAt = $from . ' 00:00:00';
|
|
$endAt = $from . ' 23:59:59';
|
|
|
|
$orders = $this->db->select('bot_bet_orders', [
|
|
'tg_user_id',
|
|
'tg_username',
|
|
'bet_amount_total',
|
|
'accepted_bet_count',
|
|
'is_shill',
|
|
], [
|
|
'group_id' => (int)$group['id'],
|
|
'sync_status' => 'success',
|
|
'created_at[<>]' => [$startAt, $endAt],
|
|
'ORDER' => ['id' => 'ASC'],
|
|
]);
|
|
|
|
$memberMap = [];
|
|
foreach ($orders as $order) {
|
|
$tgUserId = (string)($order['tg_user_id'] ?? '');
|
|
if ($tgUserId === '') {
|
|
continue;
|
|
}
|
|
|
|
if (!isset($memberMap[$tgUserId])) {
|
|
$memberMap[$tgUserId] = [
|
|
'tg_user_id' => $tgUserId,
|
|
'tg_username' => (string)($order['tg_username'] ?? ''),
|
|
'bet_order_count' => 0,
|
|
'bet_count' => 0,
|
|
'total_bet' => 0.0,
|
|
'is_shill' => (int)($order['is_shill'] ?? 0) === 1,
|
|
];
|
|
}
|
|
|
|
$memberMap[$tgUserId]['bet_order_count']++;
|
|
$memberMap[$tgUserId]['bet_count'] += (int)($order['accepted_bet_count'] ?? 0);
|
|
$memberMap[$tgUserId]['total_bet'] += (float)($order['bet_amount_total'] ?? 0);
|
|
if ($memberMap[$tgUserId]['tg_username'] === '' && !empty($order['tg_username'])) {
|
|
$memberMap[$tgUserId]['tg_username'] = (string)$order['tg_username'];
|
|
}
|
|
if ((int)($order['is_shill'] ?? 0) === 1) {
|
|
$memberMap[$tgUserId]['is_shill'] = true;
|
|
}
|
|
}
|
|
|
|
$members = $this->db->select('bot_group_members', [
|
|
'tg_user_id',
|
|
'tg_username',
|
|
'tg_nickname',
|
|
'role',
|
|
'bet_enabled',
|
|
'last_seen_at',
|
|
], [
|
|
'group_id' => (int)$group['id'],
|
|
]);
|
|
|
|
foreach ($members as $member) {
|
|
$tgUserId = (string)$member['tg_user_id'];
|
|
if (!isset($memberMap[$tgUserId])) {
|
|
$memberMap[$tgUserId] = [
|
|
'tg_user_id' => $tgUserId,
|
|
'tg_username' => (string)($member['tg_username'] ?? ''),
|
|
'bet_order_count' => 0,
|
|
'bet_count' => 0,
|
|
'total_bet' => 0.0,
|
|
'is_shill' => $member['role'] === 'shill',
|
|
];
|
|
}
|
|
|
|
$memberMap[$tgUserId]['tg_nickname'] = $member['tg_nickname'];
|
|
$memberMap[$tgUserId]['role'] = $member['role'];
|
|
$memberMap[$tgUserId]['bet_enabled'] = (int)$member['bet_enabled'] === 1;
|
|
$memberMap[$tgUserId]['last_seen_at'] = $member['last_seen_at'];
|
|
if ($memberMap[$tgUserId]['tg_username'] === '' && !empty($member['tg_username'])) {
|
|
$memberMap[$tgUserId]['tg_username'] = (string)$member['tg_username'];
|
|
}
|
|
if ($member['role'] === 'shill') {
|
|
$memberMap[$tgUserId]['is_shill'] = true;
|
|
}
|
|
}
|
|
|
|
$items = array_values($memberMap);
|
|
usort($items, function (array $left, array $right): int {
|
|
$betCompare = $right['total_bet'] <=> $left['total_bet'];
|
|
if ($betCompare !== 0) {
|
|
return $betCompare;
|
|
}
|
|
return $right['bet_order_count'] <=> $left['bet_order_count'];
|
|
});
|
|
|
|
$this->auditAndRespond(true, 'ok', [
|
|
'date' => $from,
|
|
'group_id' => (int)$group['id'],
|
|
'items' => $items,
|
|
'count' => count($items),
|
|
], 200, (int)$group['id']);
|
|
} catch (\Throwable $e) {
|
|
$this->auditAndRespond(false, 'Failed to load member stats', [], 500, (int)$group['id']);
|
|
}
|
|
}
|
|
|
|
public function getDailyStats(string $tgGroupId): void
|
|
{
|
|
$this->authenticateBotRequest();
|
|
$group = $this->requireGroupByTelegramId($tgGroupId);
|
|
|
|
try {
|
|
$wallet = $this->getGroupWallet((int)$group['id']);
|
|
$platformUserId = (int)$wallet['platform_user_id'];
|
|
$from = trim((string)($_GET['date'] ?? date('Y-m-d')));
|
|
$startAt = $from . ' 00:00:00';
|
|
$endAt = $from . ' 23:59:59';
|
|
|
|
$betWhere = [
|
|
'user_id' => $platformUserId,
|
|
'source' => 'bot',
|
|
'created_at[<>]' => [$startAt, $endAt],
|
|
];
|
|
$txWhere = [
|
|
'user_id' => $platformUserId,
|
|
'source' => 'bot',
|
|
'created_at[<>]' => [$startAt, $endAt],
|
|
];
|
|
|
|
$totalBet = (float)($this->db->sum('bets', 'amount', $betWhere) ?: 0);
|
|
$totalWin = (float)($this->db->sum('bets', 'win_amount', array_merge($betWhere, ['status' => 'win'])) ?: 0);
|
|
$betCount = (int)($this->db->count('bets', $betWhere) ?: 0);
|
|
$deposit = (float)($this->db->sum('transactions', 'amount', array_merge($txWhere, ['type' => 'credit', 'amount[>]' => 0])) ?: 0);
|
|
$withdraw = abs((float)($this->db->sum('transactions', 'amount', array_merge($txWhere, ['type' => 'debit', 'amount[<]' => 0])) ?: 0));
|
|
$orderCount = (int)($this->db->count('bot_bet_orders', [
|
|
'group_id' => (int)$group['id'],
|
|
'created_at[<>]' => [$startAt, $endAt],
|
|
'sync_status' => 'success',
|
|
]) ?: 0);
|
|
$shillOrderCount = (int)($this->db->count('bot_bet_orders', [
|
|
'group_id' => (int)$group['id'],
|
|
'created_at[<>]' => [$startAt, $endAt],
|
|
'sync_status' => 'success',
|
|
'is_shill' => 1,
|
|
]) ?: 0);
|
|
|
|
$this->auditAndRespond(true, 'ok', [
|
|
'date' => $from,
|
|
'group_id' => (int)$group['id'],
|
|
'platform_user_id' => $platformUserId,
|
|
'bet_order_count' => $orderCount,
|
|
'shill_order_count' => $shillOrderCount,
|
|
'effective_order_count' => max(0, $orderCount - $shillOrderCount),
|
|
'bet_count' => $betCount,
|
|
'total_bet' => $totalBet,
|
|
'total_win' => $totalWin,
|
|
'profit_loss' => $totalWin - $totalBet,
|
|
'credit_total' => $deposit,
|
|
'debit_total' => $withdraw,
|
|
], 200, (int)$group['id']);
|
|
} catch (\Throwable $e) {
|
|
$this->auditAndRespond(false, 'Failed to load daily stats', [], 500, (int)$group['id']);
|
|
}
|
|
}
|
|
|
|
public function getCurrentPeriod(string $tgGroupId): void
|
|
{
|
|
$this->authenticateBotRequest();
|
|
$group = $this->requireGroupByTelegramId($tgGroupId);
|
|
|
|
try {
|
|
$snapshot = $this->buildPeriodSnapshot($group);
|
|
$period = $snapshot['period'];
|
|
$this->auditAndRespond(true, 'ok', [
|
|
'id' => (int)($period['id'] ?? 0),
|
|
'game_id' => (int)$snapshot['game_id'],
|
|
'period_number' => $period['period_number'] ?? '',
|
|
'status' => $period['status'] ?? 'none',
|
|
'remaining_seconds' => (int)$snapshot['countdown'],
|
|
'lock_countdown' => (int)$snapshot['lock_countdown'],
|
|
'auto_generated' => (int)($period['auto_generated'] ?? 0),
|
|
'server_time' => $snapshot['server_time'],
|
|
], 200, (int)$group['id']);
|
|
} catch (\Throwable $e) {
|
|
$this->auditAndRespond(false, 'Failed to load current period', [], 500, (int)$group['id']);
|
|
}
|
|
}
|
|
|
|
public function credit(string $tgGroupId): void
|
|
{
|
|
$this->authenticateBotRequest();
|
|
$group = $this->requireGroupByTelegramId($tgGroupId);
|
|
$wallet = $this->getGroupWallet((int)$group['id']);
|
|
$this->applyFundChange($group, $wallet, 'credit');
|
|
}
|
|
|
|
public function debit(string $tgGroupId): void
|
|
{
|
|
$this->authenticateBotRequest();
|
|
$group = $this->requireGroupByTelegramId($tgGroupId);
|
|
$wallet = $this->getGroupWallet((int)$group['id']);
|
|
$this->applyFundChange($group, $wallet, 'debit');
|
|
}
|
|
|
|
public function placeGroupBet(string $tgGroupId): void
|
|
{
|
|
$this->authenticateBotRequest();
|
|
$group = $this->requireGroupByTelegramId($tgGroupId);
|
|
$wallet = $this->getGroupWallet((int)$group['id']);
|
|
|
|
if ((int)$group['bet_enabled'] !== 1) {
|
|
$this->auditAndRespond(false, 'Bet sync disabled for this group', [], 403, (int)$group['id']);
|
|
}
|
|
|
|
$gameId = (int)($this->requestJson['game_id'] ?? $group['game_id'] ?? 0);
|
|
$periodNumber = trim((string)($this->requestJson['period_number'] ?? ''));
|
|
$bets = $this->requestJson['bets'] ?? [];
|
|
$tgUser = $this->requestJson['tg_user'] ?? [];
|
|
$rawText = trim((string)($this->requestJson['bet_context']['raw_text'] ?? $this->requestJson['raw_text'] ?? ''));
|
|
$idempotencyKey = $this->buildIdempotencyKey('tg');
|
|
|
|
if ($gameId <= 0 || $periodNumber === '' || !is_array($bets) || empty($bets)) {
|
|
$this->auditAndRespond(false, 'Missing required bet fields', [], 422, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
|
|
$exists = $this->db->get('bot_bet_orders', '*', ['idempotency_key' => $idempotencyKey]);
|
|
if ($exists) {
|
|
$data = [
|
|
'platform_order_ref' => $exists['platform_order_ref'],
|
|
'sync_status' => $exists['sync_status'],
|
|
'bet_amount_total' => (float)$exists['bet_amount_total'],
|
|
'accepted_bet_count' => (int)$exists['accepted_bet_count'],
|
|
'duplicate' => true,
|
|
];
|
|
$this->auditAndRespond(true, 'Duplicate request ignored', $data, 200, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
|
|
$period = $this->db->get('periods', '*', [
|
|
'period_number' => $periodNumber,
|
|
'game_id' => $gameId,
|
|
]);
|
|
if (!$period || $period['status'] !== 'pending') {
|
|
$this->auditAndRespond(false, 'Period is not open for betting', [], 422, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
|
|
$oddsRows = $this->db->select('game_odds', '*', ['game_id' => $gameId]);
|
|
$oddsMap = [];
|
|
foreach ($oddsRows as $row) {
|
|
$oddsMap[$row['type'] . '_' . $row['target']] = (float)$row['odds'];
|
|
}
|
|
|
|
$limitsRaw = $this->db->select('bet_limits', '*', ['game_id' => $gameId]);
|
|
$limits = [];
|
|
foreach ($limitsRaw as $row) {
|
|
$limits[$row['bet_type']] = $row;
|
|
}
|
|
|
|
$totalAmount = 0.0;
|
|
$validBets = [];
|
|
foreach ($bets as $bet) {
|
|
$type = trim((string)($bet['type'] ?? ''));
|
|
$target = trim((string)($bet['target'] ?? $bet['value'] ?? ''));
|
|
$amount = (float)($bet['amount'] ?? 0);
|
|
if ($type === '' || $target === '' || $amount <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$key = $type . '_' . $target;
|
|
$odds = $oddsMap[$key] ?? null;
|
|
if ($odds === null) {
|
|
$this->auditAndRespond(false, 'Invalid bet: ' . $key, [], 422, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
|
|
if (isset($limits[$type])) {
|
|
if ($amount < (float)$limits[$type]['min_amount']) {
|
|
$this->auditAndRespond(false, 'Min bet for ' . $type . ': ' . $limits[$type]['min_amount'], [], 422, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
if ($amount > (float)$limits[$type]['max_amount']) {
|
|
$this->auditAndRespond(false, 'Max bet for ' . $type . ': ' . $limits[$type]['max_amount'], [], 422, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
}
|
|
|
|
$totalAmount += $amount;
|
|
$validBets[] = [
|
|
'type' => $type,
|
|
'target' => $target,
|
|
'amount' => $amount,
|
|
'odds' => $odds,
|
|
];
|
|
}
|
|
|
|
if (empty($validBets)) {
|
|
$this->auditAndRespond(false, 'No valid bets', [], 422, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
|
|
$platformUserId = (int)$wallet['platform_user_id'];
|
|
$platformOrderRef = 'BOT' . date('YmdHis') . substr(hash('sha256', $idempotencyKey), 0, 10);
|
|
$isShill = $this->db->has('bot_shills', [
|
|
'group_id' => (int)$group['id'],
|
|
'tg_user_id' => (string)($tgUser['id'] ?? ''),
|
|
'enabled' => 1,
|
|
]) ? 1 : 0;
|
|
|
|
try {
|
|
$this->db->medoo->pdo->beginTransaction();
|
|
|
|
$stmt = $this->db->medoo->pdo->prepare('SELECT balance, is_virtual, agent_id FROM users WHERE id = :id FOR UPDATE');
|
|
$stmt->execute([':id' => $platformUserId]);
|
|
$user = $stmt->fetch(\PDO::FETCH_ASSOC);
|
|
if (!$user) {
|
|
throw new \RuntimeException('Platform wallet user not found');
|
|
}
|
|
|
|
$currentBalance = (float)$user['balance'];
|
|
if ($currentBalance < $totalAmount) {
|
|
$this->db->medoo->pdo->rollBack();
|
|
$this->auditAndRespond(false, 'Insufficient balance', [], 422, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
|
|
$newBalance = $currentBalance - $totalAmount;
|
|
$this->db->update('users', ['balance' => $newBalance], ['id' => $platformUserId]);
|
|
|
|
$this->db->insert('transactions', [
|
|
'user_id' => $platformUserId,
|
|
'type' => 'bet',
|
|
'amount' => -$totalAmount,
|
|
'balance_before' => $currentBalance,
|
|
'balance_after' => $newBalance,
|
|
'related_id' => (int)$period['id'],
|
|
'description' => 'TG Bot Bet - ' . $periodNumber,
|
|
'is_virtual' => (int)($user['is_virtual'] ?? 0),
|
|
'source' => 'bot',
|
|
'source_ref' => $idempotencyKey,
|
|
'created_at' => date('Y-m-d H:i:s'),
|
|
]);
|
|
|
|
foreach ($validBets as $vb) {
|
|
$this->db->insert('bets', [
|
|
'user_id' => $platformUserId,
|
|
'game_id' => $gameId,
|
|
'period_id' => (int)$period['id'],
|
|
'period_number' => $periodNumber,
|
|
'bet_type' => $vb['type'],
|
|
'bet_value' => $vb['target'],
|
|
'amount' => $vb['amount'],
|
|
'odds' => $vb['odds'],
|
|
'status' => 'pending',
|
|
'is_virtual' => (int)($user['is_virtual'] ?? 0),
|
|
'agent_id' => $user['agent_id'] ?? null,
|
|
'source' => 'bot',
|
|
'source_ref' => $idempotencyKey,
|
|
'created_at' => date('Y-m-d H:i:s'),
|
|
]);
|
|
}
|
|
|
|
$this->db->insert('bot_bet_orders', [
|
|
'group_id' => (int)$group['id'],
|
|
'tg_chat_id' => $tgGroupId,
|
|
'tg_message_id' => (int)($this->requestJson['bet_context']['message_id'] ?? $this->requestJson['message_id'] ?? 0),
|
|
'tg_user_id' => (string)($tgUser['id'] ?? ''),
|
|
'tg_username' => (string)($tgUser['username'] ?? ''),
|
|
'platform_user_id' => $platformUserId,
|
|
'game_id' => $gameId,
|
|
'period_number' => $periodNumber,
|
|
'raw_text' => $rawText,
|
|
'parsed_payload_json' => json_encode($validBets, JSON_UNESCAPED_UNICODE),
|
|
'bet_amount_total' => $totalAmount,
|
|
'accepted_bet_count' => count($validBets),
|
|
'platform_order_ref' => $platformOrderRef,
|
|
'idempotency_key' => $idempotencyKey,
|
|
'sync_status' => 'success',
|
|
'is_shill' => $isShill,
|
|
'created_at' => date('Y-m-d H:i:s'),
|
|
'updated_at' => date('Y-m-d H:i:s'),
|
|
]);
|
|
|
|
if ((int)$group['remind_bet_success'] === 1) {
|
|
$this->db->insert('bot_push_logs', [
|
|
'group_id' => (int)$group['id'],
|
|
'period_number' => $periodNumber,
|
|
'push_type' => 'bet_success',
|
|
'payload_json' => json_encode([
|
|
'event' => 'bet_success',
|
|
'period_number' => $periodNumber,
|
|
'platform_order_ref' => $platformOrderRef,
|
|
'tg_user_id' => (string)($tgUser['id'] ?? ''),
|
|
'tg_username' => (string)($tgUser['username'] ?? ''),
|
|
'accepted_bets' => count($validBets),
|
|
'total_amount' => $totalAmount,
|
|
'balance_after' => $newBalance,
|
|
'raw_text' => $rawText,
|
|
], JSON_UNESCAPED_UNICODE),
|
|
'status' => 'pending',
|
|
'created_at' => date('Y-m-d H:i:s'),
|
|
'updated_at' => date('Y-m-d H:i:s'),
|
|
]);
|
|
}
|
|
|
|
$this->db->medoo->pdo->commit();
|
|
|
|
$data = [
|
|
'platform_order_ref' => $platformOrderRef,
|
|
'new_balance' => $newBalance,
|
|
'accepted_bets' => count($validBets),
|
|
'total_amount' => $totalAmount,
|
|
'is_shill' => $isShill === 1,
|
|
];
|
|
|
|
$this->auditAndRespond(true, 'Bet placed successfully', $data, 200, (int)$group['id'], $idempotencyKey);
|
|
} catch (\Throwable $e) {
|
|
if ($this->db->medoo && $this->db->medoo->pdo->inTransaction()) {
|
|
$this->db->medoo->pdo->rollBack();
|
|
}
|
|
|
|
try {
|
|
$this->db->insert('bot_bet_orders', [
|
|
'group_id' => (int)$group['id'],
|
|
'tg_chat_id' => $tgGroupId,
|
|
'tg_message_id' => (int)($this->requestJson['bet_context']['message_id'] ?? $this->requestJson['message_id'] ?? 0),
|
|
'tg_user_id' => (string)($tgUser['id'] ?? ''),
|
|
'tg_username' => (string)($tgUser['username'] ?? ''),
|
|
'platform_user_id' => $platformUserId,
|
|
'game_id' => $gameId,
|
|
'period_number' => $periodNumber,
|
|
'raw_text' => $rawText,
|
|
'parsed_payload_json' => json_encode($bets, JSON_UNESCAPED_UNICODE),
|
|
'bet_amount_total' => $totalAmount,
|
|
'accepted_bet_count' => 0,
|
|
'platform_order_ref' => $platformOrderRef,
|
|
'idempotency_key' => $idempotencyKey,
|
|
'sync_status' => 'failed',
|
|
'sync_error' => mb_substr($e->getMessage(), 0, 255),
|
|
'is_shill' => $isShill,
|
|
'created_at' => date('Y-m-d H:i:s'),
|
|
'updated_at' => date('Y-m-d H:i:s'),
|
|
]);
|
|
} catch (\Throwable $ignore) {
|
|
}
|
|
|
|
$this->auditAndRespond(false, 'System error', [], 500, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
}
|
|
|
|
public function getPendingPushes(string $tgGroupId): void
|
|
{
|
|
$this->authenticateBotRequest();
|
|
$group = $this->requireGroupByTelegramId($tgGroupId);
|
|
|
|
$limit = (int)($this->requestJson['limit'] ?? $_GET['limit'] ?? 20);
|
|
if ($limit <= 0) {
|
|
$limit = 20;
|
|
}
|
|
$limit = min($limit, 100);
|
|
|
|
$workerToken = trim((string)($this->requestJson['worker_token'] ?? $_GET['worker_token'] ?? ''));
|
|
if ($workerToken === '') {
|
|
$workerToken = 'worker:' . substr(hash('sha256', uniqid('', true) . '|' . microtime(true)), 0, 24);
|
|
}
|
|
|
|
try {
|
|
$reclaimBefore = date('Y-m-d H:i:s', time() - 120);
|
|
$this->db->update('bot_push_logs', [
|
|
'claim_token' => null,
|
|
'claimed_at' => null,
|
|
'updated_at' => date('Y-m-d H:i:s'),
|
|
], [
|
|
'group_id' => (int)$group['id'],
|
|
'status' => 'pending',
|
|
'claim_token[!]' => null,
|
|
'claimed_at[<]' => $reclaimBefore,
|
|
]);
|
|
|
|
$rows = $this->db->select('bot_push_logs', '*', [
|
|
'group_id' => (int)$group['id'],
|
|
'status' => 'pending',
|
|
'claim_token' => null,
|
|
'ORDER' => ['id' => 'ASC'],
|
|
'LIMIT' => 200,
|
|
]) ?: [];
|
|
|
|
$pushes = [];
|
|
$nowTs = time();
|
|
$claimedAt = date('Y-m-d H:i:s');
|
|
foreach ($rows as $row) {
|
|
$payload = [];
|
|
if (!empty($row['payload_json'])) {
|
|
$decoded = json_decode((string)$row['payload_json'], true);
|
|
if (is_array($decoded)) {
|
|
$payload = $decoded;
|
|
}
|
|
}
|
|
|
|
$dispatchAtTs = !empty($payload['dispatch_at']) ? strtotime((string)$payload['dispatch_at']) : 0;
|
|
if ($dispatchAtTs > 0 && $dispatchAtTs > $nowTs) {
|
|
continue;
|
|
}
|
|
|
|
$updated = $this->db->update('bot_push_logs', [
|
|
'claim_token' => $workerToken,
|
|
'claimed_at' => $claimedAt,
|
|
'updated_at' => $claimedAt,
|
|
], [
|
|
'id' => (int)$row['id'],
|
|
'group_id' => (int)$group['id'],
|
|
'status' => 'pending',
|
|
'claim_token' => null,
|
|
]);
|
|
|
|
if (($updated->rowCount() ?? 0) <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$pushes[] = [
|
|
'id' => (int)$row['id'],
|
|
'group_id' => (int)$row['group_id'],
|
|
'period_number' => $row['period_number'],
|
|
'push_type' => $row['push_type'],
|
|
'payload' => $payload,
|
|
'claim_token' => $workerToken,
|
|
'claimed_at' => $claimedAt,
|
|
'created_at' => $row['created_at'],
|
|
'updated_at' => $claimedAt,
|
|
];
|
|
|
|
if (count($pushes) >= $limit) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
$this->auditAndRespond(true, 'ok', [
|
|
'items' => $pushes,
|
|
'count' => count($pushes),
|
|
'limit' => $limit,
|
|
'worker_token' => $workerToken,
|
|
], 200, (int)$group['id']);
|
|
} catch (\Throwable $e) {
|
|
$this->auditAndRespond(false, 'Failed to load pending pushes', [], 500, (int)$group['id']);
|
|
}
|
|
}
|
|
|
|
public function ackPushes(string $tgGroupId): void
|
|
{
|
|
$this->authenticateBotRequest();
|
|
$group = $this->requireGroupByTelegramId($tgGroupId);
|
|
$idempotencyKey = $this->buildIdempotencyKey('push-ack');
|
|
|
|
$pushLogIds = $this->requestJson['push_log_ids'] ?? [];
|
|
$status = trim((string)($this->requestJson['status'] ?? ''));
|
|
$errorMessage = trim((string)($this->requestJson['error_message'] ?? ''));
|
|
$tgMessageId = (int)($this->requestJson['tg_message_id'] ?? 0);
|
|
$claimToken = trim((string)($this->requestJson['claim_token'] ?? ''));
|
|
|
|
if (!is_array($pushLogIds) || empty($pushLogIds)) {
|
|
$this->auditAndRespond(false, 'push_log_ids is required', [], 422, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
|
|
$allowedStatus = ['success', 'failed', 'skipped'];
|
|
if (!in_array($status, $allowedStatus, true)) {
|
|
$this->auditAndRespond(false, 'Invalid status', [], 422, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
|
|
if ($claimToken === '') {
|
|
$this->auditAndRespond(false, 'claim_token is required', [], 422, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
|
|
$pushLogIds = array_values(array_unique(array_filter(array_map('intval', $pushLogIds), static function ($id) {
|
|
return $id > 0;
|
|
})));
|
|
if (empty($pushLogIds)) {
|
|
$this->auditAndRespond(false, 'No valid push_log_ids', [], 422, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
|
|
try {
|
|
$rows = $this->db->select('bot_push_logs', ['id', 'status', 'claim_token'], [
|
|
'id' => $pushLogIds,
|
|
'group_id' => (int)$group['id'],
|
|
'claim_token' => $claimToken,
|
|
]) ?: [];
|
|
|
|
if (empty($rows)) {
|
|
$this->auditAndRespond(false, 'Push logs not found or not claimed by this worker', [], 404, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
|
|
$foundIds = array_map(static function (array $row): int {
|
|
return (int)$row['id'];
|
|
}, $rows);
|
|
sort($foundIds);
|
|
$expectedIds = $pushLogIds;
|
|
sort($expectedIds);
|
|
if ($foundIds !== $expectedIds) {
|
|
$this->auditAndRespond(false, 'Some push logs are missing or owned by another worker', [
|
|
'expected_ids' => $expectedIds,
|
|
'found_ids' => $foundIds,
|
|
], 409, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
|
|
$updateData = [
|
|
'status' => $status,
|
|
'updated_at' => date('Y-m-d H:i:s'),
|
|
'error_message' => $errorMessage !== '' ? mb_substr($errorMessage, 0, 255) : null,
|
|
'claim_token' => null,
|
|
'claimed_at' => null,
|
|
];
|
|
if ($tgMessageId > 0) {
|
|
$updateData['tg_message_id'] = $tgMessageId;
|
|
}
|
|
|
|
$this->db->update('bot_push_logs', $updateData, [
|
|
'id' => $foundIds,
|
|
'group_id' => (int)$group['id'],
|
|
'claim_token' => $claimToken,
|
|
]);
|
|
|
|
$this->auditAndRespond(true, 'Push ack updated', [
|
|
'updated_count' => count($foundIds),
|
|
'updated_ids' => $foundIds,
|
|
'status' => $status,
|
|
'tg_message_id' => $tgMessageId > 0 ? $tgMessageId : null,
|
|
'claim_token' => $claimToken,
|
|
], 200, (int)$group['id'], $idempotencyKey);
|
|
} catch (\Throwable $e) {
|
|
$this->auditAndRespond(false, 'Failed to ack pushes', [], 500, (int)$group['id'], $idempotencyKey);
|
|
}
|
|
}
|
|
}
|