mirror of
https://github.com/Kori1c/ecs-controller.git
synced 2026-05-06 22:01:48 +08:00
2303 lines
100 KiB
PHP
2303 lines
100 KiB
PHP
<?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,
|
||
'用户在控制台手动更换公网 IP,DDNS 解析已同步更新。'
|
||
);
|
||
$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();
|
||
}
|
||
}
|