Files
ecs-controller/AliyunTrafficCheck.php
2026-01-20 16:57:10 +08:00

575 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;
}
public function getSystemLogs()
{
if ($this->initError) return [];
$logs = $this->db->getLogs(50);
foreach ($logs as &$log) {
$log['time_str'] = date('Y-m-d H:i:s', $log['created_at']);
}
return $logs;
}
// --- 修改:获取流量历史数据(适配新表结构) ---
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'];
// 1. 获取最近24小时数据 (Hourly)
$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)
];
}
// 2. 获取最近30天数据 (Daily)
$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);
// 特殊检查:是否需要记录统计数据?
// 策略Monitor每分钟运行尝试插入当前整点和当前0点的数据。
// 由于数据库有 UNIQUE 索引 + INSERT OR IGNORE只有每小时/每天第一次尝试会成功。
// 因此,我们不需要复杂的“是否已记录”判断,直接尝试记录即可。
// 但为了减少API调用我们仅在“应该检查API”或者“尚未记录当前小时/天数据”时才调用API
// 简单起见,利用现有的 $shouldCheckApi 逻辑。
// 如果为了保证整点记录的及时性,我们应该每分钟都检查一下是否到了整点?
// 优化:如果当前分钟是 00 (整点),则强制刷新一次,确保整点数据最准确。
if (date('i') === '00') {
$shouldCheckApi = true;
}
$newUpdateTime = $currentTime;
if ($shouldCheckApi) {
// 使用 Safe 方法
$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 = "已更新";
// --- 修改:记录统计数据 ---
// 尝试记录小时数据 (利用DB唯一性去重)
$this->db->addHourlyStat($account['access_key_id'], $traffic);
// 尝试记录天数据 (利用DB唯一性去重)
$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);
$logs[] = sprintf("%s %s | %s | %s | %s", $logPrefix, $actionLog, $trafficDesc, $status, $apiStatusLog);
}
$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) {
// 使用 Safe 方法
$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();
}
}