diff --git a/App/Controllers/Admin/AgentController.php b/App/Controllers/Admin/AgentController.php index c992369..3dbe69f 100644 --- a/App/Controllers/Admin/AgentController.php +++ b/App/Controllers/Admin/AgentController.php @@ -34,7 +34,7 @@ class AgentController extends AdminBaseController { // 创建代理:先创建用户,再创建代理记录 $userId = (int)($data['user_id'] ?? 0); if (!$userId) { $this->json(['status'=>'error','message'=>'User ID required']); return; } - $code = strtoupper(substr(md5(uniqid()), 0, 8)); + $code = strtoupper(bin2hex(random_bytes(4))); $this->db->insert('agents', [ 'user_id' => $userId, 'parent_id' => !empty($data['parent_id']) ? (int)$data['parent_id'] : null, diff --git a/App/Controllers/Admin/BotController.php b/App/Controllers/Admin/BotController.php new file mode 100644 index 0000000..d0111b9 --- /dev/null +++ b/App/Controllers/Admin/BotController.php @@ -0,0 +1,965 @@ +checkLogin(); + $this->checkAdmin(); + $this->db = new Database(); + } + + public function index(): void + { + $stats = [ + 'bot_count' => 0, + 'group_count' => 0, + 'wallet_count' => 0, + 'enabled_group_count' => 0, + 'member_count' => 0, + 'shill_count' => 0, + 'push_count' => 0, + 'push_success_count' => 0, + 'countdown_group_count' => 0, + 'animation_group_count' => 0, + 'api_request_count' => 0, + 'api_signature_ok_count' => 0, + ]; + + $bots = []; + $groups = []; + $wallets = []; + $rules = []; + $games = []; + $users = []; + $members = []; + $shills = []; + $recentOrders = []; + $pushLogs = []; + $apiRequestLogs = []; + $pushSummary = [ + 'countdown' => ['total' => 0, 'success' => 0, 'failed' => 0, 'pending' => 0, 'skipped' => 0], + 'bet_success' => ['total' => 0, 'success' => 0, 'failed' => 0, 'pending' => 0, 'skipped' => 0], + 'draw' => ['total' => 0, 'success' => 0, 'failed' => 0, 'pending' => 0, 'skipped' => 0], + 'credit' => ['total' => 0, 'success' => 0, 'failed' => 0, 'pending' => 0, 'skipped' => 0], + 'debit' => ['total' => 0, 'success' => 0, 'failed' => 0, 'pending' => 0, 'skipped' => 0], + 'system' => ['total' => 0, 'success' => 0, 'failed' => 0, 'pending' => 0, 'skipped' => 0], + ]; + + try { + $stats['bot_count'] = (int)($this->db->count('bot_instances') ?: 0); + $stats['group_count'] = (int)($this->db->count('bot_groups') ?: 0); + $stats['wallet_count'] = (int)($this->db->count('bot_group_wallets', ['status' => 1]) ?: 0); + $stats['enabled_group_count'] = (int)($this->db->count('bot_groups', ['status' => 1, 'bet_enabled' => 1]) ?: 0); + $stats['member_count'] = (int)($this->db->count('bot_group_members') ?: 0); + $stats['shill_count'] = (int)($this->db->count('bot_shills', ['enabled' => 1]) ?: 0); + $stats['push_count'] = (int)($this->db->count('bot_push_logs') ?: 0); + $stats['push_success_count'] = (int)($this->db->count('bot_push_logs', ['status' => 'success']) ?: 0); + $stats['countdown_group_count'] = (int)($this->db->count('bot_groups', ['remind_close_countdown' => 1]) ?: 0); + $stats['animation_group_count'] = (int)($this->db->count('bot_groups', ['animation_enabled' => 1]) ?: 0); + $stats['api_request_count'] = (int)($this->db->count('bot_api_request_logs') ?: 0); + $stats['api_signature_ok_count'] = (int)($this->db->count('bot_api_request_logs', ['signature_ok' => 1]) ?: 0); + + $bots = $this->db->select('bot_instances', '*', ['ORDER' => ['id' => 'DESC']]) ?: []; + foreach ($bots as &$bot) { + $botId = (int)$bot['id']; + $bot['group_count'] = (int)($this->db->count('bot_groups', ['bot_id' => $botId]) ?: 0); + $bot['active_group_count'] = (int)($this->db->count('bot_groups', [ + 'bot_id' => $botId, + 'status' => 1, + 'bet_enabled' => 1, + ]) ?: 0); + } + unset($bot); + + $groups = $this->db->select('bot_groups', [ + '[>]bot_instances' => ['bot_id' => 'id'], + '[>]games' => ['game_id' => 'id'], + ], [ + 'bot_groups.id', + 'bot_groups.bot_id', + 'bot_groups.game_id', + 'bot_groups.bet_format_rule_id', + 'bot_groups.tg_group_id', + 'bot_groups.group_name', + 'bot_groups.group_type', + 'bot_groups.status', + 'bot_groups.bet_enabled', + 'bot_groups.remind_bet_success', + 'bot_groups.remind_draw_result', + 'bot_groups.remind_close_countdown', + 'bot_groups.animation_enabled', + 'bot_groups.countdown_config', + 'bot_groups.updated_at', + 'bot_instances.name(bot_name)', + 'games.name(game_name)', + ], [ + 'ORDER' => ['bot_groups.id' => 'DESC'], + ]) ?: []; + + $wallets = $this->db->select('bot_group_wallets', [ + '[>]bot_groups' => ['group_id' => 'id'], + '[>]users' => ['platform_user_id' => 'id'], + ], [ + 'bot_group_wallets.id', + 'bot_group_wallets.group_id', + 'bot_group_wallets.platform_user_id', + 'bot_group_wallets.wallet_mode', + 'bot_group_wallets.status', + 'bot_group_wallets.updated_at', + 'bot_groups.group_name', + 'bot_groups.tg_group_id', + 'users.id(platform_user_id)', + 'users.username(platform_username)', + 'users.balance(platform_balance)', + ], [ + 'ORDER' => ['bot_group_wallets.id' => 'DESC'], + ]) ?: []; + + $rules = $this->db->select('bot_bet_format_rules', ['id', 'rule_code', 'name', 'status'], [ + 'status' => 1, + 'ORDER' => ['id' => 'DESC'], + ]) ?: []; + + $games = $this->db->select('games', ['id', 'name', 'type', 'status'], [ + 'status' => 1, + 'ORDER' => ['sort_order' => 'ASC', 'id' => 'DESC'], + ]) ?: []; + + $users = $this->db->select('users', ['id', 'username', 'balance', 'status', 'role'], [ + 'role[!]' => 'admin', + 'ORDER' => ['id' => 'DESC'], + 'LIMIT' => 200, + ]) ?: []; + + $members = $this->db->select('bot_group_members', [ + '[>]bot_groups' => ['group_id' => 'id'], + '[>]users' => ['platform_user_id' => 'id'], + ], [ + 'bot_group_members.id', + 'bot_group_members.group_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.updated_at', + 'bot_groups.group_name', + 'bot_groups.tg_group_id', + 'users.username(platform_username)', + ], [ + 'ORDER' => ['bot_group_members.id' => 'DESC'], + 'LIMIT' => 200, + ]) ?: []; + + $shills = $this->db->select('bot_shills', [ + '[>]bot_groups' => ['group_id' => 'id'], + '[>]bot_group_members' => [ + 'bot_shills.group_id' => 'group_id', + 'bot_shills.tg_user_id' => 'tg_user_id', + ], + ], [ + 'bot_shills.id', + 'bot_shills.group_id', + 'bot_shills.tg_user_id', + 'bot_shills.note', + 'bot_shills.enabled', + 'bot_shills.updated_at', + 'bot_groups.group_name', + 'bot_groups.tg_group_id', + 'bot_group_members.tg_username', + 'bot_group_members.tg_nickname', + ], [ + 'ORDER' => ['bot_shills.id' => 'DESC'], + 'LIMIT' => 200, + ]) ?: []; + + $recentOrders = $this->db->select('bot_bet_orders', [ + '[>]bot_groups' => ['group_id' => 'id'], + ], [ + 'bot_bet_orders.id(order_id)', + 'bot_bet_orders.period_number', + 'bot_bet_orders.bet_amount_total', + 'bot_bet_orders.accepted_bet_count', + 'bot_bet_orders.sync_status', + 'bot_bet_orders.sync_error', + 'bot_bet_orders.is_shill', + 'bot_bet_orders.created_at', + 'bot_bet_orders.tg_username', + 'bot_groups.group_name', + 'bot_groups.tg_group_id', + ], [ + 'ORDER' => ['bot_bet_orders.id' => 'DESC'], + 'LIMIT' => 10, + ]) ?: []; + + $pushLogs = $this->db->select('bot_push_logs', [ + '[>]bot_groups' => ['group_id' => 'id'], + ], [ + 'bot_push_logs.id', + 'bot_push_logs.group_id', + 'bot_push_logs.period_number', + 'bot_push_logs.push_type', + 'bot_push_logs.payload_json', + 'bot_push_logs.tg_message_id', + 'bot_push_logs.status', + 'bot_push_logs.error_message', + 'bot_push_logs.created_at', + 'bot_groups.group_name', + 'bot_groups.tg_group_id', + ], [ + 'ORDER' => ['bot_push_logs.id' => 'DESC'], + 'LIMIT' => 20, + ]) ?: []; + + foreach ($pushLogs as &$pushLog) { + $payload = []; + if (!empty($pushLog['payload_json'])) { + $decodedPayload = json_decode((string)$pushLog['payload_json'], true); + if (is_array($decodedPayload)) { + $payload = $decodedPayload; + } + } + + $pushLog['payload'] = $payload; + $pushType = (string)($pushLog['push_type'] ?? 'system'); + $status = (string)($pushLog['status'] ?? 'pending'); + if (!isset($pushSummary[$pushType])) { + $pushSummary[$pushType] = ['total' => 0, 'success' => 0, 'failed' => 0, 'pending' => 0, 'skipped' => 0]; + } + $pushSummary[$pushType]['total']++; + if (isset($pushSummary[$pushType][$status])) { + $pushSummary[$pushType][$status]++; + } + } + unset($pushLog); + + $apiRequestLogs = $this->db->select('bot_api_request_logs', [ + '[>]bot_instances' => ['bot_id' => 'id'], + '[>]bot_groups' => ['group_id' => 'id'], + ], [ + 'bot_api_request_logs.id', + 'bot_api_request_logs.request_uri', + 'bot_api_request_logs.http_method', + 'bot_api_request_logs.idempotency_key', + 'bot_api_request_logs.response_code', + 'bot_api_request_logs.client_ip', + 'bot_api_request_logs.signature_ok', + 'bot_api_request_logs.created_at', + 'bot_instances.name(bot_name)', + 'bot_groups.group_name', + 'bot_groups.tg_group_id', + ], [ + 'ORDER' => ['bot_api_request_logs.id' => 'DESC'], + 'LIMIT' => 20, + ]) ?: []; + } catch (\Throwable $e) { + } + + $this->render('Admin/bots.php', compact( + 'stats', 'bots', 'groups', 'wallets', 'rules', 'games', 'users', 'members', 'shills', 'recentOrders', 'pushLogs', 'pushSummary', 'apiRequestLogs' + ) + ['title' => 'Bot 管理']); + } + + public function saveBot(): void + { + $payload = $this->getRequestData(); + $name = trim((string)($payload['name'] ?? '')); + $botToken = trim((string)($payload['bot_token'] ?? '')); + $botUsername = $this->normalizeNullableString($payload['bot_username'] ?? null); + $runMode = $this->normalizeRunMode((string)($payload['run_mode'] ?? 'polling')); + $webhookUrl = $this->normalizeNullableString($payload['webhook_url'] ?? null); + $remark = $this->normalizeNullableString($payload['remark'] ?? null); + + if ($name === '' || $botToken === '') { + $this->json(['success' => false, 'message' => 'Bot 名称和 Token 必填']); + return; + } + + try { + $id = $this->db->insert('bot_instances', [ + 'name' => $name, + 'bot_token' => $botToken, + 'bot_username' => $botUsername, + 'bot_key' => bin2hex(random_bytes(8)), + 'bot_secret' => bin2hex(random_bytes(16)), + 'webhook_url' => $webhookUrl, + 'run_mode' => $runMode, + 'status' => 1, + 'remark' => $remark, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + $this->json(['success' => true, 'message' => 'Bot 实例创建成功', 'data' => ['id' => (int)$id]]); + } catch (\Throwable $e) { + $this->json(['success' => false, 'message' => 'Bot 实例创建失败:' . $e->getMessage()]); + } + } + + public function updateBot(): void + { + $payload = $this->getRequestData(); + $id = (int)($payload['id'] ?? 0); + $name = trim((string)($payload['name'] ?? '')); + $botToken = trim((string)($payload['bot_token'] ?? '')); + $botUsername = $this->normalizeNullableString($payload['bot_username'] ?? null); + $runMode = $this->normalizeRunMode((string)($payload['run_mode'] ?? 'polling')); + $webhookUrl = $this->normalizeNullableString($payload['webhook_url'] ?? null); + $remark = $this->normalizeNullableString($payload['remark'] ?? null); + + if ($id <= 0 || $name === '' || $botToken === '') { + $this->json(['success' => false, 'message' => 'Bot ID、名称和 Token 必填']); + return; + } + + try { + $bot = $this->db->get('bot_instances', ['id'], ['id' => $id]); + if (!$bot) { + $this->json(['success' => false, 'message' => 'Bot 实例不存在']); + return; + } + + $this->db->update('bot_instances', [ + 'name' => $name, + 'bot_token' => $botToken, + 'bot_username' => $botUsername, + 'webhook_url' => $webhookUrl, + 'run_mode' => $runMode, + 'remark' => $remark, + 'updated_at' => date('Y-m-d H:i:s'), + ], ['id' => $id]); + + $this->json(['success' => true, 'message' => 'Bot 实例更新成功', 'data' => ['id' => $id]]); + } catch (\Throwable $e) { + $this->json(['success' => false, 'message' => 'Bot 实例更新失败:' . $e->getMessage()]); + } + } + + public function toggleBot(): void + { + $this->toggleRecord('bot_instances', 'Bot 参数无效', 'Bot 实例不存在', 'Bot %s已%s'); + } + + public function saveGroup(): void + { + $payload = $this->getRequestData(); + $botId = (int)($payload['bot_id'] ?? 0); + $gameId = (int)($payload['game_id'] ?? 0); + $tgGroupId = trim((string)($payload['tg_group_id'] ?? '')); + $groupName = trim((string)($payload['group_name'] ?? '')); + $groupType = $this->normalizeGroupType((string)($payload['group_type'] ?? 'group')); + $ruleId = (int)($payload['bet_format_rule_id'] ?? 0); + + if ($botId <= 0 || $gameId <= 0 || $tgGroupId === '' || $groupName === '') { + $this->json(['success' => false, 'message' => 'Bot、游戏、群ID、群名称均必填']); + return; + } + + try { + if (!$this->exists('bot_instances', $botId) || !$this->exists('games', $gameId)) { + $this->json(['success' => false, 'message' => 'Bot 或游戏不存在']); + return; + } + + $id = $this->db->insert('bot_groups', [ + 'bot_id' => $botId, + 'tg_group_id' => $tgGroupId, + 'group_name' => $groupName, + 'group_type' => $groupType, + 'game_id' => $gameId, + 'bet_format_rule_id' => $ruleId > 0 ? $ruleId : null, + 'remind_bet_success' => !empty($payload['remind_bet_success']) ? 1 : 0, + 'remind_draw_result' => !empty($payload['remind_draw_result']) ? 1 : 0, + 'remind_close_countdown' => !empty($payload['remind_close_countdown']) ? 1 : 0, + 'countdown_config' => !empty($payload['countdown_config']) ? $this->normalizeCountdownConfig((string)$payload['countdown_config']) : null, + 'animation_enabled' => !empty($payload['animation_enabled']) ? 1 : 0, + 'bet_enabled' => !empty($payload['bet_enabled']) ? 1 : 0, + 'status' => 1, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + $this->json(['success' => true, 'message' => '群配置创建成功', 'data' => ['id' => (int)$id]]); + } catch (\Throwable $e) { + $this->json(['success' => false, 'message' => '群配置创建失败:' . $e->getMessage()]); + } + } + + public function updateGroup(): void + { + $payload = $this->getRequestData(); + $id = (int)($payload['id'] ?? 0); + $botId = (int)($payload['bot_id'] ?? 0); + $gameId = (int)($payload['game_id'] ?? 0); + $tgGroupId = trim((string)($payload['tg_group_id'] ?? '')); + $groupName = trim((string)($payload['group_name'] ?? '')); + $groupType = $this->normalizeGroupType((string)($payload['group_type'] ?? 'group')); + $ruleId = (int)($payload['bet_format_rule_id'] ?? 0); + + if ($id <= 0 || $botId <= 0 || $gameId <= 0 || $tgGroupId === '' || $groupName === '') { + $this->json(['success' => false, 'message' => '群配置参数不完整']); + return; + } + + try { + if (!$this->exists('bot_groups', $id)) { + $this->json(['success' => false, 'message' => '群配置不存在']); + return; + } + if (!$this->exists('bot_instances', $botId) || !$this->exists('games', $gameId)) { + $this->json(['success' => false, 'message' => 'Bot 或游戏不存在']); + return; + } + + $this->db->update('bot_groups', [ + 'bot_id' => $botId, + 'tg_group_id' => $tgGroupId, + 'group_name' => $groupName, + 'group_type' => $groupType, + 'game_id' => $gameId, + 'bet_format_rule_id' => $ruleId > 0 ? $ruleId : null, + 'remind_bet_success' => !empty($payload['remind_bet_success']) ? 1 : 0, + 'remind_draw_result' => !empty($payload['remind_draw_result']) ? 1 : 0, + 'remind_close_countdown' => !empty($payload['remind_close_countdown']) ? 1 : 0, + 'countdown_config' => !empty($payload['countdown_config']) ? $this->normalizeCountdownConfig((string)$payload['countdown_config']) : null, + 'animation_enabled' => !empty($payload['animation_enabled']) ? 1 : 0, + 'bet_enabled' => !empty($payload['bet_enabled']) ? 1 : 0, + 'updated_at' => date('Y-m-d H:i:s'), + ], ['id' => $id]); + + $this->json(['success' => true, 'message' => '群配置更新成功', 'data' => ['id' => $id]]); + } catch (\Throwable $e) { + $this->json(['success' => false, 'message' => '群配置更新失败:' . $e->getMessage()]); + } + } + + public function toggleGroup(): void + { + $this->toggleRecord('bot_groups', '群配置参数无效', '群配置不存在', '群 %s已%s', 'group_name'); + } + + public function saveWallet(): void + { + $payload = $this->getRequestData(); + $groupId = (int)($payload['group_id'] ?? 0); + $platformUserId = (int)($payload['platform_user_id'] ?? 0); + $walletMode = $this->normalizeWalletMode((string)($payload['wallet_mode'] ?? 'master_pool')); + + if ($groupId <= 0 || $platformUserId <= 0) { + $this->json(['success' => false, 'message' => '群配置和总账号必填']); + return; + } + + try { + $user = $this->db->get('users', ['id', 'username'], ['id' => $platformUserId]); + if (!$this->exists('bot_groups', $groupId) || !$user) { + $this->json(['success' => false, 'message' => '群配置或网站账号不存在']); + return; + } + + $exists = $this->db->get('bot_group_wallets', ['id'], ['group_id' => $groupId]); + $data = [ + 'platform_user_id' => $platformUserId, + 'platform_username_snapshot' => $user['username'], + 'wallet_mode' => $walletMode, + 'status' => 1, + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + if ($exists) { + $this->db->update('bot_group_wallets', $data, ['group_id' => $groupId]); + $id = (int)$exists['id']; + $message = '群总账号绑定已更新'; + } else { + $data['group_id'] = $groupId; + $data['created_at'] = date('Y-m-d H:i:s'); + $id = (int)$this->db->insert('bot_group_wallets', $data); + $message = '群总账号绑定成功'; + } + + $this->json(['success' => true, 'message' => $message, 'data' => ['id' => $id]]); + } catch (\Throwable $e) { + $this->json(['success' => false, 'message' => '群总账号绑定失败:' . $e->getMessage()]); + } + } + + public function updateWallet(): void + { + $payload = $this->getRequestData(); + $id = (int)($payload['id'] ?? 0); + $groupId = (int)($payload['group_id'] ?? 0); + $platformUserId = (int)($payload['platform_user_id'] ?? 0); + $walletMode = $this->normalizeWalletMode((string)($payload['wallet_mode'] ?? 'master_pool')); + + if ($id <= 0 || $groupId <= 0 || $platformUserId <= 0) { + $this->json(['success' => false, 'message' => '钱包绑定参数不完整']); + return; + } + + try { + $wallet = $this->db->get('bot_group_wallets', ['id'], ['id' => $id]); + $user = $this->db->get('users', ['id', 'username'], ['id' => $platformUserId]); + if (!$wallet) { + $this->json(['success' => false, 'message' => '钱包绑定不存在']); + return; + } + if (!$this->exists('bot_groups', $groupId) || !$user) { + $this->json(['success' => false, 'message' => '群配置或网站账号不存在']); + return; + } + + $duplicate = $this->db->get('bot_group_wallets', ['id'], [ + 'group_id' => $groupId, + 'id[!]' => $id, + ]); + if ($duplicate) { + $this->json(['success' => false, 'message' => '该群已绑定其他总账号']); + return; + } + + $this->db->update('bot_group_wallets', [ + 'group_id' => $groupId, + 'platform_user_id' => $platformUserId, + 'platform_username_snapshot' => $user['username'], + 'wallet_mode' => $walletMode, + 'updated_at' => date('Y-m-d H:i:s'), + ], ['id' => $id]); + + $this->json(['success' => true, 'message' => '群总账号绑定更新成功', 'data' => ['id' => $id]]); + } catch (\Throwable $e) { + $this->json(['success' => false, 'message' => '群总账号绑定更新失败:' . $e->getMessage()]); + } + } + + public function toggleWallet(): void + { + $this->toggleRecord('bot_group_wallets', '钱包绑定参数无效', '钱包绑定不存在', '群总账号绑定已%s', 'id', false); + } + + public function saveMember(): void + { + $payload = $this->getRequestData(); + $groupId = (int)($payload['group_id'] ?? 0); + $tgUserId = trim((string)($payload['tg_user_id'] ?? '')); + $tgUsername = $this->normalizeNullableString($payload['tg_username'] ?? null); + $tgNickname = $this->normalizeNullableString($payload['tg_nickname'] ?? null); + $platformUserId = (int)($payload['platform_user_id'] ?? 0); + $role = $this->normalizeMemberRole((string)($payload['role'] ?? 'member')); + $betEnabled = !empty($payload['bet_enabled']) ? 1 : 0; + + if ($groupId <= 0 || $tgUserId === '') { + $this->json(['success' => false, 'message' => '群配置与 TG 用户 ID 必填']); + return; + } + + try { + if (!$this->exists('bot_groups', $groupId)) { + $this->json(['success' => false, 'message' => '群配置不存在']); + return; + } + if ($platformUserId > 0 && !$this->exists('users', $platformUserId)) { + $this->json(['success' => false, 'message' => '绑定网站账号不存在']); + return; + } + + $id = (int)$this->db->insert('bot_group_members', [ + 'group_id' => $groupId, + 'tg_user_id' => $tgUserId, + 'tg_username' => $tgUsername, + 'tg_nickname' => $tgNickname, + 'platform_user_id' => $platformUserId > 0 ? $platformUserId : null, + 'role' => $role, + 'bet_enabled' => $betEnabled, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + $this->syncShillRecord($groupId, $tgUserId, $role === 'shill', $this->normalizeNullableString($payload['shill_note'] ?? null)); + $this->json(['success' => true, 'message' => '群成员映射创建成功', 'data' => ['id' => $id]]); + } catch (\Throwable $e) { + $this->json(['success' => false, 'message' => '群成员映射创建失败:' . $e->getMessage()]); + } + } + + public function updateMember(): void + { + $payload = $this->getRequestData(); + $id = (int)($payload['id'] ?? 0); + $groupId = (int)($payload['group_id'] ?? 0); + $tgUserId = trim((string)($payload['tg_user_id'] ?? '')); + $tgUsername = $this->normalizeNullableString($payload['tg_username'] ?? null); + $tgNickname = $this->normalizeNullableString($payload['tg_nickname'] ?? null); + $platformUserId = (int)($payload['platform_user_id'] ?? 0); + $role = $this->normalizeMemberRole((string)($payload['role'] ?? 'member')); + $betEnabled = !empty($payload['bet_enabled']) ? 1 : 0; + + if ($id <= 0 || $groupId <= 0 || $tgUserId === '') { + $this->json(['success' => false, 'message' => '成员参数不完整']); + return; + } + + try { + if (!$this->exists('bot_group_members', $id)) { + $this->json(['success' => false, 'message' => '群成员映射不存在']); + return; + } + if (!$this->exists('bot_groups', $groupId)) { + $this->json(['success' => false, 'message' => '群配置不存在']); + return; + } + if ($platformUserId > 0 && !$this->exists('users', $platformUserId)) { + $this->json(['success' => false, 'message' => '绑定网站账号不存在']); + return; + } + + $duplicate = $this->db->get('bot_group_members', ['id'], [ + 'group_id' => $groupId, + 'tg_user_id' => $tgUserId, + 'id[!]' => $id, + ]); + if ($duplicate) { + $this->json(['success' => false, 'message' => '该 TG 用户已存在于当前群']); + return; + } + + $this->db->update('bot_group_members', [ + 'group_id' => $groupId, + 'tg_user_id' => $tgUserId, + 'tg_username' => $tgUsername, + 'tg_nickname' => $tgNickname, + 'platform_user_id' => $platformUserId > 0 ? $platformUserId : null, + 'role' => $role, + 'bet_enabled' => $betEnabled, + 'updated_at' => date('Y-m-d H:i:s'), + ], ['id' => $id]); + + $this->syncShillRecord($groupId, $tgUserId, $role === 'shill', $this->normalizeNullableString($payload['shill_note'] ?? null)); + $this->json(['success' => true, 'message' => '群成员映射更新成功', 'data' => ['id' => $id]]); + } catch (\Throwable $e) { + $this->json(['success' => false, 'message' => '群成员映射更新失败:' . $e->getMessage()]); + } + } + + public function toggleMemberBet(): void + { + $payload = $this->getRequestData(); + $id = (int)($payload['id'] ?? 0); + $betEnabled = isset($payload['bet_enabled']) ? (int)$payload['bet_enabled'] : null; + + if ($id <= 0 || !in_array($betEnabled, [0, 1], true)) { + $this->json(['success' => false, 'message' => '成员参数无效']); + return; + } + + try { + $member = $this->db->get('bot_group_members', ['id', 'tg_user_id'], ['id' => $id]); + if (!$member) { + $this->json(['success' => false, 'message' => '群成员映射不存在']); + return; + } + + $this->db->update('bot_group_members', [ + 'bet_enabled' => $betEnabled, + 'updated_at' => date('Y-m-d H:i:s'), + ], ['id' => $id]); + + $this->json([ + 'success' => true, + 'message' => sprintf('成员 %s 下注权限已%s', (string)$member['tg_user_id'], $betEnabled === 1 ? '开启' : '关闭'), + 'data' => ['id' => $id, 'bet_enabled' => $betEnabled], + ]); + } catch (\Throwable $e) { + $this->json(['success' => false, 'message' => '成员下注权限更新失败:' . $e->getMessage()]); + } + } + + public function saveShill(): void + { + $payload = $this->getRequestData(); + $groupId = (int)($payload['group_id'] ?? 0); + $tgUserId = trim((string)($payload['tg_user_id'] ?? '')); + $note = $this->normalizeNullableString($payload['note'] ?? null); + + if ($groupId <= 0 || $tgUserId === '') { + $this->json(['success' => false, 'message' => '群配置与 TG 用户 ID 必填']); + return; + } + + try { + if (!$this->exists('bot_groups', $groupId)) { + $this->json(['success' => false, 'message' => '群配置不存在']); + return; + } + + $member = $this->db->get('bot_group_members', ['id'], [ + 'group_id' => $groupId, + 'tg_user_id' => $tgUserId, + ]); + if ($member) { + $this->db->update('bot_group_members', [ + 'role' => 'shill', + 'updated_at' => date('Y-m-d H:i:s'), + ], ['id' => (int)$member['id']]); + } + + $existing = $this->db->get('bot_shills', ['id'], [ + 'group_id' => $groupId, + 'tg_user_id' => $tgUserId, + ]); + if ($existing) { + $this->db->update('bot_shills', [ + 'note' => $note, + 'enabled' => 1, + 'updated_at' => date('Y-m-d H:i:s'), + ], ['id' => (int)$existing['id']]); + $id = (int)$existing['id']; + $message = '托号已更新'; + } else { + $id = (int)$this->db->insert('bot_shills', [ + 'group_id' => $groupId, + 'tg_user_id' => $tgUserId, + 'note' => $note, + 'enabled' => 1, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + $message = '托号创建成功'; + } + + $this->json(['success' => true, 'message' => $message, 'data' => ['id' => $id]]); + } catch (\Throwable $e) { + $this->json(['success' => false, 'message' => '托号创建失败:' . $e->getMessage()]); + } + } + + public function updateShill(): void + { + $payload = $this->getRequestData(); + $id = (int)($payload['id'] ?? 0); + $groupId = (int)($payload['group_id'] ?? 0); + $tgUserId = trim((string)($payload['tg_user_id'] ?? '')); + $note = $this->normalizeNullableString($payload['note'] ?? null); + + if ($id <= 0 || $groupId <= 0 || $tgUserId === '') { + $this->json(['success' => false, 'message' => '托号参数不完整']); + return; + } + + try { + $shill = $this->db->get('bot_shills', ['id'], ['id' => $id]); + if (!$shill) { + $this->json(['success' => false, 'message' => '托号记录不存在']); + return; + } + if (!$this->exists('bot_groups', $groupId)) { + $this->json(['success' => false, 'message' => '群配置不存在']); + return; + } + + $duplicate = $this->db->get('bot_shills', ['id'], [ + 'group_id' => $groupId, + 'tg_user_id' => $tgUserId, + 'id[!]' => $id, + ]); + if ($duplicate) { + $this->json(['success' => false, 'message' => '该托号已存在']); + return; + } + + $this->db->update('bot_shills', [ + 'group_id' => $groupId, + 'tg_user_id' => $tgUserId, + 'note' => $note, + 'updated_at' => date('Y-m-d H:i:s'), + ], ['id' => $id]); + + $member = $this->db->get('bot_group_members', ['id'], [ + 'group_id' => $groupId, + 'tg_user_id' => $tgUserId, + ]); + if ($member) { + $this->db->update('bot_group_members', [ + 'role' => 'shill', + 'updated_at' => date('Y-m-d H:i:s'), + ], ['id' => (int)$member['id']]); + } + + $this->json(['success' => true, 'message' => '托号更新成功', 'data' => ['id' => $id]]); + } catch (\Throwable $e) { + $this->json(['success' => false, 'message' => '托号更新失败:' . $e->getMessage()]); + } + } + + public function toggleShill(): void + { + $payload = $this->getRequestData(); + $id = (int)($payload['id'] ?? 0); + $enabled = isset($payload['enabled']) ? (int)$payload['enabled'] : null; + + if ($id <= 0 || !in_array($enabled, [0, 1], true)) { + $this->json(['success' => false, 'message' => '托号参数无效']); + return; + } + + try { + $shill = $this->db->get('bot_shills', ['id', 'group_id', 'tg_user_id'], ['id' => $id]); + if (!$shill) { + $this->json(['success' => false, 'message' => '托号记录不存在']); + return; + } + + $this->db->update('bot_shills', [ + 'enabled' => $enabled, + 'updated_at' => date('Y-m-d H:i:s'), + ], ['id' => $id]); + + $member = $this->db->get('bot_group_members', ['id'], [ + 'group_id' => (int)$shill['group_id'], + 'tg_user_id' => (string)$shill['tg_user_id'], + ]); + if ($member) { + $this->db->update('bot_group_members', [ + 'role' => $enabled === 1 ? 'shill' : 'member', + 'updated_at' => date('Y-m-d H:i:s'), + ], ['id' => (int)$member['id']]); + } + + $this->json([ + 'success' => true, + 'message' => sprintf('托号 %s 已%s统计排除', (string)$shill['tg_user_id'], $enabled === 1 ? '加入' : '移出'), + 'data' => ['id' => $id, 'enabled' => $enabled], + ]); + } catch (\Throwable $e) { + $this->json(['success' => false, 'message' => '托号状态更新失败:' . $e->getMessage()]); + } + } + + private function getRequestData(): array + { + $data = json_decode(file_get_contents('php://input'), true); + if (is_array($data) && !empty($data)) { + return $data; + } + return $_POST; + } + + private function normalizeCountdownConfig(string $raw): string + { + $items = array_filter(array_map('trim', explode(',', $raw)), static function ($item) { + return $item !== '' && ctype_digit($item); + }); + $numbers = array_map('intval', $items); + rsort($numbers); + return json_encode(array_values(array_unique($numbers)), JSON_UNESCAPED_UNICODE); + } + + private function normalizeNullableString($value): ?string + { + $text = trim((string)$value); + return $text === '' ? null : $text; + } + + private function normalizeRunMode(string $runMode): string + { + return in_array($runMode, ['polling', 'webhook'], true) ? $runMode : 'polling'; + } + + private function normalizeGroupType(string $groupType): string + { + return in_array($groupType, ['group', 'supergroup', 'channel'], true) ? $groupType : 'group'; + } + + private function normalizeWalletMode(string $walletMode): string + { + return in_array($walletMode, ['master_pool', 'per_member'], true) ? $walletMode : 'master_pool'; + } + + private function normalizeMemberRole(string $role): string + { + return in_array($role, ['member', 'admin', 'shill'], true) ? $role : 'member'; + } + + private function exists(string $table, int $id): bool + { + return $id > 0 && $this->db->has($table, ['id' => $id]); + } + + private function syncShillRecord(int $groupId, string $tgUserId, bool $enabled, ?string $note = null): void + { + $existing = $this->db->get('bot_shills', ['id'], [ + 'group_id' => $groupId, + 'tg_user_id' => $tgUserId, + ]); + + if ($enabled) { + $data = [ + 'note' => $note, + 'enabled' => 1, + 'updated_at' => date('Y-m-d H:i:s'), + ]; + if ($existing) { + $this->db->update('bot_shills', $data, ['id' => (int)$existing['id']]); + } else { + $data['group_id'] = $groupId; + $data['tg_user_id'] = $tgUserId; + $data['created_at'] = date('Y-m-d H:i:s'); + $this->db->insert('bot_shills', $data); + } + return; + } + + if ($existing) { + $this->db->update('bot_shills', [ + 'enabled' => 0, + 'note' => $note, + 'updated_at' => date('Y-m-d H:i:s'), + ], ['id' => (int)$existing['id']]); + } + } + + private function toggleRecord(string $table, string $invalidMessage, string $notFoundMessage, string $messageTemplate, string $nameField = 'name', bool $withName = true): void + { + $payload = $this->getRequestData(); + $id = (int)($payload['id'] ?? 0); + $status = isset($payload['status']) ? (int)$payload['status'] : null; + + if ($id <= 0 || !in_array($status, [0, 1], true)) { + $this->json(['success' => false, 'message' => $invalidMessage]); + return; + } + + try { + $row = $this->db->get($table, ['id', $nameField], ['id' => $id]); + if (!$row) { + $this->json(['success' => false, 'message' => $notFoundMessage]); + return; + } + + $this->db->update($table, [ + 'status' => $status, + 'updated_at' => date('Y-m-d H:i:s'), + ], ['id' => $id]); + + $message = $withName + ? sprintf($messageTemplate, (string)($row[$nameField] ?? $id), $status === 1 ? '启用' : '停用') + : sprintf($messageTemplate, $status === 1 ? '启用' : '停用'); + $this->json(['success' => true, 'message' => $message, 'data' => ['id' => $id, 'status' => $status]]); + } catch (\Throwable $e) { + $this->json(['success' => false, 'message' => '状态更新失败:' . $e->getMessage()]); + } + } + + private function json(array $data): void + { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data, JSON_UNESCAPED_UNICODE); + exit; + } +} diff --git a/App/Controllers/Admin/FollowPlanController.php b/App/Controllers/Admin/FollowPlanController.php new file mode 100644 index 0000000..c945b00 --- /dev/null +++ b/App/Controllers/Admin/FollowPlanController.php @@ -0,0 +1,53 @@ +db = $db; + } + + public function index() { + $this->checkLogin(); $this->checkAdmin(); + $service = new FollowPlanService($this->db); + $plans = $service->getAllPlans(); + $games = $this->db->select('games', ['id', 'name', 'code'], ['status' => 1]); + $this->render('Admin/follow_plans.php', compact('plans', 'games')); + } + + public function save() { + $this->checkLogin(); $this->checkAdmin(); + header('Content-Type: application/json'); + $data = json_decode(file_get_contents('php://input'), true) ?: $_POST; + $data['created_by'] = $_SESSION['admin_id'] ?? 0; + $service = new FollowPlanService($this->db); + echo json_encode($service->savePlan($data)); + } + + public function delete() { + $this->checkLogin(); $this->checkAdmin(); + header('Content-Type: application/json'); + $data = json_decode(file_get_contents('php://input'), true) ?: $_POST; + $id = intval($data['id'] ?? 0); + if ($id <= 0) { echo json_encode(['success' => false, 'message' => 'ID invalid']); return; } + $service = new FollowPlanService($this->db); + echo json_encode($service->deletePlan($id)); + } + + public function toggle() { + $this->checkLogin(); $this->checkAdmin(); + header('Content-Type: application/json'); + $data = json_decode(file_get_contents('php://input'), true) ?: $_POST; + $id = intval($data['id'] ?? 0); + $plan = $this->db->get('follow_plans', '*', ['id' => $id]); + if (!$plan) { echo json_encode(['success' => false, 'message' => 'Not found']); return; } + $newStatus = $plan['status'] ? 0 : 1; + $this->db->update('follow_plans', ['status' => $newStatus], ['id' => $id]); + echo json_encode(['success' => true, 'status' => $newStatus]); + } +} diff --git a/App/Controllers/Admin/FundRequestController.php b/App/Controllers/Admin/FundRequestController.php new file mode 100644 index 0000000..81cedc0 --- /dev/null +++ b/App/Controllers/Admin/FundRequestController.php @@ -0,0 +1,284 @@ +checkLogin(); + } + + /** + * 充提审核列表页 + */ + public function index() { + $db = new Database(); + try { + // 查询充提申请列表,JOIN users 获取用户名 + $requests = $db->select('fund_requests', [ + '[>]users' => ['user_id' => 'id'] + ], [ + 'fund_requests.id', + 'fund_requests.user_id', + 'users.username', + 'users.usdt_address', + 'fund_requests.type', + 'fund_requests.amount', + 'fund_requests.status', + 'fund_requests.remark', + 'fund_requests.admin_remark', + 'fund_requests.operator_id', + 'fund_requests.created_at', + 'fund_requests.processed_at' + ], [ + 'ORDER' => ['fund_requests.id' => 'DESC'], + 'LIMIT' => 200 + ]); + + if (!is_array($requests)) { + $requests = []; + } + + // 统计各状态数量 + $pendingCount = $db->count('fund_requests', ['status' => 'pending']); + $todayDeposit = $db->count('fund_requests', [ + 'type' => 'deposit', + 'created_at[>=]' => date('Y-m-d 00:00:00') + ]); + $todayWithdraw = $db->count('fund_requests', [ + 'type' => 'withdraw', + 'created_at[>=]' => date('Y-m-d 00:00:00') + ]); + + } catch (\Throwable $e) { + $requests = []; + $pendingCount = 0; + $todayDeposit = 0; + $todayWithdraw = 0; + } + + $this->render('Admin/fund_requests.php', [ + 'requests' => $requests, + 'pendingCount' => $pendingCount, + 'todayDeposit' => $todayDeposit, + 'todayWithdraw' => $todayWithdraw, + 'title' => '充提审核' + ]); + } + + /** + * 审批通过 + */ + public function approve() { + header('Content-Type: application/json'); + + $rawData = file_get_contents('php://input'); + $data = json_decode($rawData, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + echo json_encode(['success' => false, 'message' => '数据格式错误']); + return; + } + + $id = isset($data['id']) ? (int)$data['id'] : 0; + $adminRemark = trim($data['admin_remark'] ?? ''); + + if ($id <= 0) { + echo json_encode(['success' => false, 'message' => '无效的申请ID']); + return; + } + + $db = new Database(); + + try { + // 查询申请记录 + $request = $db->get('fund_requests', '*', ['id' => $id]); + if (!$request) { + echo json_encode(['success' => false, 'message' => '申请记录不存在']); + return; + } + + if ($request['status'] !== 'pending') { + echo json_encode(['success' => false, 'message' => '该申请已处理,状态:' . $request['status']]); + return; + } + + $userId = (int)$request['user_id']; + $amount = floatval($request['amount']); + $type = $request['type']; + + // 获取用户信息 + $user = $db->get('users', ['id', 'balance'], ['id' => $userId]); + if (!$user) { + echo json_encode(['success' => false, 'message' => '用户不存在']); + return; + } + + $balanceBefore = floatval($user['balance']); + $now = date('Y-m-d H:i:s'); + $operatorId = $_SESSION['user_id'] ?? 0; + + // 开启事务 + $db->medoo->pdo->beginTransaction(); + + if ($type === 'deposit') { + // 充值:给用户加款 + $balanceAfter = $balanceBefore + $amount; + + $db->update('users', [ + 'balance' => $balanceAfter, + 'updated_at' => $now + ], ['id' => $userId]); + + $db->insert('transactions', [ + 'user_id' => $userId, + 'type' => 'deposit', + 'amount' => $amount, + 'balance_before' => $balanceBefore, + 'balance_after' => $balanceAfter, + 'description' => '充值申请审核通过 #' . $id, + 'created_at' => $now + ]); + } else { + // 提现:余额已在申请时预扣,确认放款 + $balanceAfter = $balanceBefore; // 余额不变,已预扣 + + $db->insert('transactions', [ + 'user_id' => $userId, + 'type' => 'withdraw', + 'amount' => -$amount, + 'balance_before' => $balanceBefore, + 'balance_after' => $balanceAfter, + 'description' => '提现申请审核通过 #' . $id, + 'created_at' => $now + ]); + } + + // 更新申请状态 + $db->update('fund_requests', [ + 'status' => 'approved', + 'admin_remark' => $adminRemark, + 'operator_id' => $operatorId, + 'processed_at' => $now + ], ['id' => $id]); + + $db->medoo->pdo->commit(); + + echo json_encode(['success' => true, 'message' => '审批通过']); + + } catch (\Exception $e) { + if ($db->medoo->pdo->inTransaction()) { + $db->medoo->pdo->rollBack(); + } + echo json_encode(['success' => false, 'message' => '操作失败:' . $e->getMessage()]); + } + } + + /** + * 拒绝申请 + */ + public function reject() { + header('Content-Type: application/json'); + + $rawData = file_get_contents('php://input'); + $data = json_decode($rawData, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + echo json_encode(['success' => false, 'message' => '数据格式错误']); + return; + } + + $id = isset($data['id']) ? (int)$data['id'] : 0; + $adminRemark = trim($data['admin_remark'] ?? ''); + + if ($id <= 0) { + echo json_encode(['success' => false, 'message' => '无效的申请ID']); + return; + } + + $db = new Database(); + + try { + // 查询申请记录 + $request = $db->get('fund_requests', '*', ['id' => $id]); + if (!$request) { + echo json_encode(['success' => false, 'message' => '申请记录不存在']); + return; + } + + if ($request['status'] !== 'pending') { + echo json_encode(['success' => false, 'message' => '该申请已处理,状态:' . $request['status']]); + return; + } + + $userId = (int)$request['user_id']; + $amount = floatval($request['amount']); + $type = $request['type']; + $now = date('Y-m-d H:i:s'); + $operatorId = $_SESSION['user_id'] ?? 0; + + // 开启事务 + $db->medoo->pdo->beginTransaction(); + + if ($type === 'withdraw') { + // 提现拒绝:退还预扣余额 + $user = $db->get('users', ['id', 'balance'], ['id' => $userId]); + if ($user) { + $balanceBefore = floatval($user['balance']); + $balanceAfter = $balanceBefore + $amount; + + $db->update('users', [ + 'balance' => $balanceAfter, + 'updated_at' => $now + ], ['id' => $userId]); + + $db->insert('transactions', [ + 'user_id' => $userId, + 'type' => 'refund', + 'amount' => $amount, + 'balance_before' => $balanceBefore, + 'balance_after' => $balanceAfter, + 'description' => '提现申请被拒绝,退还预扣金额 #' . $id, + 'created_at' => $now + ]); + } + } + // 充值拒绝:无需退款 + + // 更新申请状态 + $db->update('fund_requests', [ + 'status' => 'rejected', + 'admin_remark' => $adminRemark, + 'operator_id' => $operatorId, + 'processed_at' => $now + ], ['id' => $id]); + + $db->medoo->pdo->commit(); + + echo json_encode(['success' => true, 'message' => '已拒绝']); + + } catch (\Exception $e) { + if ($db->medoo->pdo->inTransaction()) { + $db->medoo->pdo->rollBack(); + } + echo json_encode(['success' => false, 'message' => '操作失败:' . $e->getMessage()]); + } + } + + /** + * 获取待处理数量(轮询接口) + */ + public function pendingCount() { + header('Content-Type: application/json'); + + $db = new Database(); + try { + $count = $db->count('fund_requests', ['status' => 'pending']); + echo json_encode(['count' => (int)$count]); + } catch (\Throwable $e) { + echo json_encode(['count' => 0]); + } + } +} diff --git a/App/Controllers/Admin/PK10PeriodController.php b/App/Controllers/Admin/PK10PeriodController.php index a920965..c7745bb 100644 --- a/App/Controllers/Admin/PK10PeriodController.php +++ b/App/Controllers/Admin/PK10PeriodController.php @@ -61,12 +61,23 @@ class PK10PeriodController extends AdminBaseController { } $periodNumber = PK10Algorithm::generatePeriodNumber($gameId); + $createdAt = date('Y-m-d H:i:s'); $this->db->insert('periods', [ 'game_id' => $gameId, 'period_number' => $periodNumber, 'status' => 'pending', - 'created_at' => date('Y-m-d H:i:s'), + 'start_time' => $createdAt, + 'created_at' => $createdAt, ]); + $periodId = (int)$this->db->id(); + + if ($periodId > 0) { + $this->queueCountdownPushes($gameId, [ + 'id' => $periodId, + 'period_number' => $periodNumber, + 'created_at' => $createdAt, + ]); + } $this->json(['status' => 'success', 'message' => 'New period started', 'period_number' => $periodNumber]); } @@ -83,6 +94,14 @@ class PK10PeriodController extends AdminBaseController { } $this->db->update('periods', ['status' => 'locked'], ['id' => $periodId]); + $this->queueBotPushByGame($period['game_id'], 'countdown', [ + 'event' => 'period_locked', + 'period_id' => (int)$period['id'], + 'period_number' => (string)($period['period_number'] ?? ''), + 'message' => 'Period locked', + ], [ + 'remind_close_countdown' => 1, + ]); $this->json(['status' => 'success', 'message' => 'Period locked']); } @@ -122,7 +141,8 @@ class PK10PeriodController extends AdminBaseController { ]); if (!empty($waterConfig) && !empty($bets)) { - $result = PK10Algorithm::generateControlledResult($bets, $waterConfig); + $targetProfitRate = floatval(\App\Core\SettingsHelper::get('target_profit_rate')); + $result = PK10Algorithm::generateControlledResult($bets, $waterConfig, 100, $targetProfitRate); } else { $result = PK10Algorithm::generateResult(); } @@ -150,6 +170,17 @@ class PK10PeriodController extends AdminBaseController { 'rank_10' => $result[9], 'champion_sum' => $sum, ]); + $this->queueBotPushByGame($period['game_id'], 'draw', [ + 'event' => 'period_drawn', + 'period_id' => (int)$period['id'], + 'period_number' => (string)($period['period_number'] ?? ''), + 'result' => $result, + 'champion_sum' => $sum, + 'manual' => !empty($manual), + ], [ + 'remind_draw_result' => 1, + ], (string)($period['period_number'] ?? '')); + $this->db->medoo->pdo->commit(); } catch (\Throwable $e) { $this->db->medoo->pdo->rollBack(); @@ -180,6 +211,15 @@ class PK10PeriodController extends AdminBaseController { $bets = $this->db->select('bets', '*', ['period_id' => $periodId, 'status' => 'pending']); if (empty($bets)) { $this->db->update('periods', ['status' => 'settled'], ['id' => $periodId]); + $this->queueBotPushByGame($period['game_id'], 'system', [ + 'event' => 'period_settled', + 'period_id' => (int)$period['id'], + 'period_number' => (string)($period['period_number'] ?? ''), + 'wins' => 0, + 'losses' => 0, + 'total_payout' => 0, + 'message' => 'No bets to settle', + ], [], (string)($period['period_number'] ?? '')); $this->json(['status' => 'success', 'message' => 'No bets to settle', 'wins' => 0, 'losses' => 0]); return; } @@ -230,6 +270,14 @@ class PK10PeriodController extends AdminBaseController { $this->settleAgentCommissions($bets, $periodId); $this->db->update('periods', ['status' => 'settled'], ['id' => $periodId]); + $this->queueBotPushByGame($period['game_id'], 'system', [ + 'event' => 'period_settled', + 'period_id' => (int)$period['id'], + 'period_number' => (string)($period['period_number'] ?? ''), + 'wins' => $wins, + 'losses' => $losses, + 'total_payout' => (float)$totalPayout, + ], [], (string)($period['period_number'] ?? '')); $this->db->medoo->pdo->commit(); } catch (\Throwable $e) { $this->db->medoo->pdo->rollBack(); @@ -298,6 +346,90 @@ class PK10PeriodController extends AdminBaseController { return $game ? (int)$game : 0; } + private function queueCountdownPushes(int $gameId, array $period): void { + if ($gameId <= 0 || empty($period['id']) || empty($period['period_number'])) { + return; + } + + $groups = $this->db->select('bot_groups', ['id', 'countdown_config'], [ + 'game_id' => $gameId, + 'status' => 1, + 'remind_close_countdown' => 1, + ]) ?: []; + if (empty($groups)) { + return; + } + + $game = $this->db->get('games', ['period_duration', 'lock_before_end'], ['id' => $gameId]); + $periodDuration = (int)($game['period_duration'] ?? 300); + $lockBeforeEnd = max(0, (int)($game['lock_before_end'] ?? 30)); + $periodCreatedAt = strtotime((string)($period['created_at'] ?? '')); + if ($periodCreatedAt <= 0) { + $periodCreatedAt = time(); + } + $closeAtTs = $periodCreatedAt + max(0, $periodDuration - $lockBeforeEnd); + + $now = date('Y-m-d H:i:s'); + foreach ($groups as $group) { + $decoded = json_decode((string)($group['countdown_config'] ?? ''), true); + if (!is_array($decoded) || empty($decoded)) { + continue; + } + + $secondsList = array_values(array_unique(array_filter(array_map('intval', $decoded), static function (int $seconds) use ($periodDuration, $lockBeforeEnd) { + return $seconds > 0 && $seconds <= max(0, $periodDuration - $lockBeforeEnd); + }))); + rsort($secondsList); + + foreach ($secondsList as $seconds) { + $dispatchAtTs = $closeAtTs - $seconds; + if ($dispatchAtTs <= $periodCreatedAt) { + continue; + } + $payload = [ + 'event' => 'countdown_tick', + 'period_id' => (int)$period['id'], + 'period_number' => (string)$period['period_number'], + 'countdown_seconds' => $seconds, + 'dispatch_at' => date('Y-m-d H:i:s', $dispatchAtTs), + 'message' => sprintf('Bet closes in %d seconds', $seconds), + ]; + + $this->db->insert('bot_push_logs', [ + 'group_id' => (int)$group['id'], + 'period_number' => (string)$period['period_number'], + 'push_type' => 'countdown', + 'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE), + 'status' => 'pending', + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + } + + private function queueBotPushByGame(int $gameId, string $pushType, array $payload, array $groupFilters = [], ?string $periodNumber = null): void { + if ($gameId <= 0) { + return; + } + + $where = array_merge(['game_id' => $gameId, 'status' => 1], $groupFilters); + $groups = $this->db->select('bot_groups', ['id'], $where) ?: []; + $now = date('Y-m-d H:i:s'); + + foreach ($groups as $group) { + $this->db->insert('bot_push_logs', [ + 'group_id' => (int)$group['id'], + 'period_number' => $periodNumber !== null ? $periodNumber : ((string)($payload['period_number'] ?? '') ?: null), + 'push_type' => $pushType, + 'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE), + 'status' => 'pending', + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + private function json(array $data) { header('Content-Type: application/json'); echo json_encode($data); diff --git a/App/Controllers/Admin/ReportController.php b/App/Controllers/Admin/ReportController.php index 075329a..64d9579 100644 --- a/App/Controllers/Admin/ReportController.php +++ b/App/Controllers/Admin/ReportController.php @@ -12,94 +12,132 @@ class ReportController extends AdminBaseController { $this->checkLogin(); $this->checkAdmin(); $dateFrom = $_GET['from'] ?? date('Y-m-d', strtotime('-7 days')); $dateTo = $_GET['to'] ?? date('Y-m-d'); - $range = [$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59']; - // 总投注(排除虚拟) - $totalBet = $this->db->sum('bets', 'amount', [ - 'is_virtual' => 0, 'created_at[<>]' => $range - ]) ?: 0; + $reportService = new \App\Services\ReportService($this->db); - // 总派奖 - $totalWin = $this->db->sum('bets', 'win_amount', [ - 'is_virtual' => 0, 'status' => 'win', 'created_at[<>]' => $range - ]) ?: 0; + // 总体统计 + $overview = $reportService->getOverviewStats($dateFrom, $dateTo); + extract($overview); // totalBet, totalWin, profit, profitRate, totalDeposit, totalWithdraw, totalCommission, totalRebate, totalUsers, newUsers, activeUsers - // 平台利润 - $profit = $totalBet - $totalWin; + // 日明细统计 + $dailyStats = $reportService->getDailyStats($dateFrom, $dateTo); - // 总充值 - $totalDeposit = $this->db->sum('transactions', 'amount', [ - 'is_virtual' => 0, 'type[~]' => '%deposit%', 'amount[>]' => 0, - 'created_at[<>]' => $range - ]) ?: 0; - - // 总提现 - $totalWithdraw = abs($this->db->sum('transactions', 'amount', [ - 'is_virtual' => 0, 'type[~]' => '%withdraw%', 'amount[<]' => 0, - 'created_at[<>]' => $range - ]) ?: 0); - - // 代理佣金 - $totalCommission = $this->db->sum('agent_commissions', 'commission', [ - 'created_at[<>]' => $range - ]) ?: 0; - - // 反水 - $totalRebate = $this->db->sum('agent_commissions', 'commission', [ - 'type' => 'rebate', 'created_at[<>]' => $range - ]) ?: 0; - - // 用户统计 - $totalUsers = $this->db->count('users', ['is_virtual' => 0]); - $newUsers = $this->db->count('users', [ - 'is_virtual' => 0, 'created_at[<>]' => $range - ]); - - // 每日明细 - $dailyStats = []; - $start = new \DateTime($dateFrom); - $end = new \DateTime($dateTo); - $end->modify('+1 day'); - $interval = new \DateInterval('P1D'); - $period = new \DatePeriod($start, $interval, $end); - foreach ($period as $day) { - $d = $day->format('Y-m-d'); - $dr = [$d . ' 00:00:00', $d . ' 23:59:59']; - $dailyStats[] = [ - 'date' => $d, - 'bets' => $this->db->sum('bets', 'amount', ['is_virtual'=>0,'created_at[<>]'=>$dr]) ?: 0, - 'wins' => $this->db->sum('bets', 'win_amount', ['is_virtual'=>0,'status'=>'win','created_at[<>]'=>$dr]) ?: 0, - 'deposits' => $this->db->sum('transactions', 'amount', ['is_virtual'=>0,'type[~]'=>'%deposit%','amount[>]'=>0,'created_at[<>]'=>$dr]) ?: 0, - 'withdraws' => abs($this->db->sum('transactions', 'amount', ['is_virtual'=>0,'type[~]'=>'%withdraw%','amount[<]'=>0,'created_at[<>]'=>$dr]) ?: 0), - ]; - } + // 用户排行(投注前20) + $topUsers = $reportService->getTopUsers($dateFrom, $dateTo, 20); // 代理报表 - $agentStats = []; - $agents = $this->db->select('agents', '*', ['status' => 1]); - foreach ($agents as $ag) { - $playerIds = $this->db->select('users', 'id', ['agent_id' => $ag['id'], 'is_virtual' => 0]); - $agBet = 0; $agWin = 0; - if (!empty($playerIds)) { - $agBet = $this->db->sum('bets', 'amount', ['user_id' => $playerIds, 'is_virtual'=>0, 'created_at[<>]'=>$range]) ?: 0; - $agWin = $this->db->sum('bets', 'win_amount', ['user_id' => $playerIds, 'is_virtual'=>0, 'status'=>'win', 'created_at[<>]'=>$range]) ?: 0; - } - $agComm = $this->db->sum('agent_commissions', 'commission', ['agent_id'=>$ag['id'],'created_at[<>]'=>$range]) ?: 0; - $agentStats[] = [ - 'agent' => $ag, - 'user' => $this->db->get('users', ['username'], ['id' => $ag['user_id']]), - 'players' => count($playerIds), - 'bets' => $agBet, 'wins' => $agWin, 'commission' => $agComm, - 'profit' => $agBet - $agWin - $agComm, - ]; - } + $agentStats = $reportService->getAgentStats($dateFrom, $dateTo); + + // 用户输赢明细 + $winLossData = $reportService->getUserWinLoss($dateFrom, $dateTo); + $userSummary = $winLossData['summary']; $this->render('Admin/reports.php', compact( - 'dateFrom','dateTo','totalBet','totalWin','profit', + 'dateFrom','dateTo','totalBet','totalWin','profit','profitRate', 'totalDeposit','totalWithdraw','totalCommission','totalRebate', - 'totalUsers','newUsers','dailyStats','agentStats' + 'totalUsers','newUsers','activeUsers','dailyStats','topUsers','agentStats', + 'userSummary' )); } + /** + * CSV 导出 + */ + public function export() { + $this->checkLogin(); $this->checkAdmin(); + $dateFrom = $_GET['from'] ?? date('Y-m-d', strtotime('-7 days')); + $dateTo = $_GET['to'] ?? date('Y-m-d'); + + $reportService = new \App\Services\ReportService($this->db); + + // 通过 Service 获取数据 + $dailyStats = $reportService->getDailyStats($dateFrom, $dateTo); + $topUsers = $reportService->getTopUsers($dateFrom, $dateTo, 0); // 不限条数 + $winLossData = $reportService->getUserWinLoss($dateFrom, $dateTo); + $userSummary = $winLossData['summary']; + $userWinLossRaw = $winLossData['raw']; + + // 输出 CSV + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename="report_' . $dateFrom . '_' . $dateTo . '.csv"'); + // BOM 头(Excel 兼容中文) + echo "\xEF\xBB\xBF"; + $out = fopen('php://output', 'w'); + + // 日明细 + fputcsv($out, ['=== 日明细报表 ===']); + fputcsv($out, ['日期', '投注额', '派彩额', '利润', '投注笔数', '中奖笔数', '胜率']); + foreach ($dailyStats as $day) { + $totalBet = floatval($day['total_bet'] ?? 0); + $totalWin = floatval($day['total_win'] ?? 0); + $betCount = intval($day['bet_count'] ?? 0); + $winCount = intval($day['win_count'] ?? 0); + $winRate = $betCount > 0 ? round($winCount / $betCount * 100, 1) . '%' : '0%'; + fputcsv($out, [ + $day['date'], + number_format($totalBet, 2, '.', ''), + number_format($totalWin, 2, '.', ''), + number_format($totalBet - $totalWin, 2, '.', ''), + $betCount, + $winCount, + $winRate + ]); + } + + fputcsv($out, []); + fputcsv($out, ['=== 用户投注排行 ===']); + fputcsv($out, ['排名', '用户名', '投注额', '派彩额', '平台盈亏', '投注笔数']); + $rank = 1; + foreach ($topUsers as $user) { + fputcsv($out, [ + $rank++, + $user['username'], + number_format(floatval($user['total_bet']), 2, '.', ''), + number_format(floatval($user['total_win']), 2, '.', ''), + number_format(floatval($user['profit']), 2, '.', ''), + intval($user['bet_count']) + ]); + } + + // 用户输赢明细 + fputcsv($out, []); + fputcsv($out, ['=== 用户输赢明细 ===']); + fputcsv($out, ['用户名', '当前余额', '总投注', '总派彩', '平台盈亏(投注-派彩)', '投注笔数', '中奖笔数', '胜率']); + foreach ($userSummary as $ud) { + $tb = $ud['total_bet']; + $tw = $ud['total_win']; + $bc = $ud['bet_count']; + $wc = $ud['win_count']; + $wr = $bc > 0 ? round($wc / $bc * 100, 1) . '%' : '0%'; + fputcsv($out, [ + $ud['username'], + number_format($ud['balance'], 2, '.', ''), + number_format($tb, 2, '.', ''), + number_format($tw, 2, '.', ''), + number_format($tb - $tw, 2, '.', ''), + $bc, $wc, $wr + ]); + } + + // 用户日输赢明细 + fputcsv($out, []); + fputcsv($out, ['=== 用户日输赢明细 ===']); + fputcsv($out, ['用户名', '日期', '投注额', '派彩额', '平台盈亏', '笔数']); + foreach ($userWinLossRaw as $dd) { + $tb = floatval($dd['total_bet']); + $tw = floatval($dd['total_win']); + fputcsv($out, [ + $dd['username'], $dd['date'], + number_format($tb, 2, '.', ''), + number_format($tw, 2, '.', ''), + number_format($tb - $tw, 2, '.', ''), + intval($dd['bet_count']) + ]); + } + + fclose($out); + exit; + } + private function json($data) { header('Content-Type: application/json'); echo json_encode($data); } } diff --git a/App/Controllers/Admin/SettingsController.php b/App/Controllers/Admin/SettingsController.php index 75ef65c..f33fc4f 100755 --- a/App/Controllers/Admin/SettingsController.php +++ b/App/Controllers/Admin/SettingsController.php @@ -107,6 +107,7 @@ class SettingsController extends AdminBaseController { 'site_logo', 'site_favicon', 'site_copyright', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_from_name', 'smtp_encryption', + 'customer_service_url', ]; foreach ($allowedKeys as $key) { diff --git a/App/Controllers/Admin/WaterController.php b/App/Controllers/Admin/WaterController.php index a1ac6b4..b042c4a 100644 --- a/App/Controllers/Admin/WaterController.php +++ b/App/Controllers/Admin/WaterController.php @@ -13,7 +13,8 @@ class WaterController extends AdminBaseController { $gameId = $this->getGameId(); $configs = $this->db->select('water_control', '*', ['game_id' => $gameId]); $limits = $this->db->select('bet_limits', '*', ['game_id' => $gameId]); - $this->render('Admin/water_control.php', compact('configs', 'limits', 'gameId')); + $targetProfitRate = \App\Core\SettingsHelper::get('target_profit_rate'); + $this->render('Admin/water_control.php', compact('configs', 'limits', 'gameId', 'targetProfitRate')); } public function updateWater() { @@ -61,6 +62,26 @@ class WaterController extends AdminBaseController { $this->json(['status' => 'success']); } + public function updateProfitRate() { + $this->checkLogin(); $this->checkAdmin(); + $data = json_decode(file_get_contents('php://input'), true); + $rate = isset($data['target_profit_rate']) ? floatval($data['target_profit_rate']) : 15; + if ($rate < 0 || $rate > 100) { + $this->json(['status' => 'error', 'message' => '盈利率必须在0-100之间']); + return; + } + + $db = $this->db; + $existing = $db->get('system_settings', 'id', ['setting_key' => 'target_profit_rate']); + if ($existing) { + $db->update('system_settings', ['setting_value' => (string)$rate], ['id' => $existing]); + } else { + $db->insert('system_settings', ['setting_key' => 'target_profit_rate', 'setting_value' => (string)$rate]); + } + \App\Core\SettingsHelper::clearCache(); + $this->json(['status' => 'success']); + } + private function getGameId(): int { if (!empty($_GET['game_id'])) return (int)$_GET['game_id']; return (int)($this->db->get('games', 'id', ['code' => 'pk10']) ?: 0); diff --git a/App/Controllers/Api/BotController.php b/App/Controllers/Api/BotController.php new file mode 100644 index 0000000..28e2b95 --- /dev/null +++ b/App/Controllers/Api/BotController.php @@ -0,0 +1,1351 @@ +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); + } + } +} diff --git a/App/Controllers/Web/FollowPlanController.php b/App/Controllers/Web/FollowPlanController.php new file mode 100644 index 0000000..8c685cd --- /dev/null +++ b/App/Controllers/Web/FollowPlanController.php @@ -0,0 +1,44 @@ +checkWebLogin(); + I18n::init(); + $db = new Database(); + $userId = $this->getCurrentUserId(); + $user = $db->get('users', ['id','username','balance'], ['id' => $userId]); + + $service = new FollowPlanService($db); + $game = $db->get('games', '*', ['code' => 'pk10', 'status' => 1]); + $gameId = $game['id'] ?? 0; + + $plans = $service->getActivePlans($gameId); + $userFollows = $service->getUserFollowStatus($userId); + $summary = $service->getUserSummary($userId); + + extract(compact('user', 'plans', 'userFollows', 'summary', 'game')); + include __DIR__ . '/../../Views/Web/follow_plan.php'; + } + + /** + * 切换跟单状态 API + */ + public function toggleFollow() { + $this->checkWebLogin(); + header('Content-Type: application/json'); + $db = new Database(); + $data = json_decode(file_get_contents('php://input'), true); + $planId = intval($data['plan_id'] ?? 0); + + $service = new FollowPlanService($db); + $result = $service->toggleFollow($this->getCurrentUserId(), $planId); + echo json_encode($result); + } +} diff --git a/App/Controllers/Web/HomeController.php b/App/Controllers/Web/HomeController.php index 620dc63..ad1a1ec 100755 --- a/App/Controllers/Web/HomeController.php +++ b/App/Controllers/Web/HomeController.php @@ -29,10 +29,26 @@ class HomeController extends WebBaseController { $user = $db->get('users', ['id','username','balance','lang'], ['id' => $userId]); $game = $db->get('games', '*', ['code' => 'pk10', 'status' => 1]); $isAgent = !!$db->get('agents', 'id', ['user_id' => $userId, 'status' => 1]); + // 加载赔率(优先代理专属赔率,否则用游戏默认赔率) + $gameId = $game['id'] ?? 0; + $oddsRaw = $db->select('game_odds', '*', ['game_id' => $gameId]); + $oddsMap = []; + foreach ($oddsRaw as $o) { + $oddsMap[$o['type'] . '_' . $o['target']] = (float)$o['odds']; + } + // 代理专属赔率覆盖 + $agentId = $db->get('agents', 'id', ['user_id' => $userId, 'status' => 1]); + if ($agentId) { + $agentOdds = $db->select('agent_odds', '*', ['agent_id' => $agentId, 'game_id' => $gameId]); + foreach ($agentOdds as $ao) { + $oddsMap[$ao['bet_type'] . '_' . $ao['bet_target']] = (float)$ao['odds']; + } + } extract([ 'user' => $user ?: ['id' => $userId, 'username' => $this->getCurrentUsername(), 'balance' => 0], 'game' => $game, - 'isAgent' => $isAgent, + 'isAgent' => !!$agentId, + 'oddsMap' => $oddsMap, ]); include __DIR__ . '/../../Views/Web/pk10.php'; } @@ -56,7 +72,13 @@ class HomeController extends WebBaseController { 'ORDER' => ['id' => 'DESC'], 'LIMIT' => 20 ]); - extract(compact('user', 'transactions', 'bets')); + // 充提记录 + $fundRequests = $db->select('fund_requests', '*', [ + 'user_id' => $userId, + 'ORDER' => ['id' => 'DESC'], 'LIMIT' => 20 + ]); + + extract(compact('user', 'transactions', 'bets', 'fundRequests')); include __DIR__ . '/../../Views/Web/profile.php'; } diff --git a/App/Controllers/Web/PeriodController.php b/App/Controllers/Web/PeriodController.php index ffff7e1..b395a9e 100755 --- a/App/Controllers/Web/PeriodController.php +++ b/App/Controllers/Web/PeriodController.php @@ -84,7 +84,7 @@ class PeriodController extends WebBaseController { 'game_id' => $gameId, 'status' => ['drawn', 'settled'], 'ORDER' => ['id' => 'DESC'], - 'LIMIT' => 10 + 'LIMIT' => 30 ]); foreach ($recentPeriods as $s) { $pk = $getResult($s); diff --git a/App/Controllers/Web/ReportWebController.php b/App/Controllers/Web/ReportWebController.php new file mode 100644 index 0000000..acc1020 --- /dev/null +++ b/App/Controllers/Web/ReportWebController.php @@ -0,0 +1,36 @@ +checkWebLogin(); + I18n::init(); + $db = new Database(); + $userId = $this->getCurrentUserId(); + $user = $db->get('users', ['id','username','balance'], ['id' => $userId]); + extract(compact('user')); + include __DIR__ . '/../../Views/Web/report.php'; + } + + /** + * 前台报表查询 API + */ + public function userReportApi() { + $this->checkWebLogin(); + header('Content-Type: application/json'); + $service = new ReportService(new Database()); + $dateFrom = $_GET['from'] ?? date('Y-m-d'); + $dateTo = $_GET['to'] ?? date('Y-m-d'); + $data = $service->getUserReport($this->getCurrentUserId(), $dateFrom, $dateTo); + echo json_encode(['success' => true, 'data' => $data]); + } +} diff --git a/App/Controllers/Web/TransactionController.php b/App/Controllers/Web/TransactionController.php new file mode 100644 index 0000000..fb213dc --- /dev/null +++ b/App/Controllers/Web/TransactionController.php @@ -0,0 +1,36 @@ +checkWebLogin(); + header('Content-Type: application/json'); + $data = json_decode(file_get_contents('php://input'), true); + $service = new TransactionService(new Database()); + $result = $service->fundRequest( + $this->getCurrentUserId(), + $data['type'] ?? '', + floatval($data['amount'] ?? 0), + $data['remark'] ?? '' + ); + echo json_encode($result); + } + + public function transfer() { + $this->checkWebLogin(); + header('Content-Type: application/json'); + $data = json_decode(file_get_contents('php://input'), true); + $service = new TransactionService(new Database()); + $result = $service->transfer( + $this->getCurrentUserId(), + trim($data['to_username'] ?? ''), + floatval($data['amount'] ?? 0) + ); + echo json_encode($result); + } +} diff --git a/App/Core/BotApiBaseController.php b/App/Core/BotApiBaseController.php new file mode 100644 index 0000000..62aeb49 --- /dev/null +++ b/App/Core/BotApiBaseController.php @@ -0,0 +1,181 @@ +db = new Database(); + $this->rawRequestBody = file_get_contents('php://input') ?: ''; + $this->requestJson = $this->getJsonInput(); + } + + protected function getJsonInput(): array + { + $raw = $this->rawRequestBody; + if (!$raw) { + return []; + } + + $data = json_decode($raw, true); + return is_array($data) ? $data : []; + } + + protected function jsonResponse(bool $success, string $message = '', array $data = [], int $statusCode = 200): void + { + http_response_code($statusCode); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'success' => $success, + 'message' => $message, + 'data' => $data, + ], JSON_UNESCAPED_UNICODE); + exit; + } + + protected function auditAndRespond(bool $success, string $message = '', array $data = [], int $statusCode = 200, ?int $groupId = null, ?string $idempotencyKey = null): void + { + $payload = [ + 'success' => $success, + 'message' => $message, + 'data' => $data, + ]; + $responseBody = json_encode($payload, JSON_UNESCAPED_UNICODE); + $this->logApiRequest( + (int)($this->botInstance['id'] ?? 0) ?: null, + $groupId, + $idempotencyKey, + $statusCode, + $this->botInstance !== null, + $this->rawRequestBody, + $responseBody + ); + + http_response_code($statusCode); + header('Content-Type: application/json; charset=utf-8'); + echo $responseBody; + exit; + } + + protected function getHeader(string $name): string + { + $key = 'HTTP_' . strtoupper(str_replace('-', '_', $name)); + return trim($_SERVER[$key] ?? ''); + } + + protected function authenticateBotRequest(): void + { + $botKey = $this->getHeader('X-Bot-Key'); + $timestamp = $this->getHeader('X-Timestamp'); + $signature = $this->getHeader('X-Signature'); + + if ($botKey === '' || $timestamp === '' || $signature === '') { + $this->jsonResponse(false, 'Missing bot auth headers', [], 401); + } + + if (!ctype_digit($timestamp)) { + $this->jsonResponse(false, 'Invalid timestamp', [], 401); + } + + $now = time(); + if (abs($now - (int)$timestamp) > 300) { + $this->jsonResponse(false, 'Timestamp expired', [], 401); + } + + $bot = $this->db->get('bot_instances', '*', [ + 'bot_key' => $botKey, + 'status' => 1, + ]); + + if (!$bot) { + $this->jsonResponse(false, 'Bot auth failed', [], 401); + } + + $rawBody = $this->rawRequestBody; + $method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); + $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'; + $signPayload = $timestamp . "\n" . $method . "\n" . $path . "\n" . $rawBody; + $expected = hash_hmac('sha256', $signPayload, $bot['bot_secret']); + + if (!hash_equals($expected, $signature)) { + $this->logApiRequest((int)$bot['id'], null, null, 0, false, $rawBody, ''); + $this->jsonResponse(false, 'Signature verify failed', [], 401); + } + + $this->botInstance = $bot; + } + + protected function requireGroupByTelegramId(string $tgGroupId): array + { + $group = $this->db->get('bot_groups', '*', [ + 'tg_group_id' => $tgGroupId, + 'status' => 1, + ]); + + if (!$group) { + $this->jsonResponse(false, 'Group config not found', [], 404); + } + + if ((int)$group['bot_id'] !== (int)($this->botInstance['id'] ?? 0)) { + $this->jsonResponse(false, 'Group does not belong to this bot', [], 403); + } + + $this->groupConfig = $group; + return $group; + } + + protected function getGroupWallet(int $groupId): array + { + $wallet = $this->db->get('bot_group_wallets', '*', [ + 'group_id' => $groupId, + 'status' => 1, + ]); + + if (!$wallet) { + $this->jsonResponse(false, 'Group wallet not configured', [], 422); + } + + return $wallet; + } + + protected function buildIdempotencyKey(string $fallbackPrefix = 'bot'): string + { + $key = trim((string)($this->requestJson['idempotency_key'] ?? '')); + if ($key !== '') { + return $key; + } + + $groupId = (string)($this->groupConfig['tg_group_id'] ?? 'unknown'); + $messageId = (string)($this->requestJson['bet_context']['message_id'] ?? $this->requestJson['message_id'] ?? uniqid()); + return $fallbackPrefix . ':' . $groupId . ':' . $messageId; + } + + protected function logApiRequest(?int $botId, ?int $groupId, ?string $idempotencyKey, int $responseCode, bool $signatureOk, string $requestBody, string $responseBody): void + { + try { + $this->db->insert('bot_api_request_logs', [ + 'bot_id' => $botId, + 'group_id' => $groupId, + 'request_uri' => parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/', + 'http_method' => strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'), + 'idempotency_key' => $idempotencyKey, + 'request_body' => $requestBody, + 'response_body' => $responseBody, + 'response_code' => $responseCode, + 'client_ip' => $_SERVER['REMOTE_ADDR'] ?? '', + 'signature_ok' => $signatureOk ? 1 : 0, + 'created_at' => date('Y-m-d H:i:s'), + ]); + } catch (\Throwable $e) { + // 审计失败不阻断主流程 + } + } +} diff --git a/App/Core/PK10Algorithm.php b/App/Core/PK10Algorithm.php index 635f0d7..25529ac 100644 --- a/App/Core/PK10Algorithm.php +++ b/App/Core/PK10Algorithm.php @@ -14,26 +14,56 @@ class PK10Algorithm implements GameAlgorithmInterface { /** * 带放水机制的开奖结果生成 + * + * 算法策略: + * - targetProfitRate > 0 时:选择利润最接近「总投注额 × 目标盈利率」的结果 + * - targetProfitRate == 0 时:选择平台利润最高的结果(原逻辑) + * * @param array $bets 当期所有投注 [{bet_type, bet_target, amount, odds}, ...] * @param array $waterConfig [{bet_type => win_rate_pct}, ...] + * 当前版本中 waterConfig 的 win_rate_pct 未直接参与结果选择, + * 因为开奖结果是全局排列,无法单独控制某个投注类型的胜率。 + * 保留此参数供未来扩展(如按类型加权评分、分类型概率偏移等)。 * @param int $attempts 最大尝试次数 + * @param float $targetProfitRate 目标盈利率(百分比,如 5.0 表示 5%) */ - public static function generateControlledResult(array $bets, array $waterConfig, int $attempts = 100): array { - if (empty($bets) || empty($waterConfig)) { + public static function generateControlledResult(array $bets, array $waterConfig, int $attempts = 100, float $targetProfitRate = 0): array { + if (empty($bets)) { return self::generateResult(); } + // 计算目标利润(仅当 targetProfitRate > 0 时生效) + $targetProfit = 0; + if ($targetProfitRate > 0) { + $totalBet = 0; + foreach ($bets as $bet) { + $totalBet += (float)$bet['amount']; + } + $targetProfit = $totalBet * ($targetProfitRate / 100); + } + $bestResult = null; $bestProfit = PHP_INT_MIN; + $bestDistance = PHP_FLOAT_MAX; for ($i = 0; $i < $attempts; $i++) { $result = self::generateResult(); $profit = self::calculatePlatformProfit($result, $bets); - // 选择平台利润最高的结果 - if ($profit > $bestProfit) { - $bestProfit = $profit; - $bestResult = $result; + if ($targetProfitRate > 0) { + // 目标盈利率模式:选择利润最接近目标值的结果 + $distance = abs($profit - $targetProfit); + if ($distance < $bestDistance) { + $bestDistance = $distance; + $bestProfit = $profit; + $bestResult = $result; + } + } else { + // 原逻辑:选择平台利润最高的结果 + if ($profit > $bestProfit) { + $bestProfit = $profit; + $bestResult = $result; + } } } diff --git a/App/Core/PluginManager.php b/App/Core/PluginManager.php index 2771413..ac3e41d 100755 --- a/App/Core/PluginManager.php +++ b/App/Core/PluginManager.php @@ -38,21 +38,21 @@ class PluginManager { $rootDir = realpath(__DIR__ . '/../../'); $logDir = $rootDir . '/Storage/log'; - - // 确保日志目录(如不存在则创建) - if (!is_dir($logDir)) { - if (!mkdir($logDir, 0755, true) && !is_dir($logDir)) { - throw new \RuntimeException("无法创建日志目录: $logDir ,请检查权限"); - } - } - - // 检查目录可写性 - if (!is_writable($logDir)) { - throw new \RuntimeException("日志目录不可写: $logDir ,请检查权限"); - } - $this->logFile = $logDir . '/plugin_manager.log'; - + + // 尝试准备日志目录;失败时降级到 PHP error_log,不能阻断主流程 + if (!is_dir($logDir) && !@mkdir($logDir, 0755, true) && !is_dir($logDir)) { + $this->logFile = null; + error_log("PluginManager log directory create failed: $logDir"); + return; + } + + if (!$this->canWriteLogFile()) { + $this->logFile = null; + error_log("PluginManager log path not writable: $logDir"); + return; + } + // 检查日志文件大小并自动清理(大于2MB时) $this->cleanupLogFile(2); // 传入最大允许的MB数 } @@ -62,32 +62,47 @@ class PluginManager { * @param int $maxSizeMB 最大允许的文件大小(MB) */ private function cleanupLogFile(int $maxSizeMB) { - // 检查文件是否存在 - if (!file_exists($this->logFile)) { + if (!$this->canWriteLogFile() || !file_exists($this->logFile)) { return; } - + // 转换MB为字节 $maxSizeBytes = $maxSizeMB * 1024 * 1024; - + // 获取当前文件大小 - $currentSize = filesize($this->logFile); - + $currentSize = @filesize($this->logFile); + if ($currentSize === false) { + return; + } + // 如果文件大小超过限制,清空文件 if ($currentSize > $maxSizeBytes) { // 先备份当前日志内容(可选) $backupFile = $this->logFile . '.bak_' . date('YmdHis'); - copy($this->logFile, $backupFile); - + @copy($this->logFile, $backupFile); + // 清空日志文件 - file_put_contents($this->logFile, ''); - + @file_put_contents($this->logFile, ''); + // 记录清理日志 $message = "[" . date('Y-m-d H:i:s') . "] 日志文件超过{$maxSizeMB}MB,已自动清理\n"; - file_put_contents($this->logFile, $message, FILE_APPEND); + @file_put_contents($this->logFile, $message, FILE_APPEND); } } + private function canWriteLogFile(): bool { + if (empty($this->logFile)) { + return false; + } + + $logDir = dirname($this->logFile); + if (!is_dir($logDir) || !is_writable($logDir)) { + return false; + } + + return !file_exists($this->logFile) || is_writable($this->logFile); + } + /** * 设置系统核心路由 @@ -121,13 +136,12 @@ class PluginManager { protected function log(string $msg, string $level = 'INFO'): void { $date = date('Y-m-d H:i:s'); $logMsg = "[$date] [$level] $msg\n"; - $logDir = dirname($this->logFile); try { - if (is_dir($logDir) && is_writable($logDir)) { - file_put_contents($this->logFile, $logMsg, FILE_APPEND); + if ($this->canWriteLogFile()) { + @file_put_contents($this->logFile, $logMsg, FILE_APPEND); } else { - error_log("PluginManager log directory not writable: $logDir"); + error_log("PluginManager: " . trim($logMsg)); } } catch (\Throwable $e) { error_log("Failed to write plugin log: " . $e->getMessage()); diff --git a/App/Core/SettingsHelper.php b/App/Core/SettingsHelper.php index d2eb37e..6c37a75 100755 --- a/App/Core/SettingsHelper.php +++ b/App/Core/SettingsHelper.php @@ -60,12 +60,14 @@ class SettingsHelper { */ private static function getDefaults() { return [ - 'site_title' => 'PK10 Speed Racing', - 'site_description' => 'PK10 Speed Racing - Online Betting Platform', - 'site_keywords' => 'pk10, speed racing, betting, online game', + 'site_title' => 'F1 Racing', + 'site_description' => 'F1 Racing - Online Betting Platform', + 'site_keywords' => 'f1, racing, betting, online game', 'site_logo' => '/Static/images/logo.png', 'site_favicon' => '/Static/css/favicon.ico', - 'site_copyright' => '© 2025 PK10 Racing. All rights reserved.' + 'site_copyright' => '© 2025 F1 Racing. All rights reserved.', + 'target_profit_rate' => '15', // 目标盈利率,百分比,如15表示15% + 'customer_service_url' => '', // 客服链接(充值时跳转) ]; } diff --git a/App/Services/FollowPlanService.php b/App/Services/FollowPlanService.php new file mode 100644 index 0000000..7f3e00d --- /dev/null +++ b/App/Services/FollowPlanService.php @@ -0,0 +1,245 @@ +db = $db; + } + + /** + * 获取所有启用的跟单计划(前台展示) + */ + public function getActivePlans(int $gameId = 0): array + { + $where = ['status' => 1]; + if ($gameId > 0) $where['game_id'] = $gameId; + $where['ORDER'] = ['id' => 'ASC']; + + $plans = $this->db->select('follow_plans', '*', $where); + foreach ($plans as &$plan) { + $stats = $this->getPlanStats($plan['id']); + $plan['total_records'] = $stats['total']; + $plan['win_count'] = $stats['wins']; + $plan['win_rate'] = $stats['total'] > 0 ? round($stats['wins'] / $stats['total'] * 100, 1) : 0; + // 最近 10 期记录 + $plan['recent'] = $this->db->select('follow_plan_records', '*', [ + 'plan_id' => $plan['id'], + 'is_win[!]' => null, + 'ORDER' => ['id' => 'DESC'], + 'LIMIT' => 10 + ]); + } + unset($plan); + return $plans; + } + + /** + * 获取计划统计 + */ + public function getPlanStats(int $planId): array + { + $total = $this->db->count('follow_plan_records', [ + 'plan_id' => $planId, + 'is_win[!]' => null + ]); + $wins = $this->db->count('follow_plan_records', [ + 'plan_id' => $planId, + 'is_win' => 1 + ]); + return ['total' => $total, 'wins' => $wins]; + } + + /** + * 获取用户的跟单状态 + */ + public function getUserFollowStatus(int $userId): array + { + $rows = $this->db->select('user_follow_plans', '*', [ + 'user_id' => $userId, + 'is_active' => 1 + ]); + $result = []; + foreach ($rows as $r) { + $result[$r['plan_id']] = $r; + } + return $result; + } + + /** + * 用户开始/停止跟单 + */ + public function toggleFollow(int $userId, int $planId): array + { + $existing = $this->db->get('user_follow_plans', '*', [ + 'user_id' => $userId, + 'plan_id' => $planId + ]); + + if ($existing) { + $newStatus = $existing['is_active'] ? 0 : 1; + $this->db->update('user_follow_plans', ['is_active' => $newStatus], [ + 'id' => $existing['id'] + ]); + return ['success' => true, 'active' => $newStatus]; + } + + // 新建 + $plan = $this->db->get('follow_plans', '*', ['id' => $planId, 'status' => 1]); + if (!$plan) { + return ['success' => false, 'message' => '计划不存在或已禁用']; + } + + $this->db->insert('user_follow_plans', [ + 'user_id' => $userId, + 'plan_id' => $planId, + 'is_active' => 1, + ]); + return ['success' => true, 'active' => 1]; + } + + /** + * 获取用户跟单汇总(总胜率、总盈亏) + */ + public function getUserSummary(int $userId): array + { + $pdo = $this->db->medoo->pdo; + $totalWinRate = 0; + $totalProfit = 0; + + try { + $stmt = $pdo->prepare(" + SELECT SUM(total_bets) as bets, SUM(total_wins) as wins, SUM(total_profit) as profit + FROM user_follow_plans WHERE user_id = ? + "); + $stmt->execute([$userId]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + $bets = intval($row['bets'] ?? 0); + $wins = intval($row['wins'] ?? 0); + $totalWinRate = $bets > 0 ? round($wins / $bets * 100, 1) : 0; + $totalProfit = floatval($row['profit'] ?? 0); + } catch (\Exception $e) {} + + return ['win_rate' => $totalWinRate, 'profit' => $totalProfit]; + } + + // ===== 后台管理 ===== + + /** + * 获取所有计划(后台) + */ + public function getAllPlans(): array + { + $plans = $this->db->select('follow_plans', '*', ['ORDER' => ['id' => 'DESC']]); + foreach ($plans as &$plan) { + $stats = $this->getPlanStats($plan['id']); + $plan['total_records'] = $stats['total']; + $plan['win_count'] = $stats['wins']; + $plan['win_rate'] = $stats['total'] > 0 ? round($stats['wins'] / $stats['total'] * 100, 1) : 0; + $plan['follower_count'] = $this->db->count('user_follow_plans', [ + 'plan_id' => $plan['id'], 'is_active' => 1 + ]); + } + unset($plan); + return $plans; + } + + /** + * 创建/更新计划 + */ + public function savePlan(array $data): array + { + $fields = [ + 'game_id' => intval($data['game_id'] ?? 1), + 'name' => trim($data['name'] ?? ''), + 'plan_type' => trim($data['plan_type'] ?? 'bs'), + 'target_rank' => intval($data['target_rank'] ?? 1), + 'strategy' => trim($data['strategy'] ?? 'follow'), + 'bet_amount' => floatval($data['bet_amount'] ?? 100), + 'status' => intval($data['status'] ?? 1), + ]; + + if (empty($fields['name'])) { + return ['success' => false, 'message' => '计划名称不能为空']; + } + + $id = intval($data['id'] ?? 0); + if ($id > 0) { + $this->db->update('follow_plans', $fields, ['id' => $id]); + } else { + $fields['created_by'] = intval($data['created_by'] ?? 0); + $this->db->insert('follow_plans', $fields); + $id = intval($this->db->id()); + } + + return ['success' => true, 'id' => $id]; + } + + /** + * 删除计划 + */ + public function deletePlan(int $planId): array + { + $this->db->delete('follow_plan_records', ['plan_id' => $planId]); + $this->db->delete('user_follow_plans', ['plan_id' => $planId]); + $this->db->delete('follow_plans', ['id' => $planId]); + return ['success' => true]; + } + + /** + * 添加计划推荐记录(由 cron 或后台手动触发) + */ + public function addRecord(int $planId, int $periodId, string $periodNumber, string $recommendValue): array + { + $exists = $this->db->get('follow_plan_records', 'id', [ + 'plan_id' => $planId, 'period_id' => $periodId + ]); + if ($exists) { + return ['success' => false, 'message' => '该期已有推荐记录']; + } + + $this->db->insert('follow_plan_records', [ + 'plan_id' => $planId, + 'period_id' => $periodId, + 'period_number' => $periodNumber, + 'recommend_value' => $recommendValue, + ]); + return ['success' => true, 'id' => intval($this->db->id())]; + } + + /** + * 结算推荐记录(开奖后调用) + */ + public function settleRecord(int $recordId, string $resultValue, bool $isWin): void + { + $this->db->update('follow_plan_records', [ + 'result_value' => $resultValue, + 'is_win' => $isWin ? 1 : 0, + ], ['id' => $recordId]); + + // 更新所有跟了这个计划的用户统计 + $record = $this->db->get('follow_plan_records', '*', ['id' => $recordId]); + if (!$record) return; + + $plan = $this->db->get('follow_plans', '*', ['id' => $record['plan_id']]); + if (!$plan) return; + + $betAmount = floatval($plan['bet_amount']); + $profit = $isWin ? $betAmount * 0.95 : -$betAmount; // 简化盈亏计算 + + $followers = $this->db->select('user_follow_plans', '*', [ + 'plan_id' => $record['plan_id'], 'is_active' => 1 + ]); + + foreach ($followers as $f) { + $this->db->update('user_follow_plans', [ + 'total_bets[+]' => 1, + 'total_wins[+]' => $isWin ? 1 : 0, + 'total_profit[+]' => $profit, + ], ['id' => $f['id']]); + } + } +} diff --git a/App/Services/ReportService.php b/App/Services/ReportService.php new file mode 100644 index 0000000..c5462e6 --- /dev/null +++ b/App/Services/ReportService.php @@ -0,0 +1,344 @@ +db = $db; + } + + /** + * 前台用户报表:按投注类型分组统计 + * + * @param int $userId 用户ID + * @param string $dateFrom 开始日期 (Y-m-d) + * @param string $dateTo 结束日期 (Y-m-d) + * @return array + */ + public function getUserReport(int $userId, string $dateFrom, string $dateTo): array + { + $rangeFrom = $dateFrom . ' 00:00:00'; + $rangeTo = $dateTo . ' 23:59:59'; + + $pdo = $this->db->medoo->pdo; + try { + // 投注统计(有效流水 = 已结算投注额,排除 pending) + $stmt = $pdo->prepare(" + SELECT + bet_type as type, + COUNT(*) as bet_count, + SUM(amount) as amount, + SUM(CASE WHEN status IN ('win','lose') THEN amount ELSE 0 END) as effective_flow, + SUM(CASE + WHEN status='win' THEN win_amount + WHEN status='lose' THEN -amount + ELSE 0 + END) as win_loss + FROM bets + WHERE user_id = ? AND created_at BETWEEN ? AND ? + GROUP BY bet_type + ORDER BY bet_type + "); + $stmt->execute([$userId, $rangeFrom, $rangeTo]); + $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + // 退水(代理返佣给该用户的金额) + $rebateTotal = 0; + try { + $stmt2 = $pdo->prepare(" + SELECT COALESCE(SUM(commission), 0) as total + FROM agent_commissions + WHERE from_user_id = ? AND type = 'rebate' AND created_at BETWEEN ? AND ? + "); + $stmt2->execute([$userId, $rangeFrom, $rangeTo]); + $rebateTotal = floatval($stmt2->fetch(\PDO::FETCH_ASSOC)['total'] ?? 0); + } catch (\Exception $e) { + $rebateTotal = 0; + } + + // 将退水按比例分配到各类型(或全部放在第一行) + $totalBet = array_sum(array_column($rows, 'amount')); + foreach ($rows as &$row) { + $row['amount'] = floatval($row['amount']); + $row['effective_flow'] = floatval($row['effective_flow']); + $row['win_loss'] = floatval($row['win_loss']); + // 按投注额占比分配退水 + $ratio = $totalBet > 0 ? $row['amount'] / $totalBet : 0; + $row['rebate'] = round($rebateTotal * $ratio, 2); + } + unset($row); + + return $rows; + } catch (\Exception $e) { + return []; + } + } + + /** + * 后台总体统计 + * + * @param string $dateFrom 开始日期 (Y-m-d) + * @param string $dateTo 结束日期 (Y-m-d) + * @return array + */ + public function getOverviewStats(string $dateFrom, string $dateTo): array + { + $range = [$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59']; + $pdo = $this->db->medoo->pdo; + + // 总投注(排除虚拟) + $totalBet = $this->db->sum('bets', 'amount', [ + 'is_virtual' => 0, 'created_at[<>]' => $range + ]) ?: 0; + + // 总派奖 + $totalWin = $this->db->sum('bets', 'win_amount', [ + 'is_virtual' => 0, 'status' => 'win', 'created_at[<>]' => $range + ]) ?: 0; + + // 平台利润 + $profit = $totalBet - $totalWin; + $profitRate = $totalBet > 0 ? round($profit / $totalBet * 100, 2) : 0; + + // 总充值 + $totalDeposit = $this->db->sum('transactions', 'amount', [ + 'is_virtual' => 0, 'type[~]' => '%deposit%', 'amount[>]' => 0, + 'created_at[<>]' => $range + ]) ?: 0; + + // 总提现 + $totalWithdraw = abs($this->db->sum('transactions', 'amount', [ + 'is_virtual' => 0, 'type[~]' => '%withdraw%', 'amount[<]' => 0, + 'created_at[<>]' => $range + ]) ?: 0); + + // 代理佣金 + $totalCommission = $this->db->sum('agent_commissions', 'commission', [ + 'created_at[<>]' => $range + ]) ?: 0; + + // 反水 + $totalRebate = $this->db->sum('agent_commissions', 'commission', [ + 'type' => 'rebate', 'created_at[<>]' => $range + ]) ?: 0; + + // 用户统计 + $totalUsers = $this->db->count('users', ['is_virtual' => 0]); + $newUsers = $this->db->count('users', [ + 'is_virtual' => 0, 'created_at[<>]' => $range + ]); + + // 活跃用户数(有投注记录的非虚拟用户) + $activeUsers = 0; + try { + $stmt = $pdo->prepare("SELECT COUNT(DISTINCT user_id) as cnt FROM bets WHERE is_virtual = 0 AND created_at BETWEEN ? AND ?"); + $stmt->execute([$range[0], $range[1]]); + $activeUsers = (int)($stmt->fetch(\PDO::FETCH_ASSOC)['cnt'] ?? 0); + } catch (\Exception $e) { + $activeUsers = 0; + } + + return compact( + 'totalBet', 'totalWin', 'profit', 'profitRate', + 'totalDeposit', 'totalWithdraw', 'totalCommission', 'totalRebate', + 'totalUsers', 'newUsers', 'activeUsers' + ); + } + + /** + * 日明细统计 + * + * @param string $dateFrom 开始日期 (Y-m-d) + * @param string $dateTo 结束日期 (Y-m-d) + * @return array + */ + public function getDailyStats(string $dateFrom, string $dateTo): array + { + $range = [$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59']; + $pdo = $this->db->medoo->pdo; + + $dailyStats = []; + try { + $stmt = $pdo->prepare(" + SELECT + DATE(created_at) as date, + SUM(amount) as total_bet, + SUM(CASE WHEN status='win' THEN win_amount ELSE 0 END) as total_win, + COUNT(*) as bet_count, + SUM(CASE WHEN status='win' THEN 1 ELSE 0 END) as win_count + FROM bets + WHERE is_virtual = 0 AND created_at BETWEEN ? AND ? + GROUP BY DATE(created_at) + ORDER BY date DESC + "); + $stmt->execute([$range[0], $range[1]]); + $dailyStats = $stmt->fetchAll(\PDO::FETCH_ASSOC); + } catch (\Exception $e) { + return []; + } + + // 补充每日充提数据 + foreach ($dailyStats as &$day) { + $dr = [$day['date'] . ' 00:00:00', $day['date'] . ' 23:59:59']; + $day['deposits'] = $this->db->sum('transactions', 'amount', [ + 'is_virtual' => 0, 'type[~]' => '%deposit%', 'amount[>]' => 0, + 'created_at[<>]' => $dr + ]) ?: 0; + $day['withdraws'] = abs($this->db->sum('transactions', 'amount', [ + 'is_virtual' => 0, 'type[~]' => '%withdraw%', 'amount[<]' => 0, + 'created_at[<>]' => $dr + ]) ?: 0); + $day['total_bet'] = floatval($day['total_bet'] ?? 0); + $day['total_win'] = floatval($day['total_win'] ?? 0); + $day['bet_count'] = intval($day['bet_count'] ?? 0); + $day['win_count'] = intval($day['win_count'] ?? 0); + $day['profit'] = $day['total_bet'] - $day['total_win']; + $day['win_rate'] = $day['bet_count'] > 0 ? round($day['win_count'] / $day['bet_count'] * 100, 1) : 0; + } + unset($day); + + return $dailyStats; + } + + /** + * 用户输赢明细(含日维度分组 + 用户汇总) + * + * @param string $dateFrom 开始日期 (Y-m-d) + * @param string $dateTo 结束日期 (Y-m-d) + * @return array ['raw' => 原始行, 'summary' => 按用户汇总] + */ + public function getUserWinLoss(string $dateFrom, string $dateTo): array + { + $range = [$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59']; + $pdo = $this->db->medoo->pdo; + + $userWinLoss = []; + try { + $stmt = $pdo->prepare(" + SELECT u.id as user_id, u.username, u.balance, + DATE(b.created_at) as date, + SUM(b.amount) as total_bet, + SUM(CASE WHEN b.status='win' THEN b.win_amount ELSE 0 END) as total_win, + COUNT(*) as bet_count, + SUM(CASE WHEN b.status='win' THEN 1 ELSE 0 END) as win_count + FROM bets b + JOIN users u ON b.user_id = u.id + WHERE b.is_virtual = 0 AND b.created_at BETWEEN ? AND ? + GROUP BY b.user_id, DATE(b.created_at) + ORDER BY u.username, date DESC + "); + $stmt->execute([$range[0], $range[1]]); + $userWinLoss = $stmt->fetchAll(\PDO::FETCH_ASSOC); + } catch (\Exception $e) { + return ['raw' => [], 'summary' => []]; + } + + // 按用户汇总 + $userSummary = []; + foreach ($userWinLoss as $row) { + $uid = $row['user_id']; + if (!isset($userSummary[$uid])) { + $userSummary[$uid] = [ + 'username' => $row['username'], + 'balance' => floatval($row['balance']), + 'total_bet' => 0, 'total_win' => 0, + 'bet_count' => 0, 'win_count' => 0, + 'days' => [] + ]; + } + $bet = floatval($row['total_bet']); + $win = floatval($row['total_win']); + $userSummary[$uid]['total_bet'] += $bet; + $userSummary[$uid]['total_win'] += $win; + $userSummary[$uid]['bet_count'] += intval($row['bet_count']); + $userSummary[$uid]['win_count'] += intval($row['win_count']); + $userSummary[$uid]['days'][] = [ + 'date' => $row['date'], + 'bet' => $bet, 'win' => $win, + 'profit' => $bet - $win, + 'count' => intval($row['bet_count']), + ]; + } + // 排序:按总投注额降序 + uasort($userSummary, function($a, $b) { return $b['total_bet'] <=> $a['total_bet']; }); + + return ['raw' => $userWinLoss, 'summary' => $userSummary]; + } + + /** + * 用户排行(投注额 TOP N) + * + * @param string $dateFrom 开始日期 (Y-m-d) + * @param string $dateTo 结束日期 (Y-m-d) + * @param int $limit 条数限制,0=不限 + * @return array + */ + public function getTopUsers(string $dateFrom, string $dateTo, int $limit = 20): array + { + $range = [$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59']; + $pdo = $this->db->medoo->pdo; + + $sql = " + SELECT u.username, + SUM(b.amount) as total_bet, + SUM(CASE WHEN b.status='win' THEN b.win_amount ELSE 0 END) as total_win, + SUM(b.amount) - SUM(CASE WHEN b.status='win' THEN b.win_amount ELSE 0 END) as profit, + COUNT(*) as bet_count + FROM bets b + JOIN users u ON b.user_id = u.id + WHERE b.is_virtual = 0 AND b.created_at BETWEEN ? AND ? + GROUP BY b.user_id + ORDER BY total_bet DESC + "; + if ($limit > 0) { + $sql .= " LIMIT " . intval($limit); + } + + try { + $stmt = $pdo->prepare($sql); + $stmt->execute([$range[0], $range[1]]); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } catch (\Exception $e) { + return []; + } + } + + /** + * 代理报表 + * + * @param string $dateFrom 开始日期 (Y-m-d) + * @param string $dateTo 结束日期 (Y-m-d) + * @return array + */ + public function getAgentStats(string $dateFrom, string $dateTo): array + { + $range = [$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59']; + + $agentStats = []; + $agents = $this->db->select('agents', '*', ['status' => 1]); + foreach ($agents as $ag) { + $playerIds = $this->db->select('users', 'id', ['agent_id' => $ag['id'], 'is_virtual' => 0]); + $agBet = 0; + $agWin = 0; + if (!empty($playerIds)) { + $agBet = $this->db->sum('bets', 'amount', ['user_id' => $playerIds, 'is_virtual' => 0, 'created_at[<>]' => $range]) ?: 0; + $agWin = $this->db->sum('bets', 'win_amount', ['user_id' => $playerIds, 'is_virtual' => 0, 'status' => 'win', 'created_at[<>]' => $range]) ?: 0; + } + $agComm = $this->db->sum('agent_commissions', 'commission', ['agent_id' => $ag['id'], 'created_at[<>]' => $range]) ?: 0; + $agentStats[] = [ + 'agent' => $ag, + 'user' => $this->db->get('users', ['username'], ['id' => $ag['user_id']]), + 'players' => count($playerIds), + 'bets' => $agBet, + 'wins' => $agWin, + 'commission' => $agComm, + 'profit' => $agBet - $agWin - $agComm, + ]; + } + + return $agentStats; + } +} diff --git a/App/Services/TransactionService.php b/App/Services/TransactionService.php new file mode 100644 index 0000000..9fdf4a2 --- /dev/null +++ b/App/Services/TransactionService.php @@ -0,0 +1,146 @@ +db = $db; + } + + /** + * 用户间转账 + * + * @param int $fromUserId 转出用户ID + * @param string $toUsername 收款用户名 + * @param float $amount 转账金额 + * @return array ['success' => bool, 'message' => string] + */ + public function transfer(int $fromUserId, string $toUsername, float $amount): array + { + $toUsername = trim($toUsername); + + if ($toUsername === '') { + return ['success' => false, 'message' => '请输入对方用户名']; + } + if ($amount <= 0) { + return ['success' => false, 'message' => '金额无效']; + } + + $fromUser = $this->db->get('users', '*', ['id' => $fromUserId]); + if (!$fromUser) { + return ['success' => false, 'message' => '用户不存在']; + } + if ($fromUser['balance'] < $amount) { + return ['success' => false, 'message' => '余额不足']; + } + + // 查找收款用户 + $toUser = $this->db->get('users', '*', ['username' => $toUsername]); + if (!$toUser) { + return ['success' => false, 'message' => '收款用户不存在']; + } + if ($toUser['id'] === $fromUser['id']) { + return ['success' => false, 'message' => '不能转给自己']; + } + + $this->db->medoo->pdo->beginTransaction(); + try { + $now = date('Y-m-d H:i:s'); + $fromBalanceBefore = floatval($fromUser['balance']); + $toBalanceBefore = floatval($toUser['balance']); + $fromBalanceAfter = $fromBalanceBefore - $amount; + $toBalanceAfter = $toBalanceBefore + $amount; + + // 扣款 + $this->db->update('users', ['balance' => $fromBalanceAfter, 'updated_at' => $now], ['id' => $fromUser['id']]); + // 加款 + $this->db->update('users', ['balance' => $toBalanceAfter, 'updated_at' => $now], ['id' => $toUser['id']]); + + // 转出流水 + $this->db->insert('transactions', [ + 'user_id' => $fromUser['id'], + 'type' => 'transfer_out', + 'amount' => -$amount, + 'balance_before' => $fromBalanceBefore, + 'balance_after' => $fromBalanceAfter, + 'description' => '转账给 ' . $toUser['username'], + 'created_at' => $now, + ]); + // 转入流水 + $this->db->insert('transactions', [ + 'user_id' => $toUser['id'], + 'type' => 'transfer_in', + 'amount' => $amount, + 'balance_before' => $toBalanceBefore, + 'balance_after' => $toBalanceAfter, + 'description' => '收到 ' . $fromUser['username'] . ' 的转账', + 'created_at' => $now, + ]); + + $this->db->medoo->pdo->commit(); + return ['success' => true, 'message' => '转账成功']; + } catch (\Exception $e) { + $this->db->medoo->pdo->rollBack(); + return ['success' => false, 'message' => '系统错误']; + } + } + + /** + * 充提申请 + * + * @param int $userId 用户ID + * @param string $type 类型: deposit|withdraw + * @param float $amount 金额 + * @param string $remark 备注 + * @return array ['success' => bool, 'message' => string] + */ + public function fundRequest(int $userId, string $type, float $amount, string $remark = ''): array + { + if (!in_array($type, ['deposit', 'withdraw'])) { + return ['success' => false, 'message' => '无效类型']; + } + if ($amount <= 0) { + return ['success' => false, 'message' => '金额无效']; + } + + $user = $this->db->get('users', '*', ['id' => $userId]); + if (!$user) { + return ['success' => false, 'message' => '用户不存在']; + } + + if ($type === 'withdraw') { + if ($user['balance'] < $amount) { + return ['success' => false, 'message' => '余额不足']; + } + $this->db->medoo->pdo->beginTransaction(); + try { + $this->db->update('users', ['balance[-]' => $amount], ['id' => $user['id']]); + $this->db->insert('fund_requests', [ + 'user_id' => $user['id'], + 'type' => 'withdraw', + 'amount' => $amount, + 'status' => 'pending', + 'remark' => $remark, + ]); + $this->db->medoo->pdo->commit(); + return ['success' => true, 'message' => '提现申请已提交']; + } catch (\Exception $e) { + $this->db->medoo->pdo->rollBack(); + return ['success' => false, 'message' => '系统错误']; + } + } + + // deposit - 记录申请 + $this->db->insert('fund_requests', [ + 'user_id' => $user['id'], + 'type' => 'deposit', + 'amount' => $amount, + 'status' => 'pending', + 'remark' => $remark, + ]); + return ['success' => true, 'message' => '充值申请已提交']; + } +} diff --git a/App/Views/.DS_Store b/App/Views/.DS_Store index 0cfcd92..3966231 100755 Binary files a/App/Views/.DS_Store and b/App/Views/.DS_Store differ diff --git a/App/Views/Admin/bots.php b/App/Views/Admin/bots.php new file mode 100644 index 0000000..41d3cc9 --- /dev/null +++ b/App/Views/Admin/bots.php @@ -0,0 +1,444 @@ +
Bot 实例数
群配置数
已绑总账号
启用群数
成员映射数
托号数量
推送日志数
推送成功数
倒计时群数
动画群数
API 请求数
签名通过数
现在已支持新增、编辑、启停 Bot 实例。
+| 实例 | 运行模式 | 群数 | 状态 | 更新时间 | 操作 |
|---|---|---|---|---|---|
= htmlspecialchars((string)($bot['name'] ?? '未命名 Bot')) ?> Key:= htmlspecialchars((string)($bot['bot_key'] ?? '-')) ?> @= htmlspecialchars((string)($bot['bot_username'] ?? 'unknown')) ?> |
+ = htmlspecialchars((string)($bot['run_mode'] ?? 'polling')) ?> | +总群数:= (int)($bot['group_count'] ?? 0) ?> 启用下注:= (int)($bot['active_group_count'] ?? 0) ?> |
+ = $enabled ? '启用' : '停用' ?> | += htmlspecialchars((string)($bot['updated_at'] ?? ($bot['created_at'] ?? '-'))) ?> | ++ |
| 尚未配置任何 Bot 实例。 | |||||
| 群 | Bot / 游戏 | 开关 | 状态 | 操作 |
|---|---|---|---|---|
= htmlspecialchars((string)$group['group_name']) ?> = htmlspecialchars((string)$group['tg_group_id']) ?> / = htmlspecialchars((string)$group['group_type']) ?> 倒计时:= htmlspecialchars($countdownText) ?> | = htmlspecialchars((string)($group['bot_name'] ?? '-')) ?> = htmlspecialchars((string)($group['game_name'] ?? '-')) ?> | 下注:= !empty($group['bet_enabled']) ? '开' : '关' ?> 提醒:= !empty($group['remind_bet_success']) || !empty($group['remind_draw_result']) || !empty($group['remind_close_countdown']) ? '开' : '关' ?> 动画:= !empty($group['animation_enabled']) ? '开' : '关' ?> | = $active ? '启用' : '停用' ?> | |
| 暂无群配置。 | ||||
| 群 | 网站账号 | 模式 | 状态 | 操作 |
|---|---|---|---|---|
= htmlspecialchars((string)($wallet['group_name'] ?? '-')) ?> = htmlspecialchars((string)($wallet['tg_group_id'] ?? '-')) ?> | = htmlspecialchars((string)($wallet['platform_username'] ?? '-')) ?> UID = (int)($wallet['platform_user_id'] ?? 0) ?> / 余额 = number_format((float)($wallet['platform_balance'] ?? 0), 2) ?> | = htmlspecialchars((string)($wallet['wallet_mode'] ?? 'master_pool')) ?> | = $active ? '启用' : '停用' ?> | |
| 暂无群总账号绑定。 | ||||
| 成员 | 群 / 网站账号 | 角色 | 下注 | 操作 |
|---|---|---|---|---|
= htmlspecialchars((string)($member['tg_nickname'] ?: $member['tg_username'] ?: $member['tg_user_id'])) ?> UID = htmlspecialchars((string)$member['tg_user_id']) ?> / @= htmlspecialchars((string)($member['tg_username'] ?? '-')) ?> | = htmlspecialchars((string)($member['group_name'] ?? '-')) ?> 网站:= htmlspecialchars((string)($member['platform_username'] ?? '未绑定')) ?> | = htmlspecialchars((string)($member['role'] ?? 'member')) ?> | = $betEnabled ? '允许' : '关闭' ?> | |
| 暂无成员映射。 | ||||
| 托号 | 群 | 备注 | 状态 | 操作 |
|---|---|---|---|---|
= htmlspecialchars((string)($shill['tg_nickname'] ?: $shill['tg_username'] ?: $shill['tg_user_id'])) ?> UID = htmlspecialchars((string)$shill['tg_user_id']) ?> | = htmlspecialchars((string)($shill['group_name'] ?? '-')) ?> | = htmlspecialchars((string)($shill['note'] ?? '-')) ?> | = $enabled ? '排除统计中' : '已停用' ?> | |
| 暂无托号配置。 | ||||
先把自动消息、开奖提醒、封盘倒计时、上下分提醒的运营可见性拉出来,便于对照 TG 成品补齐行为。
+| 类型 | +总数 | +成功 | +失败 | +待发/跳过 | +
|---|---|---|---|---|
| = htmlspecialchars((string)$pushType) ?> | += (int)($summary['total'] ?? 0) ?> | += (int)($summary['success'] ?? 0) ?> | += (int)($summary['failed'] ?? 0) ?> | += (int)($summary['pending'] ?? 0) + (int)($summary['skipped'] ?? 0) ?> | +
| 暂无推送汇总数据,待 TG runtime 写入。 | ||||
覆盖 countdown / bet_success / draw / credit / debit / system,先做观测面,再回填自动触发链。
| 群 | 类型 | 期号 / 消息 | 载荷摘要 | 状态 | 时间 |
|---|---|---|---|---|---|
= htmlspecialchars((string)($pushLog['group_name'] ?? '未知群组')) ?> = htmlspecialchars((string)($pushLog['tg_group_id'] ?? '-')) ?> | = htmlspecialchars((string)($pushLog['push_type'] ?? 'system')) ?> | 期号:= htmlspecialchars((string)($pushLog['period_number'] ?? '-')) ?> TG Msg ID:= htmlspecialchars((string)($pushLog['tg_message_id'] ?? '-')) ?> | = htmlspecialchars((string)$summaryText) ?> 错误:= htmlspecialchars((string)$pushLog['error_message']) ?> | = htmlspecialchars($status) ?> | = htmlspecialchars((string)($pushLog['created_at'] ?? '-')) ?> |
| 暂无推送日志。 | |||||
对照成品 TG 机器人的发送队列与多账号路由,这里补入网站侧入站请求审计链,便于排查签名、限流、重复请求。
| Bot / 群 | 请求 | 幂等 / IP | 签名 / 响应 | 时间 |
|---|---|---|---|---|
= htmlspecialchars((string)($apiLog['bot_name'] ?? '未知 Bot')) ?> = htmlspecialchars((string)($apiLog['group_name'] ?? '-')) ?> / = htmlspecialchars((string)($apiLog['tg_group_id'] ?? '-')) ?> | = htmlspecialchars((string)($apiLog['http_method'] ?? 'POST')) ?> = htmlspecialchars((string)($apiLog['request_uri'] ?? '-')) ?> | = htmlspecialchars((string)($apiLog['idempotency_key'] ?? '-')) ?> IP: = htmlspecialchars((string)($apiLog['client_ip'] ?? '-')) ?> | = $signatureOk ? 'signature ok' : 'signature fail' ?> HTTP = (int)($apiLog['response_code'] ?? 0) ?> | = htmlspecialchars((string)($apiLog['created_at'] ?? '-')) ?> |
| 暂无 API 请求日志。 | ||||
用于快速核对群消息→平台订单的同步状态。
| 群 | 用户 | 期号 | 金额 / 注数 | 状态 | 时间 |
|---|---|---|---|---|---|
= htmlspecialchars((string)($order['group_name'] ?? '未知群组')) ?> = htmlspecialchars((string)($order['tg_group_id'] ?? '-')) ?> | = htmlspecialchars((string)($order['tg_username'] ?? '-')) ?>托 | = htmlspecialchars((string)($order['period_number'] ?? '-')) ?> | = number_format((float)($order['bet_amount_total'] ?? 0), 2) ?> / = (int)($order['accepted_bet_count'] ?? 0) ?> 注 | = htmlspecialchars((string)($order['sync_status'] ?? 'unknown')) ?> = htmlspecialchars((string)$order['sync_error']) ?> | = htmlspecialchars((string)($order['created_at'] ?? '-')) ?> |
| 暂无 Bot 同步记录。 | |||||
| ID | +计划名称 | +类型 | +名次 | +建议金额 | +总期数 | +胜率 | +跟单人数 | +状态 | +操作 | +
|---|---|---|---|---|---|---|---|---|---|
| = $p['id'] ?> | += htmlspecialchars($p['name']) ?> | ++ + = ['bs'=>'大小','oe'=>'单双','dt'=>'龙虎','sum_bs'=>'冠亚大小'][$p['plan_type']] ?? $p['plan_type'] ?> + + | += $p['target_rank'] ?> | += number_format($p['bet_amount'], 2) ?> | += $p['total_records'] ?> | ++ + = $p['win_rate'] ?>% + + | += $p['follower_count'] ?> | ++ + = $p['status'] ? '启用' : '禁用' ?> + + | ++ + + | +
|
+
+
+
+ 暂无跟单计划+点击上方按钮新建跟单计划 + |
+ |||||||||
待处理
+今日充值申请
+今日提现申请
+审核用户的充值和提现申请
+| ID | +用户 | +钱包地址 | +类型 | +金额 | +状态 | + + + +操作 | +|||
|---|---|---|---|---|---|---|---|---|---|
| + #= htmlspecialchars((string)($req['id'] ?? '')) ?> + | +
+ = htmlspecialchars((string)($req['username'] ?? '未知')) ?>
+ ID: = htmlspecialchars((string)($req['user_id'] ?? '')) ?>
+ |
+
+
+
+
+ = htmlspecialchars($addr) ?>
+
+
+ 未绑定
+
+ |
+ + + 充值 + + 提现 + + | ++ + = number_format(floatval($req['amount'] ?? 0), 2) ?> + + | ++ ['text' => '待处理', 'class' => 'bg-warning/10 text-warning'], + 'approved' => ['text' => '已通过', 'class' => 'bg-success/10 text-success'], + 'rejected' => ['text' => '已拒绝', 'class' => 'bg-danger/10 text-danger'], + ]; + $statusInfo = $statusMap[$status] ?? ['text' => $status, 'class' => 'bg-gray-100 text-gray-700']; + ?> + + = $statusInfo['text'] ?> + + | + + + +
+
+
+
+
+
+
+ 已处理
+
+ |
+ |||
|
+
+
+
+ 暂无充提申请+还没有任何充提申请记录。 + |
+ |||||||||
管理后台
@@ -116,6 +123,11 @@
投注记录
+
+
+ 充提审核
+
+
财务管理
@@ -136,6 +148,14 @@
数据报表
+
+
+ 跟单计划
+
+
+
+ Bot 管理
+
系统设置
@@ -265,7 +285,7 @@
@@ -274,6 +294,22 @@
+