Files
ecs-controller/AliyunTrafficCheck.php
2026-01-20 20:46:07 +08:00

584 lines
23 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
require 'vendor/autoload.php';
require_once 'Database.php';
require_once 'ConfigManager.php';
require_once 'AliyunService.php';
require_once 'NotificationService.php';
use AlibabaCloud\Client\Exception\ClientException;
use AlibabaCloud\Client\Exception\ServerException;
class AliyunTrafficCheck
{
private $db;
private $configManager;
private $aliyunService;
private $notificationService;
private $initError = null;
const KEEP_ALIVE_COOLDOWN = 1800;
public function __construct()
{
try {
$this->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),
'Notification' => [
'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',
],
'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']
]
];
}
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;
}
// --- 新增:清空日志 ---
public function clearSystemLogs($tab = 'action')
{
if ($this->initError) return false;
if ($tab === 'heartbeat') {
return $this->db->clearLogsByTypes(['heartbeat']);
} else {
return $this->db->clearLogsByTypes(['info', 'warning', 'error']); // 清空动作日志时连带Error一起清空保持干净
}
}
public function getAccountHistory($id)
{
if ($this->initError) return [];
$account = $this->configManager->getAccountById($id);
if (!$account) return ['error' => 'Account not found'];
$ak = $account['access_key_id'];
$rawHourly = $this->db->getHourlyStats($ak);
$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($ak);
$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;
$this->db->pruneLogs(30);
$this->db->pruneStats();
$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->logMailResult($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->logMailResult($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['access_key_id'], $traffic);
$this->db->addDailyStat($account['access_key_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->logMailResult($mailRes, $account['access_key_id']);
}
}
// 4. 保活逻辑
if ($keepAlive && $account['schedule_enabled'] == 1 && !$isOverThreshold) {
if ($this->isTimeInRange($currentUserTime, $account['start_time'], $account['stop_time'])) {
if ($status === 'Stopped') {
$lastKeepAlive = $account['last_keep_alive_at'] ?? 0;
$timeSinceLast = $currentTime - $lastKeepAlive;
if ($timeSinceLast > self::KEEP_ALIVE_COOLDOWN) {
if ($this->safeControlInstance($account, 'start')) {
$actions[] = "保活启动";
$this->db->addLog('info', "执行保活启动 [{$account['access_key_id']}]");
$mailRes = $this->notificationService->notifySchedule("保活启动", $account, "检测到实例在工作时段非预期关机,已尝试自动启动。");
$this->logMailResult($mailRes, $account['access_key_id']);
$this->configManager->updateLastKeepAlive($account['id'], $currentTime);
$this->configManager->updateAccountStatus($account['id'], $traffic, 'Starting', $currentTime);
$status = 'Starting';
}
} else {
$cooldownLeft = ceil((self::KEEP_ALIVE_COOLDOWN - $timeSinceLast) / 60);
$apiStatusLog .= " [保活冷却:{$cooldownLeft}m]";
}
}
}
}
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);
$currentTime = time();
$accounts = $this->configManager->getAccounts();
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['access_key_id'], $traffic);
$this->db->addDailyStat($account['access_key_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;
$data[] = [
'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)
];
}
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['access_key_id'], $traffic);
$this->db->addDailyStat($targetAccount['access_key_id'], $traffic);
}
return $this->configManager->updateAccountStatus($id, $traffic, $status, $currentTime);
}
public function sendTestEmail($to)
{
return $this->notificationService->sendTestEmail($to);
}
private function logMailResult($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']);
} 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;
}
public function renderTemplate() {
if (!file_exists('template.html')) return "File not found";
ob_start();
include 'template.html';
return ob_get_clean();
}
}