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

2303 lines
100 KiB
PHP
Raw Permalink 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';
require_once 'DdnsService.php';
require_once 'TelegramControlService.php';
use AlibabaCloud\Client\Exception\ClientException;
use AlibabaCloud\Client\Exception\ServerException;
class AliyunTrafficCheck
{
private $db;
private $configManager;
private $aliyunService;
private $notificationService;
private $ddnsService;
private $initError = null;
public function __construct()
{
try {
$this->db = new Database();
$this->configManager = new ConfigManager($this->db);
$this->aliyunService = new AliyunService();
$this->notificationService = new NotificationService();
$this->ddnsService = new DdnsService($this->configManager->getAllSettings());
// 注入配置到通知服务
$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 getMonitorKey()
{
$key = $this->configManager->get('monitor_key', '');
if (empty($key)) {
$key = bin2hex(random_bytes(32));
$this->configManager->saveMonitorKey($key);
}
return $key;
}
public function getPublicBrand()
{
if ($this->initError) {
return ['logo_url' => ''];
}
return [
'logo_url' => $this->configManager->get('app_logo_url', '')
];
}
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} 尝试次数过多");
throw new Exception("错误次数过多,请 15 分钟后再试。");
}
$adminPass = $this->getAdminPassword();
if (empty($adminPass))
return false;
$passwordValid = false;
if ($this->isPasswordHashed($adminPass)) {
$passwordValid = password_verify($password, $adminPass);
} else {
$passwordValid = hash_equals($adminPass, $password);
if ($passwordValid) {
$this->configManager->upgradePasswordHash($password);
}
}
if ($passwordValid) {
$this->db->clearLoginAttempts($ip);
$this->db->addLog('info', "管理员登录成功 [地址: {$ip}]");
return true;
}
$this->db->recordLoginAttempt($ip);
$this->db->addLog('warning', "管理员登录失败 [地址: {$ip}]");
return false;
}
private function isPasswordHashed($password)
{
return preg_match('/^\$2[aby]?\$/', $password) === 1 || preg_match('/^\$argon2[aid]\$/', $password) === 1;
}
private function getAccountLogLabel($account)
{
$remark = trim((string) ($account['remark'] ?? ''));
if ($remark !== '') {
return $remark;
}
$instanceName = trim((string) ($account['instance_name'] ?? ''));
if ($instanceName !== '') {
return $instanceName;
}
$instanceId = trim((string) ($account['instance_id'] ?? ''));
if ($instanceId !== '') {
return $instanceId;
}
return substr((string) ($account['access_key_id'] ?? ''), 0, 7) . '***';
}
private function resolveSecretFromDatabase($accessKeyId, $regionId, $groupKey = '')
{
$pdo = $this->db->getPdo();
$groupKey = trim((string) $groupKey);
if ($groupKey !== '') {
$stmt = $pdo->prepare("SELECT access_key_secret FROM accounts WHERE group_key = ? LIMIT 1");
$stmt->execute([$groupKey]);
$row = $stmt->fetch();
if ($row && !empty($row['access_key_secret'])) {
$secret = $this->configManager->decryptAccountSecret($row['access_key_secret']);
if (!empty($secret)) {
return $secret;
}
}
}
$stmt = $pdo->prepare("SELECT access_key_secret FROM accounts WHERE access_key_id = ? AND region_id = ? LIMIT 1");
$stmt->execute([$accessKeyId, $regionId]);
$row = $stmt->fetch();
if ($row && !empty($row['access_key_secret'])) {
$secret = $this->configManager->decryptAccountSecret($row['access_key_secret']);
if (!empty($secret)) {
return $secret;
}
}
foreach ($this->configManager->getAccountGroups() as $group) {
if (
(
($groupKey !== '' && ($group['groupKey'] ?? '') === $groupKey)
|| (($group['AccessKeyId'] ?? '') === $accessKeyId && ($group['regionId'] ?? '') === $regionId)
)
&& !empty($group['AccessKeySecret'])
&& $group['AccessKeySecret'] !== '********'
) {
return $group['AccessKeySecret'];
}
}
throw new Exception('无法读取该账号的AK Secret请重新输入后保存');
}
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 uploadLogo(array $file)
{
if ($this->initError) {
return ['success' => false, 'message' => $this->initError];
}
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return ['success' => false, 'message' => 'Logo 上传失败,请重新选择图片'];
}
if (($file['size'] ?? 0) <= 0 || ($file['size'] ?? 0) > 2 * 1024 * 1024) {
return ['success' => false, 'message' => 'Logo 图片大小需小于 2MB'];
}
$tmp = $file['tmp_name'] ?? '';
if ($tmp === '' || !is_uploaded_file($tmp)) {
return ['success' => false, 'message' => 'Logo 文件无效'];
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($tmp);
$allowed = [
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/webp' => 'webp'
];
if (!isset($allowed[$mime])) {
return ['success' => false, 'message' => '仅支持 PNG、JPG、WebP 图片'];
}
$dir = __DIR__ . '/data';
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
return ['success' => false, 'message' => 'Logo 存储目录不可写'];
}
foreach (glob($dir . '/brand-logo.*') ?: [] as $oldFile) {
@unlink($oldFile);
}
$target = $dir . '/brand-logo.' . $allowed[$mime];
if (!@move_uploaded_file($tmp, $target)) {
return ['success' => false, 'message' => 'Logo 保存失败,请检查 data 目录权限'];
}
@chmod($target, 0644);
$url = 'index.php?action=brand_logo&v=' . filemtime($target);
$this->configManager->updateAppLogoUrl($url);
$this->db->addLog('info', '页面 Logo 已更新');
return ['success' => true, 'url' => $url];
}
public function getConfigForFrontend()
{
if ($this->initError)
return [];
$settings = $this->configManager->getAllSettings();
$accountGroups = $this->configManager->getAccountGroups();
$groupMetrics = $this->configManager->getAccountGroupMetrics();
$billingMetrics = $this->getAccountGroupBillingMetrics();
$config = [
'admin_password' => !empty($settings['admin_password']) ? '********' : '',
'admin_password_set' => !empty($settings['admin_password']),
'traffic_threshold' => (int) ($settings['traffic_threshold'] ?? 95),
'shutdown_mode' => $settings['shutdown_mode'] ?? 'KeepCharging',
'threshold_action' => $settings['threshold_action'] ?? 'stop_and_notify',
'keep_alive' => ($settings['keep_alive'] ?? '0') === '1',
'monthly_auto_start' => ($settings['monthly_auto_start'] ?? '0') === '1',
'api_interval' => (int) ($settings['api_interval'] ?? 600),
'enable_billing' => ($settings['enable_billing'] ?? '0') === '1',
'AppBrand' => [
'logo_url' => $settings['app_logo_url'] ?? ''
],
'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' => !empty($settings['notify_password']) ? '********' : '',
'secure' => $settings['notify_secure'] ?? 'ssl',
'telegram' => [
'enabled' => ($settings['notify_tg_enabled'] ?? '0') === '1',
'token' => !empty($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' => !empty($settings['notify_tg_proxy_pass']) ? '********' : '',
'allowed_user_ids' => $settings['notify_tg_allowed_user_ids'] ?? '',
'confirm_ttl' => (int) ($settings['notify_tg_confirm_ttl'] ?? 60)
],
'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'] ?? ''
]
],
'Ddns' => [
'enabled' => ($settings['ddns_enabled'] ?? '0') === '1',
'provider' => $settings['ddns_provider'] ?? 'cloudflare',
'domain' => $settings['ddns_domain'] ?? '',
'cloudflare' => [
'zone_id' => $settings['ddns_cf_zone_id'] ?? '',
'token' => !empty($settings['ddns_cf_token']) ? '********' : '',
'proxied' => ($settings['ddns_cf_proxied'] ?? '0') === '1'
]
],
'Accounts' => []
];
foreach ($accountGroups as $row) {
$metrics = $groupMetrics[$row['groupKey']] ?? [
'usageUsed' => 0,
'usageRemaining' => (float) ($row['maxTraffic'] ?? 0),
'usagePercent' => 0,
'instanceCount' => 0,
'lastUpdated' => 0,
'trafficStatus' => 'ok',
'trafficMessage' => ''
];
$config['Accounts'][] = [
'AccessKeyId' => $row['AccessKeyId'],
'AccessKeySecret' => '********',
'AccessKeySecretSet' => !empty($row['AccessKeySecret']),
'regionId' => $row['regionId'],
'maxTraffic' => (float) $row['maxTraffic'],
'remark' => $row['remark'] ?? '',
'siteType' => $row['siteType'] ?? 'international',
'groupKey' => $row['groupKey'] ?? '',
'scheduleEnabled' => !empty($row['scheduleEnabled']),
'scheduleStartEnabled' => !empty($row['scheduleStartEnabled']),
'scheduleStopEnabled' => !empty($row['scheduleStopEnabled']),
'startTime' => $row['startTime'] ?? '',
'stopTime' => $row['stopTime'] ?? '',
'scheduleBlockedByTraffic' => !empty($row['scheduleBlockedByTraffic']),
'usageUsed' => round((float) ($metrics['usageUsed'] ?? 0), 6),
'usageRemaining' => round((float) ($metrics['usageRemaining'] ?? 0), 6),
'usagePercent' => round((float) ($metrics['usagePercent'] ?? 0), 2),
'instanceCount' => (int) ($metrics['instanceCount'] ?? 0),
'usageLastUpdated' => !empty($metrics['lastUpdated']) ? date('Y-m-d H:i:s', (int) $metrics['lastUpdated']) : '',
'trafficStatus' => $metrics['trafficStatus'] ?? 'ok',
'trafficMessage' => $metrics['trafficMessage'] ?? '',
'billing' => $billingMetrics[$row['groupKey']] ?? [
'enabled' => ($settings['enable_billing'] ?? '0') === '1',
'monthly_cost' => null,
'balance' => null,
'currency' => ($row['siteType'] ?? 'international') === 'international' ? 'USD' : 'CNY',
'last_updated' => null,
'error' => null
]
];
}
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);
$accounts = $this->configManager->getAccounts();
$accessKeyMap = [];
foreach ($accounts as $account) {
$label = $this->getAccountLogLabel($account);
$accessKeyId = trim((string) ($account['access_key_id'] ?? ''));
if ($accessKeyId === '') {
continue;
}
$accessKeyMap[$accessKeyId] = $label;
$accessKeyMap[substr($accessKeyId, 0, 7) . '***'] = $label;
}
foreach ($logs as &$log) {
foreach ($accessKeyMap as $key => $label) {
$log['message'] = str_replace("[$key]", "[$label]", $log['message']);
$log['message'] = str_replace($key, $label, $log['message']);
}
$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'];
// 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 "错误: " . $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 = [];
$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';
$monthlyAutoStart = $this->configManager->get('monthly_auto_start', '0') === '1';
$userInterval = (int) $this->configManager->get('api_interval', 600);
$accounts = $this->configManager->getAccounts();
foreach ($accounts as $account) {
$accountLabel = $this->getAccountLogLabel($account);
$logPrefix = "[{$accountLabel}]";
$accountGroupKey = $account['group_key'] ?: substr(sha1(($account['access_key_id'] ?? '') . '|' . ($account['region_id'] ?? '')), 0, 16);
$actions = [];
$forceRefresh = false;
$protectionSuspended = !empty($account['protection_suspended']);
$protectionSuspendReason = trim((string) ($account['protection_suspend_reason'] ?? ''));
$protectionSuspendNotifiedAt = (int) ($account['protection_suspend_notified_at'] ?? 0);
// 1. 自适应心跳
$lastUpdate = $account['updated_at'] ?? 0;
$cachedStatus = $account['instance_status'] ?? 'Unknown';
$isTransientState = in_array($cachedStatus, ['Starting', 'Stopping', 'Pending', 'Unknown']);
$currentInterval = $isTransientState ? 60 : $userInterval;
$shouldCheckApi = $forceRefresh || (($currentTime - $lastUpdate) > $currentInterval);
if (date('i') === '00') {
$shouldCheckApi = true;
}
$newUpdateTime = $currentTime;
if ($shouldCheckApi) {
$trafficResult = $this->safeGetTraffic($account);
$status = $this->safeGetInstanceStatus($account);
if ($status === 'Unknown') {
usleep(500000);
$status = $this->safeGetInstanceStatus($account);
}
$metadata = [
'traffic_api_status' => $trafficResult['status'] ?? 'ok',
'traffic_api_message' => $trafficResult['message'] ?? ''
];
$authInvalid = $this->isCredentialInvalidTrafficStatus($trafficResult['status'] ?? '');
if ($authInvalid) {
$metadata['protection_suspended'] = 1;
$metadata['protection_suspend_reason'] = 'credential_invalid';
$metadata['protection_suspend_notified_at'] = $protectionSuspendNotifiedAt;
$protectionSuspended = true;
$protectionSuspendReason = 'credential_invalid';
} elseif ($protectionSuspended && $protectionSuspendReason === 'credential_invalid') {
$metadata['protection_suspended'] = 0;
$metadata['protection_suspend_reason'] = '';
$metadata['protection_suspend_notified_at'] = 0;
$protectionSuspended = false;
$protectionSuspendReason = '';
$protectionSuspendNotifiedAt = 0;
$this->db->addLog('info', "账号鉴权已恢复,自动停机保护已重新启用 [{$accountLabel}]");
}
if (empty($trafficResult['success'])) {
$traffic = $account['traffic_used'];
$apiStatusLog = "流量接口异常";
$newUpdateTime = $lastUpdate;
} else {
$traffic = (float) ($trafficResult['value'] ?? 0);
$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->notifyStatusChangeIfNeeded($account, $cachedStatus, $status, '系统同步检测到实例状态变化。');
$this->configManager->updateAccountStatus($account['id'], $traffic, $status, $newUpdateTime, $metadata);
} else {
$traffic = $account['traffic_used'];
$status = $account['instance_status'];
$timeLeft = $currentInterval - ($currentTime - $lastUpdate);
$apiStatusLog = "缓存({$timeLeft}s)";
}
$maxTraffic = $account['max_traffic'];
$accountTraffic = $this->getGroupTrafficUsed($account);
$usagePercent = ($maxTraffic > 0) ? round(($accountTraffic / $maxTraffic) * 100, 2) : 0;
$trafficDesc = "账号出口流量:{$usagePercent}%";
$isOverThreshold = $usagePercent >= $threshold;
$isHardLimitExceeded = $maxTraffic > 0 && $accountTraffic >= $maxTraffic;
$requiresTrafficProtection = $isOverThreshold || $isHardLimitExceeded;
$scheduleBlockedByTraffic = !empty($account['schedule_blocked_by_traffic']);
// 2. 流量熔断
if ($requiresTrafficProtection) {
$trafficDesc .= $isHardLimitExceeded ? "[已超出上限]" : "[接近上限]";
if ($thresholdAction === 'stop_and_notify') {
if ($protectionSuspended && $protectionSuspendReason === 'credential_invalid') {
if ($protectionSuspendNotifiedAt <= 0) {
$actions[] = "账号密钥失效,已暂停自动停机";
$notifyResult = $this->notificationService->notifyCredentialInvalid($accountLabel, $accountTraffic, $usagePercent, $threshold);
$this->logNotificationResult($notifyResult, $accountLabel);
$this->db->addLog('warning', "检测到账号鉴权失效,已暂停自动停机保护 [{$accountLabel}] 当前使用率:{$usagePercent}%");
$protectionSuspendNotifiedAt = $currentTime;
$this->configManager->updateAccountStatus($account['id'], $traffic, $status, $lastUpdate, [
'protection_suspended' => 1,
'protection_suspend_reason' => 'credential_invalid',
'protection_suspend_notified_at' => $protectionSuspendNotifiedAt
]);
} else {
$apiStatusLog .= " [鉴权失效,已暂停自动停机]";
}
} else {
$canAttemptStop = !in_array($status, ['Stopped', 'Stopping', 'Released'], true);
// 达到账号流量上限后必须立即保护,不再等待下一次接口刷新窗口。
if ($canAttemptStop) {
if ($this->safeControlInstance($account, 'stop', $shutdownMode)) {
$previousStatus = $status;
$actions[] = $isHardLimitExceeded ? "已超量自动停机" : "接近上限自动停机";
$this->db->addLog('warning', "账号出口流量达到保护线,已自动停机 [{$accountLabel}] 当前使用率:{$usagePercent}%");
$this->configManager->updateAccountStatus($account['id'], $traffic, 'Stopping', $currentTime);
$this->configManager->updateScheduleBlockedByTrafficForGroup($accountGroupKey, true);
$this->notifyStatusChangeIfNeeded($account, $previousStatus, 'Stopping', '流量达到保护线,已自动停机。');
$status = 'Stopping';
$scheduleBlockedByTraffic = true;
} else {
$actions[] = "自动停机失败";
$this->db->addLog('error', "账号出口流量达到保护线,但自动停机失败 [{$accountLabel}] 当前使用率:{$usagePercent}%");
}
}
}
} elseif ($shouldCheckApi) {
$actions[] = "超量提醒";
$this->db->addLog('warning', "账号出口流量超限触发提醒 [{$accountLabel}] 当前使用率:{$usagePercent}%");
}
if (!empty($actions) && !($protectionSuspended && $protectionSuspendReason === 'credential_invalid')) {
$mailRes = $this->notificationService->sendTrafficWarning($accountLabel, $accountTraffic, $usagePercent, implode(',', $actions), $threshold);
$this->logNotificationResult($mailRes, $accountLabel);
}
}
// 3. 定时开关机:本月一旦触发流量保护就暂停,月初重置或手动恢复后才重新接入。
$scheduleEnabled = !empty($account['schedule_enabled']);
$scheduleStartEnabled = !empty($account['schedule_start_enabled']);
$scheduleStopEnabled = !empty($account['schedule_stop_enabled']);
$startTime = trim((string) ($account['start_time'] ?? ''));
$stopTime = trim((string) ($account['stop_time'] ?? ''));
$today = date('Y-m-d', $currentTime);
if ($scheduleEnabled && !$scheduleBlockedByTraffic && !$requiresTrafficProtection && !in_array($status, ['Starting', 'Stopping', 'Pending', 'Releasing', 'Released'], true)) {
if ($scheduleStopEnabled && $this->shouldRunScheduleAt($currentTime, $stopTime, $account['schedule_last_stop_date'] ?? '')) {
if ($status === 'Running') {
if ($this->safeControlInstance($account, 'stop', $shutdownMode)) {
$actions[] = "定时停机";
$this->db->addLog('info', "执行定时停机 [{$accountLabel}] {$stopTime}");
$this->configManager->updateAccountStatus($account['id'], $traffic, 'Stopping', $currentTime);
$this->configManager->updateScheduleExecutionState($account['id'], 'stop', $today);
$scheduleNotify = $this->notificationService->notifySchedule('定时停机', $account, "已按计划时间 {$stopTime} 执行停机,停机方式沿用系统设置。");
$this->logNotificationResult($scheduleNotify, $accountLabel);
$this->notifyStatusChangeIfNeeded($account, 'Running', 'Stopping', '已按计划执行定时停机。');
$status = 'Stopping';
} else {
$apiStatusLog .= " [定时停机失败]";
}
} else {
$this->configManager->updateScheduleExecutionState($account['id'], 'stop', $today);
}
}
if ($scheduleStartEnabled && $this->shouldRunScheduleAt($currentTime, $startTime, $account['schedule_last_start_date'] ?? '')) {
if ($status === 'Stopped') {
if ($this->safeControlInstance($account, 'start')) {
$actions[] = "定时开机";
$this->db->addLog('info', "执行定时开机 [{$accountLabel}] {$startTime}");
$this->configManager->updateAccountStatus($account['id'], $traffic, 'Starting', $currentTime);
$this->configManager->updateScheduleExecutionState($account['id'], 'start', $today);
$scheduleNotify = $this->notificationService->notifySchedule('定时开机', $account, "已按计划时间 {$startTime} 执行开机。");
$this->logNotificationResult($scheduleNotify, $accountLabel);
$this->notifyStatusChangeIfNeeded($account, 'Stopped', 'Starting', '已按计划执行定时开机。');
$status = 'Starting';
} else {
$apiStatusLog .= " [定时开机失败]";
}
} else {
$this->configManager->updateScheduleExecutionState($account['id'], 'start', $today);
}
}
}
// 4. 每月自动开机:只在每月 1 号执行,且不会启动已经触发流量保护的实例。
$autoStartBlocked = !empty($account['auto_start_blocked']);
if ($monthlyAutoStart && !$autoStartBlocked && !$requiresTrafficProtection && date('j', $currentTime) === '1') {
$lastMonthlyStart = (int) ($account['last_keep_alive_at'] ?? 0);
if ($status === 'Stopped' && !$this->isSameMonth($lastMonthlyStart, $currentTime)) {
if ($this->safeControlInstance($account, 'start')) {
$actions[] = "月初自动开机";
$this->db->addLog('info', "执行月初自动开机 [{$accountLabel}]");
$this->configManager->updateAccountStatus($account['id'], $traffic, 'Starting', $currentTime);
$this->configManager->updateLastKeepAlive($account['id'], $currentTime);
$this->notifyStatusChangeIfNeeded($account, 'Stopped', 'Starting', '每月 1 号自动开机已执行。');
$status = 'Starting';
} else {
$apiStatusLog .= " [月初自动开机失败,下次重试]";
}
}
}
// 5. 保活逻辑
if ($keepAlive && !$autoStartBlocked && !$requiresTrafficProtection) {
if ($status === 'Stopped') {
if ($this->safeControlInstance($account, 'start')) {
$actions[] = "保活启动";
$this->db->addLog('info', "执行保活启动 [{$accountLabel}]");
$mailRes = $this->notificationService->notifySchedule("保活启动", $account, "检测到实例非预期关机,已尝试自动启动。");
$this->logNotificationResult($mailRes, $accountLabel);
$this->configManager->updateAccountStatus($account['id'], $traffic, 'Starting', $currentTime);
$this->configManager->updateLastKeepAlive($account['id'], $currentTime);
$this->notifyStatusChangeIfNeeded($account, 'Stopped', 'Starting', '检测到实例非预期关机,保活已尝试自动启动。');
$status = 'Starting';
} else {
$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());
// 执行异步彻底销毁循环
$this->processPendingReleases();
$this->processTelegramControl();
return implode(PHP_EOL, $logs);
}
private function processTelegramControl()
{
try {
$service = new TelegramControlService($this->db, $this->configManager, $this);
$service->processUpdates();
} catch (\Exception $e) {
$this->db->addLog('error', 'Telegram 控制处理失败: ' . strip_tags($e->getMessage()));
}
}
public function getStatusForFrontend($includeSensitive = false)
{
if ($this->initError)
return ['error' => $this->initError];
$this->configManager->syncAccountGroups();
$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';
$accounts = array_values(array_filter($this->configManager->getAccounts(), function ($account) {
return !empty($account['instance_id']);
}));
foreach ($accounts as $account) {
$data[] = $this->buildInstanceSnapshot($account, $threshold, $userInterval, $billingEnabled, $includeSensitive);
}
$pendingAccounts = $this->configManager->getPendingReleaseAccounts();
foreach ($pendingAccounts as $account) {
$snap = $this->buildInstanceSnapshot($account, $threshold, $userInterval, $billingEnabled, $includeSensitive);
$snap['instanceStatus'] = 'Releasing';
$snap['status'] = 'Releasing';
$snap['operationLocked'] = true;
$snap['operationLockedReason'] = '实例正在释放中,后台队列会继续处理。';
$data[] = $snap;
}
return [
'data' => $data,
'system_last_run' => $this->configManager->getLastRunTime(),
'sync_interval' => $userInterval,
'sensitive_visible' => $includeSensitive
];
}
public function refreshAccount($id)
{
if ($this->initError)
return false;
$targetAccount = $this->configManager->getAccountById($id);
if (!$targetAccount)
return false;
$currentTime = time();
$trafficResult = $this->safeGetTraffic($targetAccount);
$status = $this->safeGetInstanceStatus($targetAccount);
$metadata = [
'traffic_api_status' => $trafficResult['status'] ?? 'ok',
'traffic_api_message' => $trafficResult['message'] ?? ''
];
if ($this->isCredentialInvalidTrafficStatus($trafficResult['status'] ?? '')) {
$metadata['protection_suspended'] = 1;
$metadata['protection_suspend_reason'] = 'credential_invalid';
} else {
$metadata['protection_suspended'] = 0;
$metadata['protection_suspend_reason'] = '';
$metadata['protection_suspend_notified_at'] = 0;
}
if (empty($trafficResult['success'])) {
$traffic = $targetAccount['traffic_used'];
} else {
$traffic = (float) ($trafficResult['value'] ?? 0);
$this->db->addHourlyStat($targetAccount['id'], $traffic);
$this->db->addDailyStat($targetAccount['id'], $traffic);
}
$this->notifyStatusChangeIfNeeded($targetAccount, $targetAccount['instance_status'] ?? 'Unknown', $status, '手动同步检测到实例状态变化。');
$this->configManager->updateAccountStatus($id, $traffic, $status, $currentTime, $metadata);
// 刷新账单数据:仅在启用费用监控 且 无有效缓存时调用 费用中心 接口
$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();
}
}
}
}
$response = [
'success' => true,
'traffic_status' => $trafficResult['status'] ?? 'ok',
'traffic_message' => $trafficResult['message'] ?? ''
];
if ($billingError) {
$this->db->addLog('warning', "账单刷新异常 [{$this->getAccountLogLabel($targetAccount)}]: {$billingError}");
$response['billing_error'] = $billingError;
}
return $response;
}
public function fetchInstances($accessKeyId, $accessKeySecret, $regionId = '')
{
if ($this->initError) {
throw new Exception($this->initError);
}
if (empty($accessKeyId) || empty($accessKeySecret)) {
throw new Exception('请先填写AK ID和AK Secret');
}
try {
$instances = $this->aliyunService->getInstances($accessKeyId, $accessKeySecret, $regionId ?: null);
$maskedKey = substr($accessKeyId, 0, 7) . '***';
$this->db->addLog('info', "实例列表获取成功 [{$maskedKey}] 共 " . count($instances) . "");
return $instances;
} catch (ClientException $e) {
$this->db->addLog('warning', "实例列表获取失败: 鉴权错误");
throw new Exception('阿里云鉴权失败请检查AK权限或密钥是否正确');
} catch (ServerException $e) {
$this->db->addLog('warning', "实例列表获取失败: " . $e->getErrorCode() . " - " . strip_tags($e->getErrorMessage()));
throw new Exception('阿里云接口错误 [' . $e->getErrorCode() . ']: ' . $e->getErrorMessage());
} catch (\Exception $e) {
$this->db->addLog('warning', "实例列表获取失败: " . strip_tags($e->getMessage()));
throw $e;
}
}
public function testAccountCredentials($account)
{
if ($this->initError) {
throw new Exception($this->initError);
}
$accessKeyId = trim((string) ($account['AccessKeyId'] ?? ''));
$accessKeySecret = trim((string) ($account['AccessKeySecret'] ?? ''));
$regionId = trim((string) ($account['regionId'] ?? ''));
$maxTraffic = (float) ($account['maxTraffic'] ?? 0);
$accountLabel = trim((string) ($account['remark'] ?? '')) ?: (substr($accessKeyId, 0, 7) . '***');
if ($accessKeyId === '' || $accessKeySecret === '' || $regionId === '') {
throw new Exception('请先填写完整的AK、区域和账号流量');
}
if ($accessKeySecret === '********') {
$accessKeySecret = $this->resolveSecretFromDatabase($accessKeyId, $regionId, $account['groupKey'] ?? '');
}
try {
$regions = $this->aliyunService->getRegions($accessKeyId, $accessKeySecret);
$regionIds = array_column($regions, 'regionId');
if (!in_array($regionId, $regionIds, true)) {
throw new Exception('当前AK无法访问所选区域请检查权限范围');
}
$instances = $this->aliyunService->getInstances($accessKeyId, $accessKeySecret);
$regionInstances = array_values(array_filter($instances, function ($instance) use ($regionId) {
return ($instance['regionId'] ?? '') === $regionId;
}));
$instanceCount = count($regionInstances);
$monitorWarning = '';
$monitorChecked = false;
if (!empty($regionInstances)) {
$probe = $regionInstances[0];
$endMs = (int) (floor((time() - 90) / 60) * 60 * 1000);
$startMs = max($endMs - (10 * 60 * 1000), 0);
try {
$this->aliyunService->getInstanceOutboundTrafficDelta([
'access_key_id' => $accessKeyId,
'access_key_secret' => $accessKeySecret,
'instance_id' => $probe['instanceId'] ?? '',
'public_ip' => $probe['publicIp'] ?? ''
], $startMs, $endMs);
$monitorChecked = true;
} catch (\Exception $metricException) {
$monitorWarning = '云监控流量探测未通过:' . strip_tags($metricException->getMessage());
$this->db->addLog('warning', "账号云监控探测异常 [{$accountLabel}]: {$monitorWarning}");
}
}
$trafficUsed = (float) ($account['usageUsed'] ?? 0);
$trafficRemaining = max(round($maxTraffic - $trafficUsed, 2), 0);
$trafficPercent = $maxTraffic > 0 ? min(round(($trafficUsed / $maxTraffic) * 100, 2), 100) : 0;
$this->db->addLog('info', "账号测试成功 [{$accountLabel}] {$regionId} 实例 {$instanceCount}");
$message = 'AK可用ECS API已接通';
if ($monitorWarning !== '') {
$message .= '' . $monitorWarning;
} elseif ($monitorChecked) {
$message .= ',云监控 接口 已接通';
} else {
$message .= ';当前区域暂无实例,未执行云监控流量探测';
}
return [
'success' => true,
'message' => $message,
'monitorWarning' => $monitorWarning,
'monitorStatus' => $monitorWarning !== '' ? 'warning' : ($monitorChecked ? 'ok' : 'skipped'),
'monitorMessage' => $monitorWarning !== '' ? $monitorWarning : ($monitorChecked ? '云监控接口已接通,可获取实例流量。' : '当前区域暂无实例,未执行云监控流量探测'),
'usageUsed' => $trafficUsed,
'usageRemaining' => $trafficRemaining,
'usagePercent' => $trafficPercent,
'instanceCount' => $instanceCount
];
} catch (ClientException $e) {
$message = '鉴权失败请检查AK ID和AK Secret是否正确或确认是否具备ECS 权限';
$this->db->addLog('warning', "账号测试失败: {$message}");
throw new Exception($message);
} catch (ServerException $e) {
$message = '阿里云接口错误 [' . $e->getErrorCode() . ']: ' . $e->getErrorMessage();
$this->db->addLog('warning', "账号测试失败: {$message}");
throw new Exception($message);
} catch (Exception $e) {
$this->db->addLog('warning', "账号测试失败: " . strip_tags($e->getMessage()));
throw $e;
}
}
public function previewEcsCreate($data)
{
if ($this->initError) {
throw new Exception($this->initError);
}
$groupKey = trim((string) ($data['accountGroupKey'] ?? ''));
if ($groupKey === '') {
throw new Exception('请选择用于创建 ECS 的账号');
}
$account = $this->resolveAccountGroupForCreate($groupKey, $data['regionId'] ?? '');
$preview = $this->aliyunService->buildEcsCreatePreview($account, $data, $this->detectClientPublicIp());
$previewId = 'preview_' . bin2hex(random_bytes(12));
$this->db->addLog('info', "ECS 创建预检完成 [{$preview['account']['label']}] {$preview['regionId']} {$preview['instanceType']}");
return [
'success' => true,
'previewId' => $previewId,
'summary' => $preview,
'pricing' => $preview['pricing'],
'warnings' => $preview['warnings']
];
}
public function getEcsDiskOptions($data)
{
if ($this->initError) {
throw new Exception($this->initError);
}
$groupKey = trim((string) ($data['accountGroupKey'] ?? ''));
if ($groupKey === '') {
throw new Exception('请选择用于创建 ECS 的账号');
}
$account = $this->resolveAccountGroupForCreate($groupKey, $data['regionId'] ?? '');
return [
'success' => true,
'data' => $this->aliyunService->getAvailableSystemDiskOptions($account, $data)
];
}
public function createEcsFromPreview($previewId, array $preview)
{
if ($this->initError) {
throw new Exception($this->initError);
}
if (empty($preview['account']['groupKey'])) {
throw new Exception('创建预检已失效,请重新预检');
}
$groupKey = $preview['account']['groupKey'];
$account = $this->resolveAccountGroupForCreate($groupKey, $preview['regionId'] ?? '');
$taskId = 'ecs_' . bin2hex(random_bytes(10));
// 创建新 ECS 不应顺手拉起客户已有的停机实例。先把当前已停机实例视为“有意停机”,保活逻辑会跳过它们。
$this->configManager->blockCurrentlyStoppedInstances();
$this->db->createEcsCreateTask(
$taskId,
$previewId,
$groupKey,
$preview['regionId'],
$preview['instanceType'],
$preview
);
$progress = function ($step) use ($taskId) {
$this->db->updateEcsCreateTask($taskId, ['step' => $step]);
};
try {
$result = $this->aliyunService->createManagedEcsFromPreview($account, $preview, $progress);
$this->db->updateEcsCreateTask($taskId, [
'zone_id' => $preview['zoneId'] ?? '',
'image_id' => $preview['imageId'] ?? '',
'os_label' => $preview['osLabel'] ?? '',
'instance_name' => $preview['instanceName'] ?? '',
'vpc_id' => $result['vpcId'] ?? '',
'vswitch_id' => $result['vswitchId'] ?? '',
'security_group_id' => $result['securityGroupId'] ?? '',
'internet_max_bandwidth_out' => $result['internetMaxBandwidthOut'] ?? 0,
'system_disk_category' => $result['systemDiskCategory'] ?? '',
'system_disk_size' => $result['systemDiskSize'] ?? 0,
'instance_id' => $result['instanceId'] ?? '',
'public_ip' => $result['publicIp'] ?? '',
'public_ip_mode' => $result['publicIpMode'] ?? 'ecs_public_ip',
'eip_allocation_id' => $result['eipAllocationId'] ?? '',
'eip_address' => $result['eipAddress'] ?? '',
'eip_managed' => !empty($result['eipManaged']) ? 1 : 0,
'login_user' => $result['loginUser'] ?? '',
'login_password' => '',
'status' => 'success',
'step' => '创建完成'
]);
$this->configManager->syncAccountGroups(true);
$this->configManager->load();
$createdAccount = $this->configManager->getAccountByInstanceId($result['instanceId'] ?? '');
if ($createdAccount && (($result['publicIpMode'] ?? '') === 'eip')) {
$this->configManager->updateAccountNetworkMetadata($createdAccount['id'], [
'public_ip' => $result['publicIp'] ?? '',
'public_ip_mode' => 'eip',
'eip_allocation_id' => $result['eipAllocationId'] ?? '',
'eip_address' => $result['eipAddress'] ?? '',
'eip_managed' => 1,
'internet_max_bandwidth_out' => $result['internetMaxBandwidthOut'] ?? 0
]);
}
$this->syncDdnsForAccounts($this->configManager->getAccounts(), "ECS 创建后");
$this->db->addLog('info', "一键创建 ECS成功 [{$this->getAccountLogLabel($account)}] {$result['instanceId']} {$preview['instanceType']} {$preview['regionId']} {$result['internetMaxBandwidthOut']}Mbps");
$notifyResult = $this->notificationService->notifyEcsCreated($this->getAccountLogLabel($account), $result, $preview);
$this->logNotificationResult($notifyResult, $this->getAccountLogLabel($account));
return [
'success' => true,
'taskId' => $taskId,
'data' => $result
];
} catch (Exception $e) {
$this->db->updateEcsCreateTask($taskId, [
'status' => 'failed',
'step' => '创建失败',
'error_message' => strip_tags($e->getMessage())
]);
$this->db->addLog('error', "一键创建 ECS 失败 [{$this->getAccountLogLabel($account)}]: " . strip_tags($e->getMessage()));
throw $e;
}
}
public function syncAccountGroup($groupKey)
{
if ($this->initError) {
throw new Exception($this->initError);
}
$groupKey = trim((string) $groupKey);
if ($groupKey === '') {
throw new Exception('缺少账号组标识');
}
$groups = $this->configManager->getAccountGroups();
$targetGroup = null;
foreach ($groups as $group) {
if (($group['groupKey'] ?? '') === $groupKey) {
$targetGroup = $group;
break;
}
}
if (!$targetGroup) {
throw new Exception('账号组不存在,请刷新页面后重试');
}
// syncAccountGroups reconciles the full configured set, so use all groups here
// and filter refresh work to the clicked group afterwards.
$accountsBeforeSync = $this->configManager->getAccounts();
$this->configManager->syncAccountGroups(true);
$this->configManager->load();
$threshold = (int) ($this->configManager->get('traffic_threshold', 95) ?? 95);
$userInterval = (int) ($this->configManager->get('api_interval', 600) ?? 600);
$billingEnabled = $this->configManager->get('enable_billing', '0') === '1';
$instanceCount = 0;
foreach ($this->configManager->getAccounts() as $account) {
$accountGroupKey = $account['group_key'] ?: substr(sha1($account['access_key_id'] . '|' . $account['region_id']), 0, 16);
if ($accountGroupKey !== $groupKey || empty($account['instance_id'])) {
continue;
}
$this->buildInstanceSnapshot($account, $threshold, $userInterval, $billingEnabled, true, true);
$instanceCount++;
}
if ($billingEnabled) {
$this->getAccountGroupBillingMetrics(true);
}
$this->configManager->load();
$syncedAccounts = array_values(array_filter($this->configManager->getAccounts(), function ($account) use ($groupKey) {
$accountGroupKey = $account['group_key'] ?: substr(sha1($account['access_key_id'] . '|' . $account['region_id']), 0, 16);
return $accountGroupKey === $groupKey && !empty($account['instance_id']);
}));
$this->reconcileDdnsAfterAccountSync($accountsBeforeSync, $this->configManager->getAccounts(), '账号同步');
$this->db->addLog('info', "账号同步完成 [{$targetGroup['remark']}] {$targetGroup['regionId']} 实例 {$instanceCount}");
$trafficIssue = $this->summarizeTrafficIssueForAccounts($syncedAccounts);
$message = "已同步 {$instanceCount} 台实例,流量和消费情况已刷新";
if ($trafficIssue !== '') {
$message .= '' . $trafficIssue;
}
return [
'success' => true,
'message' => $message,
'instanceCount' => $instanceCount,
'trafficIssue' => $trafficIssue
];
}
public function restoreScheduleAfterTrafficBlock($groupKey)
{
if ($this->initError) {
throw new Exception($this->initError);
}
$groupKey = trim((string) $groupKey);
if ($groupKey === '') {
throw new Exception('缺少账号组标识');
}
$groups = $this->configManager->getAccountGroups();
$targetGroup = null;
foreach ($groups as $group) {
if (($group['groupKey'] ?? '') === $groupKey) {
$targetGroup = $group;
break;
}
}
if (!$targetGroup) {
throw new Exception('账号组不存在,请刷新页面后重试');
}
$this->configManager->restoreScheduleAfterTrafficBlock($groupKey);
$this->db->addLog('info', "已手动恢复定时开关机 [{$targetGroup['remark']}] {$targetGroup['regionId']}");
return [
'success' => true,
'message' => '定时开关机已恢复。请确认本月流量未继续超过阈值,否则下一轮监控仍会触发保护。'
];
}
private function summarizeTrafficIssueForAccounts(array $accounts)
{
if (empty($accounts)) {
return '';
}
$statuses = [];
foreach ($accounts as $account) {
$status = trim((string) ($account['traffic_api_status'] ?? 'ok'));
if ($status !== '' && $status !== 'ok') {
$statuses[$status] = true;
}
}
if (empty($statuses)) {
return '';
}
if (isset($statuses['permission_denied'])) {
return '部分实例缺少云监控权限,请补充 AliyunCloudMonitorMetricDataReadOnlyAccess';
}
if (isset($statuses['auth_error'])) {
return '部分实例云监控鉴权失败,请检查 AK 权限配置';
}
if (isset($statuses['timeout'])) {
return '部分实例云监控请求超时,请稍后重试';
}
return '部分实例流量同步失败,请稍后重试';
}
public function getEcsCreateTask($taskId)
{
if ($this->initError) {
return null;
}
return $this->db->getEcsCreateTask($taskId);
}
private function resolveAccountGroupForCreate($groupKey, $regionId = '')
{
$groups = $this->configManager->getAccountGroups();
foreach ($groups as $group) {
if (($group['groupKey'] ?? '') !== $groupKey) {
continue;
}
$resolvedRegion = trim((string) $regionId) ?: ($group['regionId'] ?? '');
return [
'id' => 0,
'access_key_id' => $group['AccessKeyId'],
'access_key_secret' => $group['AccessKeySecret'],
'region_id' => $resolvedRegion,
'group_key' => $group['groupKey'],
'remark' => $group['remark'] ?? '',
'site_type' => $group['siteType'] ?? 'international',
'max_traffic' => (float) ($group['maxTraffic'] ?? 200),
'instance_id' => '',
'instance_name' => ''
];
}
throw new Exception('未找到对应账号,请先在账号管理中保存账号');
}
private function detectClientPublicIp()
{
$candidates = [];
foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'] as $key) {
if (!empty($_SERVER[$key])) {
$candidates[] = trim((string) $_SERVER[$key]);
}
}
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
foreach (explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']) as $item) {
$candidates[] = trim($item);
}
}
foreach ($candidates as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return $ip;
}
}
$context = stream_context_create(['http' => ['timeout' => 3]]);
$externalIp = @file_get_contents('https://api.ipify.org', false, $context);
$externalIp = trim((string) $externalIp);
if (filter_var($externalIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return $externalIp;
}
return '';
}
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 notifyStatusChangeIfNeeded($account, $fromStatus, $toStatus, $reason = '')
{
$fromStatus = (string) ($fromStatus ?: 'Unknown');
$toStatus = (string) ($toStatus ?: 'Unknown');
// 核心过滤:
// 1. 状态未变则不通知
// 2. 只通知进入稳定态Running/Stopped的变化
// 3. 过滤瞬态跳转,例如从 Starting 到 Running 是预期行为,但如果是在同步中由于 API 抖动导致的跳变则需谨慎
if ($fromStatus === $toStatus || !in_array($toStatus, ['Running', 'Stopped'], true)) {
return;
}
// 初次发现 (Unknown) 不通知,避免重启程序时大量刷屏
// ECS 创建完成后的首次状态同步不通过此逻辑通知(已有专门的 notifyEcsCreated
if ($fromStatus === 'Unknown' || $this->isRecentlyCreatedInstance($account)) {
return;
}
// 避免从过渡态到其目标态的冗余通知
// 例如:刚刚手动触发了 Start状态变为了 Starting然后 API 检测到 Running。
// 这时通常用户已经在界面看到了,或者已有操作成功的提示,可根据需要决定是否通知。
// 这里保留过渡态到稳定态的通知,但过滤从一个稳定态快速切换到另一个稳定态(如通过脚本极速重启)时的中间干扰。
$accountLabel = $this->getAccountLogLabel($account);
$result = $this->notificationService->notifyInstanceStatusChanged($accountLabel, $account, $fromStatus, $toStatus, $reason);
$this->logNotificationResult($result, $accountLabel);
}
private function isRecentlyCreatedInstance(array $account)
{
$instanceId = trim((string) ($account['instance_id'] ?? ''));
if ($instanceId === '') {
return false;
}
try {
$stmt = $this->db->getPdo()->prepare("
SELECT updated_at
FROM ecs_create_tasks
WHERE instance_id = ?
AND status = 'success'
ORDER BY updated_at DESC
LIMIT 1
");
$stmt->execute([$instanceId]);
$updatedAt = (int) $stmt->fetchColumn();
return $updatedAt > 0 && (time() - $updatedAt) < 900;
} catch (Exception $e) {
return false;
}
}
private function isSameMonth($timestamp, $currentTime)
{
if (empty($timestamp)) {
return false;
}
return date('Y-m', (int) $timestamp) === date('Y-m', (int) $currentTime);
}
private function shouldRunScheduleAt($currentTime, $targetTime, $lastRunDate)
{
$targetTime = trim((string) $targetTime);
if ($targetTime === '' || !preg_match('/^\d{2}:\d{2}$/', $targetTime)) {
return false;
}
$today = date('Y-m-d', $currentTime);
return date('H:i', $currentTime) === $targetTime && (string) $lastRunDate !== $today;
}
private function isCredentialInvalidTrafficStatus($status)
{
return trim((string) $status) === 'auth_error';
}
private function isCredentialInvalidError($code, $message = '')
{
$normalizedCode = strtolower(trim((string) $code));
$normalizedMessage = strtolower(trim((string) $message));
if ($normalizedCode === '') {
return false;
}
$credentialErrorCodes = [
'invalidaccesskeyid.notfound',
'invalidaccesskeyid',
'signaturedoesnotmatch',
'incompletesignature',
'forbidden.accesskeydisabled',
'invalidsecuritytoken.expired',
'invalidsecuritytoken.malformed',
'missingsecuritytoken'
];
if (in_array($normalizedCode, $credentialErrorCodes, true)) {
return true;
}
if ($normalizedMessage === '') {
return false;
}
return strpos($normalizedMessage, 'access key is not found') !== false
|| strpos($normalizedMessage, 'access key id does not exist') !== false
|| strpos($normalizedMessage, 'signature does not match') !== false
|| strpos($normalizedMessage, 'incomplete signature') !== false
|| strpos($normalizedMessage, 'accesskeydisabled') !== false;
}
private function safeGetTraffic($account)
{
try {
return [
'success' => true,
'value' => $this->getMeteredOutboundTraffic($account),
'status' => 'ok',
'message' => ''
];
} catch (ClientException $e) {
$code = trim((string) $e->getErrorCode());
$message = '缺少云监控权限';
$status = 'permission_denied';
if ($this->isCredentialInvalidError($code, $e->getMessage())) {
$message = '账号 AK 已失效';
$status = 'auth_error';
} elseif ($code !== '' && !in_array($code, ['403', 'NoPermission'], true)) {
$message = '云监控鉴权失败';
$status = 'auth_error';
}
$this->db->addLog('error', "公网出口流量查询配置错误 [{$this->getAccountLogLabel($account)}]: " . ($code ?: "鉴权失败") . "请确认AK拥有云监控流量查询权限");
return ['success' => false, 'value' => null, 'status' => $status, 'message' => $message];
} catch (ServerException $e) {
$code = trim((string) $e->getErrorCode());
if ($this->isCredentialInvalidError($code, $e->getErrorMessage())) {
$this->db->addLog('error', "公网出口流量查询失败 [{$this->getAccountLogLabel($account)}]: {$code} - " . $e->getErrorMessage());
return ['success' => false, 'value' => null, 'status' => 'auth_error', 'message' => '账号 AK 已失效'];
}
$this->db->addLog('error', "公网出口流量查询失败 [{$this->getAccountLogLabel($account)}]: " . $e->getErrorCode() . " - " . $e->getErrorMessage());
return ['success' => false, 'value' => null, 'status' => 'sync_error', 'message' => '云监控接口异常'];
} catch (\Exception $e) {
if (strpos($e->getMessage(), 'cURL error') !== false) {
$this->db->addLog('error', "公网出口流量查询失败 [{$this->getAccountLogLabel($account)}]: 网络连接超时");
return ['success' => false, 'value' => null, 'status' => 'timeout', 'message' => '云监控请求超时'];
}
$this->db->addLog('error', "公网出口流量查询失败 [{$this->getAccountLogLabel($account)}]: " . strip_tags($e->getMessage()));
return ['success' => false, 'value' => null, 'status' => 'sync_error', 'message' => '流量同步失败'];
}
}
private function getGroupTrafficUsed($account)
{
$pdo = $this->db->getPdo();
$groupKey = trim((string) ($account['group_key'] ?? ''));
$billingMonth = date('Y-m');
if ($groupKey !== '') {
$stmt = $pdo->prepare("SELECT COALESCE(SUM(traffic_used), 0) FROM accounts WHERE group_key = ? AND traffic_billing_month = ?");
$stmt->execute([$groupKey, $billingMonth]);
return (float) $stmt->fetchColumn();
}
$stmt = $pdo->prepare("SELECT COALESCE(SUM(traffic_used), 0) FROM accounts WHERE access_key_id = ? AND region_id = ? AND traffic_billing_month = ?");
$stmt->execute([$account['access_key_id'] ?? '', $account['region_id'] ?? '', $billingMonth]);
return (float) $stmt->fetchColumn();
}
private function getMeteredOutboundTraffic($account)
{
if (empty($account['id']) || empty($account['instance_id'])) {
throw new Exception('缺少账号 ID 或 Instance ID无法按实例统计公网出口流量');
}
$billingMonth = date('Y-m');
$monthStartMs = strtotime($billingMonth . '-01 00:00:00') * 1000;
$record = $this->db->getInstanceTrafficUsage($account['id'], $account['instance_id'], $billingMonth);
$trafficBytes = $record ? (float) ($record['traffic_bytes'] ?? 0) : 0.0;
$lastSampleMs = $record ? (int) ($record['last_sample_ms'] ?? 0) : 0;
if ($lastSampleMs < $monthStartMs) {
$lastSampleMs = $monthStartMs;
$trafficBytes = 0.0;
}
// 云监控分钟点有轻微延迟,只同步到上一个完整分钟,避免把未收敛的数据点算进去。
$safeEndSeconds = max(strtotime($billingMonth . '-01 00:00:00'), time() - 90);
$endMs = (int) (floor($safeEndSeconds / 60) * 60 * 1000);
if ($endMs > $lastSampleMs) {
$delta = $this->aliyunService->getInstanceOutboundTrafficDelta($account, $lastSampleMs, $endMs);
$trafficBytes += (float) ($delta['bytes'] ?? 0);
$lastSampleMs = max($lastSampleMs, (int) ($delta['lastSampleMs'] ?? $lastSampleMs));
}
$this->db->upsertInstanceTrafficUsage(
(int) $account['id'],
$account['instance_id'],
$billingMonth,
$trafficBytes,
$lastSampleMs
);
return $trafficBytes / 1024 / 1024 / 1024;
}
private function safeGetInstanceStatus($account)
{
try {
return $this->aliyunService->getInstanceStatus($account);
} catch (\Exception $e) {
return 'Unknown';
}
}
private function safeGetInstanceFullStatus($account)
{
try {
return $this->aliyunService->getInstanceFullStatus($account);
} catch (\Exception $e) {
return null;
}
}
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}]: " . $e->getErrorCode() . " - " . $e->getErrorMessage());
return false;
} catch (\Exception $e) {
$this->db->addLog('error', "实例操作失败 [{$action}]: 无法连接接口");
return false;
}
}
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'] = '费用中心权限不足';
} else {
$costInfo['error'] = '账单查询失败';
}
}
}
}
$costInfo['last_updated'] = date('Y-m-d H:i:s');
return $costInfo;
}
private function getAccountGroupBillingMetrics($forceRefresh = false)
{
if ($this->configManager->get('enable_billing', '0') !== '1') {
return [];
}
$billingCycle = date('Y-m');
$groups = $this->configManager->getAccountGroups();
$accounts = $this->configManager->getAccounts();
$accountsByGroup = [];
foreach ($accounts as $account) {
$groupKey = $account['group_key'] ?: ($account['access_key_id'] . '@' . $account['region_id']);
if (!isset($accountsByGroup[$groupKey])) {
$accountsByGroup[$groupKey] = [];
}
$accountsByGroup[$groupKey][] = $account;
}
$metrics = [];
foreach ($groups as $group) {
$groupKey = $group['groupKey'] ?? '';
$row = $accountsByGroup[$groupKey][0] ?? null;
$currency = ($group['siteType'] ?? 'international') === 'international' ? 'USD' : 'CNY';
$summary = [
'enabled' => true,
'monthly_cost' => null,
'balance' => null,
'currency' => $currency,
'last_updated' => null,
'error' => null
];
if (!$row) {
$summary['error'] = '尚未同步实例';
$metrics[$groupKey] = $summary;
continue;
}
try {
$balanceCache = $forceRefresh ? null : $this->db->getBillingCache($row['id'], 'balance', '', 21600);
if ($balanceCache) {
$summary['balance'] = $balanceCache['AvailableAmount'] ?? null;
$summary['currency'] = $balanceCache['Currency'] ?? $currency;
} else {
$balance = $this->aliyunService->getAccountBalance(
$row['access_key_id'],
$row['access_key_secret'],
$row['site_type'] ?? ($group['siteType'] ?? 'international')
);
$summary['balance'] = $balance['AvailableAmount'] ?? null;
$summary['currency'] = $balance['Currency'] ?? $currency;
$this->db->setBillingCache($row['id'], 'balance', '', $balance);
}
} catch (\Exception $e) {
$summary['error'] = '余额查询失败';
}
try {
$overviewCache = $forceRefresh ? null : $this->db->getBillingCache($row['id'], 'bill_overview', $billingCycle, 21600);
if ($overviewCache) {
$summary['monthly_cost'] = $overviewCache['TotalCost'] ?? null;
} else {
$overview = $this->aliyunService->getBillOverview(
$row['access_key_id'],
$row['access_key_secret'],
$billingCycle,
$row['site_type'] ?? ($group['siteType'] ?? 'international')
);
$summary['monthly_cost'] = $overview['TotalCost'] ?? null;
$this->db->setBillingCache($row['id'], 'bill_overview', $billingCycle, $overview);
}
} catch (\Exception $e) {
$summary['error'] = $summary['error'] ? '费用中心权限不足' : '账单查询失败';
}
$summary['last_updated'] = date('Y-m-d H:i:s');
$metrics[$groupKey] = $summary;
}
return $metrics;
}
public function controlInstanceAction($accountId, $action, $shutdownMode = 'KeepCharging', $waitForSync = true)
{
if ($this->initError)
return false;
$targetAccount = $this->configManager->getAccountById($accountId);
if (!$targetAccount)
return false;
try {
$result = $this->aliyunService->controlInstance($targetAccount, $action, $shutdownMode);
if ($result) {
$this->db->addLog('info', "实例操作 [{$action}] 成功 [{$this->getAccountLogLabel($targetAccount)}] {$targetAccount['instance_id']}");
$newStatus = $action === 'stop' ? 'Stopping' : 'Starting';
$this->configManager->updateAccountStatus($accountId, $targetAccount['traffic_used'], $newStatus, time());
$this->configManager->updateAutoStartBlocked($accountId, $action === 'stop');
if ($action === 'start' && $waitForSync) {
sleep(8);
$this->configManager->syncAccountGroups(true);
$this->configManager->load();
$syncedAccount = $this->configManager->getAccountById($accountId);
if (($syncedAccount['instance_status'] ?? '') === 'Running') {
$this->notifyStatusChangeIfNeeded($syncedAccount, $targetAccount['instance_status'] ?? 'Unknown', 'Running', '用户手动启动成功。');
}
$this->syncDdnsForAccounts($this->configManager->getAccounts(), '实例启动后');
}
}
return true;
} catch (ClientException $e) {
$this->db->addLog('error', "实例操作失败 [{$action}]: 权限不足或配置错误");
return false;
} catch (ServerException $e) {
$this->db->addLog('error', "实例操作失败 [{$action}]: " . $e->getErrorCode() . " - " . $e->getErrorMessage());
return false;
} catch (\Exception $e) {
$this->db->addLog('error', "实例操作失败 [{$action}]: 无法连接接口");
return false;
}
}
public function deleteInstanceAction($accountId, $forceStop = false)
{
if ($this->initError)
return false;
$targetAccount = $this->configManager->getAccountById($accountId);
if (!$targetAccount)
return false;
// 异步方案:仅标记为删除并记录日志
$this->db->addLog('warning', "操作成功:秒级标记释放指令已提交,后台安全队列正在接管 [{$this->getAccountLogLabel($targetAccount)}] {$targetAccount['instance_id']}");
$this->configManager->markAccountAsDeleted($accountId);
return true;
}
public function replaceInstanceIpAction($accountId)
{
if ($this->initError) {
return ['success' => false, 'message' => $this->initError];
}
$targetAccount = $this->configManager->getAccountById($accountId);
if (!$targetAccount) {
return ['success' => false, 'message' => '实例不存在'];
}
if (($targetAccount['public_ip_mode'] ?? '') !== 'eip' || empty($targetAccount['eip_managed'])) {
return ['success' => false, 'message' => '当前实例不是系统托管 EIP无法更换公网 IP'];
}
try {
$oldIp = $targetAccount['public_ip'] ?? '';
$result = $this->aliyunService->replaceManagedEip($targetAccount);
$this->configManager->updateAccountNetworkMetadata($accountId, [
'public_ip' => $result['publicIp'] ?? '',
'public_ip_mode' => 'eip',
'eip_allocation_id' => $result['eipAllocationId'] ?? '',
'eip_address' => $result['eipAddress'] ?? '',
'eip_managed' => 1,
'internet_max_bandwidth_out' => $result['internetMaxBandwidthOut'] ?? ($targetAccount['internet_max_bandwidth_out'] ?? 0)
]);
$this->syncDdnsForAccounts($this->configManager->getAccounts(), 'EIP 更换后');
$newIp = $result['publicIp'] ?? '';
$this->db->addLog('info', "EIP 已更换 [{$this->getAccountLogLabel($targetAccount)}] {$targetAccount['instance_id']} {$oldIp} -> {$newIp}");
$notifyResult = $this->notificationService->notifyPublicIpChanged(
$this->getAccountLogLabel($targetAccount),
$targetAccount,
$oldIp,
$newIp,
'用户在控制台手动更换公网 IPDDNS 解析已同步更新。'
);
$this->logNotificationResult($notifyResult, $this->getAccountLogLabel($targetAccount));
return [
'success' => true,
'message' => '公网 IP 已更换',
'data' => [
'publicIp' => $newIp,
'publicIpMode' => 'eip',
'eipAllocationId' => $result['eipAllocationId'] ?? '',
'eipAddress' => $result['eipAddress'] ?? '',
'internetMaxBandwidthOut' => $result['internetMaxBandwidthOut'] ?? 0
]
];
} catch (\Exception $e) {
$this->db->addLog('error', "EIP 更换失败 [{$this->getAccountLogLabel($targetAccount)}]: " . strip_tags($e->getMessage()));
return ['success' => false, 'message' => strip_tags($e->getMessage())];
}
}
private function processPendingReleases()
{
$pendingAccounts = $this->configManager->getPendingReleaseAccounts();
foreach ($pendingAccounts as $account) {
$accountLabel = $this->getAccountLogLabel($account);
try {
$status = $this->aliyunService->getInstanceStatus($account);
} catch (\Exception $e) {
if (stripos($e->getMessage(), 'NotFound') !== false || stripos($e->getMessage(), 'InvalidInstanceId') !== false) {
$status = 'NotFound';
} else {
$this->db->addLog('error', "后台异步释放引擎探测异常 [{$accountLabel}]: " . $e->getMessage());
continue;
}
}
try {
if ($status === 'Stopped') {
if (!$this->releaseManagedEipForPendingAccount($account, $accountLabel)) {
continue;
}
$result = $this->aliyunService->deleteInstance($account, false);
if ($result) {
$this->db->addLog('warning', "后台异步彻底销毁成功 [{$accountLabel}] {$account['instance_id']}");
$releaseNotifyResult = $this->notificationService->notifyInstanceReleased(
$accountLabel,
$account,
'用户前端提交指令后,后台成功执行安全彻底销毁。'
);
$this->logNotificationResult($releaseNotifyResult, $accountLabel);
$accountsBeforeDelete = $this->configManager->getAccounts();
$this->deleteDdnsForAccount($account, $accountsBeforeDelete, '后台实例彻底释放');
$this->configManager->physicallyDeleteAccount($account['id']);
$this->reconcileDdnsAfterAccountSync($accountsBeforeDelete, $this->configManager->getAccounts(), '异步释放后同步');
}
} elseif ($status === 'NotFound') {
if (!$this->releaseManagedEipForPendingAccount($account, $accountLabel)) {
continue;
}
$this->db->addLog('warning', "待释放实例云端已灭迹,自动擦除本地账本 [{$accountLabel}]");
$accountsBeforeDelete = $this->configManager->getAccounts();
$this->deleteDdnsForAccount($account, $accountsBeforeDelete, '实例已灭迹后清理');
$this->configManager->physicallyDeleteAccount($account['id']);
$this->reconcileDdnsAfterAccountSync($accountsBeforeDelete, $this->configManager->getAccounts(), '实例灭迹后同步');
} elseif ($status === 'Unknown') {
$this->db->addLog('warning', "后台异步释放引擎暂时无法确认实例状态,将于下一轮重试 [{$accountLabel}]");
} elseif (!in_array($status, ['Stopping'])) {
$this->db->addLog('info', "后台异步释放引擎:向活跃实例下发强制离线指令 [{$accountLabel}]");
// 仅调用 stop 并允许返回,不产生同步堵塞死循环
$this->aliyunService->controlInstance($account, 'stop');
}
} catch (\Exception $e) {
// 如果 DeleteInstance 等遇到暂时性 API 禁止,让它下一分钟随 Cron 重新再轮询一次,不需要人工介入
$this->db->addLog('error', "后台异步释放行动异常,将于下一分钟轮询重试 [{$accountLabel}]: " . $e->getMessage());
}
}
}
private function releaseManagedEipForPendingAccount(array &$account, $accountLabel)
{
if (($account['public_ip_mode'] ?? '') !== 'eip' || empty($account['eip_managed'])) {
return true;
}
try {
if ($this->aliyunService->releaseManagedEip($account)) {
$this->db->addLog('info', "托管 EIP 已释放 [{$accountLabel}] " . ($account['eip_address'] ?? ''));
$this->configManager->updateAccountNetworkMetadata($account['id'], [
'public_ip' => '',
'public_ip_mode' => 'eip',
'eip_allocation_id' => '',
'eip_address' => '',
'eip_managed' => 0,
'internet_max_bandwidth_out' => $account['internet_max_bandwidth_out'] ?? 0
]);
$account['public_ip'] = '';
$account['eip_allocation_id'] = '';
$account['eip_address'] = '';
$account['eip_managed'] = 0;
}
return true;
} catch (\Exception $e) {
$this->db->addLog('warning', "托管 EIP 释放失败,将于下一轮重试 [{$accountLabel}]: " . strip_tags($e->getMessage()));
return false;
}
}
/**
* 获取所有已配置账号的实例列表(合并去重)
*/
public function getAllManagedInstances($sync = false)
{
if ($this->initError)
return [];
if ($sync) {
$accountsBeforeSync = $this->configManager->getAccounts();
$this->configManager->syncAccountGroups(true);
$this->configManager->load();
$this->reconcileDdnsAfterAccountSync($accountsBeforeSync, $this->configManager->getAccounts(), '实例手动同步');
} else {
$this->configManager->load();
}
$threshold = (int) ($this->configManager->get('traffic_threshold', 95) ?? 95);
$userInterval = (int) ($this->configManager->get('api_interval', 600) ?? 600);
$accounts = array_values(array_filter($this->configManager->getAccounts(), function ($account) {
return !empty($account['instance_id']);
}));
$allInstances = [];
foreach ($accounts as $account) {
$allInstances[] = $this->buildInstanceSnapshot($account, $threshold, $userInterval, false, true, $sync);
}
$pendingAccounts = $this->configManager->getPendingReleaseAccounts();
foreach ($pendingAccounts as $account) {
$snap = $this->buildInstanceSnapshot($account, $threshold, $userInterval, false, true, $sync);
$snap['instanceStatus'] = 'Releasing';
$snap['status'] = 'Releasing';
$snap['operationLocked'] = true;
$snap['operationLockedReason'] = '实例正在释放中,后台队列会继续处理。';
$allInstances[] = $snap;
}
return $allInstances;
}
private function syncDdnsForAccounts(array $accounts, $source = '同步')
{
if (!$this->ddnsService || !$this->ddnsService->isEnabled()) {
return;
}
$groupCounts = $this->getDdnsGroupCounts($accounts);
foreach ($accounts as $account) {
$publicIp = $this->getEffectivePublicIp($account);
if (empty($account['instance_id']) || $publicIp === '') {
continue;
}
try {
$recordName = $this->buildDdnsRecordNameForAccount($account, $groupCounts);
$result = $this->ddnsService->syncARecord($recordName, $publicIp);
if (!empty($result['success']) && empty($result['skipped'])) {
$this->db->addLog('info', "DDNS 已同步 [{$this->getAccountLogLabel($account)}] {$recordName} -> {$publicIp} ({$source})");
} elseif (empty($result['success'])) {
$this->db->addLog('warning', "DDNS 同步失败 [{$this->getAccountLogLabel($account)}]: " . strip_tags($result['message'] ?? '未知错误'));
}
} catch (Exception $e) {
$this->db->addLog('warning', "DDNS 同步失败 [{$this->getAccountLogLabel($account)}]: " . strip_tags($e->getMessage()));
}
}
}
private function getEffectivePublicIp(array $account)
{
if (($account['public_ip_mode'] ?? '') === 'eip') {
$eip = trim((string) ($account['eip_address'] ?? ''));
if ($eip !== '') {
return $eip;
}
}
return trim((string) ($account['public_ip'] ?? ''));
}
private function reconcileDdnsAfterAccountSync(array $beforeAccounts, array $afterAccounts, $source = '同步')
{
if (!$this->ddnsService || !$this->ddnsService->isEnabled()) {
return;
}
$beforeRecords = $this->getDdnsRecordNamesForAccounts($beforeAccounts);
$afterRecords = $this->getDdnsRecordNamesForAccounts($afterAccounts);
foreach ($beforeRecords as $instanceId => $recordName) {
if ($recordName === '' || in_array($recordName, $afterRecords, true)) {
continue;
}
$this->deleteDdnsRecord($recordName, $source . '清理');
}
$this->syncDdnsForAccounts($afterAccounts, $source);
}
private function deleteDdnsForAccount(array $account, array $accountsBeforeDelete, $source = '释放')
{
if (!$this->ddnsService || !$this->ddnsService->isEnabled()) {
return;
}
try {
$recordName = $this->buildDdnsRecordNameForAccount($account, $this->getDdnsGroupCounts($accountsBeforeDelete));
$this->deleteDdnsRecord($recordName, $source);
} catch (Exception $e) {
$this->db->addLog('warning', "DDNS 清理失败 [{$this->getAccountLogLabel($account)}]: " . strip_tags($e->getMessage()));
}
}
private function deleteDdnsRecord($recordName, $source = '清理')
{
try {
$result = $this->ddnsService->deleteARecord($recordName);
if (!empty($result['success']) && empty($result['skipped'])) {
$this->db->addLog('info', "DDNS 已删除 {$recordName} ({$source})");
} elseif (empty($result['success'])) {
$this->db->addLog('warning', "DDNS 删除失败 {$recordName}: " . strip_tags($result['message'] ?? '未知错误'));
}
} catch (Exception $e) {
$this->db->addLog('warning', "DDNS 删除失败 {$recordName}: " . strip_tags($e->getMessage()));
}
}
private function getDdnsRecordNamesForAccounts(array $accounts)
{
$groupCounts = $this->getDdnsGroupCounts($accounts);
$records = [];
foreach ($accounts as $account) {
if (empty($account['instance_id'])) {
continue;
}
try {
$records[$account['instance_id']] = $this->buildDdnsRecordNameForAccount($account, $groupCounts);
} catch (Exception $e) {
$this->db->addLog('warning', "DDNS 记录名生成失败 [{$this->getAccountLogLabel($account)}]: " . strip_tags($e->getMessage()));
}
}
return $records;
}
private function buildDdnsRecordNameForAccount(array $account, array $groupCounts)
{
$groupKey = $this->getDdnsGroupKey($account);
return $this->ddnsService->buildRecordName([
'account_remark' => $this->resolveGroupRemark($account),
'remark' => $account['remark'] ?? '',
'instance_name' => $account['instance_name'] ?? '',
'instance_id' => $account['instance_id'] ?? ''
], $groupCounts[$groupKey] ?? 1);
}
private function getDdnsGroupCounts(array $accounts)
{
$groupCounts = [];
foreach ($accounts as $account) {
if (empty($account['instance_id'])) {
continue;
}
$groupKey = $this->getDdnsGroupKey($account);
$groupCounts[$groupKey] = ($groupCounts[$groupKey] ?? 0) + 1;
}
return $groupCounts;
}
private function getDdnsGroupKey(array $account)
{
return $account['group_key'] ?: (($account['access_key_id'] ?? '') . '|' . ($account['region_id'] ?? ''));
}
private function resolveGroupRemark(array $account)
{
$groupKey = trim((string) ($account['group_key'] ?? ''));
if ($groupKey !== '') {
foreach ($this->configManager->getAccountGroups() as $group) {
if (($group['groupKey'] ?? '') === $groupKey) {
return trim((string) ($group['remark'] ?? ''));
}
}
}
return trim((string) ($account['remark'] ?? ''));
}
private function buildInstanceSnapshot($account, $threshold, $userInterval, $billingEnabled, $includeSensitive = true, $forceRefresh = false)
{
$currentTime = time();
$lastUpdate = (int) ($account['updated_at'] ?? 0);
$cachedStatus = $account['instance_status'] ?? 'Unknown';
$newUpdateTime = $currentTime;
$trafficApiStatus = $account['traffic_api_status'] ?? 'ok';
$trafficApiMessage = $account['traffic_api_message'] ?? '';
$isTransientState = in_array($cachedStatus, ['Starting', 'Stopping', 'Pending', 'Unknown'], true);
$checkInterval = $isTransientState ? 60 : $userInterval;
if ($forceRefresh || ($currentTime - $lastUpdate) > $checkInterval) {
$trafficResult = $this->safeGetTraffic($account);
$status = $this->safeGetInstanceStatus($account);
if ($status === 'Unknown') {
$status = $cachedStatus;
}
$metadata = [
'traffic_api_status' => $trafficResult['status'] ?? 'ok',
'traffic_api_message' => $trafficResult['message'] ?? ''
];
if ($this->isCredentialInvalidTrafficStatus($trafficResult['status'] ?? '')) {
$metadata['protection_suspended'] = 1;
$metadata['protection_suspend_reason'] = 'credential_invalid';
} else {
$metadata['protection_suspended'] = 0;
$metadata['protection_suspend_reason'] = '';
$metadata['protection_suspend_notified_at'] = 0;
}
$trafficApiStatus = $metadata['traffic_api_status'];
$trafficApiMessage = $metadata['traffic_api_message'];
if (empty($trafficResult['success'])) {
$traffic = (float) ($account['traffic_used'] ?? 0);
$newUpdateTime = $lastUpdate;
} else {
$traffic = (float) ($trafficResult['value'] ?? 0);
$this->db->addHourlyStat($account['id'], $traffic);
$this->db->addDailyStat($account['id'], $traffic);
}
if ($newUpdateTime <= 0) {
$newUpdateTime = $currentTime;
}
$this->notifyStatusChangeIfNeeded($account, $cachedStatus, $status, '页面刷新检测到实例状态变化。');
// 如果处于运行中且健康状态未知或非 OK尝试获取详细状态以识别“操作系统启动中”
if ($status === 'Running' && ($account['health_status'] ?? '') !== 'OK') {
$full = $this->safeGetInstanceFullStatus($account);
if ($full) {
$metadata['health_status'] = $full['healthStatus'];
}
}
$this->configManager->updateAccountStatus($account['id'], $traffic, $status, $newUpdateTime, $metadata);
$lastUpdate = $newUpdateTime;
} else {
$traffic = (float) ($account['traffic_used'] ?? 0);
$status = $cachedStatus;
}
$maxTraffic = (float) ($account['max_traffic'] ?? 0);
$usagePercent = $maxTraffic > 0 ? round(($traffic / $maxTraffic) * 100, 2) : 0;
$instanceName = $account['instance_name'] ?? '';
$remark = $account['remark'] ?? '';
$accountDisplayLabel = $this->getAccountLogLabel($account);
$item = [
'id' => (int) $account['id'],
'accountId' => (int) $account['id'],
'groupKey' => $account['group_key'] ?? '',
'account' => substr($account['access_key_id'], 0, 7) . '***',
'accountMasked' => substr($account['access_key_id'], 0, 7) . '***',
'accountLabel' => $accountDisplayLabel . ' / ' . $this->getRegionName($account['region_id']),
'flow_total' => $maxTraffic,
'flow_used' => round($traffic, 6),
'percentageOfUse' => $usagePercent,
'trafficStatus' => $trafficApiStatus,
'trafficMessage' => $trafficApiMessage,
'region' => $account['region_id'],
'regionId' => $account['region_id'],
'regionName' => $this->getRegionName($account['region_id']),
'rate95' => $usagePercent >= $threshold,
'threshold' => $threshold,
'instanceStatus' => $status,
'status' => $status,
'healthStatus' => $account['health_status'] ?? 'Unknown',
'stoppedMode' => $account['stopped_mode'] ?? 'KeepCharging',
'cpu' => (int) ($account['cpu'] ?? 0),
'memory' => (int) ($account['memory'] ?? 0),
'lastUpdated' => date('Y-m-d H:i:s', $lastUpdate > 0 ? $lastUpdate : $currentTime),
'remark' => $remark !== '' ? $remark : ($instanceName !== '' ? $instanceName : ($account['instance_id'] ?? '')),
'instanceId' => $account['instance_id'] ?? '',
'instanceName' => $instanceName,
'instanceType' => $account['instance_type'] ?? '',
'osName' => $account['os_name'] ?? '',
'internetMaxBandwidthOut' => (int) ($account['internet_max_bandwidth_out'] ?? 0),
'publicIp' => $includeSensitive ? ($account['public_ip'] ?? '') : '',
'publicIpMode' => $account['public_ip_mode'] ?? 'ecs_public_ip',
'eipAllocationId' => $includeSensitive ? ($account['eip_allocation_id'] ?? '') : '',
'eipAddress' => $includeSensitive ? ($account['eip_address'] ?? '') : '',
'eipManaged' => !empty($account['eip_managed']),
'privateIp' => $includeSensitive ? ($account['private_ip'] ?? '') : '',
'maxTraffic' => $maxTraffic,
'siteType' => $account['site_type'] ?? 'international'
];
if ($billingEnabled) {
$item['cost'] = $this->safeGetBillingInfo($account, date('Y-m'));
}
return $item;
}
public function renderTemplate()
{
if (!file_exists('template.html'))
return "File not found";
ob_start();
include 'template.html';
return ob_get_clean();
}
}