From 59d78b06c19ccdd98ff91bd3f3449acc7159bccd Mon Sep 17 00:00:00 2001 From: kk K Date: Fri, 31 Jul 2026 13:13:30 +0800 Subject: [PATCH] Remove legacy PHP runtime --- .gitignore | 2 - AliyunService.php | 2086 -------------------------------- AliyunTrafficCheck.php | 2302 ------------------------------------ ConfigManager.php | 1097 ----------------- Database.php | 626 ---------- DdnsService.php | 252 ---- NotificationService.php | 583 --------- README.MD | 6 +- TelegramControlService.php | 804 ------------- composer.json | 12 - composer.lock | 1264 -------------------- docker/entrypoint-go.sh | 2 +- docker/entrypoint.sh | 30 - docker/nginx.conf | 56 - docker/php-fpm-www.conf | 7 - index.php | 394 ------ monitor.php | 31 - telegram_worker.php | 32 - 18 files changed, 4 insertions(+), 9582 deletions(-) delete mode 100644 AliyunService.php delete mode 100644 AliyunTrafficCheck.php delete mode 100644 ConfigManager.php delete mode 100644 Database.php delete mode 100644 DdnsService.php delete mode 100644 NotificationService.php delete mode 100644 TelegramControlService.php delete mode 100644 composer.json delete mode 100644 composer.lock delete mode 100644 docker/entrypoint.sh delete mode 100644 docker/nginx.conf delete mode 100644 docker/php-fpm-www.conf delete mode 100644 index.php delete mode 100644 monitor.php delete mode 100644 telegram_worker.php diff --git a/.gitignore b/.gitignore index 1073b22..009efd1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ config.json *.log .env -composer.phar -composer.lock .DS_Store Thumbs.db /.idea diff --git a/AliyunService.php b/AliyunService.php deleted file mode 100644 index 6f8a7b3..0000000 --- a/AliyunService.php +++ /dev/null @@ -1,2086 +0,0 @@ -getErrorCode(); - if (stripos($errorCode, 'Throttling') !== false) { - $lastException = $e; - // 流控触发时,等待时间稍长 - $this->backoff($attempt, true); - $attempt++; - continue; - } - throw $e; // 其他 4xx 错误直接抛出(如 AccessKey 错误) - } catch (ServerException $e) { - // 服务端错误(5xx)需要重试 - $lastException = $e; - } catch (\Exception $e) { - // 网络/cURL错误(超时、无法解析DNS等)需要重试 - $lastException = $e; - } - - $attempt++; - if ($attempt < $maxRetries) { - // 记录简短日志到标准输出(可选,方便调试 Docker logs) - // echo "Warning: Retrying $action (Attempt $attempt/$maxRetries)...\n"; - $this->backoff($attempt); - } - } - - throw $lastException; - } - - /** - * 指数退避策略 - * @param int $attempt 当前尝试次数 - * @param bool $isThrottling 是否因为流控 - */ - private function backoff($attempt, $isThrottling = false) - { - // 优化点2: 基础等待时间从 0.5s 提升至 1s - // 序列变为: 1s, 2s, 4s... 3次重试总耗时控制在合理范围内 - $base = 1000000 * pow(2, $attempt); - if ($isThrottling) { - $base *= 2; // 流控时等待时间翻倍 - } - // 增加随机抖动,避免多线程/多容器并发请求撞车 - $jitter = rand(0, 500000); - usleep($base + $jitter); - } - - private $trafficCache = []; - - private function setDefaultClient($key, $secret, $regionId) - { - AlibabaCloud::accessKeyClient($key, $secret) - ->regionId($regionId) - ->asDefaultClient(); - } - - private function ecsHost($regionId) - { - return "ecs.{$regionId}.aliyuncs.com"; - } - - private function vpcHost($regionId) - { - return "vpc.{$regionId}.aliyuncs.com"; - } - - /** - * 判断是否为海外区域 - * 国内区域:cn-* (排除 cn-hongkong) - * 海外区域:其他所有区域 + cn-hongkong - */ - private function isOverseas($regionId) - { - // 简单判断:如果以 cn- 开头且不是 cn-hongkong,则是国内 - if (strpos($regionId, 'cn-') === 0 && $regionId !== 'cn-hongkong') { - return false; - } - return true; - } - - /** - * 获取 BSS 费用中心 API 的 regionId 和 endpoint - * 中国站: cn-hangzhou + business.aliyuncs.com - * 国际站: ap-southeast-1 + business.ap-southeast-1.aliyuncs.com - * @param string $siteType 'china' 或 'international' - */ - private function getBssEndpoint($siteType = 'china') - { - if ($siteType === 'international') { - return [ - 'regionId' => 'ap-southeast-1', - 'host' => 'business.ap-southeast-1.aliyuncs.com' - ]; - } - return [ - 'regionId' => 'cn-hangzhou', - 'host' => 'business.aliyuncs.com' - ]; - } - - /** - * 获取 CDT 流量 - * @param string $key AccessKey - * @param string $secret Secret - * @param string $targetRegion 目标实例的区域ID - * @throws \Exception - */ - public function getTraffic($key, $secret, $targetRegion) - { - // 1. 检查缓存 - $cacheKey = md5($key); - if (isset($this->trafficCache[$cacheKey])) { - $result = $this->trafficCache[$cacheKey]; - } else { - // 2. 如果无缓存,发起 API 请求 - $result = $this->executeWithRetry(function () use ($key, $secret) { - AlibabaCloud::accessKeyClient($key, $secret) - ->regionId('cn-hongkong') // CDT 接口通常用 cn-hongkong 或 cn-hangzhou 调用即可获取全局数据 - ->asDefaultClient(); - - return AlibabaCloud::rpc() - ->product('CDT') - ->scheme('https') - ->version('2021-08-13') - ->action('ListCdtInternetTraffic') - ->method('POST') - ->host('cdt.aliyuncs.com') - ->options([ - 'connect_timeout' => 10.0, - 'timeout' => 20.0 - ]) - ->request(); - }, 'getTraffic'); - - // 写入缓存 - $this->trafficCache[$cacheKey] = $result; - } - - if (isset($result['TrafficDetails'])) { - $isTargetOverseas = $this->isOverseas($targetRegion); - $totalTraffic = 0; - - foreach ($result['TrafficDetails'] as $detail) { - // 核心逻辑:区分国内/海外 - // 只有当流量产生区域的属性(国内/海外)与目标实例区域属性一致时,才计入 - $trafficRegion = $detail['BusinessRegionId'] ?? ''; - if ($this->isOverseas($trafficRegion) === $isTargetOverseas) { - $totalTraffic += $detail['Traffic']; - } - } - - return $totalTraffic / (1024 * 1024 * 1024); - } - - throw new \Exception("API 响应缺少 TrafficDetails 字段"); - } - - /** - * 获取 ECS 实例公网出口分钟带宽点,并换算为字节增量。 - * 阿里云 ECS 公网按出方向流量计费;这里优先使用 VPC 公网 IP 维度指标,经典网络回退到实例维度指标。 - * - * @return array ['bytes' => float, 'lastSampleMs' => int, 'points' => int, 'metric' => string] - * @throws \Exception - */ - public function getInstanceOutboundTrafficDelta($account, $startMs, $endMs) - { - if (empty($account['instance_id'])) { - throw new \Exception('未配置 Instance ID'); - } - - if ($endMs <= $startMs) { - return [ - 'bytes' => 0.0, - 'lastSampleMs' => (int) $startMs, - 'points' => 0, - 'metric' => '' - ]; - } - - $metricCandidates = []; - $publicIp = trim((string) ($account['public_ip'] ?? '')); - if ($publicIp !== '') { - $metricCandidates[] = [ - 'name' => 'VPC_PublicIP_InternetOutRate', - 'dimensions' => [[ - 'instanceId' => $account['instance_id'], - 'ip' => $publicIp - ]] - ]; - } - - $metricCandidates[] = [ - 'name' => 'InternetOutRate', - 'dimensions' => [[ - 'instanceId' => $account['instance_id'] - ]] - ]; - - $lastException = null; - foreach ($metricCandidates as $candidate) { - try { - $result = $this->queryMetricRateAsBytes( - $account['access_key_id'], - $account['access_key_secret'], - $candidate['name'], - $candidate['dimensions'], - $startMs, - $endMs - ); - - if ($result['points'] > 0 || $candidate['name'] === 'InternetOutRate') { - $result['metric'] = $candidate['name']; - return $result; - } - } catch (\Exception $e) { - $lastException = $e; - } - } - - if ($lastException) { - throw $lastException; - } - - return [ - 'bytes' => 0.0, - 'lastSampleMs' => (int) $startMs, - 'points' => 0, - 'metric' => '' - ]; - } - - private function queryMetricRateAsBytes($key, $secret, $metricName, array $dimensions, $startMs, $endMs) - { - $period = 60; - $chunkMs = 24 * 3600 * 1000; - $cursor = (int) $startMs; - $totalBytes = 0.0; - $lastSampleMs = (int) $startMs; - $pointCount = 0; - - while ($cursor < $endMs) { - $chunkEnd = min($cursor + $chunkMs, (int) $endMs); - $nextToken = null; - - do { - $query = [ - 'Namespace' => 'acs_ecs_dashboard', - 'MetricName' => $metricName, - 'Period' => (string) $period, - 'StartTime' => (string) $cursor, - 'EndTime' => (string) $chunkEnd, - 'Dimensions' => json_encode($dimensions, JSON_UNESCAPED_SLASHES), - 'Length' => '1440' - ]; - - if (!empty($nextToken)) { - $query['NextToken'] = $nextToken; - } - - $result = $this->executeWithRetry(function () use ($key, $secret, $query) { - AlibabaCloud::accessKeyClient($key, $secret) - ->regionId('cn-hangzhou') - ->asDefaultClient(); - - return AlibabaCloud::rpc() - ->product('Cms') - ->scheme('https') - ->version('2019-01-01') - ->action('DescribeMetricList') - ->method('POST') - ->host('metrics.aliyuncs.com') - ->options([ - 'query' => $query, - 'connect_timeout' => 10.0, - 'timeout' => 25.0 - ]) - ->request(); - }, 'queryMetricRateAsBytes'); - - $datapoints = $result['Datapoints'] ?? '[]'; - if (is_string($datapoints)) { - $datapoints = json_decode($datapoints, true); - } - if (!is_array($datapoints)) { - $datapoints = []; - } - - usort($datapoints, function ($a, $b) { - return ((int) ($a['timestamp'] ?? 0)) <=> ((int) ($b['timestamp'] ?? 0)); - }); - - foreach ($datapoints as $point) { - $timestamp = (int) ($point['timestamp'] ?? 0); - if ($timestamp <= $startMs || $timestamp > $endMs) { - continue; - } - - $rateBitsPerSecond = (float) ($point['Average'] ?? $point['Maximum'] ?? $point['Minimum'] ?? 0); - if ($rateBitsPerSecond < 0) { - $rateBitsPerSecond = 0; - } - - $totalBytes += ($rateBitsPerSecond * $period) / 8; - $lastSampleMs = max($lastSampleMs, $timestamp); - $pointCount++; - } - - $nextToken = $result['NextToken'] ?? null; - } while (!empty($nextToken)); - - $cursor = $chunkEnd; - } - - return [ - 'bytes' => $totalBytes, - 'lastSampleMs' => $lastSampleMs, - 'points' => $pointCount, - 'metric' => $metricName - ]; - } - - /** - * 获取实例状态 - * @throws \Exception - */ - public function getInstanceStatus($account) - { - return $this->executeWithRetry(function () use ($account) { - AlibabaCloud::accessKeyClient($account['access_key_id'], $account['access_key_secret']) - ->regionId($account['region_id']) - ->asDefaultClient(); - - $options = [ - 'query' => ['RegionId' => $account['region_id']], - // 优化点3: 同样缩短实例状态查询的超时 - 'connect_timeout' => 10.0, - 'timeout' => 20.0 - ]; - - if (!empty($account['instance_id'])) { - // 修改:阿里云 RPC 风格接口对于列表类参数(如 InstanceId.N)需要明确的索引 - $options['query']['InstanceId.1'] = $account['instance_id']; - } - - $result = AlibabaCloud::rpc() - ->product('Ecs') - ->scheme('https') - ->version('2014-05-26') - ->action('DescribeInstanceStatus') - ->method('POST') - ->host("ecs.{$account['region_id']}.aliyuncs.com") - ->options($options) - ->request(); - - $statuses = $result['InstanceStatuses']['InstanceStatus'] ?? []; - foreach ($statuses as $item) { - if (($item['InstanceId'] ?? '') === $account['instance_id']) { - return $item['Status']; - } - } - - // 如果没找到匹配的 ID,且返回了列表(说明过滤参数没生效),且当前账号只有一个实例 ID,则抛出异常 - if (empty($statuses) || count($statuses) > 1) { - throw new \Exception("API 响应未找到匹配的实例状态 (ID: {$account['instance_id']})"); - } - - return 'Unknown'; - }, 'getInstanceStatus'); - } - - /** - * 获取实例详细健康状态 (用于识别操作系统启动中等状态) - */ - public function getInstanceFullStatus($account) - { - return $this->executeWithRetry(function () use ($account) { - AlibabaCloud::accessKeyClient($account['access_key_id'], $account['access_key_secret']) - ->regionId($account['region_id']) - ->asDefaultClient(); - - $options = [ - 'query' => [ - 'RegionId' => $account['region_id'], - 'InstanceId.1' => $account['instance_id'] - ], - 'connect_timeout' => 10.0, - 'timeout' => 20.0 - ]; - - $result = AlibabaCloud::rpc() - ->product('Ecs') - ->scheme('https') - ->version('2014-05-26') - ->action('DescribeInstancesFullStatus') - ->method('POST') - ->host("ecs.{$account['region_id']}.aliyuncs.com") - ->options($options) - ->request(); - - $statusSet = $result['InstanceFullStatusSet']['InstanceFullStatus'][0] ?? null; - if ($statusSet && ($statusSet['InstanceId'] ?? '') === $account['instance_id']) { - return [ - 'status' => $statusSet['Status']['Name'] ?? 'Unknown', - 'healthStatus' => $statusSet['HealthStatus']['Name'] ?? 'Unknown', - ]; - } - - return null; - }, 'getInstanceFullStatus'); - } - - /** - * 释放(删除)实例 - * @throws \Exception - */ - public function deleteInstance($account, $forceStop = false) - { - if (empty($account['instance_id'])) { - throw new \Exception("未配置 Instance ID"); - } - - try { - return $this->executeWithRetry(function () use ($account) { - AlibabaCloud::accessKeyClient($account['access_key_id'], $account['access_key_secret']) - ->regionId($account['region_id']) - ->asDefaultClient(); - - AlibabaCloud::rpc() - ->product('Ecs') - ->scheme('https') - ->version('2014-05-26') - ->action('DeleteInstance') - ->method('POST') - ->host("ecs.{$account['region_id']}.aliyuncs.com") - ->options([ - 'query' => [ - 'RegionId' => $account['region_id'], - 'InstanceId' => $account['instance_id'], - 'Force' => true, - ], - 'connect_timeout' => 10.0, - 'timeout' => 25.0 - ]) - ->request(); - - return true; - }, 'deleteInstance'); - } catch (ServerException $e) { - $code = $e->getErrorCode(); - if (stripos($code, 'NotFound') !== false || stripos($code, 'InvalidInstanceId') !== false) { - return true; - } - throw $e; - } - } - /** - * 控制实例开关机 - * @throws \Exception - */ - public function controlInstance($account, $action, $shutdownMode = 'KeepCharging') - { - return $this->executeWithRetry(function () use ($account, $action, $shutdownMode) { - AlibabaCloud::accessKeyClient($account['access_key_id'], $account['access_key_secret']) - ->regionId($account['region_id']) - ->asDefaultClient(); - - if (empty($account['instance_id'])) { - throw new \Exception("未配置 Instance ID"); - } - - $options = [ - 'query' => [ - 'RegionId' => $account['region_id'], - 'InstanceId' => $account['instance_id'] - ], - // 优化点4: 控制操作保持一致,确保用户操作不卡死 - 'connect_timeout' => 10.0, - 'timeout' => 20.0 - ]; - - if ($action === 'stop') { - $options['query']['StoppedMode'] = $shutdownMode; - } - - AlibabaCloud::rpc() - ->product('Ecs') - ->scheme('https') - ->version('2014-05-26') - ->action($action === 'stop' ? 'StopInstance' : 'StartInstance') - ->method('POST') - ->host("ecs.{$account['region_id']}.aliyuncs.com") - ->options($options) - ->request(); - - return true; - }, 'controlInstance'); - } - - /** - * 获取当前账号可访问的地域列表 - * @param string $key - * @param string $secret - * @return array - * @throws \Exception - */ - public function getRegions($key, $secret) - { - $cacheKey = md5($key); - if (isset($this->regionCache[$cacheKey])) { - return $this->regionCache[$cacheKey]; - } - - $result = $this->executeWithRetry(function () use ($key, $secret) { - AlibabaCloud::accessKeyClient($key, $secret) - ->regionId('cn-hangzhou') - ->asDefaultClient(); - - return AlibabaCloud::rpc() - ->product('Ecs') - ->scheme('https') - ->version('2014-05-26') - ->action('DescribeRegions') - ->method('POST') - ->host('ecs.cn-hangzhou.aliyuncs.com') - ->options([ - 'connect_timeout' => 5.0, - 'timeout' => 10.0 - ]) - ->request(); - }, 'getRegions'); - - $regions = []; - foreach (($result['Regions']['Region'] ?? []) as $region) { - if (empty($region['RegionId'])) { - continue; - } - - $regions[] = [ - 'regionId' => $region['RegionId'], - 'localName' => $region['LocalName'] ?? $region['RegionId'] - ]; - } - - $this->regionCache[$cacheKey] = $regions; - return $regions; - } - - /** - * 列出当前账号下所有 ECS 实例 - * @param string $key - * @param string $secret - * @return array - * @throws \Exception - */ - public function getInstances($key, $secret, $targetRegionId = null) - { - $regions = $this->getRegions($key, $secret); - if (!empty($targetRegionId)) { - $matchedRegion = null; - foreach ($regions as $region) { - if (($region['regionId'] ?? '') === $targetRegionId) { - $matchedRegion = $region; - break; - } - } - - $regions = [[ - 'regionId' => $targetRegionId, - 'localName' => $matchedRegion['localName'] ?? $targetRegionId - ]]; - } - - $instances = []; - - foreach ($regions as $region) { - $pageNumber = 1; - - try { - do { - $result = $this->executeWithRetry(function () use ($key, $secret, $region, $pageNumber) { - AlibabaCloud::accessKeyClient($key, $secret) - ->regionId($region['regionId']) - ->asDefaultClient(); - - return AlibabaCloud::rpc() - ->product('Ecs') - ->scheme('https') - ->version('2014-05-26') - ->action('DescribeInstances') - ->method('POST') - ->host("ecs.{$region['regionId']}.aliyuncs.com") - ->options([ - 'query' => [ - 'RegionId' => $region['regionId'], - 'PageSize' => 100, - 'PageNumber' => $pageNumber - ], - 'connect_timeout' => 15.0, - 'timeout' => 30.0 - ]) - ->request(); - }, 'getInstances'); - - $items = $result['Instances']['Instance'] ?? []; - foreach ($items as $instance) { - $instances[] = [ - 'instanceId' => $instance['InstanceId'] ?? '', - 'instanceName' => $instance['InstanceName'] ?? '', - 'status' => $instance['Status'] ?? 'Unknown', - 'regionId' => $region['regionId'], - 'regionName' => $region['localName'], - 'instanceType' => $instance['InstanceType'] ?? '', - 'cpu' => $instance['Cpu'] ?? 0, - 'memory' => $instance['Memory'] ?? 0, - 'internetMaxBandwidthOut' => (int) (($instance['EipAddress']['Bandwidth'] ?? 0) ?: ($instance['InternetMaxBandwidthOut'] ?? 0)), - 'osName' => $instance['OSName'] ?? '', - 'publicIp' => $instance['PublicIpAddress']['IpAddress'][0] ?? $instance['EipAddress']['IpAddress'] ?? '', - 'eipAllocationId' => $instance['EipAddress']['AllocationId'] ?? '', - 'eipAddress' => $instance['EipAddress']['IpAddress'] ?? '', - 'privateIp' => $instance['VpcAttributes']['PrivateIpAddress']['IpAddress'][0] ?? '', - 'stoppedMode' => $instance['StoppedMode'] ?? '', - 'chargeType' => $instance['InstanceChargeType'] ?? '' - ]; - } - - $totalCount = (int) ($result['TotalCount'] ?? count($items)); - $pageSize = (int) ($result['PageSize'] ?? 100); - $pageNumber++; - } while ($totalCount > 0 && (($pageNumber - 1) * $pageSize) < $totalCount); - } catch (\Exception $e) { - if (!empty($targetRegionId)) { - throw $e; - } - continue; - } - } - - usort($instances, function ($a, $b) { - $regionCompare = strcmp($a['regionId'], $b['regionId']); - if ($regionCompare !== 0) { - return $regionCompare; - } - - return strcmp($a['instanceId'], $b['instanceId']); - }); - - return $instances; - } - - public function buildEcsCreatePreview($account, array $request, $clientIp = '') - { - $regionId = trim((string) ($request['regionId'] ?? $account['region_id'] ?? '')); - $instanceType = trim((string) ($request['instanceType'] ?? '')) ?: 'ecs.e-c4m1.large'; - $osKey = trim((string) ($request['osKey'] ?? 'debian_12')); - $publicIpMode = trim((string) ($request['publicIpMode'] ?? 'ecs_public_ip')); - if (!in_array($publicIpMode, ['ecs_public_ip', 'eip'], true)) { - $publicIpMode = 'ecs_public_ip'; - } - $instanceName = trim((string) ($request['instanceName'] ?? '')); - if ($instanceName === '') { - $instanceName = 'launch-' . date('Ymd-His'); - } - $requestedDiskSize = (int) ($request['systemDiskSize'] ?? 20); - $requestedDiskCategory = trim((string) ($request['systemDiskCategory'] ?? 'cloud_essd_entry')); - - if ($regionId === '') { - throw new \Exception('请选择区域'); - } - - $key = $account['access_key_id']; - $secret = $account['access_key_secret']; - - $zone = $this->selectAvailableZone($key, $secret, $regionId, $instanceType); - $instanceTypeInfo = $this->describeInstanceType($key, $secret, $regionId, $instanceType); - $image = $this->selectSystemImage($key, $secret, $regionId, $osKey, $instanceTypeInfo['CpuArchitecture'] ?? ''); - $diskCategory = $this->selectDiskCategory($zone, $requestedDiskCategory); - $diskRange = $this->getSystemDiskSizeRange($key, $secret, $regionId, $zone['zoneId'], $instanceType, $diskCategory); - $diskSize = $this->normalizeSystemDiskSize($requestedDiskSize, $diskRange); - $bandwidth = $this->estimateMaxBandwidthOut($instanceType, $regionId); - $loginPort = ($image['osType'] ?? 'linux') === 'windows' ? 3389 : 22; - $loginUser = ($image['osType'] ?? 'linux') === 'windows' ? 'Administrator' : 'root'; - $securityRule = '默认全开:允许 0.0.0.0/0 入方向 TCP/UDP/ICMP'; - - return [ - 'account' => [ - 'groupKey' => $account['group_key'] ?? '', - 'label' => trim((string) ($account['remark'] ?? '')) ?: substr($key, 0, 7) . '***' - ], - 'regionId' => $regionId, - 'zoneId' => $zone['zoneId'], - 'instanceType' => $instanceType, - 'instanceName' => $instanceName, - 'osKey' => $osKey, - 'osLabel' => $image['label'], - 'imageId' => $image['imageId'], - 'imageSize' => (int) ($image['size'] ?? 0), - 'loginUser' => $loginUser, - 'loginPort' => $loginPort, - 'clientCidrIp' => '0.0.0.0/0', - 'chargeType' => 'PostPaid', - 'internetChargeType' => 'PayByTraffic', - 'internetMaxBandwidthOut' => $bandwidth, - 'publicIpMode' => $publicIpMode, - 'publicIpModeLabel' => $publicIpMode === 'eip' ? 'EIP 弹性公网 IP' : 'ECS 普通公网 IP', - 'systemDisk' => [ - 'category' => $diskCategory, - 'size' => $diskSize, - 'min' => $diskRange['min'], - 'max' => $diskRange['max'], - 'unit' => $diskRange['unit'] - ], - 'network' => [ - 'vpc' => [ - 'mode' => 'auto', - 'name' => "ecs-controller-vpc-{$regionId}", - 'cidr' => '172.31.0.0/16' - ], - 'vswitch' => [ - 'mode' => 'auto', - 'name' => "ecs-controller-vsw-{$zone['zoneId']}", - 'cidr' => $this->cidrForZone($zone['zoneId']) - ], - 'securityGroup' => [ - 'mode' => 'auto', - 'name' => "ecs-controller-sg-{$regionId}", - 'rules' => [$securityRule] - ] - ], - 'cdtCompatible' => true, - 'backupEnabled' => false, - 'pricing' => [ - 'available' => false, - 'currency' => ($account['site_type'] ?? 'international') === 'international' ? 'USD' : 'CNY', - 'message' => '费用预估暂不可用。实例按量计费,公网按实际出口流量计费,最终以阿里云账单为准。', - 'trafficNote' => '公网按使用流量计费,并按 CDT 兼容方式创建。' - ], - 'warnings' => array_values(array_filter([ - $publicIpMode === 'eip' - ? 'EIP 模式会先创建无普通公网 IP 的 ECS,再申请并绑定 EIP;停机不会释放 EIP,释放实例时会自动释放系统创建的 EIP。' - : 'ECS 普通公网 IP 由实例直接分配,停机后再启动可能变化;如需可控更换 IP,建议选择 EIP 模式。', - '公网带宽峰值会自动尝试最高可用值,若账号配额或规格限制不支持,会自动降级重试。', - "系统盘将严格按 {$diskSize} GB 创建;当前 API 返回范围为 {$diskRange['min']}-{$diskRange['max']} {$diskRange['unit']},超出范围会直接报错。", - '文件备份默认不启用;如需备份,请创建后在阿里云控制台单独开启。', - '安全组默认全开,便于测试和交付;生产环境建议创建后收紧来源 IP 和端口。' - ])) - ]; - } - - public function getAvailableSystemDiskOptions($account, array $request) - { - $regionId = trim((string) ($request['regionId'] ?? $account['region_id'] ?? '')); - $instanceType = trim((string) ($request['instanceType'] ?? '')) ?: 'ecs.e-c4m1.large'; - - if ($regionId === '') { - throw new \Exception('请选择区域'); - } - - $key = $account['access_key_id']; - $secret = $account['access_key_secret']; - $zone = $this->selectAvailableZone($key, $secret, $regionId, $instanceType); - $rawCategories = $zone['raw']['AvailableDiskCategories']['DiskCategories'] ?? $zone['raw']['AvailableDiskCategories']['DiskCategory'] ?? []; - $rawCategories = is_array($rawCategories) ? $rawCategories : []; - $candidates = $rawCategories ?: ['cloud_essd_entry', 'cloud_essd', 'cloud_efficiency', 'cloud']; - $candidates = array_values(array_filter($candidates, function ($category) { - return $category !== 'cloud_auto'; - })); - $preferredOrder = ['cloud_essd_entry', 'cloud_essd', 'cloud_efficiency', 'cloud']; - $candidates = array_values(array_unique(array_filter($candidates))); - usort($candidates, function ($a, $b) use ($preferredOrder) { - $aIndex = array_search($a, $preferredOrder, true); - $bIndex = array_search($b, $preferredOrder, true); - $aIndex = $aIndex === false ? 99 : $aIndex; - $bIndex = $bIndex === false ? 99 : $bIndex; - return $aIndex <=> $bIndex ?: strcmp($a, $b); - }); - - $options = []; - $errors = []; - foreach ($candidates as $category) { - try { - $range = $this->getSystemDiskSizeRange($key, $secret, $regionId, $zone['zoneId'], $instanceType, $category); - $options[] = [ - 'value' => $category, - 'label' => $this->diskCategoryLabel($category), - 'min' => $range['min'], - 'max' => $range['max'], - 'unit' => $range['unit'], - 'zoneId' => $zone['zoneId'], - 'status' => $range['status'] ?? '', - 'statusCategory' => $range['statusCategory'] ?? '' - ]; - } catch (\Exception $e) { - $errors[$category] = $e->getMessage(); - } - } - - if (empty($options)) { - throw new \Exception('当前账号区域和实例规格没有可用的系统盘类型,请更换实例规格后重试'); - } - - return [ - 'regionId' => $regionId, - 'zoneId' => $zone['zoneId'], - 'instanceType' => $instanceType, - 'options' => $options, - 'errors' => $errors - ]; - } - - public function createManagedEcsFromPreview($account, array $preview, callable $progress = null) - { - $key = $account['access_key_id']; - $secret = $account['access_key_secret']; - $regionId = $preview['regionId']; - $zoneId = $preview['zoneId']; - $instanceType = $preview['instanceType']; - $password = $this->generateInstancePassword(); - - $this->emitProgress($progress, '准备 VPC'); - $vpc = $this->ensureVpc($key, $secret, $regionId, $preview['network']['vpc']['name'], $preview['network']['vpc']['cidr']); - - $this->emitProgress($progress, '准备交换机'); - $vswitch = $this->ensureVSwitch( - $key, - $secret, - $regionId, - $zoneId, - $vpc['VpcId'], - $preview['network']['vswitch']['name'], - $preview['network']['vswitch']['cidr'] - ); - - $this->emitProgress($progress, '准备安全组'); - $securityGroup = $this->ensureSecurityGroup($key, $secret, $regionId, $vpc['VpcId'], $preview['network']['securityGroup']['name']); - $this->authorizeOpenSecurityGroupRules($key, $secret, $regionId, $securityGroup['SecurityGroupId']); - - $bandwidthCandidates = $this->bandwidthCandidates((int) ($preview['internetMaxBandwidthOut'] ?? 100)); - $diskCategories = array_unique(array_filter([$preview['systemDisk']['category'] ?? 'cloud_essd_entry'])); - $publicIpMode = ($preview['publicIpMode'] ?? 'ecs_public_ip') === 'eip' ? 'eip' : 'ecs_public_ip'; - // 系统盘成本敏感,严格使用用户确认的值;若阿里云拒绝,不自动放大。 - $diskSize = $this->normalizeSystemDiskSize($preview['systemDisk']['size'] ?? 20, $preview['systemDisk'] ?? []); - $lastError = null; - - foreach ($bandwidthCandidates as $bandwidth) { - foreach ($diskCategories as $diskCategory) { - $allocatedEip = null; - try { - $this->emitProgress($progress, "创建 ECS({$bandwidth} Mbps / {$diskCategory})"); - $instanceIds = $this->runInstance( - $key, - $secret, - $regionId, - [ - 'zoneId' => $zoneId, - 'instanceType' => $instanceType, - 'imageId' => $preview['imageId'], - 'securityGroupId' => $securityGroup['SecurityGroupId'], - 'vSwitchId' => $vswitch['VSwitchId'], - 'instanceName' => $preview['instanceName'], - 'password' => $password, - 'internetMaxBandwidthOut' => $publicIpMode === 'eip' ? 0 : $bandwidth, - 'systemDiskCategory' => $diskCategory, - 'systemDiskSize' => $diskSize - ] - ); - - $instanceId = $instanceIds[0] ?? ''; - if ($instanceId === '') { - throw new \Exception('RunInstances 未返回 InstanceId'); - } - - $this->emitProgress($progress, '等待实例启动'); - $instance = $this->waitInstanceReady($key, $secret, $regionId, $instanceId); - - if ($publicIpMode === 'eip') { - $this->emitProgress($progress, "申请 EIP({$bandwidth} Mbps)"); - $allocatedEip = $this->allocateEipAddress($key, $secret, $regionId, $bandwidth, $preview['instanceName']); - $this->emitProgress($progress, '绑定 EIP'); - $this->associateEipAddress($key, $secret, $regionId, $allocatedEip['allocationId'], $instanceId); - $this->waitEipStatus($key, $secret, $regionId, $allocatedEip['allocationId'], 'InUse'); - $instance = $this->waitInstanceReady($key, $secret, $regionId, $instanceId); - $instance['publicIp'] = $allocatedEip['ipAddress'] ?: ($instance['publicIp'] ?? ''); - $instance['eipAllocationId'] = $allocatedEip['allocationId']; - $instance['eipAddress'] = $allocatedEip['ipAddress']; - } - - return [ - 'instanceId' => $instanceId, - 'publicIp' => $instance['publicIp'] ?? '', - 'privateIp' => $instance['privateIp'] ?? '', - 'publicIpMode' => $publicIpMode, - 'eipAllocationId' => $instance['eipAllocationId'] ?? '', - 'eipAddress' => $instance['eipAddress'] ?? '', - 'eipManaged' => $publicIpMode === 'eip', - 'status' => $instance['status'] ?? 'Unknown', - 'instanceName' => $preview['instanceName'], - 'instanceType' => $instanceType, - 'vpcId' => $vpc['VpcId'], - 'vswitchId' => $vswitch['VSwitchId'], - 'securityGroupId' => $securityGroup['SecurityGroupId'], - 'internetMaxBandwidthOut' => $bandwidth, - 'systemDiskCategory' => $diskCategory, - 'systemDiskSize' => $diskSize, - 'loginUser' => $preview['loginUser'] ?? 'root', - 'loginPassword' => $password - ]; - } catch (\Exception $e) { - if ($allocatedEip && !empty($allocatedEip['allocationId'])) { - $this->releaseEipAddressSilently($key, $secret, $regionId, $allocatedEip['allocationId']); - } - $lastError = $e; - $message = $e->getMessage(); - if ($this->isDiskSizeError($message)) { - throw new \Exception("系统盘 {$diskSize} GB 不被当前镜像或实例规格支持,请手动调整系统盘大小后重新创建。阿里云返回:" . $message); - } - if (stripos($message, 'InvalidInstanceType.NotSupported') !== false || stripos($message, 'image architecture') !== false) { - $instanceTypeInfo = $this->describeInstanceType($key, $secret, $regionId, $instanceType); - $image = $this->selectSystemImage($key, $secret, $regionId, $preview['osKey'] ?? 'ubuntu_22', $instanceTypeInfo['CpuArchitecture'] ?? ''); - $preview['imageId'] = $image['imageId']; - $preview['osLabel'] = $image['label']; - } - continue; - } - } - } - - throw new \Exception($lastError ? $lastError->getMessage() : 'ECS 创建失败'); - } - - private function selectAvailableZone($key, $secret, $regionId, $instanceType) - { - $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $instanceType) { - $this->setDefaultClient($key, $secret, $regionId); - - return AlibabaCloud::rpc() - ->product('Ecs') - ->scheme('https') - ->version('2014-05-26') - ->action('DescribeZones') - ->method('POST') - ->host($this->ecsHost($regionId)) - ->options([ - 'query' => [ - 'RegionId' => $regionId, - 'InstanceType' => $instanceType, - 'AvailableResourceCreation.1' => 'Instance' - ], - 'connect_timeout' => 5.0, - 'timeout' => 15.0 - ]) - ->request(); - }, 'selectAvailableZone'); - - $zones = $result['Zones']['Zone'] ?? []; - foreach ($zones as $zone) { - if (!empty($zone['ZoneId'])) { - return [ - 'zoneId' => $zone['ZoneId'], - 'raw' => $zone - ]; - } - } - - throw new \Exception("当前区域 {$regionId} 下未找到规格 {$instanceType} 的可用区库存"); - } - - private function describeInstanceType($key, $secret, $regionId, $instanceType) - { - $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $instanceType) { - $this->setDefaultClient($key, $secret, $regionId); - - return AlibabaCloud::rpc() - ->product('Ecs') - ->scheme('https') - ->version('2014-05-26') - ->action('DescribeInstanceTypes') - ->method('POST') - ->host($this->ecsHost($regionId)) - ->options([ - 'query' => [ - 'RegionId' => $regionId, - 'InstanceTypes' => json_encode([$instanceType]) - ], - 'connect_timeout' => 5.0, - 'timeout' => 15.0 - ]) - ->request(); - }, 'describeInstanceType'); - - $types = $result['InstanceTypes']['InstanceType'] ?? []; - foreach ($types as $type) { - if (($type['InstanceTypeId'] ?? '') === $instanceType) { - return $type; - } - } - - return $types[0] ?? []; - } - - private function selectSystemImage($key, $secret, $regionId, $osKey, $cpuArchitecture = '') - { - $profiles = [ - 'alibaba_cloud_linux_3' => ['label' => 'Alibaba Cloud Linux 3', 'osType' => 'linux', 'platform' => 'Aliyun', 'patterns' => ['aliyun_3', 'alibaba cloud linux 3']], - 'ubuntu_22' => ['label' => 'Ubuntu 22.04', 'osType' => 'linux', 'platform' => 'Ubuntu', 'patterns' => ['ubuntu_22', 'ubuntu 22', '22_04']], - 'ubuntu_24' => ['label' => 'Ubuntu 24.04', 'osType' => 'linux', 'platform' => 'Ubuntu', 'patterns' => ['ubuntu_24', 'ubuntu 24', '24_04']], - 'debian_12' => ['label' => 'Debian 12', 'osType' => 'linux', 'platform' => 'Debian', 'patterns' => ['debian_12', 'debian 12']], - 'centos_stream_9' => ['label' => 'CentOS Stream 9', 'osType' => 'linux', 'platform' => 'CentOS', 'patterns' => ['centos_stream_9', 'centos stream 9']], - 'windows_2022' => ['label' => 'Windows Server 2022', 'osType' => 'windows', 'platform' => 'Windows Server', 'patterns' => ['win2022', 'windows server 2022']] - ]; - $profile = $profiles[$osKey] ?? $profiles['debian_12']; - $architecture = $this->normalizeImageArchitecture($cpuArchitecture); - - $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $profile, $architecture) { - $this->setDefaultClient($key, $secret, $regionId); - - $query = [ - 'RegionId' => $regionId, - 'ImageOwnerAlias' => 'system', - 'OSType' => $profile['osType'], - 'Status' => 'Available', - 'PageSize' => 100 - ]; - - if (!empty($profile['platform'])) { - $query['Platform'] = $profile['platform']; - } - if ($architecture !== '') { - $query['Architecture'] = $architecture; - } - - return AlibabaCloud::rpc() - ->product('Ecs') - ->scheme('https') - ->version('2014-05-26') - ->action('DescribeImages') - ->method('POST') - ->host($this->ecsHost($regionId)) - ->options([ - 'query' => $query, - 'connect_timeout' => 5.0, - 'timeout' => 15.0 - ]) - ->request(); - }, 'selectSystemImage'); - - $images = $result['Images']['Image'] ?? []; - usort($images, function ($a, $b) { - return strcmp((string) ($b['CreationTime'] ?? ''), (string) ($a['CreationTime'] ?? '')); - }); - - foreach ($images as $image) { - $haystack = strtolower(($image['ImageId'] ?? '') . ' ' . ($image['ImageName'] ?? '') . ' ' . ($image['Description'] ?? '')); - foreach ($profile['patterns'] as $pattern) { - if (strpos($haystack, strtolower($pattern)) !== false) { - return [ - 'imageId' => $image['ImageId'], - 'label' => $profile['label'], - 'osType' => $profile['osType'], - 'size' => (int) ($image['Size'] ?? 0) - ]; - } - } - } - - throw new \Exception("当前区域未找到可用系统镜像:{$profile['label']}"); - } - - private function normalizeImageArchitecture($cpuArchitecture) - { - $value = strtolower((string) $cpuArchitecture); - if (strpos($value, 'arm') !== false || strpos($value, 'aarch64') !== false) { - return 'arm64'; - } - if (strpos($value, 'x86') !== false || strpos($value, 'amd64') !== false || strpos($value, 'i386') !== false) { - return 'x86_64'; - } - return ''; - } - - private function ensureVpc($key, $secret, $regionId, $name, $cidr) - { - $existing = $this->describeManagedVpcs($key, $secret, $regionId); - if (!empty($existing)) { - return $existing[0]; - } - - $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $name, $cidr) { - $this->setDefaultClient($key, $secret, $regionId); - - return AlibabaCloud::rpc() - ->product('Vpc') - ->scheme('https') - ->version('2016-04-28') - ->action('CreateVpc') - ->method('POST') - ->host($this->vpcHost($regionId)) - ->options([ - 'query' => [ - 'RegionId' => $regionId, - 'VpcName' => $name, - 'CidrBlock' => $cidr, - 'Tag.1.Key' => $this->managedTagKey, - 'Tag.1.Value' => $this->managedTagValue - ], - 'connect_timeout' => 5.0, - 'timeout' => 15.0 - ]) - ->request(); - }, 'ensureVpc'); - - return ['VpcId' => $result['VpcId'] ?? '', 'VpcName' => $name, 'CidrBlock' => $cidr]; - } - - private function ensureVSwitch($key, $secret, $regionId, $zoneId, $vpcId, $name, $cidr) - { - $existing = $this->describeManagedVSwitches($key, $secret, $regionId, $vpcId, $zoneId); - if (!empty($existing)) { - return $existing[0]; - } - - $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $zoneId, $vpcId, $name, $cidr) { - $this->setDefaultClient($key, $secret, $regionId); - - return AlibabaCloud::rpc() - ->product('Vpc') - ->scheme('https') - ->version('2016-04-28') - ->action('CreateVSwitch') - ->method('POST') - ->host($this->vpcHost($regionId)) - ->options([ - 'query' => [ - 'RegionId' => $regionId, - 'ZoneId' => $zoneId, - 'VpcId' => $vpcId, - 'VSwitchName' => $name, - 'CidrBlock' => $cidr, - 'Tag.1.Key' => $this->managedTagKey, - 'Tag.1.Value' => $this->managedTagValue - ], - 'connect_timeout' => 5.0, - 'timeout' => 20.0 - ]) - ->request(); - }, 'ensureVSwitch'); - - return ['VSwitchId' => $result['VSwitchId'] ?? '', 'VSwitchName' => $name, 'ZoneId' => $zoneId, 'CidrBlock' => $cidr]; - } - - private function ensureSecurityGroup($key, $secret, $regionId, $vpcId, $name) - { - $existing = $this->describeManagedSecurityGroups($key, $secret, $regionId, $vpcId); - if (!empty($existing)) { - return $existing[0]; - } - - $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $vpcId, $name) { - $this->setDefaultClient($key, $secret, $regionId); - - return AlibabaCloud::rpc() - ->product('Ecs') - ->scheme('https') - ->version('2014-05-26') - ->action('CreateSecurityGroup') - ->method('POST') - ->host($this->ecsHost($regionId)) - ->options([ - 'query' => [ - 'RegionId' => $regionId, - 'VpcId' => $vpcId, - 'SecurityGroupName' => $name, - 'Description' => 'Managed by CDT Monitor', - 'SecurityGroupType' => 'normal', - 'Tag.1.Key' => $this->managedTagKey, - 'Tag.1.Value' => $this->managedTagValue - ], - 'connect_timeout' => 5.0, - 'timeout' => 15.0 - ]) - ->request(); - }, 'ensureSecurityGroup'); - - return ['SecurityGroupId' => $result['SecurityGroupId'] ?? '', 'SecurityGroupName' => $name]; - } - - private function authorizeSecurityGroupRule($key, $secret, $regionId, $securityGroupId, $port, $sourceCidrIp) - { - try { - $this->executeWithRetry(function () use ($key, $secret, $regionId, $securityGroupId, $port, $sourceCidrIp) { - $this->setDefaultClient($key, $secret, $regionId); - - return AlibabaCloud::rpc() - ->product('Ecs') - ->scheme('https') - ->version('2014-05-26') - ->action('AuthorizeSecurityGroup') - ->method('POST') - ->host($this->ecsHost($regionId)) - ->options([ - 'query' => [ - 'RegionId' => $regionId, - 'SecurityGroupId' => $securityGroupId, - 'IpProtocol' => 'tcp', - 'PortRange' => "{$port}/{$port}", - 'SourceCidrIp' => $sourceCidrIp, - 'Policy' => 'accept', - 'Priority' => '1', - 'Description' => 'CDT Monitor managed remote access' - ], - 'connect_timeout' => 5.0, - 'timeout' => 15.0 - ]) - ->request(); - }, 'authorizeSecurityGroupRule'); - } catch (\Exception $e) { - if (stripos($e->getMessage(), 'InvalidPermission.Duplicate') === false) { - throw $e; - } - } - } - - private function authorizeOpenSecurityGroupRules($key, $secret, $regionId, $securityGroupId) - { - $rules = [ - ['protocol' => 'tcp', 'port' => '1/65535'], - ['protocol' => 'udp', 'port' => '1/65535'], - ['protocol' => 'icmp', 'port' => '-1/-1'] - ]; - - foreach ($rules as $rule) { - try { - $this->executeWithRetry(function () use ($key, $secret, $regionId, $securityGroupId, $rule) { - $this->setDefaultClient($key, $secret, $regionId); - - return AlibabaCloud::rpc() - ->product('Ecs') - ->scheme('https') - ->version('2014-05-26') - ->action('AuthorizeSecurityGroup') - ->method('POST') - ->host($this->ecsHost($regionId)) - ->options([ - 'query' => [ - 'RegionId' => $regionId, - 'SecurityGroupId' => $securityGroupId, - 'IpProtocol' => $rule['protocol'], - 'PortRange' => $rule['port'], - 'SourceCidrIp' => '0.0.0.0/0', - 'Policy' => 'accept', - 'Priority' => '1', - 'Description' => 'CDT Monitor open access' - ], - 'connect_timeout' => 5.0, - 'timeout' => 15.0 - ]) - ->request(); - }, 'authorizeOpenSecurityGroupRules'); - } catch (\Exception $e) { - if (stripos($e->getMessage(), 'InvalidPermission.Duplicate') === false) { - throw $e; - } - } - } - } - - private function runInstance($key, $secret, $regionId, array $params) - { - $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $params) { - $this->setDefaultClient($key, $secret, $regionId); - - return AlibabaCloud::rpc() - ->product('Ecs') - ->scheme('https') - ->version('2014-05-26') - ->action('RunInstances') - ->method('POST') - ->host($this->ecsHost($regionId)) - ->options([ - 'query' => [ - 'RegionId' => $regionId, - 'ZoneId' => $params['zoneId'], - 'InstanceType' => $params['instanceType'], - 'ImageId' => $params['imageId'], - 'SecurityGroupId' => $params['securityGroupId'], - 'VSwitchId' => $params['vSwitchId'], - 'InstanceName' => $params['instanceName'], - 'HostName' => preg_replace('/[^a-zA-Z0-9-]/', '-', strtolower($params['instanceName'])), - 'Password' => $params['password'], - 'InstanceChargeType' => 'PostPaid', - 'InternetChargeType' => 'PayByTraffic', - 'InternetMaxBandwidthOut' => (int) $params['internetMaxBandwidthOut'], - 'SystemDisk.Category' => $params['systemDiskCategory'], - 'SystemDisk.Size' => (int) $params['systemDiskSize'], - 'DeletionProtection' => 'false', - 'IoOptimized' => 'optimized', - 'Amount' => 1, - 'Tag.1.Key' => $this->managedTagKey, - 'Tag.1.Value' => $this->managedTagValue - ], - 'connect_timeout' => 5.0, - 'timeout' => 25.0 - ]) - ->request(); - }, 'runInstance', 1); - - return $result['InstanceIdSets']['InstanceIdSet'] ?? []; - } - - private function allocateEipAddress($key, $secret, $regionId, $bandwidth, $instanceName) - { - $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $bandwidth, $instanceName) { - $this->setDefaultClient($key, $secret, $regionId); - - return AlibabaCloud::rpc() - ->product('Vpc') - ->scheme('https') - ->version('2016-04-28') - ->action('AllocateEipAddress') - ->method('POST') - ->host($this->vpcHost($regionId)) - ->options([ - 'query' => [ - 'RegionId' => $regionId, - 'Bandwidth' => max(1, (int) $bandwidth), - 'InternetChargeType' => 'PayByTraffic', - 'Name' => $instanceName . '-eip', - 'Tag.1.Key' => $this->managedTagKey, - 'Tag.1.Value' => $this->managedTagValue - ], - 'connect_timeout' => 5.0, - 'timeout' => 20.0 - ]) - ->request(); - }, 'allocateEipAddress'); - - $allocationId = $result['AllocationId'] ?? ''; - if ($allocationId === '') { - throw new \Exception('EIP 申请成功但未返回 AllocationId'); - } - - $ipAddress = $result['EipAddress'] ?? ''; - if ($ipAddress === '') { - $detail = $this->waitEipStatus($key, $secret, $regionId, $allocationId, 'Available', 6); - $ipAddress = $detail['IpAddress'] ?? ''; - } - - return [ - 'allocationId' => $allocationId, - 'ipAddress' => $ipAddress - ]; - } - - private function associateEipAddress($key, $secret, $regionId, $allocationId, $instanceId) - { - if ($allocationId === '' || $instanceId === '') { - throw new \Exception('EIP 绑定参数缺失'); - } - - return $this->executeWithRetry(function () use ($key, $secret, $regionId, $allocationId, $instanceId) { - $this->setDefaultClient($key, $secret, $regionId); - - return AlibabaCloud::rpc() - ->product('Vpc') - ->scheme('https') - ->version('2016-04-28') - ->action('AssociateEipAddress') - ->method('POST') - ->host($this->vpcHost($regionId)) - ->options([ - 'query' => [ - 'RegionId' => $regionId, - 'AllocationId' => $allocationId, - 'InstanceId' => $instanceId, - 'InstanceType' => 'EcsInstance' - ], - 'connect_timeout' => 5.0, - 'timeout' => 20.0 - ]) - ->request(); - }, 'associateEipAddress'); - } - - private function unassociateEipAddress($key, $secret, $regionId, $allocationId, $instanceId) - { - if ($allocationId === '') { - return true; - } - - try { - $this->executeWithRetry(function () use ($key, $secret, $regionId, $allocationId, $instanceId) { - $this->setDefaultClient($key, $secret, $regionId); - - $query = [ - 'RegionId' => $regionId, - 'AllocationId' => $allocationId, - 'InstanceType' => 'EcsInstance' - ]; - if ($instanceId !== '') { - $query['InstanceId'] = $instanceId; - } - - return AlibabaCloud::rpc() - ->product('Vpc') - ->scheme('https') - ->version('2016-04-28') - ->action('UnassociateEipAddress') - ->method('POST') - ->host($this->vpcHost($regionId)) - ->options([ - 'query' => $query, - 'connect_timeout' => 5.0, - 'timeout' => 20.0 - ]) - ->request(); - }, 'unassociateEipAddress'); - } catch (\Exception $e) { - $message = $e->getMessage(); - if ( - stripos($message, 'IncorrectEipStatus') === false - && stripos($message, 'InvalidAllocationId.NotFound') === false - && stripos($message, 'not exist') === false - ) { - throw $e; - } - } - - return true; - } - - private function releaseEipAddress($key, $secret, $regionId, $allocationId) - { - if ($allocationId === '') { - return true; - } - - try { - $this->executeWithRetry(function () use ($key, $secret, $regionId, $allocationId) { - $this->setDefaultClient($key, $secret, $regionId); - - return AlibabaCloud::rpc() - ->product('Vpc') - ->scheme('https') - ->version('2016-04-28') - ->action('ReleaseEipAddress') - ->method('POST') - ->host($this->vpcHost($regionId)) - ->options([ - 'query' => [ - 'RegionId' => $regionId, - 'AllocationId' => $allocationId - ], - 'connect_timeout' => 5.0, - 'timeout' => 20.0 - ]) - ->request(); - }, 'releaseEipAddress'); - } catch (\Exception $e) { - $message = $e->getMessage(); - if (stripos($message, 'InvalidAllocationId.NotFound') === false && stripos($message, 'not exist') === false) { - throw $e; - } - } - - return true; - } - - private function releaseEipAddressSilently($key, $secret, $regionId, $allocationId) - { - try { - $this->unassociateEipAddress($key, $secret, $regionId, $allocationId, ''); - $this->waitEipStatus($key, $secret, $regionId, $allocationId, 'Available', 6); - $this->releaseEipAddress($key, $secret, $regionId, $allocationId); - } catch (\Exception $e) { - // 创建失败回滚场景不覆盖原始错误,后台日志由调用方记录。 - } - } - - private function describeEipAddress($key, $secret, $regionId, $allocationId) - { - $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $allocationId) { - $this->setDefaultClient($key, $secret, $regionId); - - return AlibabaCloud::rpc() - ->product('Vpc') - ->scheme('https') - ->version('2016-04-28') - ->action('DescribeEipAddresses') - ->method('POST') - ->host($this->vpcHost($regionId)) - ->options([ - 'query' => [ - 'RegionId' => $regionId, - 'AllocationId' => $allocationId - ], - 'connect_timeout' => 5.0, - 'timeout' => 15.0 - ]) - ->request(); - }, 'describeEipAddress'); - - return $result['EipAddresses']['EipAddress'][0] ?? null; - } - - private function waitEipStatus($key, $secret, $regionId, $allocationId, $targetStatus, $maxAttempts = 12) - { - $last = null; - for ($i = 0; $i < $maxAttempts; $i++) { - sleep($i === 0 ? 2 : 4); - $last = $this->describeEipAddress($key, $secret, $regionId, $allocationId); - if (!$last) { - continue; - } - if (($last['Status'] ?? '') === $targetStatus) { - return $last; - } - } - - return $last; - } - - public function releaseManagedEip($account) - { - $allocationId = trim((string) ($account['eip_allocation_id'] ?? '')); - if (($account['public_ip_mode'] ?? '') !== 'eip' || empty($account['eip_managed']) || $allocationId === '') { - return false; - } - - $key = $account['access_key_id']; - $secret = $account['access_key_secret']; - $regionId = $account['region_id']; - $this->unassociateEipAddress($key, $secret, $regionId, $allocationId, $account['instance_id'] ?? ''); - $this->waitEipStatus($key, $secret, $regionId, $allocationId, 'Available', 8); - $this->releaseEipAddress($key, $secret, $regionId, $allocationId); - return true; - } - - public function replaceManagedEip($account) - { - $oldAllocationId = trim((string) ($account['eip_allocation_id'] ?? '')); - if (($account['public_ip_mode'] ?? '') !== 'eip' || empty($account['eip_managed']) || $oldAllocationId === '') { - throw new \Exception('当前实例不是系统托管 EIP,无法更换公网 IP'); - } - - $key = $account['access_key_id']; - $secret = $account['access_key_secret']; - $regionId = $account['region_id']; - $instanceId = $account['instance_id'] ?? ''; - $bandwidth = max(1, (int) ($account['internet_max_bandwidth_out'] ?? 100)); - - $newEip = $this->allocateEipAddress($key, $secret, $regionId, $bandwidth, ($account['instance_name'] ?? $instanceId) . '-replace'); - - try { - $this->unassociateEipAddress($key, $secret, $regionId, $oldAllocationId, $instanceId); - $this->waitEipStatus($key, $secret, $regionId, $oldAllocationId, 'Available', 8); - $this->associateEipAddress($key, $secret, $regionId, $newEip['allocationId'], $instanceId); - $this->waitEipStatus($key, $secret, $regionId, $newEip['allocationId'], 'InUse', 12); - $this->releaseEipAddress($key, $secret, $regionId, $oldAllocationId); - } catch (\Exception $e) { - $this->releaseEipAddressSilently($key, $secret, $regionId, $newEip['allocationId'] ?? ''); - throw $e; - } - - return [ - 'publicIp' => $newEip['ipAddress'] ?? '', - 'publicIpMode' => 'eip', - 'eipAllocationId' => $newEip['allocationId'] ?? '', - 'eipAddress' => $newEip['ipAddress'] ?? '', - 'eipManaged' => true, - 'internetMaxBandwidthOut' => $bandwidth - ]; - } - - private function waitInstanceReady($key, $secret, $regionId, $instanceId) - { - $last = null; - for ($i = 0; $i < 18; $i++) { - sleep($i === 0 ? 2 : 5); - $instances = $this->describeInstancesByIds($key, $secret, $regionId, [$instanceId]); - if (!empty($instances)) { - $last = $instances[0]; - if (in_array($last['status'], ['Running', 'Stopped'], true)) { - return $last; - } - } - } - - return $last ?: ['status' => 'Unknown']; - } - - private function describeInstancesByIds($key, $secret, $regionId, array $instanceIds) - { - $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $instanceIds) { - $this->setDefaultClient($key, $secret, $regionId); - - return AlibabaCloud::rpc() - ->product('Ecs') - ->scheme('https') - ->version('2014-05-26') - ->action('DescribeInstances') - ->method('POST') - ->host($this->ecsHost($regionId)) - ->options([ - 'query' => [ - 'RegionId' => $regionId, - 'InstanceIds' => json_encode(array_values($instanceIds)) - ], - 'connect_timeout' => 5.0, - 'timeout' => 15.0 - ]) - ->request(); - }, 'describeInstancesByIds'); - - $items = $result['Instances']['Instance'] ?? []; - return array_map(function ($instance) { - return [ - 'instanceId' => $instance['InstanceId'] ?? '', - 'instanceName' => $instance['InstanceName'] ?? '', - 'status' => $instance['Status'] ?? 'Unknown', - 'instanceType' => $instance['InstanceType'] ?? '', - 'internetMaxBandwidthOut' => (int) (($instance['EipAddress']['Bandwidth'] ?? 0) ?: ($instance['InternetMaxBandwidthOut'] ?? 0)), - 'publicIp' => $instance['PublicIpAddress']['IpAddress'][0] ?? $instance['EipAddress']['IpAddress'] ?? '', - 'eipAllocationId' => $instance['EipAddress']['AllocationId'] ?? '', - 'eipAddress' => $instance['EipAddress']['IpAddress'] ?? '', - 'privateIp' => $instance['VpcAttributes']['PrivateIpAddress']['IpAddress'][0] ?? '' - ]; - }, $items); - } - - private function describeManagedVpcs($key, $secret, $regionId) - { - $result = $this->executeWithRetry(function () use ($key, $secret, $regionId) { - $this->setDefaultClient($key, $secret, $regionId); - - return AlibabaCloud::rpc() - ->product('Vpc') - ->scheme('https') - ->version('2016-04-28') - ->action('DescribeVpcs') - ->method('POST') - ->host($this->vpcHost($regionId)) - ->options([ - 'query' => [ - 'RegionId' => $regionId, - 'Tag.1.Key' => $this->managedTagKey, - 'Tag.1.Value' => $this->managedTagValue - ], - 'connect_timeout' => 5.0, - 'timeout' => 15.0 - ]) - ->request(); - }, 'describeManagedVpcs'); - - return $result['Vpcs']['Vpc'] ?? []; - } - - private function describeManagedVSwitches($key, $secret, $regionId, $vpcId, $zoneId) - { - $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $vpcId, $zoneId) { - $this->setDefaultClient($key, $secret, $regionId); - - return AlibabaCloud::rpc() - ->product('Vpc') - ->scheme('https') - ->version('2016-04-28') - ->action('DescribeVSwitches') - ->method('POST') - ->host($this->vpcHost($regionId)) - ->options([ - 'query' => [ - 'RegionId' => $regionId, - 'VpcId' => $vpcId, - 'ZoneId' => $zoneId, - 'Tag.1.Key' => $this->managedTagKey, - 'Tag.1.Value' => $this->managedTagValue - ], - 'connect_timeout' => 5.0, - 'timeout' => 15.0 - ]) - ->request(); - }, 'describeManagedVSwitches'); - - return $result['VSwitches']['VSwitch'] ?? []; - } - - private function describeManagedSecurityGroups($key, $secret, $regionId, $vpcId) - { - $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $vpcId) { - $this->setDefaultClient($key, $secret, $regionId); - - return AlibabaCloud::rpc() - ->product('Ecs') - ->scheme('https') - ->version('2014-05-26') - ->action('DescribeSecurityGroups') - ->method('POST') - ->host($this->ecsHost($regionId)) - ->options([ - 'query' => [ - 'RegionId' => $regionId, - 'VpcId' => $vpcId, - 'Tag.1.Key' => $this->managedTagKey, - 'Tag.1.Value' => $this->managedTagValue - ], - 'connect_timeout' => 5.0, - 'timeout' => 15.0 - ]) - ->request(); - }, 'describeManagedSecurityGroups'); - - return $result['SecurityGroups']['SecurityGroup'] ?? []; - } - - private function defaultMinSystemDiskSize($osKey) - { - return 20; - } - - private function getSystemDiskSizeRange($key, $secret, $regionId, $zoneId, $instanceType, $diskCategory) - { - $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $zoneId, $instanceType, $diskCategory) { - $this->setDefaultClient($key, $secret, $regionId); - - return AlibabaCloud::rpc() - ->product('Ecs') - ->scheme('https') - ->version('2014-05-26') - ->action('DescribeAvailableResource') - ->method('POST') - ->host($this->ecsHost($regionId)) - ->options([ - 'query' => [ - 'RegionId' => $regionId, - 'ZoneId' => $zoneId, - 'DestinationResource' => 'SystemDisk', - 'ResourceType' => 'instance', - 'InstanceType' => $instanceType, - 'SystemDiskCategory' => $diskCategory, - 'IoOptimized' => 'optimized', - 'NetworkCategory' => 'vpc', - 'InstanceChargeType' => 'PostPaid' - ], - 'connect_timeout' => 5.0, - 'timeout' => 15.0 - ]) - ->request(); - }, 'getSystemDiskSizeRange'); - - $zones = $result['AvailableZones']['AvailableZone'] ?? []; - foreach ($zones as $zone) { - $resources = $zone['AvailableResources']['AvailableResource'] ?? []; - foreach ($resources as $resource) { - if (($resource['Type'] ?? '') !== 'SystemDisk') { - continue; - } - - $supported = $resource['SupportedResources']['SupportedResource'] ?? []; - foreach ($supported as $item) { - $value = $item['Value'] ?? ''; - if ($value !== '' && $value !== $diskCategory) { - continue; - } - - return [ - 'min' => max(1, (int) ($item['Min'] ?? 20)), - 'max' => max(1, (int) ($item['Max'] ?? 2048)), - 'unit' => $item['Unit'] ?? 'GiB', - 'status' => $item['Status'] ?? '', - 'statusCategory' => $item['StatusCategory'] ?? '' - ]; - } - } - } - - throw new \Exception("当前可用区/规格/磁盘类型未返回系统盘容量范围,请更换磁盘类型或实例规格后重试"); - } - - private function normalizeSystemDiskSize($value, array $range = []) - { - $size = (int) $value; - $min = (int) ($range['min'] ?? 20); - $max = (int) ($range['max'] ?? 2048); - $unit = $range['unit'] ?? 'GiB'; - - if ($size < $min || $size > $max) { - throw new \Exception("系统盘大小必须在当前 API 返回范围 {$min}-{$max} {$unit} 之间"); - } - return $size; - } - - private function isDiskSizeError($message) - { - $message = strtolower((string) $message); - return strpos($message, 'systemdisk.size') !== false - || strpos($message, 'invalidsystemdisksize') !== false - || (strpos($message, 'disk') !== false && strpos($message, 'size') !== false); - } - - private function selectDiskCategory($zone, $requested = 'cloud_essd_entry') - { - $raw = $zone['raw']['AvailableDiskCategories']['DiskCategories'] ?? $zone['raw']['AvailableDiskCategories']['DiskCategory'] ?? []; - $categories = is_array($raw) ? $raw : []; - $requested = trim((string) $requested); - if ($requested !== '') { - if (empty($categories) || in_array($requested, $categories, true)) { - return $requested; - } - throw new \Exception("当前可用区不支持所选硬盘类型 {$requested},请更换硬盘类型或实例规格后重试"); - } - - foreach (['cloud_essd_entry', 'cloud_essd', 'cloud_efficiency', 'cloud'] as $preferred) { - if (empty($categories) || in_array($preferred, $categories, true)) { - return $preferred; - } - } - return 'cloud_essd_entry'; - } - - private function diskCategoryLabel($category) - { - $map = [ - 'cloud_essd_entry' => 'ESSD Entry 云盘', - 'cloud_essd' => 'ESSD 云盘', - 'cloud_efficiency' => '高效云盘', - 'cloud' => '普通云盘' - ]; - - return $map[$category] ?? $category; - } - - private function estimateMaxBandwidthOut($instanceType, $regionId) - { - return 200; - } - - private function bandwidthCandidates($max) - { - $base = [200, 100, 50, 30, 20, 10, 5, 1]; - $candidates = array_values(array_filter($base, function ($value) use ($max) { - return $value <= max(1, $max); - })); - if (!in_array($max, $candidates, true)) { - array_unshift($candidates, $max); - } - return array_values(array_unique($candidates)); - } - - private function normalizePublicCidr($ip) - { - $ip = trim((string) $ip); - if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { - return $ip . '/32'; - } - return ''; - } - - private function cidrForZone($zoneId) - { - $hash = abs(crc32($zoneId)); - $third = 1 + ($hash % 200); - return "172.31.{$third}.0/24"; - } - - private function generateInstancePassword() - { - $lower = 'abcdefghijkmnopqrstuvwxyz'; - $upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; - $digits = '23456789'; - $symbols = '!@#%^*'; - $all = $lower . $upper . $digits . $symbols; - $password = $lower[random_int(0, strlen($lower) - 1)] - . $upper[random_int(0, strlen($upper) - 1)] - . $digits[random_int(0, strlen($digits) - 1)] - . $symbols[random_int(0, strlen($symbols) - 1)]; - for ($i = strlen($password); $i < 16; $i++) { - $password .= $all[random_int(0, strlen($all) - 1)]; - } - return str_shuffle($password); - } - - private function emitProgress($progress, $step) - { - if (is_callable($progress)) { - $progress($step); - } - } - - // ==================== BSS 费用中心 API ==================== - - private $balanceCache = []; - - /** - * 查询账户可用余额 - * @param string $key AccessKey - * @param string $secret Secret - * @return array ['AvailableAmount' => '...', 'Currency' => 'CNY'] - * @throws \Exception - */ - public function getAccountBalance($key, $secret, $siteType = 'china') - { - $cacheKey = md5($key); - if (isset($this->balanceCache[$cacheKey])) { - return $this->balanceCache[$cacheKey]; - } - - $bss = $this->getBssEndpoint($siteType); - - $result = $this->executeWithRetry(function () use ($key, $secret, $bss) { - AlibabaCloud::accessKeyClient($key, $secret) - ->regionId($bss['regionId']) - ->asDefaultClient(); - - return AlibabaCloud::rpc() - ->product('BssOpenApi') - ->scheme('https') - ->version('2017-12-14') - ->action('QueryAccountBalance') - ->method('POST') - ->host($bss['host']) - ->options([ - 'connect_timeout' => 5.0, - 'timeout' => 10.0 - ]) - ->request(); - }, 'getAccountBalance'); - - $data = [ - 'AvailableAmount' => $result['Data']['AvailableAmount'] ?? '0', - 'Currency' => $result['Data']['Currency'] ?? 'CNY' - ]; - - $this->balanceCache[$cacheKey] = $data; - return $data; - } - - /** - * 查询指定实例的当月账单明细 - * @param string $key AccessKey - * @param string $secret Secret - * @param string $instanceId 实例ID - * @param string $billingCycle 账期 (格式: 2026-03) - * @return array ['TotalCost' => float, 'Items' => [...]] - * @throws \Exception - */ - public function getInstanceBill($key, $secret, $instanceId, $billingCycle, $siteType = 'china') - { - $bss = $this->getBssEndpoint($siteType); - - $result = $this->executeWithRetry(function () use ($key, $secret, $instanceId, $billingCycle, $bss) { - AlibabaCloud::accessKeyClient($key, $secret) - ->regionId($bss['regionId']) - ->asDefaultClient(); - - return AlibabaCloud::rpc() - ->product('BssOpenApi') - ->scheme('https') - ->version('2017-12-14') - ->action('DescribeInstanceBill') - ->method('POST') - ->host($bss['host']) - ->options([ - 'query' => [ - 'BillingCycle' => $billingCycle, - 'InstanceID' => $instanceId, - 'Granularity' => 'MONTHLY' - ], - 'connect_timeout' => 5.0, - 'timeout' => 15.0 - ]) - ->request(); - }, 'getInstanceBill'); - - $items = $result['Data']['Items'] ?? []; - $totalCost = 0; - $details = []; - - foreach ($items as $item) { - $cost = (float) ($item['PretaxAmount'] ?? 0); - $totalCost += $cost; - $details[] = [ - 'ProductName' => $item['ProductName'] ?? '', - 'ProductCode' => $item['ProductCode'] ?? '', - 'BillingType' => $item['BillingType'] ?? '', - 'PretaxAmount' => $cost, - 'DeductedByCashCoupons' => (float) ($item['DeductedByCashCoupons'] ?? 0), - 'DeductedByPrepaidCard' => (float) ($item['DeductedByPrepaidCard'] ?? 0), - 'PaymentAmount' => (float) ($item['PaymentAmount'] ?? 0), - ]; - } - - return [ - 'TotalCost' => round($totalCost, 2), - 'Items' => $details - ]; - } - - /** - * 查询账单总览 (按产品分类的月度费用) - * @param string $key AccessKey - * @param string $secret Secret - * @param string $billingCycle 账期 (格式: 2026-03) - * @return array ['TotalCost' => float, 'Products' => [...]] - * @throws \Exception - */ - public function getBillOverview($key, $secret, $billingCycle, $siteType = 'china') - { - $bss = $this->getBssEndpoint($siteType); - - $result = $this->executeWithRetry(function () use ($key, $secret, $billingCycle, $bss) { - AlibabaCloud::accessKeyClient($key, $secret) - ->regionId($bss['regionId']) - ->asDefaultClient(); - - return AlibabaCloud::rpc() - ->product('BssOpenApi') - ->scheme('https') - ->version('2017-12-14') - ->action('QueryBillOverview') - ->method('POST') - ->host($bss['host']) - ->options([ - 'query' => [ - 'BillingCycle' => $billingCycle - ], - 'connect_timeout' => 5.0, - 'timeout' => 15.0 - ]) - ->request(); - }, 'getBillOverview'); - - $items = $result['Data']['Items']['Item'] ?? []; - $totalCost = 0; - $products = []; - - foreach ($items as $item) { - $cost = (float) ($item['PretaxAmount'] ?? 0); - if ($cost <= 0) continue; - $totalCost += $cost; - $products[] = [ - 'ProductName' => $item['ProductName'] ?? '', - 'ProductCode' => $item['ProductCode'] ?? '', - 'PretaxAmount' => round($cost, 2), - 'PaymentAmount' => round((float) ($item['PaymentAmount'] ?? 0), 2) - ]; - } - - // 按费用降序排列 - usort($products, function ($a, $b) { - return $b['PretaxAmount'] <=> $a['PretaxAmount']; - }); - - return [ - 'TotalCost' => round($totalCost, 2), - 'Products' => $products - ]; - } -} diff --git a/AliyunTrafficCheck.php b/AliyunTrafficCheck.php deleted file mode 100644 index 6beaf35..0000000 --- a/AliyunTrafficCheck.php +++ /dev/null @@ -1,2302 +0,0 @@ -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(); - } -} diff --git a/ConfigManager.php b/ConfigManager.php deleted file mode 100644 index 3dd86ff..0000000 --- a/ConfigManager.php +++ /dev/null @@ -1,1097 +0,0 @@ -database = $db; - $this->db = $db->getPdo(); - $this->encryptionKey = $this->getEncryptionKey(); - $this->load(); - } - - private function getEncryptionKey() - { - $keyDir = __DIR__ . '/data'; - $keyFile = $keyDir . '/.secret_encryption.key'; - - if (!is_dir($keyDir)) { - @mkdir($keyDir, 0755, true); - } - - if (@file_exists($keyFile)) { - $key = @file_get_contents($keyFile); - if ($key !== false && strlen($key) === SODIUM_CRYPTO_SECRETBOX_KEYBYTES) { - return $key; - } - } - - $key = random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES); - @file_put_contents($keyFile, $key, LOCK_EX); - @chmod($keyFile, 0600); - return $key; - } - - private function encryptValue($value) - { - if (!function_exists('sodium_crypto_secretbox') || empty($value)) { - return $value; - } - $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); - $encrypted = sodium_crypto_secretbox($value, $nonce, $this->encryptionKey); - return 'ENC1' . base64_encode($nonce . $encrypted); - } - - private function decryptValue($value) - { - if (!function_exists('sodium_crypto_secretbox') || empty($value) || strlen($value) < 8 || substr($value, 0, 4) !== 'ENC1') { - return $value; - } - $raw = base64_decode(substr($value, 4)); - if ($raw === false) { - return $value; - } - $nonce = substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); - $ciphertext = substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); - $decrypted = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->encryptionKey); - return $decrypted !== false ? $decrypted : $value; - } - - private function isEncryptedValue($value) - { - return strlen($value) >= 8 && substr($value, 0, 4) === 'ENC1'; - } - - public function load() - { - $this->configCache = []; - $stmt = $this->db->query("SELECT key, value FROM settings"); - while ($row = $stmt->fetch()) { - $this->configCache[$row['key']] = $row['value']; - } - - $this->resetMonthlyTrafficCacheIfNeeded(); - - $stmt = $this->db->query("SELECT * FROM accounts WHERE is_deleted = 0 ORDER BY region_id ASC, remark ASC, id ASC"); - $this->accountsCache = $stmt->fetchAll(); - - foreach ($this->accountsCache as &$row) { - if (!empty($row['access_key_secret']) && $this->isEncryptedValue($row['access_key_secret'])) { - $row['access_key_secret'] = $this->decryptValue($row['access_key_secret']); - } - } - unset($row); - } - - private function resetMonthlyTrafficCacheIfNeeded() - { - $currentMonth = date('Y-m'); - - // 首次升级时,已有缓存默认视为当前月,避免上线当天误清空。 - $stmt = $this->db->prepare("UPDATE accounts SET traffic_billing_month = ? WHERE traffic_billing_month IS NULL OR traffic_billing_month = ''"); - $stmt->execute([$currentMonth]); - - $stmt = $this->db->prepare("SELECT COUNT(*) FROM accounts WHERE traffic_billing_month <> ?"); - $stmt->execute([$currentMonth]); - $needsMonthlyReset = (int) $stmt->fetchColumn() > 0; - - // 阿里云 CDT/公网流量按自然月结算。月切换后,展示值和熔断判断都必须从当月重新开始。 - $stmt = $this->db->prepare(" - UPDATE accounts - SET traffic_used = 0, - traffic_billing_month = ?, - updated_at = 0, - schedule_blocked_by_traffic = 0 - WHERE traffic_billing_month <> ? - "); - $stmt->execute([$currentMonth, $currentMonth]); - - if ($needsMonthlyReset) { - $this->clearStoredAccountGroupScheduleBlocks(); - $this->database->addLog('info', '检测到新的自然月,已重置账号流量并恢复定时开关机'); - } - } - - public function get($key, $default = null) - { - return $this->configCache[$key] ?? $default; - } - - public function getAllSettings() - { - return $this->configCache; - } - - public function getAccounts() - { - return $this->accountsCache; - } - - public function getAccountById($id) - { - foreach ($this->accountsCache as $acc) { - if ((int) $acc['id'] === (int) $id) { - return $acc; - } - } - return null; - } - - public function getAccountByInstanceId($instanceId) - { - foreach ($this->accountsCache as $acc) { - if (($acc['instance_id'] ?? '') === $instanceId) { - return $acc; - } - } - return null; - } - - public function decryptAccountSecret($secretFromDb) - { - if (empty($secretFromDb)) { - return ''; - } - return $this->decryptValue($secretFromDb); - } - - public function getAccountGroups() - { - $raw = $this->configCache['account_groups'] ?? ''; - if (!empty($raw)) { - $decoded = json_decode($raw, true); - if (is_array($decoded)) { - $groups = $this->normalizeAccountGroups($decoded, true); - $blockedByGroup = []; - foreach ($this->accountsCache as $row) { - $groupKey = $row['group_key'] ?: $this->buildGroupKey($row['access_key_id'], $row['region_id']); - if (!isset($blockedByGroup[$groupKey])) { - $blockedByGroup[$groupKey] = false; - } - if (!empty($row['schedule_blocked_by_traffic'])) { - $blockedByGroup[$groupKey] = true; - } - } - foreach ($groups as &$group) { - $group['scheduleBlockedByTraffic'] = !empty($blockedByGroup[$group['groupKey'] ?? '']); - } - unset($group); - return $groups; - } - } - - return $this->deriveAccountGroupsFromAccounts(); - } - - public function getAccountGroupMetrics() - { - $groups = $this->getAccountGroups(); - $metrics = []; - - foreach ($groups as $group) { - $groupKey = $group['groupKey']; - $maxTraffic = (float) ($group['maxTraffic'] ?? 0); - $metrics[$groupKey] = [ - 'usageUsed' => 0.0, - 'usageRemaining' => $maxTraffic, - 'usagePercent' => 0.0, - 'instanceCount' => 0, - 'lastUpdated' => 0, - 'trafficStatus' => 'ok', - 'trafficMessage' => '', - '_trafficErrorCount' => 0, - '_trafficFirstStatus' => '', - '_trafficFirstMessage' => '' - ]; - } - - foreach ($this->accountsCache as $row) { - $groupKey = $row['group_key'] ?: $this->buildGroupKey($row['access_key_id'], $row['region_id']); - if (!isset($metrics[$groupKey])) { - $maxTraffic = (float) ($row['max_traffic'] ?? 0); - $metrics[$groupKey] = [ - 'usageUsed' => 0.0, - 'usageRemaining' => $maxTraffic, - 'usagePercent' => 0.0, - 'instanceCount' => 0, - 'lastUpdated' => 0, - 'trafficStatus' => 'ok', - 'trafficMessage' => '', - '_trafficErrorCount' => 0, - '_trafficFirstStatus' => '', - '_trafficFirstMessage' => '' - ]; - } - - if (!empty($row['instance_id'])) { - $metrics[$groupKey]['instanceCount']++; - $trafficStatus = trim((string) ($row['traffic_api_status'] ?? 'ok')); - if ($trafficStatus !== '' && $trafficStatus !== 'ok') { - $metrics[$groupKey]['_trafficErrorCount']++; - if ($metrics[$groupKey]['_trafficFirstStatus'] === '') { - $metrics[$groupKey]['_trafficFirstStatus'] = $trafficStatus; - $metrics[$groupKey]['_trafficFirstMessage'] = trim((string) ($row['traffic_api_message'] ?? '')); - } - } - } - - $isCurrentMonthTraffic = ($row['traffic_billing_month'] ?? '') === date('Y-m'); - $metrics[$groupKey]['usageUsed'] += $isCurrentMonthTraffic ? (float) ($row['traffic_used'] ?? 0) : 0.0; - $metrics[$groupKey]['lastUpdated'] = max($metrics[$groupKey]['lastUpdated'], (int) ($row['updated_at'] ?? 0)); - } - - foreach ($groups as $group) { - $groupKey = $group['groupKey']; - $maxTraffic = (float) ($group['maxTraffic'] ?? 0); - $used = (float) ($metrics[$groupKey]['usageUsed'] ?? 0); - $metrics[$groupKey]['usageRemaining'] = max($maxTraffic - $used, 0); - $metrics[$groupKey]['usagePercent'] = $maxTraffic > 0 ? min(round(($used / $maxTraffic) * 100, 2), 100) : 0; - $errorCount = (int) ($metrics[$groupKey]['_trafficErrorCount'] ?? 0); - $instanceCount = (int) ($metrics[$groupKey]['instanceCount'] ?? 0); - if ($instanceCount > 0 && $errorCount > 0) { - if ($errorCount >= $instanceCount) { - $metrics[$groupKey]['trafficStatus'] = $metrics[$groupKey]['_trafficFirstStatus'] ?: 'error'; - $metrics[$groupKey]['trafficMessage'] = $metrics[$groupKey]['_trafficFirstMessage'] ?: '账号下实例流量同步失败'; - } else { - $metrics[$groupKey]['trafficStatus'] = 'partial'; - $metrics[$groupKey]['trafficMessage'] = '部分实例流量同步失败'; - } - } - unset( - $metrics[$groupKey]['_trafficErrorCount'], - $metrics[$groupKey]['_trafficFirstStatus'], - $metrics[$groupKey]['_trafficFirstMessage'] - ); - } - - return $metrics; - } - - public function isInitialized() - { - return !empty($this->configCache['admin_password']); - } - - private function saveSetting($key, $value) - { - $stmt = $this->db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)"); - $stmt->execute([$key, $value]); - $this->configCache[$key] = $value; - } - - public function upgradePasswordHash($plainPassword) - { - $hashed = password_hash($plainPassword, PASSWORD_BCRYPT); - $this->saveSetting('admin_password', $hashed); - } - - public function getMonitorKey() - { - return $this->get('monitor_key', ''); - } - - public function saveMonitorKey($key) - { - $this->saveSetting('monitor_key', $key); - } - - public function updateLastRunTime($time) - { - $this->saveSetting('last_monitor_run', $time); - } - - public function getLastRunTime() - { - return (int) ($this->configCache['last_monitor_run'] ?? 0); - } - - public function updateLastInstanceSyncTime($time) - { - $this->saveSetting('last_instance_sync', $time); - } - - public function getLastInstanceSyncTime() - { - return (int) ($this->configCache['last_instance_sync'] ?? 0); - } - - public function updateConfig($data) - { - try { - $this->db->beginTransaction(); - - $adminPassword = $data['admin_password'] ?? ''; - if (!empty($adminPassword) && $adminPassword !== '********') { - if (!preg_match('/^\$2[aby]?\$/', $adminPassword) && !preg_match('/^\$argon2[aid]\$/', $adminPassword)) { - $adminPassword = password_hash($adminPassword, PASSWORD_BCRYPT); - } - $this->saveSetting('admin_password', $adminPassword); - } - $this->saveSetting('traffic_threshold', $data['traffic_threshold'] ?? 95); - $this->saveSetting('shutdown_mode', $data['shutdown_mode'] ?? 'KeepCharging'); - $this->saveSetting('threshold_action', $data['threshold_action'] ?? 'stop_and_notify'); - $this->saveSetting('keep_alive', !empty($data['keep_alive']) ? '1' : '0'); - $this->saveSetting('monthly_auto_start', !empty($data['monthly_auto_start']) ? '1' : '0'); - $this->saveSetting('api_interval', $data['api_interval'] ?? 600); - $this->saveSetting('enable_billing', !empty($data['enable_billing']) ? '1' : '0'); - $appBrand = is_array($data['AppBrand'] ?? null) ? $data['AppBrand'] : []; - $this->saveSetting('app_logo_url', trim((string) ($appBrand['logo_url'] ?? ''))); - $this->saveDdnsSettings($data['Ddns'] ?? []); - - if (isset($data['Notification'])) { - $this->saveNotificationSettings($data['Notification']); - } - - $groups = $this->normalizeAccountGroups($data['Accounts'] ?? []); - $this->saveSetting('account_groups', json_encode($groups, JSON_UNESCAPED_UNICODE)); - $this->syncAccountGroups(true, $groups); - - $this->db->commit(); - $this->load(); - return true; - } catch (Exception $e) { - if ($this->db->inTransaction()) { - $this->db->rollBack(); - } - $this->database->addLog('error', "配置保存失败: " . strip_tags($e->getMessage())); - return false; - } - } - - public function syncAccountGroups($force = false, $groups = null) - { - $lastSync = $this->getLastInstanceSyncTime(); - if (!$force && (time() - $lastSync) < 60) { - $this->load(); - return; - } - - $groups = $groups === null ? $this->getAccountGroups() : $this->normalizeAccountGroups($groups, true); - - $existingRows = $this->db->query("SELECT * FROM accounts ORDER BY id ASC")->fetchAll(PDO::FETCH_ASSOC); - $existingByGroup = []; - $existingByComposite = []; - - foreach ($existingRows as $row) { - $groupKey = $row['group_key'] ?: $this->buildGroupKey($row['access_key_id'], $row['region_id']); - $existingByGroup[$groupKey][] = $row; - $existingByComposite[$groupKey . '|' . $row['instance_id']] = $row; - } - - $configuredGroupKeys = []; - - $insertStmt = $this->db->prepare(" - INSERT INTO accounts ( - access_key_id, - access_key_secret, - region_id, - instance_id, - max_traffic, - schedule_enabled, - schedule_start_enabled, - schedule_stop_enabled, - start_time, - stop_time, - schedule_blocked_by_traffic, - traffic_used, - traffic_billing_month, - instance_status, - updated_at, - last_keep_alive_at, - remark, - site_type, - group_key, - instance_name, - instance_type, - internet_max_bandwidth_out, - public_ip, - public_ip_mode, - eip_allocation_id, - eip_address, - eip_managed, - private_ip, - cpu, - memory, - os_name, - stopped_mode - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - "); - - $updateStmt = $this->db->prepare(" - UPDATE accounts - SET access_key_id = ?, - access_key_secret = ?, - region_id = ?, - instance_id = ?, - max_traffic = ?, - schedule_enabled = ?, - schedule_start_enabled = ?, - schedule_stop_enabled = ?, - start_time = ?, - stop_time = ?, - schedule_blocked_by_traffic = ?, - instance_status = ?, - remark = ?, - site_type = ?, - group_key = ?, - instance_name = ?, - instance_type = ?, - internet_max_bandwidth_out = ?, - public_ip = ?, - public_ip_mode = ?, - eip_allocation_id = ?, - eip_address = ?, - eip_managed = ?, - private_ip = ?, - cpu = ?, - memory = ?, - os_name = ?, - stopped_mode = ? - WHERE id = ? - "); - - foreach ($groups as $group) { - $configuredGroupKeys[] = $group['groupKey']; - - try { - $service = new AliyunService(); - $instances = $service->getInstances($group['AccessKeyId'], $group['AccessKeySecret'], $group['regionId']); - } catch (Exception $e) { - $maskedKey = substr($group['AccessKeyId'], 0, 7) . '***'; - $this->database->addLog('warning', "实例同步失败 [{$maskedKey}] {$group['regionId']}: " . strip_tags($e->getMessage())); - $this->updateGroupBaseSettings($group['groupKey'], $group); - continue; - } - - $remoteInstanceIds = []; - - foreach ($instances as $instance) { - $remoteInstanceIds[] = $instance['instanceId']; - $compositeKey = $group['groupKey'] . '|' . $instance['instanceId']; - $existingRow = $existingByComposite[$compositeKey] ?? null; - $remark = $this->resolveRemark($group, $instance, $existingRow); - $networkMeta = $this->resolveNetworkMetadata($instance, $existingRow); - - if ($existingRow) { - $updateStmt->execute([ - $group['AccessKeyId'], - $this->encryptValue($group['AccessKeySecret']), - $group['regionId'], - $instance['instanceId'], - $group['maxTraffic'], - !empty($group['scheduleEnabled']) ? 1 : 0, - !empty($group['scheduleStartEnabled']) ? 1 : 0, - !empty($group['scheduleStopEnabled']) ? 1 : 0, - $group['startTime'] ?? '', - $group['stopTime'] ?? '', - !empty($group['scheduleBlockedByTraffic']) ? 1 : 0, - $instance['status'] ?: ($existingRow['instance_status'] ?? 'Unknown'), - $remark, - $group['siteType'], - $group['groupKey'], - $instance['instanceName'] ?? '', - $instance['instanceType'] ?? '', - (int) ($instance['internetMaxBandwidthOut'] ?? 0), - $instance['publicIp'] ?? '', - $networkMeta['public_ip_mode'], - $networkMeta['eip_allocation_id'], - $networkMeta['eip_address'], - $networkMeta['eip_managed'], - $instance['privateIp'] ?? '', - (int) ($instance['cpu'] ?? 0), - (int) ($instance['memory'] ?? 0), - $instance['osName'] ?? '', - $instance['stoppedMode'] ?? '', - $existingRow['id'] - ]); - } else { - $insertStmt->execute([ - $group['AccessKeyId'], - $this->encryptValue($group['AccessKeySecret']), - $group['regionId'], - $instance['instanceId'], - $group['maxTraffic'], - !empty($group['scheduleEnabled']) ? 1 : 0, - !empty($group['scheduleStartEnabled']) ? 1 : 0, - !empty($group['scheduleStopEnabled']) ? 1 : 0, - $group['startTime'] ?? '', - $group['stopTime'] ?? '', - !empty($group['scheduleBlockedByTraffic']) ? 1 : 0, - date('Y-m'), - $instance['status'] ?? 'Unknown', - $remark, - $group['siteType'], - $group['groupKey'], - $instance['instanceName'] ?? '', - $instance['instanceType'] ?? '', - (int) ($instance['internetMaxBandwidthOut'] ?? 0), - $instance['publicIp'] ?? '', - $networkMeta['public_ip_mode'], - $networkMeta['eip_allocation_id'], - $networkMeta['eip_address'], - $networkMeta['eip_managed'], - $instance['privateIp'] ?? '', - (int) ($instance['cpu'] ?? 0), - (int) ($instance['memory'] ?? 0), - $instance['osName'] ?? '', - $instance['stoppedMode'] ?? '' - ]); - } - } - - if (!empty($existingByGroup[$group['groupKey']])) { - foreach ($existingByGroup[$group['groupKey']] as $row) { - if (!in_array($row['instance_id'], $remoteInstanceIds, true)) { - $deleteStmt = $this->db->prepare("DELETE FROM accounts WHERE id = ?"); - $deleteStmt->execute([$row['id']]); - } - } - } - } - - if (!empty($existingRows)) { - foreach ($existingRows as $row) { - $groupKey = $row['group_key'] ?: $this->buildGroupKey($row['access_key_id'], $row['region_id']); - if (!in_array($groupKey, $configuredGroupKeys, true)) { - $deleteStmt = $this->db->prepare("DELETE FROM accounts WHERE id = ?"); - $deleteStmt->execute([$row['id']]); - } - } - } - - $this->updateLastInstanceSyncTime(time()); - $this->load(); - } - - public function updateAppLogoUrl($url) - { - $this->saveSetting('app_logo_url', trim((string) $url)); - $this->load(); - } - - private function saveNotificationSettings($notification) - { - $this->saveSetting('notify_email_enabled', !empty($notification['email_enabled']) ? '1' : '0'); - $this->saveSetting('notify_email', $notification['email'] ?? ''); - $this->saveSetting('notify_host', $notification['host'] ?? ''); - $this->saveSetting('notify_port', $notification['port'] ?? 465); - $this->saveSetting('notify_username', $notification['username'] ?? ''); - - if (isset($notification['password']) && $notification['password'] !== '********') { - $this->saveSetting('notify_password', $notification['password'] ?? ''); - } - - $this->saveSetting('notify_secure', $notification['secure'] ?? 'ssl'); - - $telegram = $notification['telegram'] ?? []; - $this->saveSetting('notify_tg_enabled', !empty($telegram['enabled']) ? '1' : '0'); - - if (isset($telegram['token']) && $telegram['token'] !== '********') { - $this->saveSetting('notify_tg_token', $telegram['token'] ?? ''); - } - - $this->saveSetting('notify_tg_chat_id', $telegram['chat_id'] ?? ''); - $this->saveSetting('notify_tg_proxy_type', $telegram['proxy_type'] ?? 'none'); - $this->saveSetting('notify_tg_proxy_url', $telegram['proxy_url'] ?? ''); - $this->saveSetting('notify_tg_proxy_ip', $telegram['proxy_ip'] ?? ''); - $this->saveSetting('notify_tg_proxy_port', $telegram['proxy_port'] ?? ''); - $this->saveSetting('notify_tg_proxy_user', $telegram['proxy_user'] ?? ''); - $this->saveSetting('notify_tg_allowed_user_ids', trim((string) ($telegram['allowed_user_ids'] ?? ''))); - $this->saveSetting('notify_tg_confirm_ttl', max(30, (int) ($telegram['confirm_ttl'] ?? 60))); - - if (isset($telegram['proxy_pass']) && $telegram['proxy_pass'] !== '********') { - $this->saveSetting('notify_tg_proxy_pass', $telegram['proxy_pass'] ?? ''); - } - - $webhook = $notification['webhook'] ?? []; - $this->saveSetting('notify_wh_enabled', !empty($webhook['enabled']) ? '1' : '0'); - $this->saveSetting('notify_wh_url', $webhook['url'] ?? ''); - $this->saveSetting('notify_wh_method', $webhook['method'] ?? 'GET'); - $this->saveSetting('notify_wh_request_type', $webhook['request_type'] ?? 'JSON'); - $this->saveSetting('notify_wh_headers', $webhook['headers'] ?? ''); - $this->saveSetting('notify_wh_body', $webhook['body'] ?? ''); - } - - private function saveDdnsSettings($ddns) - { - $cloudflare = is_array($ddns['cloudflare'] ?? null) ? $ddns['cloudflare'] : []; - $this->saveSetting('ddns_enabled', !empty($ddns['enabled']) ? '1' : '0'); - $this->saveSetting('ddns_provider', $ddns['provider'] ?? 'cloudflare'); - $this->saveSetting('ddns_domain', trim((string) ($ddns['domain'] ?? ''))); - $this->saveSetting('ddns_cf_zone_id', trim((string) ($cloudflare['zone_id'] ?? ''))); - - $token = $cloudflare['token'] ?? ''; - if ($token !== '********') { - $this->saveSetting('ddns_cf_token', trim((string) $token)); - } - - $this->saveSetting('ddns_cf_proxied', !empty($cloudflare['proxied']) ? '1' : '0'); - } - - private function normalizeAccountGroups(array $groups, $allowEmpty = false) - { - $normalized = []; - - foreach ($groups as $group) { - $accessKeyId = trim((string) ($group['AccessKeyId'] ?? '')); - $accessKeySecret = trim((string) ($group['AccessKeySecret'] ?? '')); - $regionId = trim((string) ($group['regionId'] ?? '')); - - $isPlaceholder = $accessKeySecret === '********'; - if ($isPlaceholder) { - $accessKeySecret = $this->resolveExistingSecret( - $accessKeyId, - $regionId, - trim((string) ($group['groupKey'] ?? '')) - ); - } - - if (!$allowEmpty && $accessKeyId === '' && $accessKeySecret === '' && $regionId === '') { - continue; - } - - if ($accessKeyId === '' || $accessKeySecret === '' || $regionId === '') { - if ($isPlaceholder && !$allowEmpty) { - throw new Exception('账号配置缺少 AccessKeySecret'); - } - if ($allowEmpty) { - continue; - } - throw new Exception('账号配置缺少必填项'); - } - - $groupKey = trim((string) ($group['groupKey'] ?? '')); - if ($groupKey === '') { - $groupKey = $this->buildGroupKey($accessKeyId, $regionId); - } - - $scheduleEnabled = !empty($group['scheduleEnabled']) || !empty($group['schedule_enabled']); - $startTime = trim((string) ($group['startTime'] ?? $group['start_time'] ?? '')); - $stopTime = trim((string) ($group['stopTime'] ?? $group['stop_time'] ?? '')); - $scheduleStartEnabled = array_key_exists('scheduleStartEnabled', $group) || array_key_exists('schedule_start_enabled', $group) - ? (!empty($group['scheduleStartEnabled']) || !empty($group['schedule_start_enabled'])) - : ($scheduleEnabled && $startTime !== ''); - $scheduleStopEnabled = array_key_exists('scheduleStopEnabled', $group) || array_key_exists('schedule_stop_enabled', $group) - ? (!empty($group['scheduleStopEnabled']) || !empty($group['schedule_stop_enabled'])) - : ($scheduleEnabled && $stopTime !== ''); - - $normalized[] = [ - 'groupKey' => $groupKey, - 'AccessKeyId' => $accessKeyId, - 'AccessKeySecret' => $accessKeySecret, - 'regionId' => $regionId, - 'siteType' => $group['siteType'] ?? $this->inferSiteType($regionId), - 'maxTraffic' => (float) ($group['maxTraffic'] ?? 200), - 'remark' => trim((string) ($group['remark'] ?? '')), - 'scheduleEnabled' => $scheduleEnabled || $scheduleStartEnabled || $scheduleStopEnabled, - 'scheduleStartEnabled' => $scheduleStartEnabled, - 'scheduleStopEnabled' => $scheduleStopEnabled, - 'startTime' => $startTime, - 'stopTime' => $stopTime, - 'scheduleBlockedByTraffic' => !empty($group['scheduleBlockedByTraffic']) || !empty($group['schedule_blocked_by_traffic']) - ]; - } - - return array_values($normalized); - } - - private function resolveExistingSecret($accessKeyId, $regionId, $groupKey = '') - { - $accessKeyId = trim((string) $accessKeyId); - $regionId = trim((string) $regionId); - $requestedGroupKey = trim((string) $groupKey); - - if ($requestedGroupKey !== '') { - foreach ($this->accountsCache as $row) { - if (($row['group_key'] ?? '') === $requestedGroupKey && !empty($row['access_key_secret'])) { - return $row['access_key_secret']; - } - } - } - - foreach ($this->accountsCache as $row) { - if ($row['access_key_id'] === $accessKeyId && $row['region_id'] === $regionId) { - return $row['access_key_secret']; - } - } - - $derivedGroupKey = $this->buildGroupKey($accessKeyId, $regionId); - foreach ($this->accountsCache as $row) { - if (($row['group_key'] === $derivedGroupKey) && !empty($row['access_key_secret'])) { - return $row['access_key_secret']; - } - } - - $rawGroups = json_decode((string) ($this->configCache['account_groups'] ?? ''), true); - if (is_array($rawGroups)) { - foreach ($rawGroups as $group) { - $savedGroupKey = trim((string) ($group['groupKey'] ?? '')); - $savedAccessKeyId = trim((string) ($group['AccessKeyId'] ?? '')); - $savedRegionId = trim((string) ($group['regionId'] ?? '')); - $savedSecret = trim((string) ($group['AccessKeySecret'] ?? '')); - - if ( - ( - ($requestedGroupKey !== '' && $savedGroupKey === $requestedGroupKey) - || ($savedAccessKeyId === $accessKeyId && $savedRegionId === $regionId) - ) - && $savedSecret !== '' - && $savedSecret !== '********' - ) { - return $savedSecret; - } - } - } - - return ''; - } - - private function deriveAccountGroupsFromAccounts() - { - $groups = []; - - foreach ($this->accountsCache as $row) { - $accessKeyId = trim((string) ($row['access_key_id'] ?? '')); - $regionId = trim((string) ($row['region_id'] ?? '')); - if ($accessKeyId === '' || $regionId === '') { - continue; - } - - $groupKey = $row['group_key'] ?: $this->buildGroupKey($accessKeyId, $regionId); - if (isset($groups[$groupKey])) { - continue; - } - - $groups[$groupKey] = [ - 'groupKey' => $groupKey, - 'AccessKeyId' => $accessKeyId, - 'AccessKeySecret' => $row['access_key_secret'] ?? '', - 'regionId' => $regionId, - 'siteType' => $row['site_type'] ?? $this->inferSiteType($regionId), - 'maxTraffic' => (float) ($row['max_traffic'] ?? 200), - 'remark' => $row['remark'] ?? '', - 'scheduleEnabled' => !empty($row['schedule_enabled']), - 'scheduleStartEnabled' => !empty($row['schedule_start_enabled']), - 'scheduleStopEnabled' => !empty($row['schedule_stop_enabled']), - 'startTime' => $row['start_time'] ?? '', - 'stopTime' => $row['stop_time'] ?? '', - 'scheduleBlockedByTraffic' => !empty($row['schedule_blocked_by_traffic']) - ]; - } - - return array_values($groups); - } - - private function buildGroupKey($accessKeyId, $regionId) - { - return substr(sha1($accessKeyId . '|' . $regionId), 0, 16); - } - - private function inferSiteType($regionId) - { - if (strpos($regionId, 'cn-') === 0 && $regionId !== 'cn-hongkong') { - return 'china'; - } - return 'international'; - } - - private function resolveRemark($group, $instance, $existingRow = null) - { - if (!empty($group['remark'])) { - return $group['remark']; - } - - if ($existingRow) { - $existingRemark = trim((string) ($existingRow['remark'] ?? '')); - $existingName = trim((string) ($existingRow['instance_name'] ?? '')); - if ($existingRemark !== '' && $existingRemark !== $existingName) { - return $existingRemark; - } - } - - if (!empty($instance['instanceName'])) { - return $instance['instanceName']; - } - - return $instance['instanceId'] ?? ''; - } - - private function resolveNetworkMetadata($instance, $existingRow = null) - { - $eipAllocationId = trim((string) ($instance['eipAllocationId'] ?? '')); - $eipAddress = trim((string) ($instance['eipAddress'] ?? '')); - $existingMode = trim((string) ($existingRow['public_ip_mode'] ?? '')); - $existingManaged = (int) ($existingRow['eip_managed'] ?? 0); - - $mode = $eipAllocationId !== '' ? 'eip' : 'ecs_public_ip'; - if ($existingMode === 'eip' && $eipAllocationId !== '') { - $mode = 'eip'; - } - - return [ - 'public_ip_mode' => $mode, - 'eip_allocation_id' => $eipAllocationId !== '' ? $eipAllocationId : trim((string) ($existingRow['eip_allocation_id'] ?? '')), - 'eip_address' => $eipAddress !== '' ? $eipAddress : ($mode === 'eip' ? trim((string) ($instance['publicIp'] ?? '')) : ''), - 'eip_managed' => $existingManaged - ]; - } - - public function updateAccountNetworkMetadata($id, array $metadata) - { - $stmt = $this->db->prepare(" - UPDATE accounts - SET public_ip = ?, - public_ip_mode = ?, - eip_allocation_id = ?, - eip_address = ?, - eip_managed = ?, - internet_max_bandwidth_out = ? - WHERE id = ? - "); - - $stmt->execute([ - $metadata['public_ip'] ?? '', - $metadata['public_ip_mode'] ?? 'ecs_public_ip', - $metadata['eip_allocation_id'] ?? '', - $metadata['eip_address'] ?? '', - !empty($metadata['eip_managed']) ? 1 : 0, - (int) ($metadata['internet_max_bandwidth_out'] ?? 0), - $id - ]); - $this->load(); - } - - private function updateGroupBaseSettings($groupKey, $group) - { - $stmt = $this->db->prepare(" - UPDATE accounts - SET access_key_id = ?, - access_key_secret = ?, - region_id = ?, - max_traffic = ?, - schedule_enabled = ?, - schedule_start_enabled = ?, - schedule_stop_enabled = ?, - start_time = ?, - stop_time = ?, - schedule_blocked_by_traffic = ?, - site_type = ?, - group_key = ? - WHERE group_key = ? - "); - - $stmt->execute([ - $group['AccessKeyId'], - $this->encryptValue($group['AccessKeySecret']), - $group['regionId'], - $group['maxTraffic'], - !empty($group['scheduleEnabled']) ? 1 : 0, - !empty($group['scheduleStartEnabled']) ? 1 : 0, - !empty($group['scheduleStopEnabled']) ? 1 : 0, - $group['startTime'] ?? '', - $group['stopTime'] ?? '', - !empty($group['scheduleBlockedByTraffic']) ? 1 : 0, - $group['siteType'], - $groupKey, - $groupKey - ]); - } - - public function deleteAccountById($id) - { - $stmt = $this->db->prepare("DELETE FROM accounts WHERE id = ?"); - $stmt->execute([$id]); - $this->load(); - } - - public function markAccountAsDeleted($id) - { - $stmt = $this->db->prepare("UPDATE accounts SET is_deleted = 1 WHERE id = ?"); - $stmt->execute([$id]); - $this->load(); - } - - public function getPendingReleaseAccounts() - { - $stmt = $this->db->query("SELECT * FROM accounts WHERE is_deleted = 1"); - $accounts = $stmt->fetchAll(); - foreach ($accounts as &$row) { - if (!empty($row['access_key_secret']) && $this->isEncryptedValue($row['access_key_secret'])) { - $row['access_key_secret'] = $this->decryptValue($row['access_key_secret']); - } - } - return $accounts; - } - - public function physicallyDeleteAccount($id) - { - // 软删除机制:标记为 2 (彻底销毁完毕状态)。 - // 目的:阻断刚执行完释放后,阿里云 API 缓存尚未更新,导致同步器误认为这是"新冒出来的机器"从而执行重新插入。 - // 此尸体记录会在下一次或下下一次定时同步时,由于彻底在阿里云失联,被同步器的 deleteStmt 收尸清理。 - $stmt = $this->db->prepare("UPDATE accounts SET is_deleted = 2, instance_status = 'Released' WHERE id = ?"); - $stmt->execute([$id]); - // 不强制更新 cache,后台无声息处理。 - } - - public function updateAccountStatus($id, $traffic, $status, $updatedAt, $metadata = []) - { - $sql = "UPDATE accounts SET traffic_used = ?, traffic_billing_month = ?, instance_status = ?, updated_at = ?"; - $params = [$traffic, date('Y-m'), $status, $updatedAt]; - - if (isset($metadata['health_status'])) { - $sql .= ", health_status = ?"; - $params[] = $metadata['health_status']; - } - if (isset($metadata['stopped_mode'])) { - $sql .= ", stopped_mode = ?"; - $params[] = $metadata['stopped_mode']; - } - if (isset($metadata['traffic_api_status'])) { - $sql .= ", traffic_api_status = ?"; - $params[] = $metadata['traffic_api_status']; - } - if (isset($metadata['traffic_api_message'])) { - $sql .= ", traffic_api_message = ?"; - $params[] = $metadata['traffic_api_message']; - } - if (isset($metadata['protection_suspended'])) { - $sql .= ", protection_suspended = ?"; - $params[] = $metadata['protection_suspended'] ? 1 : 0; - } - if (isset($metadata['protection_suspend_reason'])) { - $sql .= ", protection_suspend_reason = ?"; - $params[] = (string) $metadata['protection_suspend_reason']; - } - if (isset($metadata['protection_suspend_notified_at'])) { - $sql .= ", protection_suspend_notified_at = ?"; - $params[] = (int) $metadata['protection_suspend_notified_at']; - } - - $sql .= " WHERE id = ?"; - $params[] = $id; - - $stmt = $this->db->prepare($sql); - return $stmt->execute($params); - } - - public function updateLastKeepAlive($id, $time) - { - $stmt = $this->db->prepare("UPDATE accounts SET last_keep_alive_at = ? WHERE id = ?"); - return $stmt->execute([$time, $id]); - } - - public function updateAutoStartBlocked($id, $blocked) - { - $stmt = $this->db->prepare("UPDATE accounts SET auto_start_blocked = ? WHERE id = ?"); - return $stmt->execute([$blocked ? 1 : 0, $id]); - } - - public function updateScheduleExecutionState($id, $type, $date) - { - $column = $type === 'start' ? 'schedule_last_start_date' : 'schedule_last_stop_date'; - $stmt = $this->db->prepare("UPDATE accounts SET {$column} = ? WHERE id = ?"); - return $stmt->execute([(string) $date, $id]); - } - - public function updateScheduleBlockedByTraffic($id, $blocked) - { - $stmt = $this->db->prepare("UPDATE accounts SET schedule_blocked_by_traffic = ? WHERE id = ?"); - $result = $stmt->execute([$blocked ? 1 : 0, $id]); - if ($result) { - $rowStmt = $this->db->prepare("SELECT access_key_id, region_id, group_key FROM accounts WHERE id = ? LIMIT 1"); - $rowStmt->execute([$id]); - $row = $rowStmt->fetch(); - if ($row) { - $groupKey = $row['group_key'] ?: $this->buildGroupKey($row['access_key_id'] ?? '', $row['region_id'] ?? ''); - $this->updateStoredAccountGroupScheduleBlock($groupKey, $blocked); - } - } - return $result; - } - - public function updateScheduleBlockedByTrafficForGroup($groupKey, $blocked) - { - $stmt = $this->db->prepare("UPDATE accounts SET schedule_blocked_by_traffic = ? WHERE group_key = ?"); - $result = $stmt->execute([$blocked ? 1 : 0, (string) $groupKey]); - if ($result) { - $this->updateStoredAccountGroupScheduleBlock((string) $groupKey, $blocked); - } - return $result; - } - - public function restoreScheduleAfterTrafficBlock($groupKey) - { - $groupKey = trim((string) $groupKey); - if ($groupKey === '') { - return false; - } - - $result = $this->updateScheduleBlockedByTrafficForGroup($groupKey, false); - $this->load(); - return $result; - } - - private function updateStoredAccountGroupScheduleBlock($groupKey, $blocked) - { - $groupKey = trim((string) $groupKey); - if ($groupKey === '') { - return; - } - - $raw = $this->configCache['account_groups'] ?? ''; - $groups = json_decode((string) $raw, true); - if (!is_array($groups)) { - return; - } - - $changed = false; - foreach ($groups as &$group) { - if (($group['groupKey'] ?? '') !== $groupKey) { - continue; - } - $group['scheduleBlockedByTraffic'] = (bool) $blocked; - $changed = true; - } - unset($group); - - if ($changed) { - $this->saveSetting('account_groups', json_encode($groups, JSON_UNESCAPED_UNICODE)); - } - } - - private function clearStoredAccountGroupScheduleBlocks() - { - $raw = $this->configCache['account_groups'] ?? ''; - $groups = json_decode((string) $raw, true); - if (!is_array($groups)) { - return; - } - - $changed = false; - foreach ($groups as &$group) { - if (!empty($group['scheduleBlockedByTraffic']) || !empty($group['schedule_blocked_by_traffic'])) { - $group['scheduleBlockedByTraffic'] = false; - unset($group['schedule_blocked_by_traffic']); - $changed = true; - } - } - unset($group); - - if ($changed) { - $this->saveSetting('account_groups', json_encode($groups, JSON_UNESCAPED_UNICODE)); - } - } - - public function blockCurrentlyStoppedInstances() - { - $stmt = $this->db->prepare("UPDATE accounts SET auto_start_blocked = 1 WHERE instance_status = 'Stopped'"); - $stmt->execute(); - $this->load(); - } -} diff --git a/Database.php b/Database.php deleted file mode 100644 index ce6e0c1..0000000 --- a/Database.php +++ /dev/null @@ -1,626 +0,0 @@ -dbFile = $dbFile ?: __DIR__ . '/data/data.sqlite'; - - // 环境安全检查 - $this->secureEnvironment(); - - $this->connect(); - $this->initSchema(); - } - - private function secureEnvironment() - { - $dir = dirname($this->dbFile); - $oldFile = __DIR__ . '/data.sqlite'; - - // 1. 自动创建目录 - if (!is_dir($dir)) { - if (!@mkdir($dir, 0755, true)) { - $this->throwPermissionError($dir); - } - } - - // 2. 自动迁移旧数据 - if (file_exists($oldFile) && !file_exists($this->dbFile)) { - if (!@rename($oldFile, $this->dbFile)) { - if (@copy($oldFile, $this->dbFile)) { - @unlink($oldFile); - } else { - throw new Exception("安全迁移失败:无法移动旧数据库。请检查目录权限。"); - } - } - } - - // 3. 部署 .htaccess - $htaccess = $dir . '/.htaccess'; - if (!file_exists($htaccess)) { - @file_put_contents($htaccess, "Order Deny,Allow\nDeny from all"); - } - - // 4. 部署 index.html - $indexHtml = $dir . '/index.html'; - if (!file_exists($indexHtml)) { - @file_put_contents($indexHtml, ''); - } - } - - private function connect() - { - try { - $this->pdo = new PDO('sqlite:' . $this->dbFile); - $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - $this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); - // 开启 WAL 模式,提高并发读写性能 - $this->pdo->exec('PRAGMA journal_mode = WAL;'); - } catch (PDOException $e) { - if (strpos($e->getMessage(), 'unable to open database file') !== false) { - $this->throwPermissionError(dirname($this->dbFile)); - } - throw new Exception("Database Error: " . $e->getMessage()); - } - } - - private function throwPermissionError($dir) - { - $user = get_current_user(); - throw new Exception("权限不足:Web用户 ({$user}) 无法读写 {$dir}。
请修复权限:chown -R {$user}:{$user} " . __DIR__ . ""); - } - - private function initSchema() - { - $this->pdo->exec("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)"); - - $this->pdo->exec("CREATE TABLE IF NOT EXISTS accounts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - access_key_id TEXT, - access_key_secret TEXT, - region_id TEXT, - instance_id TEXT, - max_traffic REAL, - schedule_enabled INTEGER DEFAULT 0, - start_time TEXT, - stop_time TEXT, - traffic_used REAL DEFAULT 0, - instance_status TEXT DEFAULT 'Unknown', - updated_at INTEGER DEFAULT 0, - last_keep_alive_at INTEGER DEFAULT 0, - is_deleted INTEGER DEFAULT 0 - )"); - - // 向下兼容:自动补充新增字段 - try { - $this->pdo->exec("ALTER TABLE accounts ADD COLUMN is_deleted INTEGER DEFAULT 0"); - } catch (PDOException $e) { - // 忽略字段已存在错误 - } - - $this->pdo->exec("CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, message TEXT, created_at INTEGER)"); - - $this->pdo->exec("CREATE TABLE IF NOT EXISTS login_attempts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ip TEXT, - attempt_time INTEGER - )"); - - // 1. 小时级表 (24小时折线图) - $this->pdo->exec("CREATE TABLE IF NOT EXISTS traffic_hourly ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - account_id INTEGER, - traffic REAL, - recorded_at INTEGER - )"); - $this->pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_traffic_hourly_unique ON traffic_hourly (account_id, recorded_at)"); - - // 2. 天级表 (30天柱状图) - $this->pdo->exec("CREATE TABLE IF NOT EXISTS traffic_daily ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - account_id INTEGER, - traffic REAL, - recorded_at INTEGER - )"); - $this->pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_traffic_daily_unique ON traffic_daily (account_id, recorded_at)"); - - // 3. 账单缓存表 (BSS API 结果缓存) - $this->pdo->exec("CREATE TABLE IF NOT EXISTS billing_cache ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - account_id INTEGER NOT NULL, - cache_type TEXT NOT NULL, - billing_cycle TEXT DEFAULT '', - data TEXT NOT NULL, - updated_at INTEGER NOT NULL, - UNIQUE(account_id, cache_type, billing_cycle) - )"); - - // ECS 公网出口流量累计账本。CDT/账单接口有延迟且偏账号聚合,这里按实例和自然月保存云监控分钟采样的累计结果。 - $this->pdo->exec("CREATE TABLE IF NOT EXISTS instance_traffic_usage ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - account_id INTEGER NOT NULL, - instance_id TEXT NOT NULL, - billing_month TEXT NOT NULL, - traffic_bytes REAL DEFAULT 0, - last_sample_ms INTEGER DEFAULT 0, - updated_at INTEGER NOT NULL, - UNIQUE(account_id, instance_id, billing_month) - )"); - - $this->pdo->exec("CREATE TABLE IF NOT EXISTS ecs_create_tasks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT UNIQUE NOT NULL, - preview_id TEXT DEFAULT '', - account_group_key TEXT NOT NULL, - region_id TEXT NOT NULL, - zone_id TEXT DEFAULT '', - instance_type TEXT NOT NULL, - image_id TEXT DEFAULT '', - os_label TEXT DEFAULT '', - instance_name TEXT DEFAULT '', - vpc_id TEXT DEFAULT '', - vswitch_id TEXT DEFAULT '', - security_group_id TEXT DEFAULT '', - internet_max_bandwidth_out INTEGER DEFAULT 0, - system_disk_category TEXT DEFAULT '', - system_disk_size INTEGER DEFAULT 0, - instance_id TEXT DEFAULT '', - public_ip TEXT DEFAULT '', - public_ip_mode TEXT DEFAULT 'ecs_public_ip', - eip_allocation_id TEXT DEFAULT '', - eip_address TEXT DEFAULT '', - eip_managed INTEGER DEFAULT 0, - login_user TEXT DEFAULT '', - login_password TEXT DEFAULT '', - status TEXT NOT NULL, - step TEXT DEFAULT '', - error_message TEXT DEFAULT '', - payload TEXT DEFAULT '', - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - )"); - - $this->pdo->exec("CREATE TABLE IF NOT EXISTS telegram_bot_state ( - key TEXT PRIMARY KEY, - value TEXT - )"); - - $this->pdo->exec("CREATE TABLE IF NOT EXISTS telegram_action_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - token TEXT UNIQUE NOT NULL, - user_id TEXT NOT NULL, - chat_id TEXT NOT NULL, - action TEXT NOT NULL, - account_id INTEGER NOT NULL, - payload TEXT DEFAULT '', - expires_at INTEGER NOT NULL, - used_at INTEGER DEFAULT 0, - created_at INTEGER NOT NULL - )"); - - $this->ensureColumn('accounts', 'traffic_used', 'REAL DEFAULT 0'); - $this->ensureColumn('accounts', 'traffic_billing_month', "TEXT DEFAULT ''"); - $this->ensureColumn('accounts', 'instance_status', "TEXT DEFAULT 'Unknown'"); - $this->ensureColumn('accounts', 'updated_at', 'INTEGER DEFAULT 0'); - $this->ensureColumn('accounts', 'last_keep_alive_at', 'INTEGER DEFAULT 0'); - $this->ensureColumn('accounts', 'auto_start_blocked', 'INTEGER DEFAULT 0'); - $this->ensureColumn('accounts', 'schedule_start_enabled', 'INTEGER DEFAULT 0'); - $this->ensureColumn('accounts', 'schedule_stop_enabled', 'INTEGER DEFAULT 0'); - $this->ensureColumn('accounts', 'schedule_last_start_date', "TEXT DEFAULT ''"); - $this->ensureColumn('accounts', 'schedule_last_stop_date', "TEXT DEFAULT ''"); - $this->ensureColumn('accounts', 'schedule_blocked_by_traffic', 'INTEGER DEFAULT 0'); - $this->ensureColumn('accounts', 'remark', "TEXT DEFAULT ''"); - $this->ensureColumn('accounts', 'site_type', "TEXT DEFAULT 'international'"); - $this->ensureColumn('accounts', 'group_key', "TEXT DEFAULT ''"); - $this->ensureColumn('accounts', 'instance_name', "TEXT DEFAULT ''"); - $this->ensureColumn('accounts', 'instance_type', "TEXT DEFAULT ''"); - $this->ensureColumn('accounts', 'internet_max_bandwidth_out', 'INTEGER DEFAULT 0'); - $this->ensureColumn('accounts', 'public_ip', "TEXT DEFAULT ''"); - $this->ensureColumn('accounts', 'public_ip_mode', "TEXT DEFAULT 'ecs_public_ip'"); - $this->ensureColumn('accounts', 'eip_allocation_id', "TEXT DEFAULT ''"); - $this->ensureColumn('accounts', 'eip_address', "TEXT DEFAULT ''"); - $this->ensureColumn('accounts', 'eip_managed', 'INTEGER DEFAULT 0'); - $this->ensureColumn('accounts', 'private_ip', "TEXT DEFAULT ''"); - $this->ensureColumn('accounts', 'cpu', "INTEGER DEFAULT 0"); - $this->ensureColumn('accounts', 'memory', "INTEGER DEFAULT 0"); - $this->ensureColumn('accounts', 'os_name', "TEXT DEFAULT ''"); - $this->ensureColumn('accounts', 'stopped_mode', "TEXT DEFAULT ''"); - $this->ensureColumn('accounts', 'health_status', "TEXT DEFAULT 'Unknown'"); - $this->ensureColumn('accounts', 'traffic_api_status', "TEXT DEFAULT 'ok'"); - $this->ensureColumn('accounts', 'traffic_api_message', "TEXT DEFAULT ''"); - $this->ensureColumn('accounts', 'protection_suspended', 'INTEGER DEFAULT 0'); - $this->ensureColumn('accounts', 'protection_suspend_reason', "TEXT DEFAULT ''"); - $this->ensureColumn('accounts', 'protection_suspend_notified_at', 'INTEGER DEFAULT 0'); - $this->ensureColumn('ecs_create_tasks', 'public_ip_mode', "TEXT DEFAULT 'ecs_public_ip'"); - $this->ensureColumn('ecs_create_tasks', 'eip_allocation_id', "TEXT DEFAULT ''"); - $this->ensureColumn('ecs_create_tasks', 'eip_address', "TEXT DEFAULT ''"); - $this->ensureColumn('ecs_create_tasks', 'eip_managed', 'INTEGER DEFAULT 0'); - - $this->migrateStatsToAccountId(); - } - - private function ensureColumn($table, $column, $definition) - { - try { - $this->pdo->query("SELECT $column FROM $table LIMIT 1"); - } catch (Exception $e) { - $this->pdo->exec("ALTER TABLE $table ADD COLUMN $column $definition"); - } - } - - private function migrateStatsToAccountId() - { - // check if traffic_hourly has access_key_id column - $needsMigration = false; - try { - $this->pdo->query("SELECT access_key_id FROM traffic_hourly LIMIT 1"); - $needsMigration = true; - } catch (Exception $e) { - // column does not exist, no migration needed (or already migrated) - } - - if ($needsMigration) { - try { - $this->pdo->beginTransaction(); - - // 1. Migrate hourly stats - $this->pdo->exec("CREATE TABLE traffic_hourly_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - account_id INTEGER, - traffic REAL, - recorded_at INTEGER - )"); - - // Try to link stats to the first matching account ID for that AK - $this->pdo->exec("INSERT INTO traffic_hourly_new (account_id, traffic, recorded_at) - SELECT a.id, t.traffic, t.recorded_at - FROM traffic_hourly t - JOIN accounts a ON t.access_key_id = a.access_key_id - GROUP BY a.id, t.recorded_at"); // Group by to avoid duplicates if multiple accounts share AK (though previous schema didn't allow duplicates, this is safety) - - $this->pdo->exec("DROP TABLE traffic_hourly"); - $this->pdo->exec("ALTER TABLE traffic_hourly_new RENAME TO traffic_hourly"); - $this->pdo->exec("CREATE UNIQUE INDEX idx_traffic_hourly_unique ON traffic_hourly (account_id, recorded_at)"); - - // 2. Migrate daily stats - $this->pdo->exec("CREATE TABLE traffic_daily_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - account_id INTEGER, - traffic REAL, - recorded_at INTEGER - )"); - - $this->pdo->exec("INSERT INTO traffic_daily_new (account_id, traffic, recorded_at) - SELECT a.id, t.traffic, t.recorded_at - FROM traffic_daily t - JOIN accounts a ON t.access_key_id = a.access_key_id - GROUP BY a.id, t.recorded_at"); - - $this->pdo->exec("DROP TABLE traffic_daily"); - $this->pdo->exec("ALTER TABLE traffic_daily_new RENAME TO traffic_daily"); - $this->pdo->exec("CREATE UNIQUE INDEX idx_traffic_daily_unique ON traffic_daily (account_id, recorded_at)"); - - $this->pdo->commit(); - } catch (Exception $e) { - if ($this->pdo->inTransaction()) - $this->pdo->rollBack(); - // Log error but don't stop execution, schema might be in mixed state but next run/update might fix or user can reset - $this->addLog('error', "Database Migration Failed: " . $e->getMessage()); - } - } - } - - public function getPdo() - { - return $this->pdo; - } - - public function addLog($type, $message) - { - $stmt = $this->pdo->prepare("INSERT INTO logs (type, message, created_at) VALUES (?, ?, ?)"); - $stmt->execute([$type, $message, time()]); - } - - public function getLogs($limit = 100) - { - $stmt = $this->pdo->prepare("SELECT * FROM logs ORDER BY id DESC LIMIT ?"); - $stmt->execute([$limit]); - return $stmt->fetchAll(PDO::FETCH_ASSOC); - } - - public function getLogsByTypes(array $types, $limit = 20) - { - $placeholders = implode(',', array_fill(0, count($types), '?')); - $sql = "SELECT * FROM logs WHERE type IN ($placeholders) ORDER BY id DESC LIMIT ?"; - - $params = $types; - $params[] = $limit; - - $stmt = $this->pdo->prepare($sql); - $stmt->execute($params); - return $stmt->fetchAll(PDO::FETCH_ASSOC); - } - - public function clearLogsByTypes(array $types) - { - $placeholders = implode(',', array_fill(0, count($types), '?')); - $stmt = $this->pdo->prepare("DELETE FROM logs WHERE type IN ($placeholders)"); - return $stmt->execute($types); - } - - /** - * 优化后的日志清理逻辑 - * @param int $defaultDays 默认保留天数(用于重要日志) - * @param int $heartbeatDays 心跳日志保留天数(建议设置较短,如3天) - */ - public function pruneLogs($defaultDays = 30, $heartbeatDays = 3) - { - $now = time(); - - // 1. 清理过期心跳日志 (Heartbeat) - 激进清理 - $stmt = $this->pdo->prepare("DELETE FROM logs WHERE type = 'heartbeat' AND created_at < ?"); - $stmt->execute([$now - ($heartbeatDays * 86400)]); - - // 2. 清理其他过期日志 (Info, Warning, Error) - 保守清理 - $stmt = $this->pdo->prepare("DELETE FROM logs WHERE type != 'heartbeat' AND created_at < ?"); - $stmt->execute([$now - ($defaultDays * 86400)]); - } - - /** - * 重置 Logs 表的自增 ID 并重新排序 - * 这是一个较重的操作,但能保证 ID 连续 - */ - public function reorderLogsIds() - { - try { - $this->pdo->beginTransaction(); - - // 1. 检查是否有数据 - $count = $this->pdo->query("SELECT COUNT(*) FROM logs")->fetchColumn(); - - if ($count == 0) { - // 如果没数据,直接重置序号为 0 - $this->pdo->exec("DELETE FROM sqlite_sequence WHERE name='logs'"); - $this->pdo->exec("DELETE FROM logs"); // 确保空 - } else { - // 2. 使用临时表重排数据 - // 创建临时表保存现有数据,按时间正序排列 - $this->pdo->exec("CREATE TEMPORARY TABLE logs_temp AS SELECT type, message, created_at FROM logs ORDER BY created_at ASC"); - - // 清空原表 - $this->pdo->exec("DELETE FROM logs"); - - // 重置自增序列 - $this->pdo->exec("DELETE FROM sqlite_sequence WHERE name='logs'"); - - // 将数据插回原表,ID 会自动从 1 开始重新生成 - $this->pdo->exec("INSERT INTO logs (type, message, created_at) SELECT type, message, created_at FROM logs_temp"); - - // 删除临时表 - $this->pdo->exec("DROP TABLE logs_temp"); - } - - $this->pdo->commit(); - return true; - } catch (Exception $e) { - if ($this->pdo->inTransaction()) { - $this->pdo->rollBack(); - } - // 记录错误到错误日志文件(如果有),或者忽略,因为这不是关键业务 - return false; - } - } - - /** - * 整理数据库碎片 (VACUUM) - * 释放已删除数据占用的磁盘空间 - */ - public function vacuum() - { - $this->pdo->exec("VACUUM"); - } - - // --- 登录频率限制相关方法 --- - - public function recordLoginAttempt($ip) - { - $stmt = $this->pdo->prepare("INSERT INTO login_attempts (ip, attempt_time) VALUES (?, ?)"); - $stmt->execute([$ip, time()]); - } - - public function getRecentFailedAttempts($ip, $windowSeconds = 900) - { - $stmt = $this->pdo->prepare("SELECT COUNT(*) FROM login_attempts WHERE ip = ? AND attempt_time > ?"); - $stmt->execute([$ip, time() - $windowSeconds]); - return (int) $stmt->fetchColumn(); - } - - public function clearLoginAttempts($ip) - { - $stmt = $this->pdo->prepare("DELETE FROM login_attempts WHERE ip = ?"); - $stmt->execute([$ip]); - } - - // --- 流量记录逻辑 --- - - public function addHourlyStat($accountId, $traffic) - { - $hourTimestamp = floor(time() / 3600) * 3600; - $stmt = $this->pdo->prepare("INSERT OR REPLACE INTO traffic_hourly (account_id, traffic, recorded_at) VALUES (?, ?, ?)"); - $stmt->execute([$accountId, $traffic, $hourTimestamp]); - } - - public function addDailyStat($accountId, $traffic) - { - $dayTimestamp = strtotime(date('Y-m-d 00:00:00')); - $stmt = $this->pdo->prepare("INSERT OR REPLACE INTO traffic_daily (account_id, traffic, recorded_at) VALUES (?, ?, ?)"); - $stmt->execute([$accountId, $traffic, $dayTimestamp]); - } - - public function getHourlyStats($accountId) - { - $stmt = $this->pdo->prepare("SELECT traffic, recorded_at FROM traffic_hourly WHERE account_id = ? ORDER BY recorded_at DESC LIMIT 25"); - $stmt->execute([$accountId]); - $data = $stmt->fetchAll(PDO::FETCH_ASSOC); - return array_reverse($data); - } - - public function getDailyStats($accountId) - { - $stmt = $this->pdo->prepare("SELECT traffic, recorded_at FROM traffic_daily WHERE account_id = ? ORDER BY recorded_at DESC LIMIT 31"); - $stmt->execute([$accountId]); - $data = $stmt->fetchAll(PDO::FETCH_ASSOC); - return array_reverse($data); - } - - public function pruneStats() - { - $hourLimit = time() - (48 * 3600); - $this->pdo->exec("DELETE FROM traffic_hourly WHERE recorded_at < $hourLimit"); - - $dayLimit = time() - (60 * 86400); - $this->pdo->exec("DELETE FROM traffic_daily WHERE recorded_at < $dayLimit"); - - $monthLimit = date('Y-m', strtotime('-4 months')); - $stmt = $this->pdo->prepare("DELETE FROM instance_traffic_usage WHERE billing_month < ?"); - $stmt->execute([$monthLimit]); - } - - public function getInstanceTrafficUsage($accountId, $instanceId, $billingMonth) - { - $stmt = $this->pdo->prepare("SELECT * FROM instance_traffic_usage WHERE account_id = ? AND instance_id = ? AND billing_month = ? LIMIT 1"); - $stmt->execute([$accountId, $instanceId, $billingMonth]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); - - return $row ?: null; - } - - public function upsertInstanceTrafficUsage($accountId, $instanceId, $billingMonth, $trafficBytes, $lastSampleMs) - { - $stmt = $this->pdo->prepare(" - INSERT INTO instance_traffic_usage (account_id, instance_id, billing_month, traffic_bytes, last_sample_ms, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(account_id, instance_id, billing_month) - DO UPDATE SET - traffic_bytes = excluded.traffic_bytes, - last_sample_ms = excluded.last_sample_ms, - updated_at = excluded.updated_at - "); - - return $stmt->execute([ - $accountId, - $instanceId, - $billingMonth, - max(0, (float) $trafficBytes), - max(0, (int) $lastSampleMs), - time() - ]); - } - - // --- 账单缓存相关方法 --- - - /** - * 写入/更新账单缓存 - */ - public function setBillingCache($accountId, $cacheType, $billingCycle, $data) - { - $stmt = $this->pdo->prepare("INSERT OR REPLACE INTO billing_cache (account_id, cache_type, billing_cycle, data, updated_at) VALUES (?, ?, ?, ?, ?)"); - $stmt->execute([$accountId, $cacheType, $billingCycle, json_encode($data), time()]); - } - - /** - * 读取账单缓存 (含过期判断) - * @param int $maxAge 最大缓存时间(秒),默认6小时 - * @return array|null 缓存数据或 null(已过期/不存在) - */ - public function getBillingCache($accountId, $cacheType, $billingCycle, $maxAge = 21600) - { - $stmt = $this->pdo->prepare("SELECT data, updated_at FROM billing_cache WHERE account_id = ? AND cache_type = ? AND billing_cycle = ? LIMIT 1"); - $stmt->execute([$accountId, $cacheType, $billingCycle]); - $row = $stmt->fetch(); - - if (!$row) return null; - if ((time() - $row['updated_at']) > $maxAge) return null; - - return json_decode($row['data'], true); - } - - /** - * 清理超过90天的历史账单缓存 - */ - public function pruneBillingCache() - { - $limit = time() - (90 * 86400); - $this->pdo->exec("DELETE FROM billing_cache WHERE updated_at < $limit"); - } - - public function createEcsCreateTask($taskId, $previewId, $groupKey, $regionId, $instanceType, $payload) - { - $stmt = $this->pdo->prepare(" - INSERT INTO ecs_create_tasks ( - task_id, preview_id, account_group_key, region_id, instance_type, status, step, payload, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - "); - - $now = time(); - $stmt->execute([ - $taskId, - $previewId, - $groupKey, - $regionId, - $instanceType, - 'running', - '初始化创建任务', - json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - $now, - $now - ]); - } - - public function updateEcsCreateTask($taskId, array $fields) - { - if (empty($fields)) { - return false; - } - - $fields['updated_at'] = time(); - $allowed = [ - 'zone_id', 'image_id', 'os_label', 'instance_name', 'vpc_id', 'vswitch_id', - 'security_group_id', 'internet_max_bandwidth_out', 'system_disk_category', - 'system_disk_size', 'instance_id', 'public_ip', 'login_user', 'login_password', - 'public_ip_mode', 'eip_allocation_id', 'eip_address', 'eip_managed', - 'status', 'step', 'error_message', 'payload', 'updated_at' - ]; - - $sets = []; - $values = []; - foreach ($fields as $key => $value) { - if (!in_array($key, $allowed, true)) { - continue; - } - $sets[] = "$key = ?"; - $values[] = is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : $value; - } - - if (empty($sets)) { - return false; - } - - $values[] = $taskId; - $stmt = $this->pdo->prepare("UPDATE ecs_create_tasks SET " . implode(', ', $sets) . " WHERE task_id = ?"); - return $stmt->execute($values); - } - - public function getEcsCreateTask($taskId) - { - $stmt = $this->pdo->prepare("SELECT * FROM ecs_create_tasks WHERE task_id = ? LIMIT 1"); - $stmt->execute([$taskId]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); - return $row ?: null; - } -} diff --git a/DdnsService.php b/DdnsService.php deleted file mode 100644 index 09d77e6..0000000 --- a/DdnsService.php +++ /dev/null @@ -1,252 +0,0 @@ -config = $config; - } - - public function isEnabled() - { - return ($this->config['ddns_enabled'] ?? '0') === '1' - && ($this->config['ddns_provider'] ?? 'cloudflare') === 'cloudflare' - && !empty($this->config['ddns_cf_token']) - && !empty($this->config['ddns_domain']); - } - - public function buildRecordName(array $account, $sameGroupInstanceCount = 1) - { - $domain = $this->normalizeDomain($this->config['ddns_domain'] ?? ''); - if ($domain === '') { - throw new Exception('请先填写 DDNS 根域名'); - } - - $accountSlug = $this->slug($account['account_remark'] ?? $account['remark'] ?? ''); - $instanceSlug = $this->slug($account['instance_name'] ?? ''); - $instanceId = trim((string) ($account['instance_id'] ?? '')); - $shortId = $this->slug($instanceId !== '' ? preg_replace('/^i-/', '', $instanceId) : ''); - - if ($accountSlug === '') { - $accountSlug = $instanceSlug ?: $shortId; - } - if ($accountSlug === '') { - throw new Exception('DDNS 记录名生成失败,请检查账号备注或实例名称'); - } - - $subdomain = $accountSlug; - if ((int) $sameGroupInstanceCount > 1) { - $suffix = $instanceSlug ?: $shortId; - if ($suffix !== '' && $suffix !== $accountSlug) { - $subdomain .= '-' . $suffix; - } - } - - $subdomain = trim($subdomain, '-'); - return $subdomain . '.' . $domain; - } - - public function syncARecord($recordName, $ip) - { - if (!$this->isEnabled()) { - return ['success' => true, 'skipped' => true, 'message' => 'DDNS 未启用']; - } - - $recordName = strtolower(trim((string) $recordName)); - $ip = trim((string) $ip); - if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { - return ['success' => false, 'message' => '公网 IP 为空或不是公网 IPv4']; - } - - $existing = $this->findRecord($recordName); - $payload = [ - 'type' => 'A', - 'name' => $recordName, - 'content' => $ip, - 'ttl' => 1, - 'proxied' => ($this->config['ddns_cf_proxied'] ?? '0') === '1', - 'comment' => 'Managed by CDT Monitor' - ]; - - if ($existing) { - $response = $this->request('PUT', '/dns_records/' . $existing['id'], $payload); - } else { - $response = $this->request('POST', '/dns_records', $payload); - } - - if (empty($response['success'])) { - return ['success' => false, 'message' => $this->formatErrors($response)]; - } - - return [ - 'success' => true, - 'record' => $recordName, - 'ip' => $ip, - 'action' => $existing ? 'updated' : 'created' - ]; - } - - public function deleteARecord($recordName) - { - if (!$this->isEnabled()) { - return ['success' => true, 'skipped' => true, 'message' => 'DDNS 未启用']; - } - - $recordName = strtolower(trim((string) $recordName)); - if ($recordName === '') { - return ['success' => false, 'message' => 'DDNS 记录名为空']; - } - - $existing = $this->findRecord($recordName); - if (!$existing) { - return ['success' => true, 'skipped' => true, 'record' => $recordName, 'message' => '记录不存在']; - } - - $response = $this->request('DELETE', '/dns_records/' . $existing['id']); - if (empty($response['success'])) { - return ['success' => false, 'message' => $this->formatErrors($response)]; - } - - return [ - 'success' => true, - 'record' => $recordName, - 'action' => 'deleted' - ]; - } - - private function findRecord($recordName) - { - $response = $this->request('GET', '/dns_records?type=A&name=' . rawurlencode($recordName)); - if (empty($response['success'])) { - throw new Exception('Cloudflare 查询记录失败: ' . $this->formatErrors($response)); - } - - $records = $response['result'] ?? []; - return $records[0] ?? null; - } - - private function request($method, $path, array $payload = null) - { - $zoneId = $this->resolveZoneId(); - $token = trim((string) ($this->config['ddns_cf_token'] ?? '')); - $url = 'https://api.cloudflare.com/client/v4/zones/' . rawurlencode($zoneId) . $path; - - $ch = curl_init($url); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 15); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Authorization: Bearer ' . $token, - 'Content-Type: application/json' - ]); - - if ($payload !== null) { - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); - } - - $body = curl_exec($ch); - $error = curl_error($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($error) { - throw new Exception('Cloudflare 网络请求失败: ' . $error); - } - - $decoded = json_decode((string) $body, true); - if (!is_array($decoded)) { - throw new Exception("Cloudflare 响应解析失败,状态码 {$httpCode}"); - } - - return $decoded; - } - - private function resolveZoneId() - { - $zoneId = trim((string) ($this->config['ddns_cf_zone_id'] ?? '')); - if ($zoneId !== '') { - return $zoneId; - } - - $domain = $this->normalizeDomain($this->config['ddns_domain'] ?? ''); - if ($domain === '') { - throw new Exception('请先填写 DDNS 根域名'); - } - - $token = trim((string) ($this->config['ddns_cf_token'] ?? '')); - $url = 'https://api.cloudflare.com/client/v4/zones?name=' . rawurlencode($domain) . '&status=active'; - $ch = curl_init($url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 15); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Authorization: Bearer ' . $token, - 'Content-Type: application/json' - ]); - $body = curl_exec($ch); - $error = curl_error($ch); - curl_close($ch); - - if ($error) { - throw new Exception('Cloudflare Zone 查询失败: ' . $error); - } - - $decoded = json_decode((string) $body, true); - if (empty($decoded['success'])) { - throw new Exception('Cloudflare Zone 查询失败: ' . $this->formatErrors(is_array($decoded) ? $decoded : [])); - } - - $zone = $decoded['result'][0] ?? null; - if (empty($zone['id'])) { - throw new Exception("Cloudflare 未找到域名 {$domain},请确认 Token 有该域名的 DNS 编辑权限,或手动填写 Zone ID"); - } - - return $zone['id']; - } - - private function formatErrors(array $response) - { - $errors = $response['errors'] ?? []; - if (empty($errors)) { - return '未知错误'; - } - - $messages = []; - foreach ($errors as $error) { - $messages[] = $error['message'] ?? json_encode($error, JSON_UNESCAPED_UNICODE); - } - return implode(';', $messages); - } - - private function slug($value) - { - $original = trim((string) $value); - $value = strtolower($original); - if ($value === '') { - return ''; - } - - if (function_exists('iconv')) { - $converted = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value); - if ($converted !== false) { - $value = $converted; - } - } - - $value = preg_replace('/[^a-z0-9]+/', '-', $value); - $value = trim($value, '-'); - if ($value === '') { - $value = substr(sha1($original), 0, 8); - } - return substr($value, 0, 48); - } - - private function normalizeDomain($domain) - { - $domain = strtolower(trim((string) $domain)); - $domain = preg_replace('#^https?://#', '', $domain); - $domain = trim(explode('/', $domain)[0] ?? '', '.'); - return $domain; - } -} diff --git a/NotificationService.php b/NotificationService.php deleted file mode 100644 index 2ed35f0..0000000 --- a/NotificationService.php +++ /dev/null @@ -1,583 +0,0 @@ -config = $config; - } - - /** - * 发送定时任务通知 - * @return bool|string 成功返回 true,失败返回错误信息 - */ - public function notifySchedule($actionType, $account, $description = "") - { - $title = "定时任务: " . $actionType; - $maskedKey = substr($account['access_key_id'], 0, 7) . '***'; - $traffic = isset($account['traffic_used']) ? $this->formatTraffic((float) $account['traffic_used']) : '暂无'; - $threshold = $this->config['traffic_threshold'] ?? 95; - - $details = [ - ['label' => '账号', 'value' => $maskedKey], - ['label' => '执行动作', 'value' => $actionType, 'highlight' => true], - ['label' => '执行时间', 'value' => date('Y-m-d H:i:s')], - ['label' => '当前流量', 'value' => $traffic], - ['label' => '设定阈值', 'value' => $threshold . '%'], - ['label' => '详情说明', 'value' => $description ?: '根据预设时间表自动执行。'] - ]; - - $textMsg = "【ECS 服务器管家】{$title}\n" . - "账号: {$maskedKey}\n" . - "执行动作: {$actionType}\n" . - "当前流量: {$traffic}\n" . - "设定阈值: {$threshold}%\n" . - "执行时间: " . date('Y-m-d H:i:s') . "\n" . - "详情说明: " . ($description ?: '根据预设时间表自动执行。'); - - return $this->dispatchNotifications($title, "您的实例已执行{$actionType}操作", $details, 'info', $textMsg, $account['access_key_id']); - } - - /** - * 发送流量告警 - * @return bool|string - */ - public function sendTrafficWarning($accessKeyId, $traffic, $percentage, $statusText, $threshold) - { - $title = "流量告警 - " . $statusText; - $trafficText = $this->formatTraffic((float) $traffic); - $details = [ - ['label' => '账号', 'value' => substr($accessKeyId, 0, 7) . '***'], - ['label' => '当前流量', 'value' => $trafficText], - ['label' => '使用率', 'value' => $percentage . '%', 'highlight' => true], - ['label' => '设定阈值', 'value' => $threshold . '%'], - ['label' => '当前状态', 'value' => $statusText] - ]; - - $textMsg = "【ECS 服务器管家】{$title}\n" . - "账号: " . substr($accessKeyId, 0, 7) . '***' . "\n" . - "当前流量: {$trafficText}\n" . - "使用率: {$percentage}%\n" . - "设定阈值: {$threshold}%\n" . - "当前状态: {$statusText}"; - - return $this->dispatchNotifications($title, "检测到流量异常或达到阈值", $details, 'warning', $textMsg, $accessKeyId); - } - - public function notifyCredentialInvalid($accessKeyId, $traffic, $percentage, $threshold) - { - $title = "流量告警 - 账号密钥已失效"; - $trafficText = $this->formatTraffic((float) $traffic); - $maskedAccount = substr($accessKeyId, 0, 7) . '***'; - $statusText = '检测到 AK 已失效,已暂停自动停机'; - $details = [ - ['label' => '账号', 'value' => $maskedAccount], - ['label' => '当前流量', 'value' => $trafficText], - ['label' => '使用率', 'value' => $percentage . '%', 'highlight' => true], - ['label' => '设定阈值', 'value' => $threshold . '%'], - ['label' => '当前状态', 'value' => $statusText], - ['label' => '处理建议', 'value' => '请更新 AK 后再恢复自动停机保护。'] - ]; - - $textMsg = "【ECS 服务器管家】{$title}\n" . - "账号: {$maskedAccount}\n" . - "当前流量: {$trafficText}\n" . - "使用率: {$percentage}%\n" . - "设定阈值: {$threshold}%\n" . - "当前状态: {$statusText}\n" . - "处理建议: 请更新 AK 后再恢复自动停机保护。"; - - return $this->dispatchNotifications($title, "检测到账号密钥失效,已暂停自动停机保护,避免重复失败通知。", $details, 'warning', $textMsg, $accessKeyId); - } - - public function notifyEcsCreated($accountLabel, array $result, array $preview = []) - { - $title = 'ECS 创建并启动成功'; - $instanceId = $result['instanceId'] ?? ''; - $publicIp = $result['publicIp'] ?? ''; - $loginUser = $result['loginUser'] ?? ''; - $loginPassword = $result['loginPassword'] ?? ''; - $regionId = $preview['regionId'] ?? ''; - $instanceType = $preview['instanceType'] ?? ($result['instanceType'] ?? ''); - $instanceName = $preview['instanceName'] ?? ($result['instanceName'] ?? ''); - - $details = [ - ['label' => '账号', 'value' => $accountLabel], - ['label' => '实例名称', 'value' => $instanceName ?: '-'], - ['label' => '实例编号', 'value' => $instanceId ?: '-', 'highlight' => true], - ['label' => '区域', 'value' => $regionId ?: '-'], - ['label' => '实例规格', 'value' => $instanceType ?: '-'], - ['label' => '公网地址', 'value' => $publicIp ?: '等待阿里云分配'], - ['label' => '登录用户', 'value' => $loginUser ?: '-'], - ['label' => '初始密码', 'value' => $loginPassword ?: '-', 'highlight' => true], - ['label' => '安全提醒', 'value' => '初始密码仅在本次创建完成通知和控制台弹窗展示,请立即保存。'] - ]; - - $textMsg = "【ECS 服务器管家】ECS 创建并启动成功\n" . - "账号: {$accountLabel}\n" . - "实例名称: " . ($instanceName ?: '-') . "\n" . - "实例编号: " . ($instanceId ?: '-') . "\n" . - "区域: " . ($regionId ?: '-') . "\n" . - "规格: " . ($instanceType ?: '-') . "\n" . - "公网地址: " . ($publicIp ?: '等待阿里云分配') . "\n" . - "登录用户: " . ($loginUser ?: '-') . "\n" . - "初始密码: " . ($loginPassword ?: '-') . "\n" . - "安全提醒: 初始密码仅在本次创建完成通知和控制台弹窗展示,请立即保存。"; - - return $this->dispatchNotifications($title, '实例已创建并启动,请立即保存一次性登录密码。', $details, 'success', $textMsg, $accountLabel); - } - - public function notifyInstanceStatusChanged($accountLabel, array $account, $fromStatus, $toStatus, $reason = '') - { - $fromLabel = $this->statusLabel($fromStatus); - $toLabel = $this->statusLabel($toStatus); - $instanceName = $account['instance_name'] ?? ($account['remark'] ?? ''); - $instanceId = $account['instance_id'] ?? ''; - $region = $account['region_id'] ?? ''; - $title = $toStatus === 'Running' ? '实例已启动' : ($toStatus === 'Stopped' ? '实例已停机' : "实例状态变化 - {$toLabel}"); - - $details = [ - ['label' => '账号', 'value' => $accountLabel], - ['label' => '实例名称', 'value' => $instanceName ?: '-'], - ['label' => '实例编号', 'value' => $instanceId ?: '-', 'highlight' => true], - ['label' => '区域', 'value' => $region ?: '-'], - ['label' => '原状态', 'value' => $fromLabel], - ['label' => '新状态', 'value' => $toLabel, 'highlight' => true], - ['label' => '变化时间', 'value' => date('Y-m-d H:i:s')], - ['label' => '说明', 'value' => $reason ?: '系统检测到实例运行状态发生变化。'] - ]; - - $textMsg = "【ECS 服务器管家】{$title}\n" . - "账号: {$accountLabel}\n" . - "实例名称: " . ($instanceName ?: '-') . "\n" . - "实例编号: " . ($instanceId ?: '-') . "\n" . - "区域: " . ($region ?: '-') . "\n" . - "原状态: {$fromLabel}\n" . - "新状态: {$toLabel}\n" . - "变化时间: " . date('Y-m-d H:i:s') . "\n" . - "说明: " . ($reason ?: '系统检测到实例运行状态发生变化。'); - - return $this->dispatchNotifications($title, '实例已进入最终状态', $details, 'success', $textMsg, $accountLabel); - } - - public function notifyInstanceReleased($accountLabel, array $account, $reason = '') - { - $instanceName = $account['instance_name'] ?? ($account['remark'] ?? ''); - $instanceId = $account['instance_id'] ?? ''; - $region = $account['region_id'] ?? ''; - $publicIp = $account['public_ip'] ?? ''; - $title = '实例已释放'; - - $details = [ - ['label' => '账号', 'value' => $accountLabel], - ['label' => '实例名称', 'value' => $instanceName ?: '-'], - ['label' => '实例编号', 'value' => $instanceId ?: '-', 'highlight' => true], - ['label' => '区域', 'value' => $region ?: '-'], - ['label' => '公网地址', 'value' => $publicIp ?: '-'], - ['label' => '释放时间', 'value' => date('Y-m-d H:i:s')], - ['label' => '说明', 'value' => $reason ?: '实例已从 ECS 控制台释放,本地记录和 DDNS 解析将同步清理。'] - ]; - - $textMsg = "【ECS 服务器管家】实例已释放\n" . - "账号: {$accountLabel}\n" . - "实例名称: " . ($instanceName ?: '-') . "\n" . - "实例编号: " . ($instanceId ?: '-') . "\n" . - "区域: " . ($region ?: '-') . "\n" . - "公网地址: " . ($publicIp ?: '-') . "\n" . - "释放时间: " . date('Y-m-d H:i:s') . "\n" . - "说明: " . ($reason ?: '实例已从 ECS 控制台释放,本地记录和 DDNS 解析将同步清理。'); - - return $this->dispatchNotifications($title, '实例已释放,本地记录和 DDNS 解析将同步清理。', $details, 'warning', $textMsg, $accountLabel); - } - - public function notifyPublicIpChanged($accountLabel, array $account, $oldIp, $newIp, $reason = '') - { - $instanceName = $account['instance_name'] ?? ($account['remark'] ?? ''); - $instanceId = $account['instance_id'] ?? ''; - $region = $account['region_id'] ?? ''; - $title = '公网 IP 已更换'; - - $details = [ - ['label' => '账号', 'value' => $accountLabel], - ['label' => '实例名称', 'value' => $instanceName ?: '-'], - ['label' => '实例编号', 'value' => $instanceId ?: '-', 'highlight' => true], - ['label' => '区域', 'value' => $region ?: '-'], - ['label' => '原公网 IP', 'value' => $oldIp ?: '-'], - ['label' => '新公网 IP', 'value' => $newIp ?: '-', 'highlight' => true], - ['label' => '变更时间', 'value' => date('Y-m-d H:i:s')], - ['label' => '说明', 'value' => $reason ?: '系统已更换托管 EIP,并同步更新 DDNS 解析。'] - ]; - - $textMsg = "【ECS 服务器管家】公网 IP 已更换\n" . - "账号: {$accountLabel}\n" . - "实例名称: " . ($instanceName ?: '-') . "\n" . - "实例编号: " . ($instanceId ?: '-') . "\n" . - "区域: " . ($region ?: '-') . "\n" . - "原公网 IP: " . ($oldIp ?: '-') . "\n" . - "新公网 IP: " . ($newIp ?: '-') . "\n" . - "变更时间: " . date('Y-m-d H:i:s') . "\n" . - "说明: " . ($reason ?: '系统已更换托管 EIP,并同步更新 DDNS 解析。'); - - return $this->dispatchNotifications($title, '公网 IP 已成功更换,DDNS 解析已同步更新。', $details, 'success', $textMsg, $accountLabel); - } - - private function statusLabel($status) - { - $map = [ - 'Running' => '已启动', - 'Starting' => '启动中', - 'Stopping' => '停机中', - 'Stopped' => '已停机', - 'Pending' => '创建中', - 'Released' => '已释放', - 'Unknown' => '未知' - ]; - return $map[$status] ?? ($status ?: '未知'); - } - - public function sendTestEmail($to) - { - $details = [ - ['label' => '测试结果', 'value' => '成功'], - ['label' => '发送时间', 'value' => date('Y-m-d H:i:s')], - ['label' => '服务器', 'value' => $_SERVER['SERVER_NAME'] ?? 'localhost'] - ]; - $html = $this->renderEmailTemplate("测试邮件", "邮件服务配置验证成功", $details, 'success'); - return $this->sendMail($to, '管理员', 'ECS 服务器管家测试邮件', $html); - } - - public function sendTestTelegram($data) - { - $textMsg = "【ECS 服务器管理】测试推送\n这是一条来自 Telegram 的测试消息。\n发送时间: " . date('Y-m-d H:i:s'); - return $this->sendTelegram($textMsg, $data); - } - - public function sendTestWebhook($data) - { - $textMsg = "【ECS 服务器管家】测试推送\n这是一条来自接口回调的测试消息。\n发送时间: " . date('Y-m-d H:i:s'); - $summary = "这是一条来自接口回调的测试消息。"; - $threshold = $this->config['traffic_threshold'] ?? 95; - $details = [ - ['label' => '当前流量', 'value' => '0 MB'], - ['label' => '设定阈值', 'value' => $threshold . '%'] - ]; - return $this->sendWebhook($textMsg, "测试推送", $summary, $details, 'test_account_id', $data); - } - - private function dispatchNotifications($title, $summary, $details, $type, $textMsg, $accountId = '') - { - $errors = []; - $successCount = 0; - $attemptCount = 0; - - // 邮件通知 - if (($this->config['notify_email_enabled'] ?? '1') === '1' && !empty($this->config['notify_email'])) { - $attemptCount++; - $html = $this->renderEmailTemplate($title, $summary, $details, $type); - $res = $this->sendMail($this->config['notify_email'], '', "ECS 服务器管家通知 - " . $title, $html); - if ($res === true) - $successCount++; - else - $errors[] = "邮件通知: " . $res; - } - - // Telegram - if (($this->config['notify_tg_enabled'] ?? '0') === '1' && !empty($this->config['notify_tg_token']) && !empty($this->config['notify_tg_chat_id'])) { - $attemptCount++; - $res = $this->sendTelegram($textMsg); - if ($res === true) - $successCount++; - else - $errors[] = "Telegram: " . $res; - } - - // 接口回调 - if (($this->config['notify_wh_enabled'] ?? '0') === '1' && !empty($this->config['notify_wh_url'])) { - $attemptCount++; - $res = $this->sendWebhook($textMsg, $title, $summary, $details, $accountId); - if ($res === true) - $successCount++; - else - $errors[] = "接口回调: " . $res; - } - - if ($attemptCount == 0) - return true; - - if ($successCount == 0 && count($errors) > 0) { - return implode(" | ", $errors); - } else if (count($errors) > 0) { - return "部分完成: " . implode(" | ", $errors); - } - return true; - } - - private function renderEmailTemplate($title, $summary, $details, $type = 'info') - { - $color = '#007AFF'; - if ($type === 'warning') - $color = '#FF3B30'; - if ($type === 'success') - $color = '#34C759'; - - $rows = ''; - foreach ($details as $item) { - $valColor = isset($item['highlight']) && $item['highlight'] ? $color : '#1C1C1E'; - $rows .= " - - {$item['label']} - {$item['value']} - "; - } - - return " - - - - - - -
- - - - -
-
ECS 服务器管家
-

{$title}

-

{$summary}

- {$rows}
-
© " . date('Y') . " ECS 服务器管家
-
- "; - } - - private function sendMail($to, $name, $subject, $body) - { - $mail = new PHPMailer(); - $mail->CharSet = 'UTF-8'; - $mail->IsSMTP(); - $mail->SMTPDebug = 0; - $mail->SMTPAuth = true; - - $secure = $this->config['notify_secure'] ?? 'ssl'; - if (!empty($secure)) { - $mail->SMTPSecure = $secure; - } else { - $mail->SMTPSecure = ''; - $mail->SMTPAutoTLS = false; - } - - $mail->Host = $this->config['notify_host'] ?? ''; - $mail->Port = $this->config['notify_port'] ?? 465; - $mail->Username = $this->config['notify_username'] ?? ''; - $mail->Password = $this->config['notify_password'] ?? ''; - - $mail->SetFrom($mail->Username, 'ECS 服务器管家'); - $mail->Subject = $subject; - $mail->MsgHTML($body); - $mail->AddAddress($to, $name); - - // 修改:返回 true 或 错误信息字符串 - if ($mail->Send()) { - return true; - } else { - return $mail->ErrorInfo; - } - } - - private function sendTelegram($text, $overrideConfig = null) - { - $token = $overrideConfig['token'] ?? $this->config['notify_tg_token'] ?? ''; - $chatId = $overrideConfig['chat_id'] ?? $this->config['notify_tg_chat_id'] ?? ''; - $proxyType = $overrideConfig['proxy_type'] ?? $this->config['notify_tg_proxy_type'] ?? 'none'; - - if (empty($token) || empty($chatId)) - return "Telegram 的机器人令牌或接收会话编号为空"; - - $url = "https://api.telegram.org/bot{$token}/sendMessage"; - if ($proxyType === 'custom' && !empty($overrideConfig['proxy_url'] ?? $this->config['notify_tg_proxy_url'] ?? '')) { - $baseUrl = rtrim($overrideConfig['proxy_url'] ?? $this->config['notify_tg_proxy_url'], '/'); - $url = "{$baseUrl}/bot{$token}/sendMessage"; - } - - $postFields = [ - 'chat_id' => $chatId, - 'text' => $text - ]; - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postFields)); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 10); - - if ($proxyType === 'socks5') { - $proxyIp = $overrideConfig['proxy_ip'] ?? $this->config['notify_tg_proxy_ip'] ?? ''; - $proxyPort = $overrideConfig['proxy_port'] ?? $this->config['notify_tg_proxy_port'] ?? ''; - if ($proxyIp && $proxyPort) { - curl_setopt($ch, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5_HOSTNAME); - curl_setopt($ch, CURLOPT_PROXY, "{$proxyIp}:{$proxyPort}"); - $proxyUser = $overrideConfig['proxy_user'] ?? $this->config['notify_tg_proxy_user'] ?? ''; - $proxyPass = $overrideConfig['proxy_pass'] ?? $this->config['notify_tg_proxy_pass'] ?? ''; - if ($proxyUser || $proxyPass) { - curl_setopt($ch, CURLOPT_PROXYUSERPWD, "{$proxyUser}:{$proxyPass}"); - } - } - } - - $result = curl_exec($ch); - $error = curl_error($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($error) - return "网络请求错误: " . $error; - if ($httpCode != 200) - return "接口返回错误 {$httpCode}: " . $result; - return true; - } - - private function sendWebhook($text, $title, $summary, $details, $accountId = '', $overrideConfig = null) - { - $url = $overrideConfig['url'] ?? $this->config['notify_wh_url'] ?? ''; - $method = strtoupper($overrideConfig['method'] ?? $this->config['notify_wh_method'] ?? 'GET'); - $requestType = strtoupper($overrideConfig['request_type'] ?? $this->config['notify_wh_request_type'] ?? 'JSON'); - $headersStr = $overrideConfig['headers'] ?? $this->config['notify_wh_headers'] ?? ''; - $bodyTemplate = $overrideConfig['body'] ?? $this->config['notify_wh_body'] ?? ''; - - if (empty($url)) - return "接口回调地址为空"; - - // Parse variables - $traffic = '暂无'; - $maxTraffic = '暂无'; - foreach ($details as $d) { - if ($d['label'] === '当前流量') - $traffic = str_replace([' GB', ' MB', ' GB', ' MB'], '', $d['value']); - if ($d['label'] === '设定阈值') - $maxTraffic = str_replace('%', '', $d['value']); - } - $replacePairs = [ - '#TITLE#' => $title, - '#MSG#' => $summary ?: $text, - '#ACCOUNT#' => $accountId, - '#TRAFFIC#' => $traffic, - '#MAX_TRAFFIC#' => $maxTraffic - ]; - - $ch = curl_init(); - $customHeaders = []; - - // Parse custom headers - if (!empty($headersStr)) { - $parsedHeaders = json_decode($headersStr, true); - if (is_array($parsedHeaders)) { - foreach ($parsedHeaders as $k => $v) { - $customHeaders[] = "{$k}: {$v}"; - } - } - } - - if ($method === 'GET') { - $urlReplacePairs = []; - foreach ($replacePairs as $k => $v) { - $urlReplacePairs[$k] = urlencode((string) $v); - } - $finalUrl = strtr($url, $urlReplacePairs); - - // 读取请求没有请求体时,将默认参数拼到地址上。 - if (empty($bodyTemplate) && strpos($finalUrl, '?') === false && strpos($url, '#') === false) { - $payload = [ - 'title' => $title, - 'text' => $text, - 'time' => date('Y-m-d H:i:s') - ]; - $finalUrl .= '?' . http_build_query($payload); - } - curl_setopt($ch, CURLOPT_URL, $finalUrl); - } else { - // 发送请求 - $urlReplacePairs = []; - foreach ($replacePairs as $k => $v) { - $urlReplacePairs[$k] = urlencode((string) $v); - } - curl_setopt($ch, CURLOPT_URL, strtr($url, $urlReplacePairs)); - curl_setopt($ch, CURLOPT_POST, true); - - $finalBody = ''; - if (!empty($bodyTemplate)) { - $bodyReplacePairs = $replacePairs; - if ($requestType === 'JSON') { - foreach ($bodyReplacePairs as $k => $v) { - // 数据格式安全转义,避免模板变量破坏 JSON 字符串。 - $bodyReplacePairs[$k] = substr(json_encode((string) $v, JSON_UNESCAPED_UNICODE), 1, -1); - } - } else if ($requestType === 'FORM') { - foreach ($bodyReplacePairs as $k => $v) { - $bodyReplacePairs[$k] = urlencode((string) $v); - } - } - $finalBody = strtr($bodyTemplate, $bodyReplacePairs); - - // Content Type - if ($requestType === 'JSON') { - $customHeaders[] = 'Content-Type: application/json'; - } else if ($requestType === 'FORM') { - $customHeaders[] = 'Content-Type: application/x-www-form-urlencoded'; - // 用户误填 JSON 时,尽量转换为表单格式。 - $decoded = json_decode($finalBody, true); - if (is_array($decoded)) { - $finalBody = http_build_query($decoded); - } - } - } else { - // 未配置请求体时发送默认内容。 - $payload = ['title' => $title, 'text' => $text, 'time' => date('Y-m-d H:i:s')]; - if ($requestType === 'JSON') { - $finalBody = json_encode($payload, JSON_UNESCAPED_UNICODE); - $customHeaders[] = 'Content-Type: application/json'; - } else { - $finalBody = http_build_query($payload); - $customHeaders[] = 'Content-Type: application/x-www-form-urlencoded'; - } - } - curl_setopt($ch, CURLOPT_POSTFIELDS, $finalBody); - } - - if (!empty($customHeaders)) { - curl_setopt($ch, CURLOPT_HTTPHEADER, array_unique($customHeaders)); - } - - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 10); - - $result = curl_exec($ch); - $error = curl_error($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($error) - return "网络请求错误: " . $error; - if ($httpCode >= 400) - return "接口返回错误 {$httpCode}: " . $result; - return true; - } - - private function formatTraffic($trafficGb) - { - $trafficGb = (float) $trafficGb; - if ($trafficGb <= 0) { - return '0 MB'; - } - if ($trafficGb < 1) { - return round($trafficGb * 1024, 2) . ' MB'; - } - return round($trafficGb, 2) . ' GB'; - } -} diff --git a/README.MD b/README.MD index 5277fd4..327a1e1 100644 --- a/README.MD +++ b/README.MD @@ -28,7 +28,7 @@ ## 🚀 快速部署 -当前默认容器已经切换到 Go 单进程版本:HTTP API、SQLite、任务 worker 和监控 worker 在同一个可优雅关闭的服务中运行,不再需要 PHP-FPM、Nginx 或 Cron。旧 PHP 文件仍保留在仓库中,便于迁移期间对照。 +当前容器使用 Go 单进程版本:HTTP API、SQLite、任务 worker 和监控 worker 在同一个可优雅关闭的服务中运行,不需要 PHP-FPM、Nginx 或 Cron。 ### 方式一:Docker Compose (推荐) Colima、Docker Desktop 和 Linux Docker 均可使用。Compose 会创建非 root 应用使用的 named volume,并把旧的 `./data` 目录作为只读迁移源;首次启动时若 volume 为空,会自动复制旧 SQLite 数据和加密密钥。 @@ -248,10 +248,10 @@ cdt:ListCdtInternetTraffic ## 🛠️ 技术架构 -- **Backend**: Go 单进程 HTTP 服务,内置 action API、任务 worker、监控 worker 和 Telegram 控制 worker;旧 PHP 文件仅作为迁移对照。 +- **Backend**: Go 单进程 HTTP 服务,内置 action API、任务 worker、监控 worker 和 Telegram 控制 worker。 - **Storage**: SQLite 3 WAL,负责配置、账号、session、任务队列、流量账本、账单缓存和审计日志;敏感字段使用 SecretBox 加密。 - **Cloud adapter**: 通过轻量 Alibaba RPC 客户端完成 ECS、VPC、EIP、CMS、CDT 和 BSS 调用,支持 fake/mock client 做离线测试。 -- **Frontend**: 保留 Vue 3 + 静态资源和原有 `index.php?action=...` 兼容协议,Go 服务直接托管页面。 +- **Frontend**: Vue 3 + 静态资源;Go 服务直接托管页面,并保留历史 `index.php?action=...` API 路径以兼容现有客户端。 - **Runtime**: Docker/Colima/Linux Docker 均可运行;Compose 使用 named volume、非 root 应用用户和 `/healthz` 健康检查。 --- diff --git a/TelegramControlService.php b/TelegramControlService.php deleted file mode 100644 index 59c7a96..0000000 --- a/TelegramControlService.php +++ /dev/null @@ -1,804 +0,0 @@ -db = $db; - $this->pdo = $db->getPdo(); - $this->configManager = $configManager; - $this->app = $app; - $this->settings = $configManager->getAllSettings(); - } - - public function processUpdates() - { - return $this->processUpdatesWithTimeout(1); - } - - public function processUpdatesWithTimeout($timeout = 1) - { - if (!$this->isConfigured()) { - return 0; - } - - $lock = $this->acquireLock(); - if (!$lock) { - return 0; - } - - try { - $this->cleanupExpiredTokens(); - $offset = ((int) $this->getState('last_update_id', '0')) + 1; - $response = $this->api('getUpdates', [ - 'offset' => $offset, - 'limit' => 20, - 'timeout' => max(0, (int) $timeout), - 'allowed_updates' => json_encode(['message', 'callback_query']) - ]); - - if (!$response['ok']) { - $message = $response['description'] ?? '未知错误'; - $this->db->addLog('error', 'Telegram 控制拉取消息失败: ' . $message); - return 0; - } - - $processed = 0; - foreach (($response['result'] ?? []) as $update) { - $updateId = (int) ($update['update_id'] ?? 0); - if ($updateId > 0) { - // 先确认 offset,避免发送消息过程中被重启后重复响应旧指令。 - $this->setState('last_update_id', (string) $updateId); - } - - try { - if (isset($update['callback_query'])) { - $this->handleCallback($update['callback_query']); - } elseif (isset($update['message'])) { - $this->handleMessage($update['message']); - } - $processed++; - } catch (\Exception $e) { - $this->db->addLog('error', 'Telegram 控制指令处理失败: ' . strip_tags($e->getMessage())); - } - } - return $processed; - } finally { - flock($lock, LOCK_UN); - fclose($lock); - } - } - - private function acquireLock() - { - $dir = __DIR__ . '/data'; - if (!is_dir($dir)) { - @mkdir($dir, 0775, true); - } - - $lock = @fopen($dir . '/telegram-control.lock', 'c'); - if (!$lock) { - return null; - } - - if (!flock($lock, LOCK_EX | LOCK_NB)) { - fclose($lock); - return null; - } - - return $lock; - } - - private function isConfigured() - { - return trim((string) ($this->settings['notify_tg_token'] ?? '')) !== '' - && trim((string) ($this->settings['notify_tg_chat_id'] ?? '')) !== ''; - } - - private function handleMessage(array $message) - { - $chat = $message['chat'] ?? []; - $from = $message['from'] ?? []; - $chatId = (string) ($chat['id'] ?? ''); - $userId = (string) ($from['id'] ?? ''); - if (!$this->isAllowed($chatId, $userId)) { - return; - } - - $text = trim((string) ($message['text'] ?? '')); - $command = strtolower(preg_replace('/@.+$/', '', strtok($text, " \n\t") ?: '')); - - if (in_array($command, ['/traffic', '流量'], true)) { - $this->sendMessage($chatId, $this->buildTrafficText(), $this->trafficKeyboard()); - return; - } - - if (in_array($command, ['/instances', '实例'], true)) { - $this->sendMessage($chatId, $this->buildInstancesText(1), $this->instancesKeyboard(1)); - return; - } - - $this->sendMessage($chatId, $this->mainMenuText(), $this->mainMenuKeyboard()); - } - - private function handleCallback(array $callback) - { - $id = (string) ($callback['id'] ?? ''); - $from = $callback['from'] ?? []; - $message = $callback['message'] ?? []; - $chat = $message['chat'] ?? []; - $chatId = (string) ($chat['id'] ?? ''); - $userId = (string) ($from['id'] ?? ''); - $messageId = (int) ($message['message_id'] ?? 0); - $data = (string) ($callback['data'] ?? ''); - - if (!$this->isAllowed($chatId, $userId)) { - $this->answerCallback($id, '没有权限执行该操作'); - return; - } - - $parts = explode(':', $data); - if (($parts[0] ?? '') !== 'm') { - $this->answerCallback($id); - return; - } - - $action = $parts[1] ?? 'home'; - if ($action === 'home') { - $this->answerCallback($id); - $this->editMessage($chatId, $messageId, $this->mainMenuText(), $this->mainMenuKeyboard()); - } elseif ($action === 'help') { - $this->answerCallback($id); - $this->editMessage($chatId, $messageId, $this->helpText(), $this->mainMenuKeyboard()); - } elseif ($action === 'traffic') { - $this->answerCallback($id, '正在刷新流量...'); - $this->refreshAllData(); - $this->editMessage($chatId, $messageId, $this->buildTrafficText(), $this->trafficKeyboard()); - return; - } elseif ($action === 'list') { - $this->answerCallback($id); - $page = max(1, (int) ($parts[2] ?? 1)); - $this->editMessage($chatId, $messageId, $this->buildInstancesText($page), $this->instancesKeyboard($page)); - } elseif ($action === 'listrefresh') { - $this->answerCallback($id, '正在刷新列表...'); - $page = max(1, (int) ($parts[2] ?? 1)); - $this->refreshAllData(); - $this->editMessage($chatId, $messageId, $this->buildInstancesText($page), $this->instancesKeyboard($page)); - return; - } elseif ($action === 'inst') { - $this->answerCallback($id); - $accountId = (int) ($parts[2] ?? 0); - $this->editMessage($chatId, $messageId, $this->buildInstanceDetailText($accountId), $this->instanceKeyboard($accountId)); - } elseif ($action === 'refresh') { - $this->answerCallback($id, '正在刷新状态...'); - $accountId = (int) ($parts[2] ?? 0); - if ($accountId > 0) { - $this->app->refreshAccount($accountId); - } - $this->editMessage($chatId, $messageId, $this->buildInstanceDetailText($accountId), $this->instanceKeyboard($accountId)); - return; - } elseif ($action === 'refreshall') { - $this->answerCallback($id, '正在同步数据...'); - $this->refreshAllData(); - $this->editMessage($chatId, $messageId, $this->buildTrafficText(), $this->trafficKeyboard()); - return; - } elseif ($action === 'start') { - $this->answerCallback($id, '正在提交开机指令...'); - $accountId = (int) ($parts[2] ?? 0); - $this->startInstance($chatId, $messageId, $accountId); - return; - } elseif ($action === 'stop') { - $this->answerCallback($id, '正在提交停机指令...'); - $accountId = (int) ($parts[2] ?? 0); - $this->stopInstance($chatId, $messageId, $accountId); - return; - } elseif ($action === 'release') { - $this->answerCallback($id); - $accountId = (int) ($parts[2] ?? 0); - if (!$this->findInstance($accountId)) { - $this->editMessage($chatId, $messageId, "释放失败:实例不存在或已被清理。", $this->mainMenuKeyboard()); - return; - } - $token = $this->createActionToken($userId, $chatId, 'release', $accountId); - $this->editMessage($chatId, $messageId, $this->buildReleaseConfirmText($accountId), $this->releaseConfirmKeyboard($token, $accountId)); - } elseif ($action === 'confirm') { - $this->answerCallback($id, '正在提交释放指令...'); - $token = (string) ($parts[2] ?? ''); - $this->confirmRelease($chatId, $messageId, $userId, $token); - return; - } elseif ($action === 'cancel') { - $this->answerCallback($id); - $token = (string) ($parts[2] ?? ''); - $this->useActionToken($token, $userId, $chatId, false); - $this->editMessage($chatId, $messageId, "已取消释放操作。", $this->mainMenuKeyboard()); - } else { - $this->answerCallback($id); - } - } - - private function isAllowed($chatId, $userId) - { - $configuredChatId = trim((string) ($this->settings['notify_tg_chat_id'] ?? '')); - if ($configuredChatId === '' || $chatId !== $configuredChatId) { - return false; - } - - $allowed = $this->parseCsvIds($this->settings['notify_tg_allowed_user_ids'] ?? ''); - if (!empty($allowed)) { - return in_array($userId, $allowed, true); - } - - // 私聊场景下 chat_id 通常等于 from.id;群聊未配置白名单时不允许控制。 - return $chatId !== '' && $chatId[0] !== '-' && $chatId === $userId; - } - - private function parseCsvIds($value) - { - $items = preg_split('/[\s,;,;]+/', trim((string) $value)) ?: []; - return array_values(array_filter(array_map('trim', $items), function ($item) { - return $item !== ''; - })); - } - - private function mainMenuText() - { - return "🛡️ ECS 服务器管家\n\n请选择要执行的操作:"; - } - - private function helpText() - { - return "📘 使用说明\n\n" - . "配置 Telegram 通知后,当前 Bot 默认支持远程控制。\n\n" - . "可用功能:\n" - . "📊 查看账号概览\n" - . "🖥️ 查看实例列表和详情\n" - . "🚀 对已停止实例一键开机\n" - . "🗑️ 二次确认后释放实例\n\n" - . "⚠️ 释放实例会进入后台安全队列,系统会继续处理停机、托管 EIP 回收、ECS 删除和 DDNS 清理。"; - } - - private function mainMenuKeyboard() - { - return [ - 'inline_keyboard' => [ - [ - ['text' => '📊 账号概览', 'callback_data' => 'm:traffic'], - ['text' => '🖥️ 实例列表', 'callback_data' => 'm:list:1'] - ], - [ - ['text' => '🔄 刷新数据', 'callback_data' => 'm:refreshall'], - ['text' => '📘 帮助说明', 'callback_data' => 'm:help'] - ] - ] - ]; - } - - private function trafficKeyboard() - { - return [ - 'inline_keyboard' => [ - [ - ['text' => '🔄 刷新流量', 'callback_data' => 'm:traffic'], - ['text' => '🖥️ 查看实例', 'callback_data' => 'm:list:1'] - ], - [ - ['text' => '🏠 返回主菜单', 'callback_data' => 'm:home'] - ] - ] - ]; - } - - private function buildTrafficText() - { - $config = $this->app->getConfigForFrontend(); - $accounts = $config['Accounts'] ?? []; - if (empty($accounts)) { - return "📊 账号概览\n\n暂无账号数据,请先在控制台添加账号。"; - } - - $lines = ["📊 账号概览"]; - foreach ($accounts as $account) { - $used = (float) ($account['usageUsed'] ?? 0); - $total = (float) ($account['maxTraffic'] ?? 0); - $percent = (float) ($account['usagePercent'] ?? 0); - $status = $percent >= 100 ? '已超量' : ($percent >= (float) ($config['traffic_threshold'] ?? 95) ? '接近阈值' : '正常'); - if (($account['trafficStatus'] ?? 'ok') !== 'ok') { - $status = $account['trafficMessage'] ?: '流量同步异常'; - } - - $lines[] = ""; - $lines[] = "👤 账号:" . ($account['remark'] ?: '未命名账号'); - $lines[] = "📍 区域:" . $this->regionName($account['regionId'] ?? ''); - $lines[] = "📦 已用:" . $this->formatTraffic($used) . " / " . $this->formatTraffic($total); - $lines[] = "📈 使用率:" . $percent . "%"; - $lines[] = $this->trafficStatusIcon($status) . " 状态:" . $status; - } - - return implode("\n", $lines); - } - - private function buildInstancesText($page) - { - $instances = $this->getInstances(); - if (empty($instances)) { - return "🖥️ 实例列表\n\n暂无实例数据。"; - } - - $pageSize = 6; - $totalPages = max(1, (int) ceil(count($instances) / $pageSize)); - $page = min(max(1, $page), $totalPages); - $slice = array_slice($instances, ($page - 1) * $pageSize, $pageSize); - - $lines = ["🖥️ 实例列表 第 {$page}/{$totalPages} 页"]; - foreach ($slice as $inst) { - $lines[] = ""; - $status = $inst['instanceStatus'] ?? ''; - $lines[] = "🖥️ " . ($inst['remark'] ?: $inst['instanceName'] ?: $inst['instanceId']); - $lines[] = $this->statusIcon($status) . " 状态:" . $this->statusLabel($status); - $lines[] = "📦 流量:" . $this->formatTraffic((float) ($inst['flow_used'] ?? 0)) . " / " . $this->formatTraffic((float) ($inst['flow_total'] ?? 0)); - $lines[] = "🌐 IP:" . (($inst['publicIp'] ?? '') ?: '-'); - } - - return implode("\n", $lines); - } - - private function refreshAllData() - { - if (method_exists($this->app, 'getAllManagedInstances')) { - $this->app->getAllManagedInstances(true); - $this->configManager->load(); - } - } - - private function instancesKeyboard($page) - { - $instances = $this->getInstances(); - $pageSize = 6; - $totalPages = max(1, (int) ceil(count($instances) / $pageSize)); - $page = min(max(1, $page), $totalPages); - $slice = array_slice($instances, ($page - 1) * $pageSize, $pageSize); - - $keyboard = []; - foreach ($slice as $inst) { - $label = $this->shortButtonText($this->statusIcon($inst['instanceStatus'] ?? '') . ' ' . ($inst['remark'] ?: $inst['instanceName'] ?: $inst['instanceId']) . ' / ' . $this->statusLabel($inst['instanceStatus'] ?? '')); - $keyboard[] = [['text' => $label, 'callback_data' => 'm:inst:' . (int) $inst['accountId']]]; - } - - $pager = []; - if ($page > 1) { - $pager[] = ['text' => '⬅️ 上一页', 'callback_data' => 'm:list:' . ($page - 1)]; - } - if ($page < $totalPages) { - $pager[] = ['text' => '下一页 ➡️', 'callback_data' => 'm:list:' . ($page + 1)]; - } - if (!empty($pager)) { - $keyboard[] = $pager; - } - - $keyboard[] = [ - ['text' => '🔄 刷新列表', 'callback_data' => 'm:listrefresh:' . $page], - ['text' => '🏠 返回主菜单', 'callback_data' => 'm:home'] - ]; - - return ['inline_keyboard' => $keyboard]; - } - - private function buildInstanceDetailText($accountId) - { - $inst = $this->findInstance($accountId); - if (!$inst) { - return "🖥️ 实例详情\n\n实例不存在或已被清理。"; - } - - $status = $inst['instanceStatus'] ?? ''; - return "🖥️ 实例详情\n\n" - . "🏷️ 备注:" . ($inst['remark'] ?: '-') . "\n" - . "📍 区域:" . ($inst['regionName'] ?? $this->regionName($inst['regionId'] ?? '')) . "\n" - . $this->statusIcon($status) . " 状态:" . $this->statusLabel($status) . "\n" - . "🆔 实例 ID:" . ($inst['instanceId'] ?: '-') . "\n" - . "🌐 公网 IP:" . (($inst['publicIp'] ?? '') ?: '-') . "\n" - . "🔌 公网类型:" . (($inst['publicIpMode'] ?? '') === 'eip' ? 'EIP' : 'ECS 公网') . "\n" - . "⚙️ 规格:" . (($inst['instanceType'] ?? '') ?: '-') . "\n" - . "📦 出口流量:" . $this->formatTraffic((float) ($inst['flow_used'] ?? 0)) . " / " . $this->formatTraffic((float) ($inst['flow_total'] ?? 0)) . "\n" - . "📈 使用率:" . ((float) ($inst['percentageOfUse'] ?? 0)) . "%\n" - . "🚧 阈值:" . ((int) ($inst['threshold'] ?? 95)) . "%"; - } - - private function instanceKeyboard($accountId) - { - $inst = $this->findInstance($accountId); - if (!$inst) { - return ['inline_keyboard' => [[['text' => '🖥️ 返回实例列表', 'callback_data' => 'm:list:1']]]]; - } - - $status = $inst['instanceStatus'] ?? ''; - $locked = !empty($inst['operationLocked']) || in_array($status, ['Releasing', 'Released'], true); - $keyboard = []; - if (!$locked && $status === 'Stopped') { - $keyboard[] = [['text' => '🚀 开机', 'callback_data' => 'm:start:' . (int) $accountId]]; - } - if (!$locked && $status === 'Running') { - $keyboard[] = [['text' => '🛑 停机', 'callback_data' => 'm:stop:' . (int) $accountId]]; - } - if (!$locked && !in_array($status, ['Releasing', 'Released'], true)) { - $keyboard[] = [['text' => '🗑️ 释放实例', 'callback_data' => 'm:release:' . (int) $accountId]]; - } - $keyboard[] = [['text' => '🔄 刷新状态', 'callback_data' => 'm:refresh:' . (int) $accountId]]; - $keyboard[] = [ - ['text' => '🖥️ 返回实例列表', 'callback_data' => 'm:list:1'], - ['text' => '🏠 返回主菜单', 'callback_data' => 'm:home'] - ]; - return ['inline_keyboard' => $keyboard]; - } - - private function buildReleaseConfirmText($accountId) - { - $inst = $this->findInstance($accountId); - if (!$inst) { - return "🗑️ 确认释放实例\n\n实例不存在或已被清理。"; - } - - $ttl = $this->confirmTtl(); - return "⚠️ 确认释放实例?\n\n" - . "🖥️ 实例:" . ($inst['remark'] ?: $inst['instanceName'] ?: '-') . "\n" - . "📍 区域:" . ($inst['regionName'] ?? $this->regionName($inst['regionId'] ?? '')) . "\n" - . "🆔 实例 ID:" . ($inst['instanceId'] ?: '-') . "\n" - . "🔌 公网类型:" . (($inst['publicIpMode'] ?? '') === 'eip' ? 'EIP' : 'ECS 公网') . "\n\n" - . "🗑️ 释放后 ECS 会被删除,系统托管 EIP 和 DDNS 解析会同步清理,操作不可恢复。\n\n" - . "⏱️ 请在 {$ttl} 秒内确认。"; - } - - private function releaseConfirmKeyboard($token, $accountId) - { - return [ - 'inline_keyboard' => [ - [ - ['text' => '⚠️ 确认释放', 'callback_data' => 'm:confirm:' . $token], - ['text' => '取消', 'callback_data' => 'm:cancel:' . $token] - ], - [ - ['text' => '🖥️ 返回实例详情', 'callback_data' => 'm:inst:' . (int) $accountId] - ] - ] - ]; - } - - private function startInstance($chatId, $messageId, $accountId) - { - $inst = $this->findInstance($accountId); - if (!$inst) { - $this->editMessage($chatId, $messageId, "❌ 开机失败:实例不存在。", $this->mainMenuKeyboard()); - return; - } - - if (($inst['instanceStatus'] ?? '') !== 'Stopped') { - $this->editMessage($chatId, $messageId, "ℹ️ 当前实例不是已停机状态,无需开机。", $this->instanceKeyboard($accountId)); - return; - } - - $success = $this->app->controlInstanceAction($accountId, 'start', 'KeepCharging', false); - $this->db->addLog($success ? 'info' : 'error', "Telegram 控制开机" . ($success ? '成功' : '失败') . " [{$inst['remark']}] {$inst['instanceId']}"); - $this->editMessage( - $chatId, - $messageId, - $success ? "🚀 开机指令已提交。\n\n🖥️ 实例:" . ($inst['remark'] ?: $inst['instanceId']) . "\n🟡 当前状态:启动中" : "❌ 开机失败,请查看系统日志。", - $this->instanceKeyboard($accountId) - ); - } - - private function stopInstance($chatId, $messageId, $accountId) - { - $inst = $this->findInstance($accountId); - if (!$inst) { - $this->editMessage($chatId, $messageId, "❌ 停机失败:实例不存在。", $this->mainMenuKeyboard()); - return; - } - - if (($inst['instanceStatus'] ?? '') !== 'Running') { - $this->editMessage($chatId, $messageId, "ℹ️ 当前实例不是运行中状态,无需停机。", $this->instanceKeyboard($accountId)); - return; - } - - $success = $this->app->controlInstanceAction($accountId, 'stop', 'KeepCharging', false); - $this->db->addLog($success ? 'info' : 'error', "Telegram 控制停机" . ($success ? '成功' : '失败') . " [{$inst['remark']}] {$inst['instanceId']}"); - $this->editMessage( - $chatId, - $messageId, - $success ? "🛑 停机指令已提交。\n\n🖥️ 实例:" . ($inst['remark'] ?: $inst['instanceId']) . "\n🟠 当前状态:停机中" : "❌ 停机失败,请查看系统日志。", - $this->instanceKeyboard($accountId) - ); - } - - private function confirmRelease($chatId, $messageId, $userId, $token) - { - $record = $this->useActionToken($token, $userId, $chatId, true); - if (!$record) { - $this->editMessage($chatId, $messageId, "⏱️ 释放确认已失效,请重新发起释放操作。", $this->mainMenuKeyboard()); - return; - } - - $accountId = (int) $record['account_id']; - $inst = $this->findInstance($accountId); - $success = $this->app->deleteInstanceAction($accountId); - $label = $inst ? ($inst['remark'] ?: $inst['instanceId']) : ('实例 #' . $accountId); - $this->db->addLog($success ? 'warning' : 'error', "Telegram 提交释放" . ($success ? '成功' : '失败') . " [{$label}]"); - $this->editMessage( - $chatId, - $messageId, - $success - ? "🗑️ 释放指令已提交。\n\n🖥️ 实例:{$label}\n后台释放队列已接管,会继续处理停机、托管 EIP 回收、ECS 删除和 DDNS 清理。" - : "❌ 释放指令提交失败,请查看系统日志。", - $this->mainMenuKeyboard() - ); - } - - private function getInstances() - { - $status = $this->app->getStatusForFrontend(true); - $items = $status['data'] ?? []; - usort($items, function ($a, $b) { - return strcmp(($a['regionName'] ?? '') . ($a['remark'] ?? ''), ($b['regionName'] ?? '') . ($b['remark'] ?? '')); - }); - return $items; - } - - private function findInstance($accountId) - { - foreach ($this->getInstances() as $inst) { - if ((int) ($inst['accountId'] ?? 0) === (int) $accountId) { - return $inst; - } - } - return null; - } - - private function createActionToken($userId, $chatId, $action, $accountId, array $payload = []) - { - $token = bin2hex(random_bytes(8)); - $stmt = $this->pdo->prepare(" - INSERT INTO telegram_action_tokens - (token, user_id, chat_id, action, account_id, payload, expires_at, used_at, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?) - "); - $now = time(); - $stmt->execute([ - $token, - (string) $userId, - (string) $chatId, - $action, - (int) $accountId, - json_encode($payload, JSON_UNESCAPED_UNICODE), - $now + $this->confirmTtl(), - $now - ]); - return $token; - } - - private function useActionToken($token, $userId, $chatId, $markUsed) - { - $stmt = $this->pdo->prepare(" - SELECT * - FROM telegram_action_tokens - WHERE token = ? - AND user_id = ? - AND chat_id = ? - AND used_at = 0 - AND expires_at >= ? - LIMIT 1 - "); - $stmt->execute([(string) $token, (string) $userId, (string) $chatId, time()]); - $record = $stmt->fetch(PDO::FETCH_ASSOC); - if (!$record) { - return null; - } - - $update = $this->pdo->prepare("UPDATE telegram_action_tokens SET used_at = ? WHERE id = ?"); - $update->execute([time(), (int) $record['id']]); - - return $record; - } - - private function cleanupExpiredTokens() - { - $stmt = $this->pdo->prepare("DELETE FROM telegram_action_tokens WHERE expires_at < ? OR (used_at > 0 AND used_at < ?)"); - $stmt->execute([time() - 3600, time() - 86400]); - } - - private function getState($key, $default = '') - { - $stmt = $this->pdo->prepare("SELECT value FROM telegram_bot_state WHERE key = ? LIMIT 1"); - $stmt->execute([$key]); - $value = $stmt->fetchColumn(); - return $value === false ? $default : (string) $value; - } - - private function setState($key, $value) - { - $stmt = $this->pdo->prepare(" - INSERT INTO telegram_bot_state (key, value) - VALUES (?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value - "); - $stmt->execute([$key, $value]); - } - - private function sendMessage($chatId, $text, array $keyboard = null) - { - $payload = [ - 'chat_id' => $chatId, - 'text' => $text - ]; - if ($keyboard) { - $payload['reply_markup'] = json_encode($keyboard, JSON_UNESCAPED_UNICODE); - } - return $this->api('sendMessage', $payload); - } - - private function editMessage($chatId, $messageId, $text, array $keyboard = null) - { - if ($messageId <= 0) { - return $this->sendMessage($chatId, $text, $keyboard); - } - - $payload = [ - 'chat_id' => $chatId, - 'message_id' => $messageId, - 'text' => $text - ]; - if ($keyboard) { - $payload['reply_markup'] = json_encode($keyboard, JSON_UNESCAPED_UNICODE); - } - $response = $this->api('editMessageText', $payload); - if (!$response['ok']) { - return $this->sendMessage($chatId, $text, $keyboard); - } - return $response; - } - - private function answerCallback($callbackId, $text = '') - { - if ($callbackId === '') { - return; - } - $payload = ['callback_query_id' => $callbackId]; - if ($text !== '') { - $payload['text'] = $text; - } - $this->api('answerCallbackQuery', $payload); - } - - private function api($method, array $payload = []) - { - $token = trim((string) ($this->settings['notify_tg_token'] ?? '')); - $proxyType = $this->settings['notify_tg_proxy_type'] ?? 'none'; - $url = "https://api.telegram.org/bot{$token}/{$method}"; - if ($proxyType === 'custom' && !empty($this->settings['notify_tg_proxy_url'])) { - $url = rtrim($this->settings['notify_tg_proxy_url'], '/') . "/bot{$token}/{$method}"; - } - - $ch = curl_init($url); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($payload)); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 8); - curl_setopt($ch, CURLOPT_TIMEOUT, 15); - - if ($proxyType === 'socks5') { - $proxyIp = trim((string) ($this->settings['notify_tg_proxy_ip'] ?? '')); - $proxyPort = trim((string) ($this->settings['notify_tg_proxy_port'] ?? '')); - if ($proxyIp !== '' && $proxyPort !== '') { - curl_setopt($ch, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5_HOSTNAME); - curl_setopt($ch, CURLOPT_PROXY, "{$proxyIp}:{$proxyPort}"); - $proxyUser = $this->settings['notify_tg_proxy_user'] ?? ''; - $proxyPass = $this->settings['notify_tg_proxy_pass'] ?? ''; - if ($proxyUser !== '' || $proxyPass !== '') { - curl_setopt($ch, CURLOPT_PROXYUSERPWD, "{$proxyUser}:{$proxyPass}"); - } - } - } - - $raw = curl_exec($ch); - $error = curl_error($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($error) { - return ['ok' => false, 'description' => '网络请求错误: ' . $error]; - } - - $decoded = json_decode((string) $raw, true); - if (!is_array($decoded)) { - return ['ok' => false, 'description' => "接口返回异常 {$httpCode}: " . (string) $raw]; - } - return $decoded; - } - - private function confirmTtl() - { - return max(30, (int) ($this->settings['notify_tg_confirm_ttl'] ?? 60)); - } - - private function shortButtonText($text) - { - $text = trim((string) $text); - if (function_exists('mb_strlen') && mb_strlen($text, 'UTF-8') > 28) { - return mb_substr($text, 0, 25, 'UTF-8') . '...'; - } - return $text; - } - - private function formatTraffic($value) - { - $value = (float) $value; - if ($value <= 0) { - return '0 MB'; - } - if ($value < 1) { - return round($value * 1024, 2) . ' MB'; - } - return round($value, 2) . ' GB'; - } - - private function statusLabel($status) - { - $map = [ - 'Running' => '运行中', - 'Starting' => '启动中', - 'Stopping' => '停机中', - 'Stopped' => '已停止', - 'Pending' => '创建中', - 'Releasing' => '释放中', - 'Released' => '已释放', - 'Unknown' => '未知' - ]; - return $map[$status] ?? ($status ?: '未知'); - } - - private function statusIcon($status) - { - $map = [ - 'Running' => '🟢', - 'Starting' => '🟡', - 'Stopping' => '🟠', - 'Stopped' => '🔴', - 'Pending' => '🟡', - 'Releasing' => '🗑️', - 'Released' => '⚫', - 'Unknown' => '⚪' - ]; - return $map[$status] ?? '⚪'; - } - - private function trafficStatusIcon($status) - { - if (strpos($status, '超量') !== false || strpos($status, '异常') !== false) { - return '🔴'; - } - if (strpos($status, '接近') !== false) { - return '🟠'; - } - return '🟢'; - } - - private function regionName($regionId) - { - $map = [ - 'cn-hongkong' => '中国香港', - 'ap-southeast-1' => '新加坡', - 'ap-northeast-1' => '日本(东京)', - 'us-west-1' => '美国(硅谷)', - 'us-east-1' => '美国(弗吉尼亚)', - 'cn-hangzhou' => '华东 1(杭州)', - 'cn-shanghai' => '华东 2(上海)', - 'cn-beijing' => '华北 2(北京)', - 'cn-shenzhen' => '华南 1(深圳)' - ]; - return $map[$regionId] ?? ($regionId ?: '-'); - } -} diff --git a/composer.json b/composer.json deleted file mode 100644 index c49b8fc..0000000 --- a/composer.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "require": { - "alibabacloud/client": "^1.5", - "phpmailer/phpmailer": "^6.9" - }, - "repositories": { - "packagist": { - "type": "composer", - "url": "https://mirrors.aliyun.com/composer/" - } - } -} diff --git a/composer.lock b/composer.lock deleted file mode 100644 index 305586b..0000000 --- a/composer.lock +++ /dev/null @@ -1,1264 +0,0 @@ -{ - "_readme": [ - "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", - "This file is @generated automatically" - ], - "content-hash": "6b85b2f8e774592ba50536aa09b2035d", - "packages": [ - { - "name": "adbario/php-dot-notation", - "version": "2.5.0", - "source": { - "type": "git", - "url": "https://github.com/adbario/php-dot-notation.git", - "reference": "081e2cca50c84bfeeea2e3ef9b2c8d206d80ccae" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/adbario/php-dot-notation/zipball/081e2cca50c84bfeeea2e3ef9b2c8d206d80ccae", - "reference": "081e2cca50c84bfeeea2e3ef9b2c8d206d80ccae", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] - }, - "require": { - "ext-json": "*", - "php": "^5.5 || ^7.0 || ^8.0" - }, - "require-dev": { - "phpunit/phpunit": "^4.8|^5.7|^6.6|^7.5|^8.5|^9.5", - "squizlabs/php_codesniffer": "^3.6" - }, - "type": "library", - "autoload": { - "files": [ - "src/helpers.php" - ], - "psr-4": { - "Adbar\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Riku Särkinen", - "email": "riku@adbar.io" - } - ], - "description": "PHP dot notation access to arrays", - "homepage": "https://github.com/adbario/php-dot-notation", - "keywords": [ - "ArrayAccess", - "dotnotation" - ], - "support": { - "issues": "https://github.com/adbario/php-dot-notation/issues", - "source": "https://github.com/adbario/php-dot-notation/tree/2.5.0" - }, - "time": "2022-10-14T20:31:46+00:00" - }, - { - "name": "alibabacloud/client", - "version": "1.5.32", - "source": { - "type": "git", - "url": "https://github.com/aliyun/openapi-sdk-php-client.git", - "reference": "5bc6f6d660797dcee2c3aef29700ab41ee764f4d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/aliyun/openapi-sdk-php-client/zipball/5bc6f6d660797dcee2c3aef29700ab41ee764f4d", - "reference": "5bc6f6d660797dcee2c3aef29700ab41ee764f4d", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] - }, - "require": { - "adbario/php-dot-notation": "^2.4.1", - "clagiordano/weblibs-configmanager": "^1.0", - "ext-curl": "*", - "ext-json": "*", - "ext-libxml": "*", - "ext-mbstring": "*", - "ext-openssl": "*", - "ext-simplexml": "*", - "ext-xmlwriter": "*", - "guzzlehttp/guzzle": "^6.3|^7.0", - "mtdowling/jmespath.php": "^2.5", - "php": ">=5.5" - }, - "require-dev": { - "composer/composer": "^1.8", - "drupal/coder": "^8.3", - "ext-dom": "*", - "ext-pcre": "*", - "ext-sockets": "*", - "ext-spl": "*", - "league/climate": "^3.2.4", - "mikey179/vfsstream": "^1.6", - "monolog/monolog": "^1.24", - "phpunit/phpunit": "^5.7|^6.6|^7.5|^8.5|^9.5", - "psr/cache": "^1.0", - "symfony/dotenv": "^3.4", - "symfony/var-dumper": "^3.4" - }, - "suggest": { - "ext-sockets": "To use client-side monitoring" - }, - "type": "library", - "autoload": { - "files": [ - "src/Functions.php" - ], - "psr-4": { - "AlibabaCloud\\Client\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Alibaba Cloud SDK", - "email": "sdk-team@alibabacloud.com", - "homepage": "http://www.alibabacloud.com" - } - ], - "description": "Alibaba Cloud Client for PHP - Use Alibaba Cloud in your PHP project", - "homepage": "https://www.alibabacloud.com/", - "keywords": [ - "alibaba", - "alibabacloud", - "aliyun", - "client", - "cloud", - "library", - "sdk", - "tool" - ], - "support": { - "issues": "https://github.com/aliyun/openapi-sdk-php-client/issues", - "source": "https://github.com/aliyun/openapi-sdk-php-client" - }, - "time": "2022-12-09T04:05:55+00:00" - }, - { - "name": "clagiordano/weblibs-configmanager", - "version": "v1.5.0", - "source": { - "type": "git", - "url": "https://github.com/clagiordano/weblibs-configmanager.git", - "reference": "8802c7396d61a923c9a73e37ead062b24bb1b273" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/clagiordano/weblibs-configmanager/zipball/8802c7396d61a923c9a73e37ead062b24bb1b273", - "reference": "8802c7396d61a923c9a73e37ead062b24bb1b273", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] - }, - "require": { - "ext-json": "*", - "php": ">=5.4", - "symfony/yaml": "^2.8" - }, - "require-dev": { - "clagiordano/phpunit-result-printer": "^1", - "php-coveralls/php-coveralls": "^1.1", - "phpunit/phpunit": "^4.8" - }, - "type": "library", - "autoload": { - "psr-4": { - "clagiordano\\weblibs\\configmanager\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-3.0-or-later" - ], - "authors": [ - { - "name": "Claudio Giordano", - "email": "claudio.giordano@autistici.org", - "role": "Developer" - } - ], - "description": "weblibs-configmanager is a tool library for easily read and access to php config array file and direct read/write configuration file / object", - "keywords": [ - "clagiordano", - "configuration", - "manager", - "tool", - "weblibs" - ], - "support": { - "issues": "https://github.com/clagiordano/weblibs-configmanager/issues", - "source": "https://github.com/clagiordano/weblibs-configmanager/tree/v1.5.0" - }, - "time": "2021-07-12T15:27:21+00:00" - }, - { - "name": "guzzlehttp/guzzle", - "version": "7.10.0", - "source": { - "type": "git", - "url": "https://github.com/guzzle/guzzle.git", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] - }, - "require": { - "ext-json": "*", - "guzzlehttp/promises": "^2.3", - "guzzlehttp/psr7": "^2.8", - "php": "^7.2.5 || ^8.0", - "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" - }, - "provide": { - "psr/http-client-implementation": "1.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", - "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.39 || ^9.6.20", - "psr/log": "^1.1 || ^2.0 || ^3.0" - }, - "suggest": { - "ext-curl": "Required for CURL handler support", - "ext-intl": "Required for Internationalized Domain Name (IDN) support", - "psr/log": "Required for using the Log middleware" - }, - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, - "autoload": { - "files": [ - "src/functions_include.php" - ], - "psr-4": { - "GuzzleHttp\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "Jeremy Lindblom", - "email": "jeremeamia@gmail.com", - "homepage": "https://github.com/jeremeamia" - }, - { - "name": "George Mponos", - "email": "gmponos@gmail.com", - "homepage": "https://github.com/gmponos" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://github.com/sagikazarmark" - }, - { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" - } - ], - "description": "Guzzle is a PHP HTTP client library", - "keywords": [ - "client", - "curl", - "framework", - "http", - "http client", - "psr-18", - "psr-7", - "rest", - "web service" - ], - "support": { - "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.0" - }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", - "type": "tidelift" - } - ], - "time": "2025-08-23T22:36:01+00:00" - }, - { - "name": "guzzlehttp/promises", - "version": "2.3.0", - "source": { - "type": "git", - "url": "https://github.com/guzzle/promises.git", - "reference": "481557b130ef3790cf82b713667b43030dc9c957" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", - "reference": "481557b130ef3790cf82b713667b43030dc9c957", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] - }, - "require": { - "php": "^7.2.5 || ^8.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" - }, - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, - "autoload": { - "psr-4": { - "GuzzleHttp\\Promise\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" - }, - { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" - } - ], - "description": "Guzzle promises library", - "keywords": [ - "promise" - ], - "support": { - "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.3.0" - }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", - "type": "tidelift" - } - ], - "time": "2025-08-22T14:34:08+00:00" - }, - { - "name": "guzzlehttp/psr7", - "version": "2.8.0", - "source": { - "type": "git", - "url": "https://github.com/guzzle/psr7.git", - "reference": "21dc724a0583619cd1652f673303492272778051" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", - "reference": "21dc724a0583619cd1652f673303492272778051", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] - }, - "require": { - "php": "^7.2.5 || ^8.0", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0" - }, - "provide": { - "psr/http-factory-implementation": "1.0", - "psr/http-message-implementation": "1.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "0.9.0", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" - }, - "suggest": { - "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" - }, - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, - "autoload": { - "psr-4": { - "GuzzleHttp\\Psr7\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "George Mponos", - "email": "gmponos@gmail.com", - "homepage": "https://github.com/gmponos" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://github.com/sagikazarmark" - }, - { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://sagikazarmark.hu" - } - ], - "description": "PSR-7 message implementation that also provides common utility methods", - "keywords": [ - "http", - "message", - "psr-7", - "request", - "response", - "stream", - "uri", - "url" - ], - "support": { - "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.8.0" - }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", - "type": "tidelift" - } - ], - "time": "2025-08-23T21:21:41+00:00" - }, - { - "name": "mtdowling/jmespath.php", - "version": "2.7.0", - "source": { - "type": "git", - "url": "https://github.com/jmespath/jmespath.php.git", - "reference": "bbb69a935c2cbb0c03d7f481a238027430f6440b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/bbb69a935c2cbb0c03d7f481a238027430f6440b", - "reference": "bbb69a935c2cbb0c03d7f481a238027430f6440b", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] - }, - "require": { - "php": "^7.2.5 || ^8.0", - "symfony/polyfill-mbstring": "^1.17" - }, - "require-dev": { - "composer/xdebug-handler": "^3.0.3", - "phpunit/phpunit": "^8.5.33" - }, - "bin": [ - "bin/jp.php" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.7-dev" - } - }, - "autoload": { - "files": [ - "src/JmesPath.php" - ], - "psr-4": { - "JmesPath\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - } - ], - "description": "Declaratively specify how to extract elements from a JSON document", - "keywords": [ - "json", - "jsonpath" - ], - "support": { - "issues": "https://github.com/jmespath/jmespath.php/issues", - "source": "https://github.com/jmespath/jmespath.php/tree/2.7.0" - }, - "time": "2023-08-25T10:54:48+00:00" - }, - { - "name": "phpmailer/phpmailer", - "version": "v6.11.1", - "source": { - "type": "git", - "url": "https://github.com/PHPMailer/PHPMailer.git", - "reference": "d9e3b36b47f04b497a0164c5a20f92acb4593284" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/d9e3b36b47f04b497a0164c5a20f92acb4593284", - "reference": "d9e3b36b47f04b497a0164c5a20f92acb4593284", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] - }, - "require": { - "ext-ctype": "*", - "ext-filter": "*", - "ext-hash": "*", - "php": ">=5.5.0" - }, - "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "^1.0", - "doctrine/annotations": "^1.2.6 || ^1.13.3", - "php-parallel-lint/php-console-highlighter": "^1.0.0", - "php-parallel-lint/php-parallel-lint": "^1.3.2", - "phpcompatibility/php-compatibility": "^9.3.5", - "roave/security-advisories": "dev-latest", - "squizlabs/php_codesniffer": "^3.7.2", - "yoast/phpunit-polyfills": "^1.0.4" - }, - "suggest": { - "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", - "ext-imap": "Needed to support advanced email address parsing according to RFC822", - "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", - "ext-openssl": "Needed for secure SMTP sending and DKIM signing", - "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", - "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication", - "league/oauth2-google": "Needed for Google XOAUTH2 authentication", - "psr/log": "For optional PSR-3 debug logging", - "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)", - "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication" - }, - "type": "library", - "autoload": { - "psr-4": { - "PHPMailer\\PHPMailer\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-2.1-only" - ], - "authors": [ - { - "name": "Marcus Bointon", - "email": "phpmailer@synchromedia.co.uk" - }, - { - "name": "Jim Jagielski", - "email": "jimjag@gmail.com" - }, - { - "name": "Andy Prevost", - "email": "codeworxtech@users.sourceforge.net" - }, - { - "name": "Brent R. Matzelle" - } - ], - "description": "PHPMailer is a full-featured email creation and transfer class for PHP", - "support": { - "issues": "https://github.com/PHPMailer/PHPMailer/issues", - "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.11.1" - }, - "funding": [ - { - "url": "https://github.com/Synchro", - "type": "github" - } - ], - "time": "2025-09-30T11:54:53+00:00" - }, - { - "name": "psr/http-client", - "version": "1.0.3", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-client.git", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] - }, - "require": { - "php": "^7.0 || ^8.0", - "psr/http-message": "^1.0 || ^2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Client\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for HTTP clients", - "homepage": "https://github.com/php-fig/http-client", - "keywords": [ - "http", - "http-client", - "psr", - "psr-18" - ], - "support": { - "source": "https://github.com/php-fig/http-client" - }, - "time": "2023-09-23T14:17:50+00:00" - }, - { - "name": "psr/http-factory", - "version": "1.0.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-factory.git", - "reference": "e616d01114759c4c489f93b099585439f795fe35" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-factory/zipball/e616d01114759c4c489f93b099585439f795fe35", - "reference": "e616d01114759c4c489f93b099585439f795fe35", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] - }, - "require": { - "php": ">=7.0.0", - "psr/http-message": "^1.0 || ^2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interfaces for PSR-7 HTTP message factories", - "keywords": [ - "factory", - "http", - "message", - "psr", - "psr-17", - "psr-7", - "request", - "response" - ], - "support": { - "source": "https://github.com/php-fig/http-factory/tree/1.0.2" - }, - "time": "2023-04-10T20:10:41+00:00" - }, - { - "name": "psr/http-message", - "version": "2.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-message.git", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for HTTP messages", - "homepage": "https://github.com/php-fig/http-message", - "keywords": [ - "http", - "http-message", - "psr", - "psr-7", - "request", - "response" - ], - "support": { - "source": "https://github.com/php-fig/http-message/tree/2.0" - }, - "time": "2023-04-04T09:54:51+00:00" - }, - { - "name": "ralouphie/getallheaders", - "version": "3.0.3", - "source": { - "type": "git", - "url": "https://github.com/ralouphie/getallheaders.git", - "reference": "120b605dfeb996808c31b6477290a714d356e822" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", - "reference": "120b605dfeb996808c31b6477290a714d356e822", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] - }, - "require": { - "php": ">=5.6" - }, - "require-dev": { - "php-coveralls/php-coveralls": "^2.1", - "phpunit/phpunit": "^5 || ^6.5" - }, - "type": "library", - "autoload": { - "files": [ - "src/getallheaders.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ralph Khattar", - "email": "ralph.khattar@gmail.com" - } - ], - "description": "A polyfill for getallheaders.", - "support": { - "issues": "https://github.com/ralouphie/getallheaders/issues", - "source": "https://github.com/ralouphie/getallheaders/tree/develop" - }, - "time": "2019-03-08T08:55:37+00:00" - }, - { - "name": "symfony/deprecation-contracts", - "version": "v3.0.2", - "source": { - "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "26954b3d62a6c5fd0ea8a2a00c0353a14978d05c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/26954b3d62a6c5fd0ea8a2a00c0353a14978d05c", - "reference": "26954b3d62a6c5fd0ea8a2a00c0353a14978d05c", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] - }, - "require": { - "php": ">=8.0.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.0-dev" - } - }, - "autoload": { - "files": [ - "function.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "A generic function and convention to trigger deprecation notices", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.0.2" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2022-01-02T09:55:41+00:00" - }, - { - "name": "symfony/polyfill-ctype", - "version": "v1.32.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] - }, - "require": { - "php": ">=7.2" - }, - "provide": { - "ext-ctype": "*" - }, - "suggest": { - "ext-ctype": "For best performance" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for ctype functions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "ctype", - "polyfill", - "portable" - ], - "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-09T11:45:10+00:00" - }, - { - "name": "symfony/polyfill-mbstring", - "version": "v1.32.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] - }, - "require": { - "ext-iconv": "*", - "php": ">=7.2" - }, - "provide": { - "ext-mbstring": "*" - }, - "suggest": { - "ext-mbstring": "For best performance" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for the Mbstring extension", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "mbstring", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-12-23T08:48:59+00:00" - }, - { - "name": "symfony/yaml", - "version": "v2.8.52", - "source": { - "type": "git", - "url": "https://github.com/symfony/yaml.git", - "reference": "02c1859112aa779d9ab394ae4f3381911d84052b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/02c1859112aa779d9ab394ae4f3381911d84052b", - "reference": "02c1859112aa779d9ab394ae4f3381911d84052b", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] - }, - "require": { - "php": ">=5.3.9", - "symfony/polyfill-ctype": "~1.8" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.8-dev" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Component\\Yaml\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony Yaml Component", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/yaml/tree/v2.8.52" - }, - "time": "2018-11-11T11:18:13+00:00" - } - ], - "packages-dev": [], - "aliases": [], - "minimum-stability": "stable", - "stability-flags": [], - "prefer-stable": false, - "prefer-lowest": false, - "platform": [], - "platform-dev": [], - "plugin-api-version": "2.0.0" -} diff --git a/docker/entrypoint-go.sh b/docker/entrypoint-go.sh index d924c3c..28a64ba 100644 --- a/docker/entrypoint-go.sh +++ b/docker/entrypoint-go.sh @@ -4,7 +4,7 @@ set -eu data_dir="${ECS_DATA_DIR:-/var/lib/ecs-controller}" mkdir -p "$data_dir" -# Keep old bind-mounted PHP data readable without making the app process root. +# Keep legacy bind-mounted data readable without making the app process root. if [ ! -f "$data_dir/data.sqlite" ] && [ -f /migration-data/data.sqlite ]; then cp -p /migration-data/data.sqlite "$data_dir/data.sqlite" if [ ! -f "$data_dir/.secret_encryption.key" ] && [ -f /migration-data/.secret_encryption.key ]; then diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh deleted file mode 100644 index eb1656e..0000000 --- a/docker/entrypoint.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/sh -set -e - -echo "Starting ECS-Controller..." - -# 1. 确保数据目录权限正确 -# Docker 挂载卷时可能会导致权限归属为 root,这里强制修正为 www-data -if [ -d "/var/www/html/data" ]; then - chown -R www-data:www-data /var/www/html/data -fi - -# 2. 启动 Cron 服务 (后台运行) -# Alpine 使用 dcron,-b 表示后台运行,-L 指定日志级别 -crond -b -l 8 -echo "Cron daemon started." - -# 3. 启动 Telegram 控制轮询 (后台运行) -# 如果没有配置 Telegram,进程会保持低频等待;配置后按钮控制可秒级响应。 -su -s /bin/sh www-data -c "php /var/www/html/telegram_worker.php" >/dev/null 2>&1 & -echo "Telegram control worker started." - -# 4. 启动 PHP-FPM (后台运行) -# -D 表示 Daemonize (守护进程模式) -php-fpm -D -echo "PHP-FPM started." - -# 5. 启动 Nginx (前台运行) -# 保持 Nginx 在前台运行,防止容器退出 -echo "Nginx started." -nginx -g 'daemon off;' diff --git a/docker/nginx.conf b/docker/nginx.conf deleted file mode 100644 index 6b8f118..0000000 --- a/docker/nginx.conf +++ /dev/null @@ -1,56 +0,0 @@ -server { - listen 80; - server_name localhost; - root /var/www/html; - index index.php index.html; - - # 日志输出到 stdout/stderr,由 Docker 收集 - error_log /var/log/nginx/error.log; - access_log /var/log/nginx/access.log; - - # Gzip 压缩 - gzip on; - gzip_types text/css application/javascript application/json text/plain; - gzip_min_length 1000; - gzip_vary on; - - # 静态资源缓存 - location /static/ { - expires 30d; - add_header Cache-Control "public, immutable"; - } - - # 处理前端路由 - location / { - try_files $uri $uri/ /index.php?$query_string; - } - - # 处理 PHP 请求 - location ~ \.php$ { - fastcgi_pass 127.0.0.1:9000; - fastcgi_index index.php; - fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; - include fastcgi_params; - } - - # 安全配置:禁止访问 .ht* 文件 - location ~ /\.ht { - deny all; - } - - # 安全配置:严禁外部直接访问 SQLite 数据库文件及 data 目录 - location ^~ /data/ { - deny all; - return 403; - } - - # 禁止访问 git 文件 - location ~ /\.git { - deny all; - } - - # 禁止访问 composer 文件 - location ~ /composer\.(json|lock) { - deny all; - } -} \ No newline at end of file diff --git a/docker/php-fpm-www.conf b/docker/php-fpm-www.conf deleted file mode 100644 index 3bc6ed0..0000000 --- a/docker/php-fpm-www.conf +++ /dev/null @@ -1,7 +0,0 @@ -[www] -pm = dynamic -pm.max_children = 12 -pm.start_servers = 4 -pm.min_spare_servers = 2 -pm.max_spare_servers = 6 -pm.max_requests = 500 diff --git a/index.php b/index.php deleted file mode 100644 index 30dbcaa..0000000 --- a/index.php +++ /dev/null @@ -1,394 +0,0 @@ -getInitError(); - if ($initError) { - echo json_encode(['initialized' => false, 'error' => $initError]); - } else { - echo json_encode([ - 'initialized' => $app->isInitialized(), - 'brand' => $app->getPublicBrand() - ]); - } - exit; -} - -if ($action === 'setup') { - header('Content-Type: application/json'); - if ($app->isInitialized()) { - http_response_code(403); - echo json_encode(['success' => false, 'message' => '系统已完成初始化']); - exit; - } - - $data = json_decode(file_get_contents('php://input'), true); - try { - if ($app->setup($data)) { - $_SESSION['is_admin'] = true; - echo json_encode(['success' => true]); - } else { - echo json_encode(['success' => false, 'message' => '初始化失败']); - } - } catch (Exception $e) { - echo json_encode(['success' => false, 'message' => $e->getMessage()]); - } - exit; -} - -if ($action === 'login') { - $data = json_decode(file_get_contents('php://input'), true); - try { - if ($app->login($data['password'] ?? '')) { - $_SESSION['is_admin'] = true; - echo json_encode(['success' => true]); - } else { - echo json_encode(['success' => false, 'message' => '密码错误']); - } - } catch (Exception $e) { - echo json_encode(['success' => false, 'message' => $e->getMessage()]); - } - exit; -} - -if ($action === 'check_login') { - echo json_encode(['logged_in' => isset($_SESSION['is_admin']) && $_SESSION['is_admin'] === true]); - exit; -} - -if ($action === 'brand_logo') { - $dir = __DIR__ . '/data'; - $files = array_merge( - glob($dir . '/brand-logo.png') ?: [], - glob($dir . '/brand-logo.jpg') ?: [], - glob($dir . '/brand-logo.webp') ?: [] - ); - $file = $files[0] ?? ''; - if ($file === '' || !is_file($file)) { - http_response_code(404); - exit; - } - - $mimeMap = [ - 'png' => 'image/png', - 'jpg' => 'image/jpeg', - 'webp' => 'image/webp' - ]; - $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); - header('Content-Type: ' . ($mimeMap[$ext] ?? 'application/octet-stream')); - header('Cache-Control: public, max-age=86400'); - readfile($file); - exit; -} - -if ($action === 'get_status') { - header('Content-Type: application/json; charset=utf-8'); - $initError = $app->getInitError(); - if ($initError) { - echo json_encode(['error' => $initError]); - } else { - $isAdmin = isset($_SESSION['is_admin']) && $_SESSION['is_admin'] === true; - if (!$isAdmin) { - http_response_code(403); - echo json_encode(['error' => '请先登录后再操作']); - } else { - echo json_encode($app->getStatusForFrontend(true)); - } - } - exit; -} - -// ---------------- 需鉴权接口 ---------------- - -if ($action !== 'view' && !isset($_SESSION['is_admin'])) { - http_response_code(403); - echo json_encode(['error' => '请先登录后再操作']); - exit; -} - -if ($action === 'get_config') { - echo json_encode($app->getConfigForFrontend()); - exit; -} - -if ($action === 'save_config') { - $data = json_decode(file_get_contents('php://input'), true); - if ($app->updateConfig($data)) { - echo json_encode(['success' => true]); - } else { - echo json_encode(['success' => false, 'message' => '保存失败']); - } - exit; -} - -if ($action === 'upload_logo') { - header('Content-Type: application/json; charset=utf-8'); - echo json_encode($app->uploadLogo($_FILES['logo'] ?? [])); - exit; -} - -if ($action === 'send_test_email') { - $data = json_decode(file_get_contents('php://input'), true); - $result = $app->sendTestEmail($data['email'] ?? ''); - echo json_encode(['success' => $result === true, 'message' => $result]); - exit; -} - -if ($action === 'send_test_telegram') { - $data = json_decode(file_get_contents('php://input'), true); - $result = $app->sendTestTelegram($data['telegram'] ?? []); - echo json_encode(['success' => $result === true, 'message' => $result]); - exit; -} - -if ($action === 'send_test_webhook') { - $data = json_decode(file_get_contents('php://input'), true); - $result = $app->sendTestWebhook($data['webhook'] ?? []); - echo json_encode(['success' => $result === true, 'message' => $result]); - exit; -} - -if ($action === 'refresh_account') { - $data = json_decode(file_get_contents('php://input'), true); - $id = $data['id'] ?? 0; - $result = $app->refreshAccount($id); - if ($result === false) { - echo json_encode(['success' => false, 'message' => '刷新失败']); - } elseif (is_array($result)) { - // 流量/状态刷新成功,但账单获取失败 - echo json_encode($result); - } else { - echo json_encode(['success' => true]); - } - exit; -} - -if ($action === 'fetch_instances') { - header('Content-Type: application/json; charset=utf-8'); - $data = json_decode(file_get_contents('php://input'), true); - - try { - $instances = $app->fetchInstances($data['accessKeyId'] ?? '', $data['accessKeySecret'] ?? '', $data['regionId'] ?? ''); - echo json_encode(['success' => true, 'data' => $instances]); - } catch (Exception $e) { - http_response_code(400); - echo json_encode(['success' => false, 'message' => $e->getMessage()]); - } - exit; -} - -if ($action === 'test_account') { - header('Content-Type: application/json; charset=utf-8'); - $data = json_decode(file_get_contents('php://input'), true); - - try { - $result = $app->testAccountCredentials($data['account'] ?? []); - echo json_encode($result); - } catch (Exception $e) { - http_response_code(400); - echo json_encode([ - 'success' => false, - 'message' => $e->getMessage() - ]); - } - exit; -} - -if ($action === 'sync_account_group') { - header('Content-Type: application/json; charset=utf-8'); - $data = json_decode(file_get_contents('php://input'), true) ?: []; - - try { - echo json_encode($app->syncAccountGroup($data['groupKey'] ?? '')); - } catch (Exception $e) { - http_response_code(400); - echo json_encode([ - 'success' => false, - 'message' => $e->getMessage() - ]); - } - exit; -} - -if ($action === 'restore_schedule_block') { - header('Content-Type: application/json; charset=utf-8'); - $data = json_decode(file_get_contents('php://input'), true) ?: []; - - try { - echo json_encode($app->restoreScheduleAfterTrafficBlock($data['groupKey'] ?? '')); - } catch (Exception $e) { - http_response_code(400); - echo json_encode([ - 'success' => false, - 'message' => $e->getMessage() - ]); - } - exit; -} - -if ($action === 'preview_ecs_create') { - header('Content-Type: application/json; charset=utf-8'); - $data = json_decode(file_get_contents('php://input'), true) ?: []; - - try { - $result = $app->previewEcsCreate($data); - $_SESSION['ecs_create_previews'] = $_SESSION['ecs_create_previews'] ?? []; - $_SESSION['ecs_create_previews'][$result['previewId']] = [ - 'summary' => $result['summary'], - 'created_at' => time() - ]; - echo json_encode($result); - } catch (Exception $e) { - http_response_code(400); - echo json_encode(['success' => false, 'message' => $e->getMessage()]); - } - exit; -} - -if ($action === 'get_ecs_disk_options') { - header('Content-Type: application/json; charset=utf-8'); - $data = json_decode(file_get_contents('php://input'), true) ?: []; - - try { - echo json_encode($app->getEcsDiskOptions($data)); - } catch (Exception $e) { - http_response_code(400); - echo json_encode(['success' => false, 'message' => $e->getMessage()]); - } - exit; -} - -if ($action === 'create_ecs') { - header('Content-Type: application/json; charset=utf-8'); - $data = json_decode(file_get_contents('php://input'), true) ?: []; - $previewId = $data['previewId'] ?? ''; - $confirmed = !empty($data['confirmed']); - - try { - if (!$confirmed) { - throw new Exception('请先确认配置清单和费用提示'); - } - $previewStore = $_SESSION['ecs_create_previews'][$previewId] ?? null; - if (!$previewStore || (time() - ($previewStore['created_at'] ?? 0)) > 900) { - throw new Exception('配置清单已过期,请重新预检'); - } - - $result = $app->createEcsFromPreview($previewId, $previewStore['summary']); - unset($_SESSION['ecs_create_previews'][$previewId]); - echo json_encode($result); - } catch (Exception $e) { - http_response_code(400); - echo json_encode(['success' => false, 'message' => $e->getMessage()]); - } - exit; -} - -if ($action === 'get_ecs_create_task') { - header('Content-Type: application/json; charset=utf-8'); - $taskId = $_GET['taskId'] ?? ''; - $task = $app->getEcsCreateTask($taskId); - if (!$task) { - http_response_code(404); - echo json_encode(['success' => false, 'message' => '任务不存在']); - } else { - unset($task['login_password']); - echo json_encode(['success' => true, 'data' => $task]); - } - exit; -} - -// 修改:获取系统日志,支持 Tab -if ($action === 'get_logs') { - header('Content-Type: application/json; charset=utf-8'); - $tab = $_GET['tab'] ?? 'action'; // 默认是动作日志 - echo json_encode(['data' => $app->getSystemLogs($tab)]); - exit; -} - -// 新增:清空日志 -if ($action === 'clear_logs') { - $data = json_decode(file_get_contents('php://input'), true); - $tab = $data['tab'] ?? 'action'; - if ($app->clearSystemLogs($tab)) { - echo json_encode(['success' => true]); - } else { - echo json_encode(['success' => false, 'message' => '清空失败']); - } - exit; -} - -if ($action === 'get_history') { - header('Content-Type: application/json; charset=utf-8'); - $id = $_GET['id'] ?? 0; - echo json_encode(['data' => $app->getAccountHistory($id)]); - exit; -} - -if ($action === 'logout') { - session_destroy(); - echo json_encode(['success' => true]); - exit; -} - -if ($action === 'get_all_instances') { - header('Content-Type: application/json; charset=utf-8'); - $sync = ($_GET['sync'] ?? '0') === '1'; - echo json_encode(['data' => $app->getAllManagedInstances($sync)]); - exit; -} - -if ($action === 'control_instance') { - $data = json_decode(file_get_contents('php://input'), true); - $accountId = $data['accountId'] ?? 0; - $actionType = $data['action'] ?? ''; - $shutdownMode = $data['shutdownMode'] ?? 'KeepCharging'; - - if (!in_array($actionType, ['start', 'stop'])) { - http_response_code(400); - echo json_encode(['success' => false, 'message' => '无效的操作类型']); - exit; - } - - $result = $app->controlInstanceAction($accountId, $actionType, $shutdownMode); - echo json_encode(['success' => $result]); - exit; -} - -if ($action === 'delete_instance') { - $data = json_decode(file_get_contents('php://input'), true); - $accountId = $data['accountId'] ?? 0; - $forceStop = $data['forceStop'] ?? false; - - $result = $app->deleteInstanceAction($accountId, $forceStop); - echo json_encode(['success' => $result]); - exit; -} - -if ($action === 'replace_instance_ip') { - $data = json_decode(file_get_contents('php://input'), true); - $accountId = $data['accountId'] ?? 0; - - $result = $app->replaceInstanceIpAction($accountId); - echo json_encode($result); - exit; -} - -echo $app->renderTemplate(); diff --git a/monitor.php b/monitor.php deleted file mode 100644 index d09fcd7..0000000 --- a/monitor.php +++ /dev/null @@ -1,31 +0,0 @@ -getMonitorKey(); - if (empty($monitorKey) || !hash_equals($monitorKey, $authHeader)) { - http_response_code(403); - echo "访问被拒绝,请使用有效的监控密钥。"; - exit; - } -} - -// 输出简洁日志 -echo "--- ECS 服务器管理 开始检测: " . date('Y-m-d H:i:s') . " ---\n"; -echo $app->monitor(); -echo "\n--- 检测结束 ---\n"; diff --git a/telegram_worker.php b/telegram_worker.php deleted file mode 100644 index 14017cd..0000000 --- a/telegram_worker.php +++ /dev/null @@ -1,32 +0,0 @@ -getProperty('db'); -$dbProp->setAccessible(true); -$db = $dbProp->getValue($app); - -$configProp = $ref->getProperty('configManager'); -$configProp->setAccessible(true); -$configManager = $configProp->getValue($app); - -$service = new TelegramControlService($db, $configManager, $app); - -while (true) { - try { - $service->processUpdatesWithTimeout(20); - } catch (\Throwable $e) { - $db->addLog('error', 'Telegram 控制常驻进程异常: ' . strip_tags($e->getMessage())); - sleep(5); - } -}