db = new Database(); $this->configManager = new ConfigManager($this->db); $this->aliyunService = new AliyunService(); $this->notificationService = new NotificationService(); // 注入配置到通知服务 $this->notificationService->setConfig($this->configManager->getAllSettings()); } catch (Exception $e) { $this->initError = $e->getMessage(); } } public function getInitError() { return $this->initError; } public function isInitialized() { if ($this->initError) return false; return $this->configManager->isInitialized(); } public function getAdminPassword() { return $this->configManager->get('admin_password', ''); } public function login($password) { $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) { $ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']); $ip = trim($ips[0]); } $attempts = $this->db->getRecentFailedAttempts($ip, 900); if ($attempts >= 5) { $this->db->addLog('warning', "登录被锁定: IP {$ip} 尝试次数过多"); throw new Exception("错误次数过多,请 15 分钟后再试。"); } $adminPass = $this->getAdminPassword(); if (empty($adminPass)) return false; if (hash_equals((string) $adminPass, (string) $password)) { $this->db->clearLoginAttempts($ip); $this->db->addLog('info', "管理员登录成功 [IP: {$ip}]"); return true; } $this->db->recordLoginAttempt($ip); $this->db->addLog('warning', "管理员登录失败 [IP: {$ip}]"); return false; } public function setup($data) { if ($this->initError) throw new Exception($this->initError); if ($this->isInitialized()) return false; return $this->configManager->updateConfig($data); } public function updateConfig($data) { $success = $this->configManager->updateConfig($data); if ($success) { $this->notificationService->setConfig($this->configManager->getAllSettings()); } return $success; } public function getConfigForFrontend() { if ($this->initError) return []; $settings = $this->configManager->getAllSettings(); $accounts = $this->configManager->getAccounts(); $config = [ 'admin_password' => $settings['admin_password'] ?? '', 'traffic_threshold' => (int) ($settings['traffic_threshold'] ?? 95), 'enable_schedule_email' => ($settings['enable_schedule_email'] ?? '0') === '1', 'shutdown_mode' => $settings['shutdown_mode'] ?? 'KeepCharging', 'threshold_action' => $settings['threshold_action'] ?? 'stop_and_notify', 'keep_alive' => ($settings['keep_alive'] ?? '0') === '1', 'api_interval' => (int) ($settings['api_interval'] ?? 600), 'enable_billing' => ($settings['enable_billing'] ?? '0') === '1', 'Notification' => [ 'email_enabled' => ($settings['notify_email_enabled'] ?? '1') === '1', 'email' => $settings['notify_email'] ?? '', 'host' => $settings['notify_host'] ?? '', 'port' => $settings['notify_port'] ?? 465, 'username' => $settings['notify_username'] ?? '', 'password' => $settings['notify_password'] ?? '', 'secure' => $settings['notify_secure'] ?? 'ssl', 'telegram' => [ 'enabled' => ($settings['notify_tg_enabled'] ?? '0') === '1', 'token' => $settings['notify_tg_token'] ?? '', 'chat_id' => $settings['notify_tg_chat_id'] ?? '', 'proxy_type' => $settings['notify_tg_proxy_type'] ?? 'none', 'proxy_url' => $settings['notify_tg_proxy_url'] ?? '', 'proxy_ip' => $settings['notify_tg_proxy_ip'] ?? '', 'proxy_port' => $settings['notify_tg_proxy_port'] ?? '', 'proxy_user' => $settings['notify_tg_proxy_user'] ?? '', 'proxy_pass' => $settings['notify_tg_proxy_pass'] ?? '' ], 'webhook' => [ 'enabled' => ($settings['notify_wh_enabled'] ?? '0') === '1', 'url' => $settings['notify_wh_url'] ?? '', 'method' => $settings['notify_wh_method'] ?? 'GET', 'request_type' => $settings['notify_wh_request_type'] ?? 'JSON', 'headers' => $settings['notify_wh_headers'] ?? '', 'body' => $settings['notify_wh_body'] ?? '' ] ], 'Accounts' => [] ]; foreach ($accounts as $row) { $config['Accounts'][] = [ 'AccessKeyId' => $row['access_key_id'], 'AccessKeySecret' => $row['access_key_secret'], 'regionId' => $row['region_id'], 'instanceId' => $row['instance_id'], 'maxTraffic' => (float) $row['max_traffic'], 'schedule' => [ 'enabled' => $row['schedule_enabled'] == 1, 'startTime' => $row['start_time'], 'stopTime' => $row['stop_time'] ], 'remark' => $row['remark'] ?? '', 'siteType' => $row['site_type'] ?? 'china' ]; } return $config; } // --- 修改:支持按 Tab 获取日志 --- public function getSystemLogs($tab = 'action') { if ($this->initError) return []; if ($tab === 'heartbeat') { // 心跳日志:只看 heartbeat 类型 $types = ['heartbeat']; } else { // 动作日志:只看 info 和 warning,排除 error (超时/接口错误) $types = ['info', 'warning']; } // 仅返回最近 20 条 $logs = $this->db->getLogsByTypes($types, 20); foreach ($logs as &$log) { $log['time_str'] = date('Y-m-d H:i:s', $log['created_at']); } return $logs; } // --- 新增:清空日志并重排 ID --- public function clearSystemLogs($tab = 'action') { if ($this->initError) return false; $result = false; if ($tab === 'heartbeat') { $result = $this->db->clearLogsByTypes(['heartbeat']); } else { $result = $this->db->clearLogsByTypes(['info', 'warning', 'error']); } // 关键改动:清空后立即重排剩余 ID if ($result) { $this->db->reorderLogsIds(); } return $result; } public function getAccountHistory($id) { if ($this->initError) return []; $account = $this->configManager->getAccountById($id); if (!$account) return ['error' => 'Account not found']; if (!$account) return ['error' => 'Account not found']; // Use account ID for stats query $rawHourly = $this->db->getHourlyStats($id); $chartHourly = []; foreach ($rawHourly as $row) { $chartHourly[] = [ 'time' => date('H:00', $row['recorded_at']), 'full_time' => date('Y-m-d H:i', $row['recorded_at']), 'value' => round($row['traffic'], 3) ]; } $rawDaily = $this->db->getDailyStats($id); $chartDaily = []; foreach ($rawDaily as $row) { $chartDaily[] = [ 'date' => date('Y-m-d', $row['recorded_at']), 'value' => round($row['traffic'], 3) ]; } return [ 'history_24h' => $chartHourly, 'history_30d' => $chartDaily ]; } // --- 核心监控逻辑 --- public function monitor() { if ($this->initError) return "Error: " . $this->initError; // 优化:分级清理日志 // 普通/重要日志保留 30 天,高频心跳日志仅保留 3 天 $this->db->pruneLogs(30, 3); // 关键改动:每次清理后重排 ID,保证 ID 永远紧凑 $this->db->reorderLogsIds(); $this->db->pruneStats(); // 优化:每天凌晨 04:xx 执行一次 VACUUM 整理数据库碎片 if (date('H') === '04' && date('i') === '00') { $this->db->vacuum(); } $logs = []; $currentUserTime = date('H:i'); $currentTime = time(); $threshold = (int) $this->configManager->get('traffic_threshold', 95); $shutdownMode = $this->configManager->get('shutdown_mode', 'KeepCharging'); $thresholdAction = $this->configManager->get('threshold_action', 'stop_and_notify'); $keepAlive = $this->configManager->get('keep_alive', '0') === '1'; $userInterval = (int) $this->configManager->get('api_interval', 600); $accounts = $this->configManager->getAccounts(); foreach ($accounts as $account) { $logPrefix = "[{$account['access_key_id']}]"; $actions = []; $forceRefresh = false; $statusTransformed = false; // 1. 定时任务 if ($account['schedule_enabled'] == 1) { if ($account['start_time'] && $currentUserTime === $account['start_time']) { if ($this->safeControlInstance($account, 'start')) { $actions[] = "定时启动"; $this->db->addLog('info', "执行定时启动 [{$account['access_key_id']}]"); $mailRes = $this->notificationService->notifySchedule("定时启动", $account, "计划任务已触发,实例正在启动。"); $this->logNotificationResult($mailRes, $account['access_key_id']); $forceRefresh = true; $statusTransformed = true; } } if ($account['stop_time'] && $currentUserTime === $account['stop_time']) { if ($this->safeControlInstance($account, 'stop', $shutdownMode)) { $actions[] = "定时停止({$shutdownMode})"; $this->db->addLog('info', "执行定时停止 [{$account['access_key_id']}]"); $mailRes = $this->notificationService->notifySchedule("定时停止", $account, "计划任务已触发,实例已停止。"); $this->logNotificationResult($mailRes, $account['access_key_id']); $forceRefresh = true; $statusTransformed = true; } } } // 2. 自适应心跳 $lastUpdate = $account['updated_at'] ?? 0; $cachedStatus = $account['instance_status'] ?? 'Unknown'; $isTransientState = in_array($cachedStatus, ['Starting', 'Stopping', 'Pending', 'Unknown']); $currentInterval = ($isTransientState || $statusTransformed) ? 60 : $userInterval; $shouldCheckApi = $forceRefresh || (($currentTime - $lastUpdate) > $currentInterval); if (date('i') === '00') { $shouldCheckApi = true; } $newUpdateTime = $currentTime; if ($shouldCheckApi) { $newTraffic = $this->safeGetTraffic($account); $status = $this->safeGetInstanceStatus($account); if ($status === 'Unknown') { usleep(500000); $status = $this->safeGetInstanceStatus($account); } if ($newTraffic < 0) { $traffic = $account['traffic_used']; $apiStatusLog = "流量API异常"; $newUpdateTime = $lastUpdate; } else { $traffic = $newTraffic; $apiStatusLog = "已更新"; $this->db->addHourlyStat($account['id'], $traffic); $this->db->addDailyStat($account['id'], $traffic); } if ($status === 'Unknown') { $newUpdateTime = $lastUpdate; $apiStatusLog .= "(状态Unknown)"; } else { $apiStatusLog .= in_array($status, ['Starting', 'Stopping', 'Pending']) ? " [过渡态]" : " [稳定态]"; } $this->configManager->updateAccountStatus($account['id'], $traffic, $status, $newUpdateTime); } else { $traffic = $account['traffic_used']; $status = $account['instance_status']; $timeLeft = $currentInterval - ($currentTime - $lastUpdate); $apiStatusLog = "缓存({$timeLeft}s)"; } $maxTraffic = $account['max_traffic']; $usagePercent = ($maxTraffic > 0) ? round(($traffic / $maxTraffic) * 100, 2) : 0; $trafficDesc = "流量:{$usagePercent}%"; $isOverThreshold = $usagePercent >= $threshold; // 3. 流量熔断 if ($isOverThreshold) { $trafficDesc .= "[警告]"; if ($shouldCheckApi) { if ($thresholdAction === 'stop_and_notify') { if ($status !== 'Stopped') { if ($this->safeControlInstance($account, 'stop', $shutdownMode)) { $actions[] = "超限关机"; $this->db->addLog('warning', "流量超限自动关机 [{$account['access_key_id']}] 使用率:{$usagePercent}%"); $this->configManager->updateAccountStatus($account['id'], $traffic, 'Stopping', $currentTime); $status = 'Stopping'; } } } else { $actions[] = "超限告警"; $this->db->addLog('warning', "流量超限触发告警 [{$account['access_key_id']}] 使用率:{$usagePercent}%"); } $mailRes = $this->notificationService->sendTrafficWarning($account['access_key_id'], $traffic, $usagePercent, implode(',', $actions), $threshold); $this->logNotificationResult($mailRes, $account['access_key_id']); } } // 4. 保活逻辑 (跳过已被定时任务操作的实例) if ($keepAlive && !$isOverThreshold && !$statusTransformed) { if ($account['schedule_enabled'] == 0 || $this->isTimeInRange($currentUserTime, $account['start_time'], $account['stop_time'])) { if ($status === 'Stopped') { if ($this->safeControlInstance($account, 'start')) { $actions[] = "保活启动"; $this->db->addLog('info', "执行保活启动 [{$account['access_key_id']}]"); $mailRes = $this->notificationService->notifySchedule("保活启动", $account, "检测到实例在工作时段非预期关机,已尝试自动启动。"); $this->logNotificationResult($mailRes, $account['access_key_id']); $this->configManager->updateAccountStatus($account['id'], $traffic, 'Starting', $currentTime); $status = 'Starting'; } else { $apiStatusLog .= " [保活启动失败,下次重试]"; } } } } if ($statusTransformed) { $tempStatus = in_array("定时启动", $actions) ? 'Starting' : 'Stopping'; $this->configManager->updateAccountStatus($account['id'], $traffic, $tempStatus, $currentTime); $apiStatusLog .= " -> 强制过渡态"; } $actionLog = empty($actions) ? "无动作" : implode(", ", $actions); $logLine = sprintf("%s %s | %s | %s | %s", $logPrefix, $actionLog, $trafficDesc, $status, $apiStatusLog); // --- 修改:将心跳日志写入数据库 --- $this->db->addLog('heartbeat', $logLine); $logs[] = $logLine; } $this->configManager->updateLastRunTime(time()); return implode(PHP_EOL, $logs); } public function getStatusForFrontend() { if ($this->initError) return ['error' => $this->initError]; $data = []; $threshold = (int) $this->configManager->get('traffic_threshold', 95); $userInterval = (int) $this->configManager->get('api_interval', 600); $billingEnabled = $this->configManager->get('enable_billing', '0') === '1'; $currentTime = time(); $accounts = $this->configManager->getAccounts(); $billingCycle = date('Y-m'); foreach ($accounts as $account) { $lastUpdate = $account['updated_at'] ?? 0; $cachedStatus = $account['instance_status'] ?? 'Unknown'; $newUpdateTime = $currentTime; $isTransientState = in_array($cachedStatus, ['Starting', 'Stopping', 'Pending', 'Unknown']); $checkInterval = $isTransientState ? 60 : $userInterval; if (($currentTime - $lastUpdate) > $checkInterval) { $newTraffic = $this->safeGetTraffic($account); $status = $this->safeGetInstanceStatus($account); if ($status === 'Unknown') { usleep(500000); $status = $this->safeGetInstanceStatus($account); } if ($newTraffic < 0) { $traffic = $account['traffic_used']; $newUpdateTime = $lastUpdate; } else { $traffic = $newTraffic; $this->db->addHourlyStat($account['id'], $traffic); $this->db->addDailyStat($account['id'], $traffic); } if ($status === 'Unknown') { $newUpdateTime = $lastUpdate; } $this->configManager->updateAccountStatus($account['id'], $traffic, $status, $newUpdateTime); } else { $traffic = $account['traffic_used']; $status = $account['instance_status']; } $usagePercent = ($account['max_traffic'] > 0) ? round(($traffic / $account['max_traffic']) * 100, 2) : 0; $isFull = $usagePercent >= $threshold; $item = [ 'id' => $account['id'], 'account' => substr($account['access_key_id'], 0, 7) . '***', 'flow_total' => (float) $account['max_traffic'], 'flow_used' => round($traffic, 2), 'percentageOfUse' => $usagePercent, 'region' => $account['region_id'], 'regionName' => $this->getRegionName($account['region_id']), 'rate95' => $isFull, 'threshold' => $threshold, 'instanceStatus' => $status, 'lastUpdated' => date('Y-m-d H:i:s', $lastUpdate > 0 ? $lastUpdate : $currentTime), 'remark' => $account['remark'] ?? '' ]; // 注入费用数据 (如果启用) if ($billingEnabled) { $item['cost'] = $this->safeGetBillingInfo($account, $billingCycle); } $data[] = $item; } return [ 'data' => $data, 'system_last_run' => $this->configManager->getLastRunTime() ]; } public function refreshAccount($id) { if ($this->initError) return false; $targetAccount = $this->configManager->getAccountById($id); if (!$targetAccount) return false; $currentTime = time(); $traffic = $this->safeGetTraffic($targetAccount); $status = $this->safeGetInstanceStatus($targetAccount); if ($traffic < 0) { $traffic = $targetAccount['traffic_used']; } else { $this->db->addHourlyStat($targetAccount['id'], $traffic); $this->db->addDailyStat($targetAccount['id'], $traffic); } $this->configManager->updateAccountStatus($id, $traffic, $status, $currentTime); // 刷新账单数据:仅在启用费用监控 且 无有效缓存时调用 BSS API $billingError = null; $billingEnabled = $this->configManager->get('enable_billing', '0') === '1'; if ($billingEnabled) { $billingCycle = date('Y-m'); // 余额:无有效缓存时重新获取 $balanceCache = $this->db->getBillingCache($targetAccount['id'], 'balance', '', 21600); if (!$balanceCache) { try { $balance = $this->aliyunService->getAccountBalance( $targetAccount['access_key_id'], $targetAccount['access_key_secret'], $targetAccount['site_type'] ?? 'china' ); $this->db->setBillingCache($targetAccount['id'], 'balance', '', $balance); } catch (\Exception $e) { $billingError = '余额查询失败: ' . $e->getMessage(); } } // 实例账单:无有效缓存时重新获取 if (!empty($targetAccount['instance_id'])) { $billCache = $this->db->getBillingCache($targetAccount['id'], 'instance_bill', $billingCycle, 21600); if (!$billCache) { try { $bill = $this->aliyunService->getInstanceBill( $targetAccount['access_key_id'], $targetAccount['access_key_secret'], $targetAccount['instance_id'], $billingCycle, $targetAccount['site_type'] ?? 'china' ); $this->db->setBillingCache($targetAccount['id'], 'instance_bill', $billingCycle, $bill); } catch (\Exception $e) { $billingError = ($billingError ? $billingError . '; ' : '') . '账单查询失败: ' . $e->getMessage(); } } } } if ($billingError) { $this->db->addLog('warning', "账单刷新异常 [{$targetAccount['access_key_id']}]: {$billingError}"); return ['success' => true, 'billing_error' => $billingError]; } return true; } public function sendTestEmail($to) { return $this->notificationService->sendTestEmail($to); } public function sendTestTelegram($data) { return $this->notificationService->sendTestTelegram($data); } public function sendTestWebhook($data) { return $this->notificationService->sendTestWebhook($data); } private function logNotificationResult($result, $key) { if ($result === true) { $this->db->addLog('info', "通知推送成功 [$key]"); } elseif ($result !== false && $result !== true) { $this->db->addLog('warning', "通知推送异常/失败 [$key]: " . strip_tags($result)); } } private function safeGetTraffic($account) { try { return $this->aliyunService->getTraffic($account['access_key_id'], $account['access_key_secret'], $account['region_id']); } catch (ClientException $e) { $code = $e->getErrorCode(); $this->db->addLog('error', "流量查询配置错误: " . ($code ?: "鉴权失败")); return -1; } catch (ServerException $e) { $this->db->addLog('error', "流量查询失败: 阿里云接口超时"); return -1; } catch (\Exception $e) { if (strpos($e->getMessage(), 'cURL error') !== false) { $this->db->addLog('error', "流量查询失败: 网络连接超时"); } else { $this->db->addLog('error', "流量查询失败: 系统未知错误"); } return -1; } } private function safeGetInstanceStatus($account) { try { return $this->aliyunService->getInstanceStatus($account); } catch (\Exception $e) { if (strpos($e->getMessage(), 'cURL error') !== false) { } elseif ($e instanceof ClientException) { $this->db->addLog('error', "实例状态查询配置错误: 鉴权失败"); } else { } return 'Unknown'; } } private function safeControlInstance($account, $action, $shutdownMode = 'KeepCharging') { try { return $this->aliyunService->controlInstance($account, $action, $shutdownMode); } catch (ClientException $e) { $this->db->addLog('error', "实例操作失败 [{$action}]: 权限不足或配置错误"); return false; } catch (ServerException $e) { $this->db->addLog('error', "实例操作失败 [{$action}]: 阿里云服务无响应"); return false; } catch (\Exception $e) { $this->db->addLog('error', "实例操作失败 [{$action}]: 无法连接API"); return false; } } private function isTimeInRange($current, $start, $end) { if (!$start || !$end) return false; if ($start < $end) { return $current >= $start && $current < $end; } else { return $current >= $start || $current < $end; } } private function getRegionName($regionId) { $regions = [ 'cn-hongkong' => '中国香港', 'ap-southeast-1' => '新加坡', 'us-west-1' => '美国(硅谷)', 'us-east-1' => '美国(弗吉尼亚)', 'cn-hangzhou' => '华东1(杭州)', 'cn-shanghai' => '华东2(上海)', 'cn-qingdao' => '华北1(青岛)', 'cn-beijing' => '华北2(北京)', 'cn-zhangjiakou' => '华北3(张家口)', 'cn-huhehaote' => '华北5(呼和浩特)', 'cn-wulanchabu' => '华北6(乌兰察布)', 'cn-shenzhen' => '华南1(深圳)', 'cn-heyuan' => '华南2(河源)', 'cn-guangzhou' => '华南3(广州)', 'cn-chengdu' => '西南1(成都)', 'ap-northeast-1' => '日本(东京)', ]; return $regions[$regionId] ?? $regionId; } // ==================== 费用分析 ==================== /** * 安全获取账户费用摘要信息 (带缓存) * 用于实例卡片上显示 */ private function safeGetBillingInfo($account, $billingCycle) { $costInfo = [ 'enabled' => true, 'monthly_cost' => null, 'balance' => null, 'currency' => 'CNY', 'last_updated' => null, 'error' => null ]; // 1. 尝试读取余额缓存 $balanceCache = $this->db->getBillingCache($account['id'], 'balance', '', 21600); if ($balanceCache) { $costInfo['balance'] = $balanceCache['AvailableAmount']; $costInfo['currency'] = $balanceCache['Currency'] ?? 'CNY'; } else { try { $balance = $this->aliyunService->getAccountBalance( $account['access_key_id'], $account['access_key_secret'], $account['site_type'] ?? 'china' ); $costInfo['balance'] = $balance['AvailableAmount']; $costInfo['currency'] = $balance['Currency'] ?? 'CNY'; $this->db->setBillingCache($account['id'], 'balance', '', $balance); } catch (\Exception $e) { $costInfo['error'] = '余额查询失败'; } } // 2. 尝试读取实例账单缓存 if (!empty($account['instance_id'])) { $billCache = $this->db->getBillingCache($account['id'], 'instance_bill', $billingCycle, 21600); if ($billCache) { $costInfo['monthly_cost'] = $billCache['TotalCost']; } else { try { $bill = $this->aliyunService->getInstanceBill( $account['access_key_id'], $account['access_key_secret'], $account['instance_id'], $billingCycle, $account['site_type'] ?? 'china' ); $costInfo['monthly_cost'] = $bill['TotalCost']; $this->db->setBillingCache($account['id'], 'instance_bill', $billingCycle, $bill); } catch (\Exception $e) { if ($costInfo['error']) { $costInfo['error'] = 'BSS权限不足'; } else { $costInfo['error'] = '账单查询失败'; } } } } $costInfo['last_updated'] = date('Y-m-d H:i:s'); return $costInfo; } public function renderTemplate() { if (!file_exists('template.html')) return "File not found"; ob_start(); include 'template.html'; return ob_get_clean(); } }