config = $config; } /** * 发送定时任务通知 * @return bool|string 成功返回 true,失败返回错误信息 */ public function notifySchedule($actionType, $account, $description = "") { $title = "定时任务: " . $actionType; $maskedKey = substr($account['access_key_id'], 0, 7) . '***'; $traffic = isset($account['traffic_used']) ? $this->formatTraffic((float) $account['traffic_used']) : '暂无'; $threshold = $this->config['traffic_threshold'] ?? 95; $details = [ ['label' => '账号', 'value' => $maskedKey], ['label' => '执行动作', 'value' => $actionType, 'highlight' => true], ['label' => '执行时间', 'value' => date('Y-m-d H:i:s')], ['label' => '当前流量', 'value' => $traffic], ['label' => '设定阈值', 'value' => $threshold . '%'], ['label' => '详情说明', 'value' => $description ?: '根据预设时间表自动执行。'] ]; $textMsg = "【ECS 服务器管家】{$title}\n" . "账号: {$maskedKey}\n" . "执行动作: {$actionType}\n" . "当前流量: {$traffic}\n" . "设定阈值: {$threshold}%\n" . "执行时间: " . date('Y-m-d H:i:s') . "\n" . "详情说明: " . ($description ?: '根据预设时间表自动执行。'); return $this->dispatchNotifications($title, "您的实例已执行{$actionType}操作", $details, 'info', $textMsg, $account['access_key_id']); } /** * 发送流量告警 * @return bool|string */ public function sendTrafficWarning($accessKeyId, $traffic, $percentage, $statusText, $threshold) { $title = "流量告警 - " . $statusText; $trafficText = $this->formatTraffic((float) $traffic); $details = [ ['label' => '账号', 'value' => substr($accessKeyId, 0, 7) . '***'], ['label' => '当前流量', 'value' => $trafficText], ['label' => '使用率', 'value' => $percentage . '%', 'highlight' => true], ['label' => '设定阈值', 'value' => $threshold . '%'], ['label' => '当前状态', 'value' => $statusText] ]; $textMsg = "【ECS 服务器管家】{$title}\n" . "账号: " . substr($accessKeyId, 0, 7) . '***' . "\n" . "当前流量: {$trafficText}\n" . "使用率: {$percentage}%\n" . "设定阈值: {$threshold}%\n" . "当前状态: {$statusText}"; return $this->dispatchNotifications($title, "检测到流量异常或达到阈值", $details, 'warning', $textMsg, $accessKeyId); } public function notifyCredentialInvalid($accessKeyId, $traffic, $percentage, $threshold) { $title = "流量告警 - 账号密钥已失效"; $trafficText = $this->formatTraffic((float) $traffic); $maskedAccount = substr($accessKeyId, 0, 7) . '***'; $statusText = '检测到 AK 已失效,已暂停自动停机'; $details = [ ['label' => '账号', 'value' => $maskedAccount], ['label' => '当前流量', 'value' => $trafficText], ['label' => '使用率', 'value' => $percentage . '%', 'highlight' => true], ['label' => '设定阈值', 'value' => $threshold . '%'], ['label' => '当前状态', 'value' => $statusText], ['label' => '处理建议', 'value' => '请更新 AK 后再恢复自动停机保护。'] ]; $textMsg = "【ECS 服务器管家】{$title}\n" . "账号: {$maskedAccount}\n" . "当前流量: {$trafficText}\n" . "使用率: {$percentage}%\n" . "设定阈值: {$threshold}%\n" . "当前状态: {$statusText}\n" . "处理建议: 请更新 AK 后再恢复自动停机保护。"; return $this->dispatchNotifications($title, "检测到账号密钥失效,已暂停自动停机保护,避免重复失败通知。", $details, 'warning', $textMsg, $accessKeyId); } public function notifyEcsCreated($accountLabel, array $result, array $preview = []) { $title = 'ECS 创建并启动成功'; $instanceId = $result['instanceId'] ?? ''; $publicIp = $result['publicIp'] ?? ''; $loginUser = $result['loginUser'] ?? ''; $loginPassword = $result['loginPassword'] ?? ''; $regionId = $preview['regionId'] ?? ''; $instanceType = $preview['instanceType'] ?? ($result['instanceType'] ?? ''); $instanceName = $preview['instanceName'] ?? ($result['instanceName'] ?? ''); $details = [ ['label' => '账号', 'value' => $accountLabel], ['label' => '实例名称', 'value' => $instanceName ?: '-'], ['label' => '实例编号', 'value' => $instanceId ?: '-', 'highlight' => true], ['label' => '区域', 'value' => $regionId ?: '-'], ['label' => '实例规格', 'value' => $instanceType ?: '-'], ['label' => '公网地址', 'value' => $publicIp ?: '等待阿里云分配'], ['label' => '登录用户', 'value' => $loginUser ?: '-'], ['label' => '初始密码', 'value' => $loginPassword ?: '-', 'highlight' => true], ['label' => '安全提醒', 'value' => '初始密码仅在本次创建完成通知和控制台弹窗展示,请立即保存。'] ]; $textMsg = "【ECS 服务器管家】ECS 创建并启动成功\n" . "账号: {$accountLabel}\n" . "实例名称: " . ($instanceName ?: '-') . "\n" . "实例编号: " . ($instanceId ?: '-') . "\n" . "区域: " . ($regionId ?: '-') . "\n" . "规格: " . ($instanceType ?: '-') . "\n" . "公网地址: " . ($publicIp ?: '等待阿里云分配') . "\n" . "登录用户: " . ($loginUser ?: '-') . "\n" . "初始密码: " . ($loginPassword ?: '-') . "\n" . "安全提醒: 初始密码仅在本次创建完成通知和控制台弹窗展示,请立即保存。"; return $this->dispatchNotifications($title, '实例已创建并启动,请立即保存一次性登录密码。', $details, 'success', $textMsg, $accountLabel); } public function notifyInstanceStatusChanged($accountLabel, array $account, $fromStatus, $toStatus, $reason = '') { $fromLabel = $this->statusLabel($fromStatus); $toLabel = $this->statusLabel($toStatus); $instanceName = $account['instance_name'] ?? ($account['remark'] ?? ''); $instanceId = $account['instance_id'] ?? ''; $region = $account['region_id'] ?? ''; $title = $toStatus === 'Running' ? '实例已启动' : ($toStatus === 'Stopped' ? '实例已停机' : "实例状态变化 - {$toLabel}"); $details = [ ['label' => '账号', 'value' => $accountLabel], ['label' => '实例名称', 'value' => $instanceName ?: '-'], ['label' => '实例编号', 'value' => $instanceId ?: '-', 'highlight' => true], ['label' => '区域', 'value' => $region ?: '-'], ['label' => '原状态', 'value' => $fromLabel], ['label' => '新状态', 'value' => $toLabel, 'highlight' => true], ['label' => '变化时间', 'value' => date('Y-m-d H:i:s')], ['label' => '说明', 'value' => $reason ?: '系统检测到实例运行状态发生变化。'] ]; $textMsg = "【ECS 服务器管家】{$title}\n" . "账号: {$accountLabel}\n" . "实例名称: " . ($instanceName ?: '-') . "\n" . "实例编号: " . ($instanceId ?: '-') . "\n" . "区域: " . ($region ?: '-') . "\n" . "原状态: {$fromLabel}\n" . "新状态: {$toLabel}\n" . "变化时间: " . date('Y-m-d H:i:s') . "\n" . "说明: " . ($reason ?: '系统检测到实例运行状态发生变化。'); return $this->dispatchNotifications($title, '实例已进入最终状态', $details, 'success', $textMsg, $accountLabel); } public function notifyInstanceReleased($accountLabel, array $account, $reason = '') { $instanceName = $account['instance_name'] ?? ($account['remark'] ?? ''); $instanceId = $account['instance_id'] ?? ''; $region = $account['region_id'] ?? ''; $publicIp = $account['public_ip'] ?? ''; $title = '实例已释放'; $details = [ ['label' => '账号', 'value' => $accountLabel], ['label' => '实例名称', 'value' => $instanceName ?: '-'], ['label' => '实例编号', 'value' => $instanceId ?: '-', 'highlight' => true], ['label' => '区域', 'value' => $region ?: '-'], ['label' => '公网地址', 'value' => $publicIp ?: '-'], ['label' => '释放时间', 'value' => date('Y-m-d H:i:s')], ['label' => '说明', 'value' => $reason ?: '实例已从 ECS 控制台释放,本地记录和 DDNS 解析将同步清理。'] ]; $textMsg = "【ECS 服务器管家】实例已释放\n" . "账号: {$accountLabel}\n" . "实例名称: " . ($instanceName ?: '-') . "\n" . "实例编号: " . ($instanceId ?: '-') . "\n" . "区域: " . ($region ?: '-') . "\n" . "公网地址: " . ($publicIp ?: '-') . "\n" . "释放时间: " . date('Y-m-d H:i:s') . "\n" . "说明: " . ($reason ?: '实例已从 ECS 控制台释放,本地记录和 DDNS 解析将同步清理。'); return $this->dispatchNotifications($title, '实例已释放,本地记录和 DDNS 解析将同步清理。', $details, 'warning', $textMsg, $accountLabel); } public function notifyPublicIpChanged($accountLabel, array $account, $oldIp, $newIp, $reason = '') { $instanceName = $account['instance_name'] ?? ($account['remark'] ?? ''); $instanceId = $account['instance_id'] ?? ''; $region = $account['region_id'] ?? ''; $title = '公网 IP 已更换'; $details = [ ['label' => '账号', 'value' => $accountLabel], ['label' => '实例名称', 'value' => $instanceName ?: '-'], ['label' => '实例编号', 'value' => $instanceId ?: '-', 'highlight' => true], ['label' => '区域', 'value' => $region ?: '-'], ['label' => '原公网 IP', 'value' => $oldIp ?: '-'], ['label' => '新公网 IP', 'value' => $newIp ?: '-', 'highlight' => true], ['label' => '变更时间', 'value' => date('Y-m-d H:i:s')], ['label' => '说明', 'value' => $reason ?: '系统已更换托管 EIP,并同步更新 DDNS 解析。'] ]; $textMsg = "【ECS 服务器管家】公网 IP 已更换\n" . "账号: {$accountLabel}\n" . "实例名称: " . ($instanceName ?: '-') . "\n" . "实例编号: " . ($instanceId ?: '-') . "\n" . "区域: " . ($region ?: '-') . "\n" . "原公网 IP: " . ($oldIp ?: '-') . "\n" . "新公网 IP: " . ($newIp ?: '-') . "\n" . "变更时间: " . date('Y-m-d H:i:s') . "\n" . "说明: " . ($reason ?: '系统已更换托管 EIP,并同步更新 DDNS 解析。'); return $this->dispatchNotifications($title, '公网 IP 已成功更换,DDNS 解析已同步更新。', $details, 'success', $textMsg, $accountLabel); } private function statusLabel($status) { $map = [ 'Running' => '已启动', 'Starting' => '启动中', 'Stopping' => '停机中', 'Stopped' => '已停机', 'Pending' => '创建中', 'Released' => '已释放', 'Unknown' => '未知' ]; return $map[$status] ?? ($status ?: '未知'); } public function sendTestEmail($to) { $details = [ ['label' => '测试结果', 'value' => '成功'], ['label' => '发送时间', 'value' => date('Y-m-d H:i:s')], ['label' => '服务器', 'value' => $_SERVER['SERVER_NAME'] ?? 'localhost'] ]; $html = $this->renderEmailTemplate("测试邮件", "邮件服务配置验证成功", $details, 'success'); return $this->sendMail($to, '管理员', 'ECS 服务器管家测试邮件', $html); } public function sendTestTelegram($data) { $textMsg = "【ECS 服务器管理】测试推送\n这是一条来自 Telegram 的测试消息。\n发送时间: " . date('Y-m-d H:i:s'); return $this->sendTelegram($textMsg, $data); } public function sendTestWebhook($data) { $textMsg = "【ECS 服务器管家】测试推送\n这是一条来自接口回调的测试消息。\n发送时间: " . date('Y-m-d H:i:s'); $summary = "这是一条来自接口回调的测试消息。"; $threshold = $this->config['traffic_threshold'] ?? 95; $details = [ ['label' => '当前流量', 'value' => '0 MB'], ['label' => '设定阈值', 'value' => $threshold . '%'] ]; return $this->sendWebhook($textMsg, "测试推送", $summary, $details, 'test_account_id', $data); } private function dispatchNotifications($title, $summary, $details, $type, $textMsg, $accountId = '') { $errors = []; $successCount = 0; $attemptCount = 0; // 邮件通知 if (($this->config['notify_email_enabled'] ?? '1') === '1' && !empty($this->config['notify_email'])) { $attemptCount++; $html = $this->renderEmailTemplate($title, $summary, $details, $type); $res = $this->sendMail($this->config['notify_email'], '', "ECS 服务器管家通知 - " . $title, $html); if ($res === true) $successCount++; else $errors[] = "邮件通知: " . $res; } // Telegram if (($this->config['notify_tg_enabled'] ?? '0') === '1' && !empty($this->config['notify_tg_token']) && !empty($this->config['notify_tg_chat_id'])) { $attemptCount++; $res = $this->sendTelegram($textMsg); if ($res === true) $successCount++; else $errors[] = "Telegram: " . $res; } // 接口回调 if (($this->config['notify_wh_enabled'] ?? '0') === '1' && !empty($this->config['notify_wh_url'])) { $attemptCount++; $res = $this->sendWebhook($textMsg, $title, $summary, $details, $accountId); if ($res === true) $successCount++; else $errors[] = "接口回调: " . $res; } if ($attemptCount == 0) return true; if ($successCount == 0 && count($errors) > 0) { return implode(" | ", $errors); } else if (count($errors) > 0) { return "部分完成: " . implode(" | ", $errors); } return true; } private function renderEmailTemplate($title, $summary, $details, $type = 'info') { $color = '#007AFF'; if ($type === 'warning') $color = '#FF3B30'; if ($type === 'success') $color = '#34C759'; $rows = ''; foreach ($details as $item) { $valColor = isset($item['highlight']) && $item['highlight'] ? $color : '#1C1C1E'; $rows .= " {$item['label']} {$item['value']} "; } return "
ECS 服务器管家

{$title}

{$summary}

{$rows}
© " . date('Y') . " ECS 服务器管家
"; } private function sendMail($to, $name, $subject, $body) { $mail = new PHPMailer(); $mail->CharSet = 'UTF-8'; $mail->IsSMTP(); $mail->SMTPDebug = 0; $mail->SMTPAuth = true; $secure = $this->config['notify_secure'] ?? 'ssl'; if (!empty($secure)) { $mail->SMTPSecure = $secure; } else { $mail->SMTPSecure = ''; $mail->SMTPAutoTLS = false; } $mail->Host = $this->config['notify_host'] ?? ''; $mail->Port = $this->config['notify_port'] ?? 465; $mail->Username = $this->config['notify_username'] ?? ''; $mail->Password = $this->config['notify_password'] ?? ''; $mail->SetFrom($mail->Username, 'ECS 服务器管家'); $mail->Subject = $subject; $mail->MsgHTML($body); $mail->AddAddress($to, $name); // 修改:返回 true 或 错误信息字符串 if ($mail->Send()) { return true; } else { return $mail->ErrorInfo; } } private function sendTelegram($text, $overrideConfig = null) { $token = $overrideConfig['token'] ?? $this->config['notify_tg_token'] ?? ''; $chatId = $overrideConfig['chat_id'] ?? $this->config['notify_tg_chat_id'] ?? ''; $proxyType = $overrideConfig['proxy_type'] ?? $this->config['notify_tg_proxy_type'] ?? 'none'; if (empty($token) || empty($chatId)) return "Telegram 的机器人令牌或接收会话编号为空"; $url = "https://api.telegram.org/bot{$token}/sendMessage"; if ($proxyType === 'custom' && !empty($overrideConfig['proxy_url'] ?? $this->config['notify_tg_proxy_url'] ?? '')) { $baseUrl = rtrim($overrideConfig['proxy_url'] ?? $this->config['notify_tg_proxy_url'], '/'); $url = "{$baseUrl}/bot{$token}/sendMessage"; } $postFields = [ 'chat_id' => $chatId, 'text' => $text ]; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postFields)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 10); if ($proxyType === 'socks5') { $proxyIp = $overrideConfig['proxy_ip'] ?? $this->config['notify_tg_proxy_ip'] ?? ''; $proxyPort = $overrideConfig['proxy_port'] ?? $this->config['notify_tg_proxy_port'] ?? ''; if ($proxyIp && $proxyPort) { curl_setopt($ch, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5_HOSTNAME); curl_setopt($ch, CURLOPT_PROXY, "{$proxyIp}:{$proxyPort}"); $proxyUser = $overrideConfig['proxy_user'] ?? $this->config['notify_tg_proxy_user'] ?? ''; $proxyPass = $overrideConfig['proxy_pass'] ?? $this->config['notify_tg_proxy_pass'] ?? ''; if ($proxyUser || $proxyPass) { curl_setopt($ch, CURLOPT_PROXYUSERPWD, "{$proxyUser}:{$proxyPass}"); } } } $result = curl_exec($ch); $error = curl_error($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($error) return "网络请求错误: " . $error; if ($httpCode != 200) return "接口返回错误 {$httpCode}: " . $result; return true; } private function sendWebhook($text, $title, $summary, $details, $accountId = '', $overrideConfig = null) { $url = $overrideConfig['url'] ?? $this->config['notify_wh_url'] ?? ''; $method = strtoupper($overrideConfig['method'] ?? $this->config['notify_wh_method'] ?? 'GET'); $requestType = strtoupper($overrideConfig['request_type'] ?? $this->config['notify_wh_request_type'] ?? 'JSON'); $headersStr = $overrideConfig['headers'] ?? $this->config['notify_wh_headers'] ?? ''; $bodyTemplate = $overrideConfig['body'] ?? $this->config['notify_wh_body'] ?? ''; if (empty($url)) return "接口回调地址为空"; // Parse variables $traffic = '暂无'; $maxTraffic = '暂无'; foreach ($details as $d) { if ($d['label'] === '当前流量') $traffic = str_replace([' GB', ' MB', ' GB', ' MB'], '', $d['value']); if ($d['label'] === '设定阈值') $maxTraffic = str_replace('%', '', $d['value']); } $replacePairs = [ '#TITLE#' => $title, '#MSG#' => $summary ?: $text, '#ACCOUNT#' => $accountId, '#TRAFFIC#' => $traffic, '#MAX_TRAFFIC#' => $maxTraffic ]; $ch = curl_init(); $customHeaders = []; // Parse custom headers if (!empty($headersStr)) { $parsedHeaders = json_decode($headersStr, true); if (is_array($parsedHeaders)) { foreach ($parsedHeaders as $k => $v) { $customHeaders[] = "{$k}: {$v}"; } } } if ($method === 'GET') { $urlReplacePairs = []; foreach ($replacePairs as $k => $v) { $urlReplacePairs[$k] = urlencode((string) $v); } $finalUrl = strtr($url, $urlReplacePairs); // 读取请求没有请求体时,将默认参数拼到地址上。 if (empty($bodyTemplate) && strpos($finalUrl, '?') === false && strpos($url, '#') === false) { $payload = [ 'title' => $title, 'text' => $text, 'time' => date('Y-m-d H:i:s') ]; $finalUrl .= '?' . http_build_query($payload); } curl_setopt($ch, CURLOPT_URL, $finalUrl); } else { // 发送请求 $urlReplacePairs = []; foreach ($replacePairs as $k => $v) { $urlReplacePairs[$k] = urlencode((string) $v); } curl_setopt($ch, CURLOPT_URL, strtr($url, $urlReplacePairs)); curl_setopt($ch, CURLOPT_POST, true); $finalBody = ''; if (!empty($bodyTemplate)) { $bodyReplacePairs = $replacePairs; if ($requestType === 'JSON') { foreach ($bodyReplacePairs as $k => $v) { // 数据格式安全转义,避免模板变量破坏 JSON 字符串。 $bodyReplacePairs[$k] = substr(json_encode((string) $v, JSON_UNESCAPED_UNICODE), 1, -1); } } else if ($requestType === 'FORM') { foreach ($bodyReplacePairs as $k => $v) { $bodyReplacePairs[$k] = urlencode((string) $v); } } $finalBody = strtr($bodyTemplate, $bodyReplacePairs); // Content Type if ($requestType === 'JSON') { $customHeaders[] = 'Content-Type: application/json'; } else if ($requestType === 'FORM') { $customHeaders[] = 'Content-Type: application/x-www-form-urlencoded'; // 用户误填 JSON 时,尽量转换为表单格式。 $decoded = json_decode($finalBody, true); if (is_array($decoded)) { $finalBody = http_build_query($decoded); } } } else { // 未配置请求体时发送默认内容。 $payload = ['title' => $title, 'text' => $text, 'time' => date('Y-m-d H:i:s')]; if ($requestType === 'JSON') { $finalBody = json_encode($payload, JSON_UNESCAPED_UNICODE); $customHeaders[] = 'Content-Type: application/json'; } else { $finalBody = http_build_query($payload); $customHeaders[] = 'Content-Type: application/x-www-form-urlencoded'; } } curl_setopt($ch, CURLOPT_POSTFIELDS, $finalBody); } if (!empty($customHeaders)) { curl_setopt($ch, CURLOPT_HTTPHEADER, array_unique($customHeaders)); } curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 10); $result = curl_exec($ch); $error = curl_error($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($error) return "网络请求错误: " . $error; if ($httpCode >= 400) return "接口返回错误 {$httpCode}: " . $result; return true; } private function formatTraffic($trafficGb) { $trafficGb = (float) $trafficGb; if ($trafficGb <= 0) { return '0 MB'; } if ($trafficGb < 1) { return round($trafficGb * 1024, 2) . ' MB'; } return round($trafficGb, 2) . ' GB'; } }