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 ]; } }