diff --git a/.gitignore b/.gitignore
index 52e73a3..3ecac5b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ Thumbs.db
/.idea
/.vscode
/vendor
+/.secret_encryption.key
/.settings
/.buildpath
/.project
@@ -15,3 +16,8 @@ Thumbs.db
package.json
package-lock.json
static/tailwindcss-browser.js
+
+# Local runtime data
+/data/
+*.sqlite*
+monitor.db
diff --git a/AliyunService.php b/AliyunService.php
index 1bd37c5..16d55c6 100644
--- a/AliyunService.php
+++ b/AliyunService.php
@@ -6,6 +6,10 @@ use AlibabaCloud\Client\Exception\ServerException;
class AliyunService
{
+ private $regionCache = [];
+ private $managedTagKey = 'ecs-controller-managed';
+ private $managedTagValue = 'true';
+
/**
* 智能重试执行器
* 自动处理网络抖动、超时和服务端临时错误
@@ -73,6 +77,23 @@ class AliyunService
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)
@@ -164,6 +185,170 @@ class AliyunService
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' => 5.0,
+ 'timeout' => 15.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
@@ -183,7 +368,8 @@ class AliyunService
];
if (!empty($account['instance_id'])) {
- $options['query']['InstanceId'] = $account['instance_id'];
+ // 修改:阿里云 RPC 风格接口对于列表类参数(如 InstanceId.N)需要明确的索引
+ $options['query']['InstanceId.1'] = $account['instance_id'];
}
$result = AlibabaCloud::rpc()
@@ -196,14 +382,107 @@ class AliyunService
->options($options)
->request();
- if (isset($result['InstanceStatuses']['InstanceStatus'][0]['Status'])) {
- return $result['InstanceStatuses']['InstanceStatus'][0]['Status'];
+ $statuses = $result['InstanceStatuses']['InstanceStatus'] ?? [];
+ foreach ($statuses as $item) {
+ if (($item['InstanceId'] ?? '') === $account['instance_id']) {
+ return $item['Status'];
+ }
}
- throw new \Exception("API 响应未找到实例状态 (请检查 Instance ID)");
+ // 如果没找到匹配的 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' => 5.0,
+ 'timeout' => 10.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' => 5.0,
+ 'timeout' => 15.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
@@ -247,6 +526,1020 @@ class AliyunService
}, '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' => 8.0,
+ 'timeout' => 20.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['InternetMaxBandwidthOut'] ?? 0),
+ 'osName' => $instance['OSName'] ?? '',
+ 'publicIp' => $instance['PublicIpAddress']['IpAddress'][0] ?? $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'] ?? 'ubuntu_22'));
+ $instanceName = trim((string) ($request['instanceName'] ?? ''));
+ if ($instanceName === '') {
+ $instanceName = 'launch-' . date('Ymd-His');
+ }
+ $requestedDiskSize = (int) ($request['systemDiskSize'] ?? 20);
+
+ 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);
+ $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,
+ '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([
+ '公网带宽峰值会自动尝试最高可用值,若账号配额或规格限制不支持,会自动降级重试。',
+ "系统盘将严格按 {$diskSize} GB 创建;当前 API 返回范围为 {$diskRange['min']}-{$diskRange['max']} {$diskRange['unit']},超出范围会直接报错。",
+ '文件备份默认不启用;如需备份,请创建后在阿里云控制台单独开启。',
+ '安全组默认全开,便于测试和交付;生产环境建议创建后收紧来源 IP 和端口。'
+ ]))
+ ];
+ }
+
+ 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',
+ 'cloud_auto',
+ 'cloud_essd',
+ 'cloud_efficiency',
+ 'cloud'
+ ]));
+ // 系统盘成本敏感,严格使用用户确认的值;若阿里云拒绝,不自动放大。
+ $diskSize = $this->normalizeSystemDiskSize($preview['systemDisk']['size'] ?? 20, $preview['systemDisk'] ?? []);
+ $lastError = null;
+
+ foreach ($bandwidthCandidates as $bandwidth) {
+ foreach ($diskCategories as $diskCategory) {
+ 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' => $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);
+
+ return [
+ 'instanceId' => $instanceId,
+ 'publicIp' => $instance['publicIp'] ?? '',
+ 'privateIp' => $instance['privateIp'] ?? '',
+ '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) {
+ $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['ubuntu_22'];
+ $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 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['InternetMaxBandwidthOut'] ?? 0),
+ 'publicIp' => $instance['PublicIpAddress']['IpAddress'][0] ?? $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)
+ {
+ $raw = $zone['raw']['AvailableDiskCategories']['DiskCategories'] ?? $zone['raw']['AvailableDiskCategories']['DiskCategory'] ?? [];
+ $categories = is_array($raw) ? $raw : [];
+ foreach (['cloud_essd', 'cloud_auto', 'cloud_efficiency', 'cloud'] as $preferred) {
+ if (empty($categories) || in_array($preferred, $categories, true)) {
+ return $preferred;
+ }
+ }
+ return 'cloud_essd';
+ }
+
+ 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 = [];
@@ -416,4 +1709,4 @@ class AliyunService
'Products' => $products
];
}
-}
\ No newline at end of file
+}
diff --git a/AliyunTrafficCheck.php b/AliyunTrafficCheck.php
index be55517..223aa6f 100644
--- a/AliyunTrafficCheck.php
+++ b/AliyunTrafficCheck.php
@@ -5,6 +5,7 @@ require_once 'Database.php';
require_once 'ConfigManager.php';
require_once 'AliyunService.php';
require_once 'NotificationService.php';
+require_once 'DdnsService.php';
use AlibabaCloud\Client\Exception\ClientException;
use AlibabaCloud\Client\Exception\ServerException;
@@ -15,6 +16,7 @@ class AliyunTrafficCheck
private $configManager;
private $aliyunService;
private $notificationService;
+ private $ddnsService;
private $initError = null;
@@ -26,6 +28,7 @@ class AliyunTrafficCheck
$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());
@@ -52,6 +55,16 @@ class AliyunTrafficCheck
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 login($password)
{
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
@@ -62,7 +75,7 @@ class AliyunTrafficCheck
$attempts = $this->db->getRecentFailedAttempts($ip, 900);
if ($attempts >= 5) {
- $this->db->addLog('warning', "登录被锁定: IP {$ip} 尝试次数过多");
+ $this->db->addLog('warning', "登录被锁定: 地址 {$ip} 尝试次数过多");
throw new Exception("错误次数过多,请 15 分钟后再试。");
}
@@ -70,17 +83,81 @@ class AliyunTrafficCheck
if (empty($adminPass))
return false;
- if (hash_equals((string) $adminPass, (string) $password)) {
+ $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: {$ip}]");
+ $this->db->addLog('info', "管理员登录成功 [地址: {$ip}]");
return true;
}
$this->db->recordLoginAttempt($ip);
- $this->db->addLog('warning', "管理员登录失败 [IP: {$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)
+ {
+ $pdo = $this->db->getPdo();
+ $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 (
+ ($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)
@@ -105,15 +182,18 @@ class AliyunTrafficCheck
return [];
$settings = $this->configManager->getAllSettings();
- $accounts = $this->configManager->getAccounts();
+ $accountGroups = $this->configManager->getAccountGroups();
+ $groupMetrics = $this->configManager->getAccountGroupMetrics();
+ $billingMetrics = $this->getAccountGroupBillingMetrics();
$config = [
- 'admin_password' => $settings['admin_password'] ?? '',
+ 'admin_password' => !empty($settings['admin_password']) ? '********' : '',
+ 'admin_password_set' => !empty($settings['admin_password']),
'traffic_threshold' => (int) ($settings['traffic_threshold'] ?? 95),
- 'enable_schedule_email' => ($settings['enable_schedule_email'] ?? '0') === '1',
'shutdown_mode' => $settings['shutdown_mode'] ?? 'KeepCharging',
'threshold_action' => $settings['threshold_action'] ?? 'stop_and_notify',
'keep_alive' => ($settings['keep_alive'] ?? '0') === '1',
+ 'monthly_auto_start' => ($settings['monthly_auto_start'] ?? '0') === '1',
'api_interval' => (int) ($settings['api_interval'] ?? 600),
'enable_billing' => ($settings['enable_billing'] ?? '0') === '1',
'Notification' => [
@@ -122,18 +202,18 @@ class AliyunTrafficCheck
'host' => $settings['notify_host'] ?? '',
'port' => $settings['notify_port'] ?? 465,
'username' => $settings['notify_username'] ?? '',
- 'password' => $settings['notify_password'] ?? '',
+ 'password' => !empty($settings['notify_password']) ? '********' : '',
'secure' => $settings['notify_secure'] ?? 'ssl',
'telegram' => [
'enabled' => ($settings['notify_tg_enabled'] ?? '0') === '1',
- 'token' => $settings['notify_tg_token'] ?? '',
+ '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' => $settings['notify_tg_proxy_pass'] ?? ''
+ 'proxy_pass' => !empty($settings['notify_tg_proxy_pass']) ? '********' : ''
],
'webhook' => [
'enabled' => ($settings['notify_wh_enabled'] ?? '0') === '1',
@@ -144,23 +224,49 @@ class AliyunTrafficCheck
'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 ($accounts as $row) {
+ foreach ($accountGroups as $row) {
+ $metrics = $groupMetrics[$row['groupKey']] ?? [
+ 'usageUsed' => 0,
+ 'usageRemaining' => (float) ($row['maxTraffic'] ?? 0),
+ 'usagePercent' => 0,
+ 'instanceCount' => 0,
+ 'lastUpdated' => 0
+ ];
$config['Accounts'][] = [
- 'AccessKeyId' => $row['access_key_id'],
- 'AccessKeySecret' => $row['access_key_secret'],
- 'regionId' => $row['region_id'],
- 'instanceId' => $row['instance_id'],
- 'maxTraffic' => (float) $row['max_traffic'],
- 'schedule' => [
- 'enabled' => $row['schedule_enabled'] == 1,
- 'startTime' => $row['start_time'],
- 'stopTime' => $row['stop_time']
- ],
+ 'AccessKeyId' => $row['AccessKeyId'],
+ 'AccessKeySecret' => '********',
+ 'AccessKeySecretSet' => !empty($row['AccessKeySecret']),
+ 'regionId' => $row['regionId'],
+ 'maxTraffic' => (float) $row['maxTraffic'],
'remark' => $row['remark'] ?? '',
- 'siteType' => $row['site_type'] ?? 'china'
+ 'siteType' => $row['siteType'] ?? 'international',
+ 'groupKey' => $row['groupKey'] ?? '',
+ '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']) : '',
+ '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
+ ]
];
}
@@ -183,8 +289,25 @@ class AliyunTrafficCheck
// 仅返回最近 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;
@@ -220,9 +343,6 @@ class AliyunTrafficCheck
if (!$account)
return ['error' => 'Account not found'];
- if (!$account)
- return ['error' => 'Account not found'];
-
// Use account ID for stats query
$rawHourly = $this->db->getHourlyStats($id);
$chartHourly = [];
@@ -254,7 +374,7 @@ class AliyunTrafficCheck
public function monitor()
{
if ($this->initError)
- return "Error: " . $this->initError;
+ return "错误: " . $this->initError;
// 优化:分级清理日志
// 普通/重要日志保留 30 天,高频心跳日志仅保留 3 天
@@ -271,56 +391,28 @@ class AliyunTrafficCheck
}
$logs = [];
- $currentUserTime = date('H:i');
$currentTime = time();
$threshold = (int) $this->configManager->get('traffic_threshold', 95);
$shutdownMode = $this->configManager->get('shutdown_mode', 'KeepCharging');
$thresholdAction = $this->configManager->get('threshold_action', 'stop_and_notify');
$keepAlive = $this->configManager->get('keep_alive', '0') === '1';
+ $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) {
- $logPrefix = "[{$account['access_key_id']}]";
+ $accountLabel = $this->getAccountLogLabel($account);
+ $logPrefix = "[{$accountLabel}]";
$actions = [];
$forceRefresh = false;
- $statusTransformed = false;
- // 1. 定时任务
- if ($account['schedule_enabled'] == 1) {
- if ($account['start_time'] && $currentUserTime === $account['start_time']) {
- if ($this->safeControlInstance($account, 'start')) {
- $actions[] = "定时启动";
- $this->db->addLog('info', "执行定时启动 [{$account['access_key_id']}]");
-
- $mailRes = $this->notificationService->notifySchedule("定时启动", $account, "计划任务已触发,实例正在启动。");
- $this->logNotificationResult($mailRes, $account['access_key_id']);
-
- $forceRefresh = true;
- $statusTransformed = true;
- }
- }
- if ($account['stop_time'] && $currentUserTime === $account['stop_time']) {
- if ($this->safeControlInstance($account, 'stop', $shutdownMode)) {
- $actions[] = "定时停止({$shutdownMode})";
- $this->db->addLog('info', "执行定时停止 [{$account['access_key_id']}]");
-
- $mailRes = $this->notificationService->notifySchedule("定时停止", $account, "计划任务已触发,实例已停止。");
- $this->logNotificationResult($mailRes, $account['access_key_id']);
-
- $forceRefresh = true;
- $statusTransformed = true;
- }
- }
- }
-
- // 2. 自适应心跳
+ // 1. 自适应心跳
$lastUpdate = $account['updated_at'] ?? 0;
$cachedStatus = $account['instance_status'] ?? 'Unknown';
$isTransientState = in_array($cachedStatus, ['Starting', 'Stopping', 'Pending', 'Unknown']);
- $currentInterval = ($isTransientState || $statusTransformed) ? 60 : $userInterval;
+ $currentInterval = $isTransientState ? 60 : $userInterval;
$shouldCheckApi = $forceRefresh || (($currentTime - $lastUpdate) > $currentInterval);
@@ -341,7 +433,7 @@ class AliyunTrafficCheck
if ($newTraffic < 0) {
$traffic = $account['traffic_used'];
- $apiStatusLog = "流量API异常";
+ $apiStatusLog = "流量接口异常";
$newUpdateTime = $lastUpdate;
} else {
$traffic = $newTraffic;
@@ -358,6 +450,7 @@ class AliyunTrafficCheck
$apiStatusLog .= in_array($status, ['Starting', 'Stopping', 'Pending']) ? " [过渡态]" : " [稳定态]";
}
+ $this->notifyStatusChangeIfNeeded($account, $cachedStatus, $status, '系统同步检测到实例状态变化。');
$this->configManager->updateAccountStatus($account['id'], $traffic, $status, $newUpdateTime);
} else {
$traffic = $account['traffic_used'];
@@ -367,59 +460,84 @@ class AliyunTrafficCheck
}
$maxTraffic = $account['max_traffic'];
- $usagePercent = ($maxTraffic > 0) ? round(($traffic / $maxTraffic) * 100, 2) : 0;
- $trafficDesc = "流量:{$usagePercent}%";
+ $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;
- // 3. 流量熔断
- if ($isOverThreshold) {
- $trafficDesc .= "[警告]";
- if ($shouldCheckApi) {
- if ($thresholdAction === 'stop_and_notify') {
- if ($status !== 'Stopped') {
- if ($this->safeControlInstance($account, 'stop', $shutdownMode)) {
- $actions[] = "超限关机";
- $this->db->addLog('warning', "流量超限自动关机 [{$account['access_key_id']}] 使用率:{$usagePercent}%");
- $this->configManager->updateAccountStatus($account['id'], $traffic, 'Stopping', $currentTime);
- $status = 'Stopping';
- }
- }
- } else {
- $actions[] = "超限告警";
- $this->db->addLog('warning', "流量超限触发告警 [{$account['access_key_id']}] 使用率:{$usagePercent}%");
- }
+ // 2. 流量熔断
+ if ($requiresTrafficProtection) {
+ $trafficDesc .= $isHardLimitExceeded ? "[已超出上限]" : "[接近上限]";
- $mailRes = $this->notificationService->sendTrafficWarning($account['access_key_id'], $traffic, $usagePercent, implode(',', $actions), $threshold);
- $this->logNotificationResult($mailRes, $account['access_key_id']);
- }
- }
+ if ($thresholdAction === 'stop_and_notify') {
+ $canAttemptStop = !in_array($status, ['Stopped', 'Stopping', 'Released'], true);
- // 4. 保活逻辑 (跳过已被定时任务操作的实例)
- if ($keepAlive && !$isOverThreshold && !$statusTransformed) {
- if ($account['schedule_enabled'] == 0 || $this->isTimeInRange($currentUserTime, $account['start_time'], $account['stop_time'])) {
- if ($status === 'Stopped') {
- if ($this->safeControlInstance($account, 'start')) {
- $actions[] = "保活启动";
- $this->db->addLog('info', "执行保活启动 [{$account['access_key_id']}]");
-
- $mailRes = $this->notificationService->notifySchedule("保活启动", $account, "检测到实例在工作时段非预期关机,已尝试自动启动。");
- $this->logNotificationResult($mailRes, $account['access_key_id']);
-
- $this->configManager->updateAccountStatus($account['id'], $traffic, 'Starting', $currentTime);
- $status = 'Starting';
+ // 达到账号流量上限后必须立即保护,不再等待下一次接口刷新窗口。
+ 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->notifyStatusChangeIfNeeded($account, $previousStatus, 'Stopping', '流量达到保护线,已自动停机。');
+ $status = 'Stopping';
} else {
- $apiStatusLog .= " [保活启动失败,下次重试]";
+ $actions[] = "自动停机失败";
+ $this->db->addLog('error', "账号出口流量达到保护线,但自动停机失败 [{$accountLabel}] 当前使用率:{$usagePercent}%");
}
}
+ } elseif ($shouldCheckApi) {
+ $actions[] = "超量提醒";
+ $this->db->addLog('warning', "账号出口流量超限触发提醒 [{$accountLabel}] 当前使用率:{$usagePercent}%");
+ }
+
+ if (!empty($actions)) {
+ $mailRes = $this->notificationService->sendTrafficWarning($accountLabel, $accountTraffic, $usagePercent, implode(',', $actions), $threshold);
+ $this->logNotificationResult($mailRes, $accountLabel);
+ }
+ }
+
+ // 3. 每月自动开机:只在每月 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 .= " [月初自动开机失败,下次重试]";
+ }
}
}
- if ($statusTransformed) {
- $tempStatus = in_array("定时启动", $actions) ? 'Starting' : 'Stopping';
- $this->configManager->updateAccountStatus($account['id'], $traffic, $tempStatus, $currentTime);
- $apiStatusLog .= " -> 强制过渡态";
+ // 4. 保活逻辑
+ 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);
@@ -430,88 +548,44 @@ class AliyunTrafficCheck
$this->configManager->updateLastRunTime(time());
+ // 执行异步彻底销毁循环
+ $this->processPendingReleases();
+
return implode(PHP_EOL, $logs);
}
- public function getStatusForFrontend()
+ 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';
-
- $currentTime = time();
- $accounts = $this->configManager->getAccounts();
- $billingCycle = date('Y-m');
+ $accounts = array_values(array_filter($this->configManager->getAccounts(), function ($account) {
+ return !empty($account['instance_id']);
+ }));
foreach ($accounts as $account) {
- $lastUpdate = $account['updated_at'] ?? 0;
- $cachedStatus = $account['instance_status'] ?? 'Unknown';
- $newUpdateTime = $currentTime;
+ $data[] = $this->buildInstanceSnapshot($account, $threshold, $userInterval, $billingEnabled, $includeSensitive);
+ }
- $isTransientState = in_array($cachedStatus, ['Starting', 'Stopping', 'Pending', 'Unknown']);
- $checkInterval = $isTransientState ? 60 : $userInterval;
-
- if (($currentTime - $lastUpdate) > $checkInterval) {
- $newTraffic = $this->safeGetTraffic($account);
- $status = $this->safeGetInstanceStatus($account);
-
- if ($status === 'Unknown') {
- usleep(500000);
- $status = $this->safeGetInstanceStatus($account);
- }
-
- if ($newTraffic < 0) {
- $traffic = $account['traffic_used'];
- $newUpdateTime = $lastUpdate;
- } else {
- $traffic = $newTraffic;
- $this->db->addHourlyStat($account['id'], $traffic);
- $this->db->addDailyStat($account['id'], $traffic);
- }
-
- if ($status === 'Unknown') {
- $newUpdateTime = $lastUpdate;
- }
-
- $this->configManager->updateAccountStatus($account['id'], $traffic, $status, $newUpdateTime);
- } else {
- $traffic = $account['traffic_used'];
- $status = $account['instance_status'];
- }
-
- $usagePercent = ($account['max_traffic'] > 0) ? round(($traffic / $account['max_traffic']) * 100, 2) : 0;
- $isFull = $usagePercent >= $threshold;
-
- $item = [
- 'id' => $account['id'],
- 'account' => substr($account['access_key_id'], 0, 7) . '***',
- 'flow_total' => (float) $account['max_traffic'],
- 'flow_used' => round($traffic, 2),
- 'percentageOfUse' => $usagePercent,
- 'region' => $account['region_id'],
- 'regionName' => $this->getRegionName($account['region_id']),
- 'rate95' => $isFull,
- 'threshold' => $threshold,
- 'instanceStatus' => $status,
- 'lastUpdated' => date('Y-m-d H:i:s', $lastUpdate > 0 ? $lastUpdate : $currentTime),
- 'remark' => $account['remark'] ?? ''
- ];
-
- // 注入费用数据 (如果启用)
- if ($billingEnabled) {
- $item['cost'] = $this->safeGetBillingInfo($account, $billingCycle);
- }
-
- $data[] = $item;
+ $pendingAccounts = $this->configManager->getPendingReleaseAccounts();
+ foreach ($pendingAccounts as $account) {
+ $snap = $this->buildInstanceSnapshot($account, $threshold, $userInterval, $billingEnabled, $includeSensitive);
+ $snap['instanceStatus'] = 'Releasing';
+ $snap['status'] = 'Releasing';
+ $data[] = $snap;
}
return [
'data' => $data,
- 'system_last_run' => $this->configManager->getLastRunTime()
+ 'system_last_run' => $this->configManager->getLastRunTime(),
+ 'sync_interval' => $userInterval,
+ 'sensitive_visible' => $includeSensitive
];
}
@@ -535,9 +609,10 @@ class AliyunTrafficCheck
$this->db->addDailyStat($targetAccount['id'], $traffic);
}
+ $this->notifyStatusChangeIfNeeded($targetAccount, $targetAccount['instance_status'] ?? 'Unknown', $status, '手动同步检测到实例状态变化。');
$this->configManager->updateAccountStatus($id, $traffic, $status, $currentTime);
- // 刷新账单数据:仅在启用费用监控 且 无有效缓存时调用 BSS API
+ // 刷新账单数据:仅在启用费用监控 且 无有效缓存时调用 费用中心 接口
$billingError = null;
$billingEnabled = $this->configManager->get('enable_billing', '0') === '1';
if ($billingEnabled) {
@@ -579,13 +654,355 @@ class AliyunTrafficCheck
}
if ($billingError) {
- $this->db->addLog('warning', "账单刷新异常 [{$targetAccount['access_key_id']}]: {$billingError}");
+ $this->db->addLog('warning', "账单刷新异常 [{$this->getAccountLogLabel($targetAccount)}]: {$billingError}");
return ['success' => true, 'billing_error' => $billingError];
}
return true;
}
+ 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);
+ }
+
+ 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,
+ '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 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'] ?? '',
+ 'login_user' => $result['loginUser'] ?? '',
+ 'login_password' => '',
+ 'status' => 'success',
+ 'step' => '创建完成'
+ ]);
+
+ $this->configManager->syncAccountGroups(true);
+ $this->configManager->load();
+ $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();
+ $this->reconcileDdnsAfterAccountSync($accountsBeforeSync, $this->configManager->getAccounts(), '账号同步');
+ $this->db->addLog('info', "账号同步完成 [{$targetGroup['remark']}] {$targetGroup['regionId']} 实例 {$instanceCount} 台");
+
+ return [
+ 'success' => true,
+ 'message' => "已同步 {$instanceCount} 台实例,流量和消费情况已刷新",
+ 'instanceCount' => $instanceCount
+ ];
+ }
+
+ 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);
@@ -610,41 +1027,161 @@ class AliyunTrafficCheck
}
}
+ 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 safeGetTraffic($account)
{
try {
- return $this->aliyunService->getTraffic($account['access_key_id'], $account['access_key_secret'], $account['region_id']);
+ return $this->getMeteredOutboundTraffic($account);
} catch (ClientException $e) {
$code = $e->getErrorCode();
- $this->db->addLog('error', "流量查询配置错误: " . ($code ?: "鉴权失败"));
+ $this->db->addLog('error', "公网出口流量查询配置错误 [{$this->getAccountLogLabel($account)}]: " . ($code ?: "鉴权失败") . ",请确认AK拥有云监控流量查询权限");
return -1;
} catch (ServerException $e) {
- $this->db->addLog('error', "流量查询失败: 阿里云接口超时");
+ $this->db->addLog('error', "公网出口流量查询失败 [{$this->getAccountLogLabel($account)}]: " . $e->getErrorCode() . " - " . $e->getErrorMessage());
return -1;
} catch (\Exception $e) {
if (strpos($e->getMessage(), 'cURL error') !== false) {
- $this->db->addLog('error', "流量查询失败: 网络连接超时");
+ $this->db->addLog('error', "公网出口流量查询失败 [{$this->getAccountLogLabel($account)}]: 网络连接超时");
} else {
- $this->db->addLog('error', "流量查询失败: 系统未知错误");
+ $this->db->addLog('error', "公网出口流量查询失败 [{$this->getAccountLogLabel($account)}]: " . strip_tags($e->getMessage()));
}
return -1;
}
}
+ 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) {
- if (strpos($e->getMessage(), 'cURL error') !== false) {
- } elseif ($e instanceof ClientException) {
- $this->db->addLog('error', "实例状态查询配置错误: 鉴权失败");
- } else {
- }
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 {
@@ -653,25 +1190,14 @@ class AliyunTrafficCheck
$this->db->addLog('error', "实例操作失败 [{$action}]: 权限不足或配置错误");
return false;
} catch (ServerException $e) {
- $this->db->addLog('error', "实例操作失败 [{$action}]: 阿里云服务无响应");
+ $this->db->addLog('error', "实例操作失败 [{$action}]: " . $e->getErrorCode() . " - " . $e->getErrorMessage());
return false;
} catch (\Exception $e) {
- $this->db->addLog('error', "实例操作失败 [{$action}]: 无法连接API");
+ $this->db->addLog('error', "实例操作失败 [{$action}]: 无法连接接口");
return false;
}
}
- private function isTimeInRange($current, $start, $end)
- {
- if (!$start || !$end)
- return false;
- if ($start < $end) {
- return $current >= $start && $current < $end;
- } else {
- return $current >= $start || $current < $end;
- }
- }
-
private function getRegionName($regionId)
{
$regions = [
@@ -750,7 +1276,7 @@ class AliyunTrafficCheck
$this->db->setBillingCache($account['id'], 'instance_bill', $billingCycle, $bill);
} catch (\Exception $e) {
if ($costInfo['error']) {
- $costInfo['error'] = 'BSS权限不足';
+ $costInfo['error'] = '费用中心权限不足';
} else {
$costInfo['error'] = '账单查询失败';
}
@@ -762,6 +1288,470 @@ class AliyunTrafficCheck
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')
+ {
+ 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') {
+ 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;
+ }
+
+ 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') {
+ $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' || $status === 'Unknown') {
+ $this->db->addLog('warning', "待释放实例云端已灭迹,自动擦除本地账本 [{$accountLabel}]");
+ $this->configManager->physicallyDeleteAccount($account['id']);
+ } 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());
+ }
+ }
+ }
+
+ /**
+ * 获取所有已配置账号的实例列表(合并去重)
+ */
+ 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';
+ $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) {
+ if (empty($account['instance_id']) || empty($account['public_ip'])) {
+ continue;
+ }
+
+ try {
+ $recordName = $this->buildDdnsRecordNameForAccount($account, $groupCounts);
+
+ $result = $this->ddnsService->syncARecord($recordName, $account['public_ip']);
+ if (!empty($result['success']) && empty($result['skipped'])) {
+ $this->db->addLog('info', "DDNS 已同步 [{$this->getAccountLogLabel($account)}] {$recordName} -> {$account['public_ip']} ({$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 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;
+
+ $isTransientState = in_array($cachedStatus, ['Starting', 'Stopping', 'Pending', 'Unknown'], true);
+ $checkInterval = $isTransientState ? 60 : $userInterval;
+
+ if ($forceRefresh || ($currentTime - $lastUpdate) > $checkInterval) {
+ $newTraffic = $this->safeGetTraffic($account);
+ $status = $this->safeGetInstanceStatus($account);
+
+ if ($status === 'Unknown') {
+ $status = $cachedStatus;
+ }
+
+ if ($newTraffic < 0) {
+ $traffic = (float) ($account['traffic_used'] ?? 0);
+ $newUpdateTime = $lastUpdate;
+ } else {
+ $traffic = $newTraffic;
+ $this->db->addHourlyStat($account['id'], $traffic);
+ $this->db->addDailyStat($account['id'], $traffic);
+ }
+
+ if ($newUpdateTime <= 0) {
+ $newUpdateTime = $currentTime;
+ }
+
+ $this->notifyStatusChangeIfNeeded($account, $cachedStatus, $status, '页面刷新检测到实例状态变化。');
+
+ $metadata = [];
+ // 如果处于运行中且健康状态未知或非 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,
+ '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'] ?? '') : '',
+ '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'))
diff --git a/ConfigManager.php b/ConfigManager.php
index 112e67e..ec42778 100644
--- a/ConfigManager.php
+++ b/ConfigManager.php
@@ -2,25 +2,110 @@
class ConfigManager
{
+ private $database;
private $db;
private $configCache = [];
private $accountsCache = [];
+ private $encryptionKey = null;
public function __construct(Database $db)
{
+ $this->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'];
}
- $stmt = $this->db->query("SELECT * FROM accounts ORDER BY id ASC");
+ $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]);
+
+ // 阿里云 CDT/公网流量按自然月结算。月切换后,展示值和熔断判断都必须从当月重新开始。
+ $stmt = $this->db->prepare("
+ UPDATE accounts
+ SET traffic_used = 0,
+ traffic_billing_month = ?,
+ updated_at = 0
+ WHERE traffic_billing_month <> ?
+ ");
+ $stmt->execute([$currentMonth, $currentMonth]);
}
public function get($key, $default = null)
@@ -41,12 +126,84 @@ class ConfigManager
public function getAccountById($id)
{
foreach ($this->accountsCache as $acc) {
- if ($acc['id'] == $id)
+ if ((int) $acc['id'] === (int) $id) {
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)) {
+ return $this->normalizeAccountGroups($decoded, true);
+ }
+ }
+
+ 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
+ ];
+ }
+
+ 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
+ ];
+ }
+
+ if (!empty($row['instance_id'])) {
+ $metrics[$groupKey]['instanceCount']++;
+ }
+
+ $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;
+ }
+
+ return $metrics;
+ }
+
public function isInitialized()
{
return !empty($this->configCache['admin_password']);
@@ -59,7 +216,21 @@ class ConfigManager
$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)
{
@@ -71,177 +242,525 @@ class ConfigManager
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();
- // 1. 保存全局设置
- $this->saveSetting('admin_password', $data['admin_password']);
- $this->saveSetting('traffic_threshold', $data['traffic_threshold']);
- $this->saveSetting('enable_schedule_email', $data['enable_schedule_email'] ? '1' : '0');
- $this->saveSetting('shutdown_mode', $data['shutdown_mode']);
- $this->saveSetting('threshold_action', $data['threshold_action']);
- $this->saveSetting('keep_alive', isset($data['keep_alive']) && $data['keep_alive'] ? '1' : '0');
+ $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', isset($data['enable_billing']) && $data['enable_billing'] ? '1' : '0');
+ $this->saveSetting('enable_billing', !empty($data['enable_billing']) ? '1' : '0');
+ $this->saveDdnsSettings($data['Ddns'] ?? []);
if (isset($data['Notification'])) {
- // Email
- $this->saveSetting('notify_email_enabled', isset($data['Notification']['email_enabled']) && $data['Notification']['email_enabled'] ? '1' : '0');
- $this->saveSetting('notify_email', $data['Notification']['email'] ?? '');
- $this->saveSetting('notify_host', $data['Notification']['host'] ?? '');
- $this->saveSetting('notify_port', $data['Notification']['port'] ?? 465);
- $this->saveSetting('notify_username', $data['Notification']['username'] ?? '');
- $this->saveSetting('notify_password', $data['Notification']['password'] ?? '');
- $this->saveSetting('notify_secure', $data['Notification']['secure'] ?? 'ssl');
-
- // Telegram
- if (isset($data['Notification']['telegram'])) {
- $tg = $data['Notification']['telegram'];
- $this->saveSetting('notify_tg_enabled', isset($tg['enabled']) && $tg['enabled'] ? '1' : '0');
- $this->saveSetting('notify_tg_token', $tg['token'] ?? '');
- $this->saveSetting('notify_tg_chat_id', $tg['chat_id'] ?? '');
- $this->saveSetting('notify_tg_proxy_type', $tg['proxy_type'] ?? 'none');
- $this->saveSetting('notify_tg_proxy_url', $tg['proxy_url'] ?? '');
- $this->saveSetting('notify_tg_proxy_ip', $tg['proxy_ip'] ?? '');
- $this->saveSetting('notify_tg_proxy_port', $tg['proxy_port'] ?? '');
- $this->saveSetting('notify_tg_proxy_user', $tg['proxy_user'] ?? '');
- $this->saveSetting('notify_tg_proxy_pass', $tg['proxy_pass'] ?? '');
- }
-
- // Webhook
- if (isset($data['Notification']['webhook'])) {
- $wh = $data['Notification']['webhook'];
- $this->saveSetting('notify_wh_enabled', isset($wh['enabled']) && $wh['enabled'] ? '1' : '0');
- $this->saveSetting('notify_wh_url', $wh['url'] ?? '');
- $this->saveSetting('notify_wh_method', $wh['method'] ?? 'GET');
- $this->saveSetting('notify_wh_request_type', $wh['request_type'] ?? 'JSON');
- $this->saveSetting('notify_wh_headers', $wh['headers'] ?? '');
- $this->saveSetting('notify_wh_body', $wh['body'] ?? '');
- }
+ $this->saveNotificationSettings($data['Notification']);
}
- // 2. 账号增量同步
- $newAccounts = $data['Accounts'] ?? [];
- $stmt = $this->db->query("SELECT id, access_key_id, region_id, instance_id FROM accounts");
- $existingMap = [];
- while ($row = $stmt->fetch()) {
- // Use composite key for deduplication: AK + Region + InstanceID
- $compositeKey = $row['access_key_id'] . '|' . $row['region_id'] . '|' . ($row['instance_id'] ?? '');
- $existingMap[$compositeKey] = $row['id'];
- }
-
- $keptIds = [];
- $insertStmt = $this->db->prepare("INSERT INTO accounts (access_key_id, access_key_secret, region_id, instance_id, max_traffic, schedule_enabled, start_time, stop_time, remark, site_type, traffic_used, instance_status, updated_at, last_keep_alive_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'Unknown', 0, 0)");
- $updateStmt = $this->db->prepare("UPDATE accounts SET access_key_secret = ?, region_id = ?, instance_id = ?, max_traffic = ?, schedule_enabled = ?, start_time = ?, stop_time = ?, remark = ?, site_type = ? WHERE id = ?");
-
- foreach ($newAccounts as $acc) {
- $key = $acc['AccessKeyId'];
- $region = $acc['regionId'];
- $instance = $acc['instanceId'] ?? '';
- $compositeKey = $key . '|' . $region . '|' . $instance;
-
- $params = [
- $acc['AccessKeySecret'],
- $region,
- $instance,
- $acc['maxTraffic'],
- ($acc['schedule']['enabled'] ?? false) ? 1 : 0,
- $acc['schedule']['startTime'] ?? '',
- $acc['schedule']['stopTime'] ?? '',
- $acc['remark'] ?? '',
- $acc['siteType'] ?? 'china'
- ];
-
- if (isset($existingMap[$compositeKey])) {
- $id = $existingMap[$compositeKey];
- $params[] = $id;
- $updateStmt->execute($params);
- $keptIds[] = $id;
- } else {
- $insertParams = [$key];
- array_push($insertParams, ...$params);
- $insertStmt->execute($insertParams);
- // For new inserts, we need to track the ID to avoid deleting it if user sends duplicate valid entries in one request?
- // But assume frontend sends unique list. If not, this logic might add duplicates.
- // Ideally we should track inserted IDs too but here we just rely on existingMap keys.
- // Actually, if we just inserted, we can't easily get the ID back without lastInsertId but we don't strictly need it for the delete logic below if we assume input list is unique.
- // However, to be safe against deleting effectively "new" accounts just added, let's just trust input list defines the "desired state".
- // Wait, $idsToDelete is calculated from existingMap vs keptIds. If it's a new insert, it wasn't in existingMap, so it won't be in idsToDelete anyway.
- }
- }
-
- // 3. 删除移除的账号
- $idsToDelete = array_diff(array_values($existingMap), $keptIds);
- if (!empty($idsToDelete)) {
- $placeholders = implode(',', array_fill(0, count($idsToDelete), '?'));
- $deleteStmt = $this->db->prepare("DELETE FROM accounts WHERE id IN ($placeholders)");
- $deleteStmt->execute(array_values($idsToDelete));
- }
+ $groups = $this->normalizeAccountGroups($data['Accounts'] ?? []);
+ $this->saveSetting('account_groups', json_encode($groups, JSON_UNESCAPED_UNICODE));
+ $this->syncAccountGroups(true, $groups);
$this->db->commit();
-
- // 4. 重排 ID
- $this->reorderIds();
-
- // 5. 刷新缓存
$this->load();
return true;
} catch (Exception $e) {
- if ($this->db->inTransaction())
+ if ($this->db->inTransaction()) {
$this->db->rollBack();
+ }
+ $this->database->addLog('error', "配置保存失败: " . strip_tags($e->getMessage()));
return false;
}
}
- private function reorderIds()
+ public function syncAccountGroups($force = false, $groups = null)
{
- try {
- $this->db->beginTransaction();
- $stmt = $this->db->query("SELECT * FROM accounts ORDER BY id ASC");
- $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
+ $lastSync = $this->getLastInstanceSyncTime();
+ if (!$force && (time() - $lastSync) < 60) {
+ $this->load();
+ return;
+ }
- if (!empty($rows)) {
- $this->db->exec("DELETE FROM accounts");
- $this->db->exec("DELETE FROM sqlite_sequence WHERE name='accounts'");
+ $groups = $groups === null ? $this->getAccountGroups() : $this->normalizeAccountGroups($groups, true);
- $insertStmt = $this->db->prepare("INSERT INTO accounts (id, access_key_id, access_key_secret, region_id, instance_id, max_traffic, schedule_enabled, start_time, stop_time, remark, site_type, traffic_used, instance_status, updated_at, last_keep_alive_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
+ $existingRows = $this->db->query("SELECT * FROM accounts ORDER BY id ASC")->fetchAll(PDO::FETCH_ASSOC);
+ $existingByGroup = [];
+ $existingByComposite = [];
- $newId = 1;
- foreach ($rows as $row) {
+ 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,
+ start_time,
+ stop_time,
+ 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,
+ 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 = ?,
+ start_time = ?,
+ stop_time = ?,
+ instance_status = ?,
+ remark = ?,
+ site_type = ?,
+ group_key = ?,
+ instance_name = ?,
+ instance_type = ?,
+ internet_max_bandwidth_out = ?,
+ public_ip = ?,
+ 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);
+
+ if ($existingRow) {
+ $updateStmt->execute([
+ $group['AccessKeyId'],
+ $this->encryptValue($group['AccessKeySecret']),
+ $group['regionId'],
+ $instance['instanceId'],
+ $group['maxTraffic'],
+ 0,
+ '',
+ '',
+ $instance['status'] ?: ($existingRow['instance_status'] ?? 'Unknown'),
+ $remark,
+ $group['siteType'],
+ $group['groupKey'],
+ $instance['instanceName'] ?? '',
+ $instance['instanceType'] ?? '',
+ (int) ($instance['internetMaxBandwidthOut'] ?? 0),
+ $instance['publicIp'] ?? '',
+ $instance['privateIp'] ?? '',
+ (int) ($instance['cpu'] ?? 0),
+ (int) ($instance['memory'] ?? 0),
+ $instance['osName'] ?? '',
+ $instance['stoppedMode'] ?? '',
+ $existingRow['id']
+ ]);
+ } else {
$insertStmt->execute([
- $newId++,
- $row['access_key_id'],
- $row['access_key_secret'],
- $row['region_id'],
- $row['instance_id'],
- $row['max_traffic'],
- $row['schedule_enabled'],
- $row['start_time'],
- $row['stop_time'],
- $row['remark'] ?? '',
- $row['site_type'] ?? 'china',
- $row['traffic_used'],
- $row['instance_status'],
- $row['updated_at'],
- $row['last_keep_alive_at']
+ $group['AccessKeyId'],
+ $this->encryptValue($group['AccessKeySecret']),
+ $group['regionId'],
+ $instance['instanceId'],
+ $group['maxTraffic'],
+ 0,
+ '',
+ '',
+ date('Y-m'),
+ $instance['status'] ?? 'Unknown',
+ $remark,
+ $group['siteType'],
+ $group['groupKey'],
+ $instance['instanceName'] ?? '',
+ $instance['instanceType'] ?? '',
+ (int) ($instance['internetMaxBandwidthOut'] ?? 0),
+ $instance['publicIp'] ?? '',
+ $instance['privateIp'] ?? '',
+ (int) ($instance['cpu'] ?? 0),
+ (int) ($instance['memory'] ?? 0),
+ $instance['osName'] ?? '',
+ $instance['stoppedMode'] ?? ''
]);
}
}
- $this->db->commit();
- } catch (Exception $e) {
- if ($this->db->inTransaction())
- $this->db->rollBack();
+
+ 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 updateAccountStatus($id, $traffic, $status, $updatedAt)
+ private function saveNotificationSettings($notification)
{
- $stmt = $this->db->prepare("UPDATE accounts SET traffic_used = ?, instance_status = ?, updated_at = ? WHERE id = ?");
- return $stmt->execute([$traffic, $status, $updatedAt, $id]);
+ $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'] ?? '');
+
+ 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);
+ }
+
+ 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);
+ }
+
+ $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'] ?? ''))
+ ];
+ }
+
+ return array_values($normalized);
+ }
+
+ private function resolveExistingSecret($accessKeyId, $regionId)
+ {
+ $accessKeyId = trim((string) $accessKeyId);
+ $regionId = trim((string) $regionId);
+
+ foreach ($this->accountsCache as $row) {
+ if ($row['access_key_id'] === $accessKeyId && $row['region_id'] === $regionId) {
+ return $row['access_key_secret'];
+ }
+ }
+
+ $groupKey = $this->buildGroupKey($accessKeyId, $regionId);
+ foreach ($this->accountsCache as $row) {
+ if (($row['group_key'] === $groupKey) && !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) {
+ $savedAccessKeyId = trim((string) ($group['AccessKeyId'] ?? ''));
+ $savedRegionId = trim((string) ($group['regionId'] ?? ''));
+ $savedSecret = trim((string) ($group['AccessKeySecret'] ?? ''));
+
+ if (
+ $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'] ?? ''
+ ];
+ }
+
+ 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 updateGroupBaseSettings($groupKey, $group)
+ {
+ $stmt = $this->db->prepare("
+ UPDATE accounts
+ SET access_key_id = ?,
+ access_key_secret = ?,
+ region_id = ?,
+ max_traffic = ?,
+ schedule_enabled = ?,
+ start_time = ?,
+ stop_time = ?,
+ site_type = ?,
+ group_key = ?
+ WHERE group_key = ?
+ ");
+
+ $stmt->execute([
+ $group['AccessKeyId'],
+ $this->encryptValue($group['AccessKeySecret']),
+ $group['regionId'],
+ $group['maxTraffic'],
+ 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'];
+ }
+
+ $sql .= " WHERE id = ?";
+ $params[] = $id;
+
+ $stmt = $this->db->prepare($sql);
+ return $stmt->execute($params);
}
public function updateLastKeepAlive($id, $time)
@@ -249,4 +768,17 @@ class ConfigManager
$stmt = $this->db->prepare("UPDATE accounts SET last_keep_alive_at = ? WHERE id = ?");
return $stmt->execute([$time, $id]);
}
-}
\ No newline at end of file
+
+ 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 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
index 72b0148..a6d8a84 100644
--- a/Database.php
+++ b/Database.php
@@ -92,9 +92,17 @@ class Database
traffic_used REAL DEFAULT 0,
instance_status TEXT DEFAULT 'Unknown',
updated_at INTEGER DEFAULT 0,
- last_keep_alive_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 (
@@ -132,12 +140,66 @@ class Database
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 '',
+ 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->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', 'remark', "TEXT DEFAULT ''");
- $this->ensureColumn('accounts', 'site_type', "TEXT DEFAULT 'china'");
+ $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', '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->migrateStatsToAccountId();
}
@@ -383,6 +445,41 @@ class Database
$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()
+ ]);
}
// --- 账单缓存相关方法 ---
@@ -421,4 +518,68 @@ class Database
$limit = time() - (90 * 86400);
$this->pdo->exec("DELETE FROM billing_cache WHERE updated_at < $limit");
}
-}
\ No newline at end of file
+
+ 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',
+ '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
new file mode 100644
index 0000000..09d77e6
--- /dev/null
+++ b/DdnsService.php
@@ -0,0 +1,252 @@
+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/Dockerfile b/Dockerfile
index 407e1da..a6ccb6c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,7 +8,9 @@ WORKDIR /app
COPY composer.json composer.lock ./
# 安装依赖 (排除开发依赖,优化自动加载)
-RUN composer install --no-dev --optimize-autoloader --ignore-platform-reqs --no-interaction --no-scripts
+# 注意:SDK Sign::uuid() 的 microtime() 返回带空格的字符串导致签名失败,需修复
+RUN composer install --no-dev --optimize-autoloader --ignore-platform-reqs --no-interaction --no-scripts \
+ && sed -i 's/return md5(\$salt . uniqid(md5(microtime(true)), true)) . microtime();/return md5(\$salt . uniqid(md5(microtime(true)), true)) . str_replace(" ", "", microtime());/' /app/vendor/alibabacloud/client/src/Support/Sign.php
# 复制其余项目文件
COPY . .
@@ -20,7 +22,7 @@ COPY . .
FROM php:8.2-fpm-alpine
# 设置镜像元数据
-LABEL maintainer="CDT-Monitor-Docker"
+LABEL maintainer="ECS-Controller-Docker"
# 设置环境变量
ENV TZ=Asia/Shanghai
diff --git a/NotificationService.php b/NotificationService.php
index c07003a..31bb8f8 100644
--- a/NotificationService.php
+++ b/NotificationService.php
@@ -22,22 +22,22 @@ class NotificationService
$title = "定时任务: " . $actionType;
$maskedKey = substr($account['access_key_id'], 0, 7) . '***';
- $traffic = isset($account['traffic_used']) ? round($account['traffic_used'], 2) : 'N/A';
+ $traffic = isset($account['traffic_used']) ? $this->formatTraffic((float) $account['traffic_used']) : '暂无';
$threshold = $this->config['traffic_threshold'] ?? 95;
$details = [
- ['label' => '账号 ID', 'value' => $maskedKey],
+ ['label' => '账号', 'value' => $maskedKey],
['label' => '执行动作', 'value' => $actionType, 'highlight' => true],
['label' => '执行时间', 'value' => date('Y-m-d H:i:s')],
- ['label' => '当前流量', 'value' => $traffic . ' GB'],
+ ['label' => '当前流量', 'value' => $traffic],
['label' => '设定阈值', 'value' => $threshold . '%'],
['label' => '详情说明', 'value' => $description ?: '根据预设时间表自动执行。']
];
- $textMsg = "【CDT Monitor】{$title}\n" .
- "账号 ID: {$maskedKey}\n" .
+ $textMsg = "【ECS 服务器管家】{$title}\n" .
+ "账号: {$maskedKey}\n" .
"执行动作: {$actionType}\n" .
- "当前流量: {$traffic} GB\n" .
+ "当前流量: {$traffic}\n" .
"设定阈值: {$threshold}%\n" .
"执行时间: " . date('Y-m-d H:i:s') . "\n" .
"详情说明: " . ($description ?: '根据预设时间表自动执行。');
@@ -52,17 +52,18 @@ class NotificationService
public function sendTrafficWarning($accessKeyId, $traffic, $percentage, $statusText, $threshold)
{
$title = "流量告警 - " . $statusText;
+ $trafficText = $this->formatTraffic((float) $traffic);
$details = [
- ['label' => '账号 ID', 'value' => substr($accessKeyId, 0, 7) . '***'],
- ['label' => '当前流量', 'value' => $traffic . ' GB'],
+ ['label' => '账号', 'value' => substr($accessKeyId, 0, 7) . '***'],
+ ['label' => '当前流量', 'value' => $trafficText],
['label' => '使用率', 'value' => $percentage . '%', 'highlight' => true],
['label' => '设定阈值', 'value' => $threshold . '%'],
['label' => '当前状态', 'value' => $statusText]
];
- $textMsg = "【CDT Monitor】{$title}\n" .
- "账号 ID: " . substr($accessKeyId, 0, 7) . '***' . "\n" .
- "当前流量: {$traffic} GB\n" .
+ $textMsg = "【ECS 服务器管家】{$title}\n" .
+ "账号: " . substr($accessKeyId, 0, 7) . '***' . "\n" .
+ "当前流量: {$trafficText}\n" .
"使用率: {$percentage}%\n" .
"设定阈值: {$threshold}%\n" .
"当前状态: {$statusText}";
@@ -70,30 +71,144 @@ class NotificationService
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);
+ }
+
+ private function statusLabel($status)
+ {
+ $map = [
+ 'Running' => '已启动',
+ 'Starting' => '启动中',
+ 'Stopping' => '停机中',
+ 'Stopped' => '已停机',
+ 'Pending' => '创建中',
+ 'Released' => '已释放',
+ 'Unknown' => '未知'
+ ];
+ return $map[$status] ?? ($status ?: '未知');
+ }
+
public function sendTestEmail($to)
{
$details = [
- ['label' => '测试结果', 'value' => '成功 (Success)'],
+ ['label' => '测试结果', 'value' => '成功'],
['label' => '发送时间', 'value' => date('Y-m-d H:i:s')],
['label' => '服务器', 'value' => $_SERVER['SERVER_NAME'] ?? 'localhost']
];
- $html = $this->renderEmailTemplate("测试邮件", "SMTP 配置验证成功", $details, 'success');
- return $this->sendMail($to, 'Admin', 'CDT Monitor Test', $html);
+ $html = $this->renderEmailTemplate("测试邮件", "邮件服务配置验证成功", $details, 'success');
+ return $this->sendMail($to, '管理员', 'ECS 服务器管家测试邮件', $html);
}
public function sendTestTelegram($data)
{
- $textMsg = "【CDT Monitor】测试推送\n这是一条来自 Telegram 的测试消息。\n发送时间: " . date('Y-m-d H:i:s');
+ $textMsg = "【ECS 服务器管理】测试推送\n这是一条来自 Telegram 的测试消息。\n发送时间: " . date('Y-m-d H:i:s');
return $this->sendTelegram($textMsg, $data);
}
public function sendTestWebhook($data)
{
- $textMsg = "【CDT Monitor】测试推送\n这是一条来自 Webhook 的测试消息。\n发送时间: " . date('Y-m-d H:i:s');
- $summary = "这是一条来自 Webhook 的测试消息。";
+ $textMsg = "【ECS 服务器管家】测试推送\n这是一条来自接口回调的测试消息。\n发送时间: " . date('Y-m-d H:i:s');
+ $summary = "这是一条来自接口回调的测试消息。";
$threshold = $this->config['traffic_threshold'] ?? 95;
$details = [
- ['label' => '当前流量', 'value' => '0 GB'],
+ ['label' => '当前流量', 'value' => '0 MB'],
['label' => '设定阈值', 'value' => $threshold . '%']
];
return $this->sendWebhook($textMsg, "测试推送", $summary, $details, 'test_account_id', $data);
@@ -105,15 +220,15 @@ class NotificationService
$successCount = 0;
$attemptCount = 0;
- // Email
+ // 邮件通知
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'], '', "CDT通知 - " . $title, $html);
+ $res = $this->sendMail($this->config['notify_email'], '', "ECS 服务器管家通知 - " . $title, $html);
if ($res === true)
$successCount++;
else
- $errors[] = "Email: " . $res;
+ $errors[] = "邮件通知: " . $res;
}
// Telegram
@@ -123,21 +238,21 @@ class NotificationService
if ($res === true)
$successCount++;
else
- $errors[] = "TG: " . $res;
+ $errors[] = "Telegram: " . $res;
}
- // Webhook
+ // 接口回调
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[] = "WH: " . $res;
+ $errors[] = "接口回调: " . $res;
}
if ($attemptCount == 0)
- return true; // No notifications enabled
+ return true;
if ($successCount == 0 && count($errors) > 0) {
return implode(" | ", $errors);
@@ -175,12 +290,12 @@ class NotificationService
|
|
- CDT MONITOR
+ ECS 服务器管家
{$title}
{$summary}
|
- | © " . date('Y') . " CDT Monitor |
+ | © " . date('Y') . " ECS 服务器管家 |
@@ -208,7 +323,7 @@ class NotificationService
$mail->Username = $this->config['notify_username'] ?? '';
$mail->Password = $this->config['notify_password'] ?? '';
- $mail->SetFrom($mail->Username, '阿里云CDT监控');
+ $mail->SetFrom($mail->Username, 'ECS 服务器管家');
$mail->Subject = $subject;
$mail->MsgHTML($body);
$mail->AddAddress($to, $name);
@@ -228,7 +343,7 @@ class NotificationService
$proxyType = $overrideConfig['proxy_type'] ?? $this->config['notify_tg_proxy_type'] ?? 'none';
if (empty($token) || empty($chatId))
- return "Telegram Token 或 Chat ID 为空";
+ return "Telegram 的机器人令牌或接收会话编号为空";
$url = "https://api.telegram.org/bot{$token}/sendMessage";
if ($proxyType === 'custom' && !empty($overrideConfig['proxy_url'] ?? $this->config['notify_tg_proxy_url'] ?? '')) {
@@ -268,9 +383,9 @@ class NotificationService
curl_close($ch);
if ($error)
- return "Curl Error: " . $error;
+ return "网络请求错误: " . $error;
if ($httpCode != 200)
- return "HTTP Error {$httpCode}: " . $result;
+ return "接口返回错误 {$httpCode}: " . $result;
return true;
}
@@ -283,14 +398,14 @@ class NotificationService
$bodyTemplate = $overrideConfig['body'] ?? $this->config['notify_wh_body'] ?? '';
if (empty($url))
- return "Webhook URL为空";
+ return "接口回调地址为空";
// Parse variables
- $traffic = 'N/A';
- $maxTraffic = 'N/A';
+ $traffic = '暂无';
+ $maxTraffic = '暂无';
foreach ($details as $d) {
if ($d['label'] === '当前流量')
- $traffic = str_replace(' GB', '', $d['value']);
+ $traffic = str_replace([' GB', ' MB', ' GB', ' MB'], '', $d['value']);
if ($d['label'] === '设定阈值')
$maxTraffic = str_replace('%', '', $d['value']);
}
@@ -322,10 +437,8 @@ class NotificationService
}
$finalUrl = strtr($url, $urlReplacePairs);
- // If body template exists, replace vars and append it to URL query string if no URL vars were found.
- // Fallback for simple GET without body:
+ // 读取请求没有请求体时,将默认参数拼到地址上。
if (empty($bodyTemplate) && strpos($finalUrl, '?') === false && strpos($url, '#') === false) {
- // Default fallback if no variables exist in URL or body
$payload = [
'title' => $title,
'text' => $text,
@@ -335,7 +448,7 @@ class NotificationService
}
curl_setopt($ch, CURLOPT_URL, $finalUrl);
} else {
- // POST request
+ // 发送请求
$urlReplacePairs = [];
foreach ($replacePairs as $k => $v) {
$urlReplacePairs[$k] = urlencode((string) $v);
@@ -348,7 +461,7 @@ class NotificationService
$bodyReplacePairs = $replacePairs;
if ($requestType === 'JSON') {
foreach ($bodyReplacePairs as $k => $v) {
- // Safe JSON encoding for values injected into string literals
+ // 数据格式安全转义,避免模板变量破坏 JSON 字符串。
$bodyReplacePairs[$k] = substr(json_encode((string) $v, JSON_UNESCAPED_UNICODE), 1, -1);
}
} else if ($requestType === 'FORM') {
@@ -363,14 +476,14 @@ class NotificationService
$customHeaders[] = 'Content-Type: application/json';
} else if ($requestType === 'FORM') {
$customHeaders[] = 'Content-Type: application/x-www-form-urlencoded';
- // Test if user provided JSON instead of form data, attempt conversion
+ // 用户误填 JSON 时,尽量转换为表单格式。
$decoded = json_decode($finalBody, true);
if (is_array($decoded)) {
$finalBody = http_build_query($decoded);
}
}
} else {
- // Fallback default payload if no body is configured
+ // 未配置请求体时发送默认内容。
$payload = ['title' => $title, 'text' => $text, 'time' => date('Y-m-d H:i:s')];
if ($requestType === 'JSON') {
$finalBody = json_encode($payload, JSON_UNESCAPED_UNICODE);
@@ -396,9 +509,21 @@ class NotificationService
curl_close($ch);
if ($error)
- return "Curl Error: " . $error;
+ return "网络请求错误: " . $error;
if ($httpCode >= 400)
- return "HTTP Error {$httpCode}: " . $result;
+ return "接口返回错误 {$httpCode}: " . $result;
return true;
}
-}
\ No newline at end of file
+
+ 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 0dbc0d1..735f124 100644
--- a/README.MD
+++ b/README.MD
@@ -1,189 +1,143 @@
-# CDT-Monitor 🌩️
+# ecs-controller 🌩️
-> **阿里云 CDT 流量监控与自动化熔断解决方案**
+
+
+
+
+> **阿里云 CDT 流量监控与自动化管理终极解决方案**
>
-> 旨在通过集成流量追踪、阈值熔断机制及抢占式实例保活策略,优化云端资源成本管理。
+> 专为 **MJJ** 大佬设计。旨在最大化榨干阿里云 CDT 200GB 免费流量,集成流量实时监控、自动熔断保护、抢占式实例保活、**实例规格与价格透明展示**于一体。
-## 📖 项目概述 (Introduction)
+## ⚠️ 免责声明
-**CDT-Monitor** 是一款专为阿里云云数据传输(CDT)用户研发的轻量级流量监控与管理系统。该系统基于 **PHP** 与 **SQLite** 架构设计,无需复杂的数据库配置即可快速部署。
+1. **配置参考**:本项目提供的默认一键创建配置仅供参考,请在下单前务必核对阿里云最新的 API 返回价格。
+2. **代码修改**:代码完全开源,您可以根据个人需求自行修改逻辑或 UI。
+3. **AI 开发声明**:本项目由 **AI (Antigravity)** 深度参与开发。作者已尽最大努力确保核心功能(如流量熔断、自动释放)的逻辑正确性。
+4. **Bug 修复**:由于云平台 API 变动或环境差异,如遇见 Bug 建议先行尝试自行修复,或提交 **Issue / PR**,由于精力有限,作者不保证实时维护。
+5. **风险自担**:因使用本脚本、或是阿里云 API 异常导致的相关资源损失或超支费用,作者概不负责。
-其核心价值在于自动化解决云资源管理中的“黑天鹅”事件:**通过实时流量熔断机制防止 CDT 免费额度超支扣费,利用智能保活算法解决抢占式实例非预期释放导致的业务中断,并提供精准的定时任务调度,是个人开发者与中小团队控制云成本的理想管家。**
-
-## 📸 界面预览 (Interface Preview)
-
-| 登陆界面 | 初始化 |
-| --- | --- |
-|  |  |
-
-| 后台页面 | 后台页面 |
-| --- | --- |
-|  |  |
-
-| 图表展示 | 邮件展示 |
-| --- | --- |
-|  |  |
-## ✨ 核心功能 (Core Features)
-
-* **便捷部署架构** :基于 SQLite 构建,无需配置 MySQL 或 Redis 等外部数据库,支持数据私有化存储与快速部署。
-* **多账户聚合监控** :提供统一管理面板,实现对多个阿里云账户下 CDT 流量使用情况及实例运行状态的集中监控。
-* **流量熔断机制** :
- * **阈值控制** :支持自定义流量使用阈值(例如 95%)。
- * **自动化停机** :当流量超出预设阈值时自动触发实例停机,支持配置 **普通停机** 或 **节省停机(停止计费)** 模式。
- * **告警通知** :集成 SMTP 协议,支持通过邮件发送即时告警通知。
-* **实例保活策略** :内置保活逻辑算法,旨在防止抢占式实例在预定运行时间段内被异常回收。
-* **定时任务管理** :支持针对特定实例设定每日自动开关机计划。
-* **现代化用户界面** :采用原生 Tailwind CSS 构建,提供统一的响应式布局,确保移动端与桌面端的兼容性与视觉一致性。
-* **安全保障体系** :内置初始化配置向导,确保敏感配置信息的本地加密存储与安全性。
-
-## 🛠️ 环境要求 (System Requirements)
-
-* **PHP** 版本 >= 8.0
-* **Composer** 依赖管理工具
-* **PHP 扩展** :`pdo_sqlite`, `curl`, `json`
-* **Web 服务器** :Nginx / Apache / OpenLiteSpeed
-
-## 🚀 安装指南 (Installation Guide)
-
-### 1. 获取源代码
-
-```
-git clone https://github.com/wang4386/CDT-Monitor.git
-cd CDT-Monitor
-```
-
-### 2. 安装依赖包
-
-```
-composer install --no-dev
-```
-
-### 3. 配置目录权限
-
-请确保 Web 服务器用户对项目根目录及 `data.sqlite` 文件拥有 **写入权限** 。
-
-```
-# 示例:假设 Web 根目录为 /www/wwwroot/yoursite.com
-chown -R www:www /www/wwwroot/yoursite.com
-chmod -R 755 /www/wwwroot/yoursite.com
-```
-
-> **🛡️ Nginx 安全配置(重要)**
->
-> 为防止数据库文件被下载,请务必在 Nginx 配置文件(server 段)中添加以下伪静态规则以禁止访问 `/data/` 目录:
->
-> ```
-> location ^~ /data/ {
-> deny all;
-> return 403;
-> }
-> ```
-
-### 4. 系统初始化
-
-通过浏览器访问站点(例如 `https://monitor.yourdomain.com`)。首次访问将自动进入 **初始化向导** ,请按照提示完成管理员密码及相关参数的配置。
-
-## 🐳 Docker 部署 (Docker Deployment)
-
-无需配置 PHP 环境,直接使用 Docker 即可快速部署。Docker 版本已内置定时任务,**无需** 额外配置 Crontab。
+---
+## 🚀 快速部署
### 方式一:Docker Compose (推荐)
+这是最省心的部署方案,内置了自动化的定时任务巡检,无需额外配置 Crontab。
-创建一个 `docker-compose.yml` 文件:
-
-```
+1. **新建配置文件** `docker-compose.yml`:
+```yaml
services:
- cdt-monitor:
- image: qninq/cdt-monitor:latest
- # build: .
- container_name: cdt-monitor
+ ecs-controller:
+ image: kori1c/ecs-controller:latest
+ container_name: ecs-controller
restart: always
ports:
- - "43210:80" # 将容器的 80 端口映射到宿主机的 43210 端口
+ - "43210:80"
volumes:
- # 持久化数据目录,确保重启后配置和数据库不丢失
- ./data:/var/www/html/data
- # 可选:如果你想查看 Docker 内部的日志
- # - ./logs:/var/log/nginx
environment:
- - TZ=Asia/Shanghai # 设置时区,对 Cron 任务很重要
+ - TZ=Asia/Shanghai
```
-启动服务:
-
-```
+2. **启动服务**:
+```bash
docker-compose up -d
```
+访问 `http://localhost:43210` 即可开始使用。
-### 方式二:Docker CLI
+---
-```
-docker run -d \
- --name cdt-monitor \
- --restart always \
- -p 43210:80 \
- -v $(pwd)/data:/var/www/html/data \
- -e TZ=Asia/Shanghai \
- qninq/cdt-monitor:latest
-```
+## 🔑 获取阿里云密钥
-部署完成后,访问 `http://服务器IP:43210` 即可开始使用。
+为了让系统能够正常获取流量数据并管理实例,您需要准备具有相关权限的阿里云 AccessKey:
-## ⚙️ 自动化任务配置 (Automation Setup)
+1. **登录阿里云控制台**,前往 [RAM 访问控制 - 用户](https://ram.console.aliyun.com/users)。
+2. **创建用户**:点击“创建用户”,勾选“OpenAPI 调用访问”。
+3. **获取密钥**:保存好生成的 `AccessKey ID` 和 `AccessKey Secret`。
+4. **添加权限**:为该用户添加以下三个权限策略(必须包含):
+ - `AliyunECSFullAccess`:用于查看实例状态、开关机及释放。
+ - `AliyunBSSFullAccess`:用于查询账号余额及消费账单。
+ - `AliyunCDTFullAccess`:用于获取 CDT 流量实时统计信息。
-> **注意:** 如果您使用 Docker 部署,请跳过此步骤(容器已内置自动任务)。
+---
-CDT-Monitor 依赖定时任务以实现精确的流量监控与实例保活功能。建议执行频率为 **每分钟一次** 。
+## ✨ 核心功能
-您可以根据服务器环境选择以下任意一种方式:
-### 方式一:命令行 Crontab (推荐)
+### 🛡️ 流量盾牌 (CDT 监控)
+- **多账户聚合**:支持同时管理多个阿里云 AK/SK,多区域实例一屏尽览。
+- **自然月流量重置**:自动适配阿里云 CDT 计费周期,每月 1 号零点自动重置已用流量统计。
+- **熔断机制**:支持设定 **告警阈值 (如 95%)**,触发时自动执行关机动作。
+- **灵活关机模式**:可选 **普通停机 (KeepCharging)** 或 **节省停机 (StopCharging/释放计算资源停止计费)**。
-最稳定且高效的方式,适合有服务器 SSH 权限的用户。
+### ⚡ 自动化高阶管理
+- **异步安全释放**:彻底解决释放逻辑响应缓慢问题。点击后后台接管,自动执行“强制离线 -> 等待状态 -> 物理销毁”全流程,无需前台苦等。
+- **抢占式实例保活**:实时守护低成本 Spot 实例,检测到非预期停机时(如被回收)自动重试拉起。
+- **定时任务清单**:支持为指定实例设置每日定时开机、定时关机计划(自定义时间点)。
+- **ECS 快速创建**:支持从预设规格中一键拉起新实例(默认采用最低配置、最低价格方案),并自动配置安全组与防火墙规则。
+- **预检与成本预览**:创建前自动调用阿里云 API 进行库存预检与其费用估算,拒绝盲目下单。
+- **初次登录信息保护**:针对新建实例,系统仅在创建成功的瞬间展示初始密码,确保 AK/SK 与凭据安全。
+- **DDNS 联动**:深度集成 **Cloudflare**,实例重启 IP 变更后自动同步 A 记录。
-```
-# CDT-Monitor 核心任务
-* * * * * /usr/bin/php /path/to/your/project/monitor.php > /dev/null 2>&1
-```
+### 📊 成本与审计
+- **费用中心**:实时拉取账号 **可用余额**,并预估当月实例已产生账单金额。
+- **实时日志审计**:详细记录系统心跳、API 调用状态、告警触发记录,支持分级清理,保证系统轻量运行。
+- **一键同步**:支持主动从阿里云云端同步最新机器规格、状态、公网 IP 等所有属性。
-> **注意** :请将路径替换为实际的项目部署路径,并确保使用 PHP CLI 版本执行。
+### 📢 预警系统
+- **多通路覆盖**:集成 **Telegram (纸飞机)**、**SMTP 邮件** 及 **通用 Webhook** 接口。
+- **状态变更通知**:实例关机、启动、释放成功、流量超标时均会发送详尽的富文本通知。
-### 方式二:URL 监控 (Web Cron)
+---
-如果您使用虚拟主机或希望通过第三方监控服务(如 UptimeRobot, 宝塔计划任务-访问URL)来触发,可以使用此方式。
+## 💡 省钱小秘籍 (Saving Tips)
-**监控地址格式:**
+- **流量熔断**:系统默认检测到流量即将用尽时自动关机(建议配合“节省停机”模式),确保不产生额外扣费,真正做到“用完即止”。
+- **折扣充值**:本项目可搭配 [portal.acm.ee](https://portal.acm.ee/) 使用,享受阿里云七折充值优惠,叠加 CDT 200GB 免费流量,实现在线极致性价比。
-```
-https://您的域名/monitor.php?key=您的管理员密码
-```
+---
-> **注意** :
->
-> 1. 为了安全起见,Web 访问必须携带 `key` 参数,该参数值即为您初始化时设置的 **管理员密码** 。
-> 2. 如果密码错误,系统将返回 403 禁止访问。
+## 📸 界面预览
-无论使用哪种方式,`monitor.php` 均内置了智能缓存机制,高频执行不会导致 API 调用额度的过度消耗。
+### 汇总监控
+
-## 💡 功能详解 (Advanced Documentation)
+### 系统配置
+
-### 停机模式说明
+### 实例生命周期管理
+
-* **普通停机 (KeepCharging)** :停止实例后保留计算资源与 IP 地址,持续产生费用,具备快速启动特性。
-* **节省停机 (StopCharging)** :释放计算资源并停止计费。**注意:固定公网 IP 地址可能会在重启后发生变更,但弹性公网 IP (EIP) 地址保持不变。建议用于成本控制场景。**
+---
-### 抢占式实例保活机制
-该机制专为**抢占式实例**设计。系统激活后,将实时监控处于“预定运行时间段”内的实例状态。若检测到非预期的关机状态(如被云平台释放),系统将尝试自动重启实例以维持服务可用性。
-* *注:为防止状态频繁震荡,触发保活操作后将执行 30 分钟的冷却期。*
+## 🛠️ 技术架构
-## 🤝 参与贡献 (Contributing)
+- **Backend**: 原生 PHP 8.1+,无框架依赖,追求极致性能。
+- **Database**: SQLite 3 (WAL 模式),兼顾轻量与读写并发。
+- **Frontend**: Vue 3.x (SFC 理念) + 原生 Vanilla CSS。
+- **SDK**: Alibaba Cloud SDK for PHP (V1)。
-欢迎提交 Issue 或 Pull Request 以协助改进本项目。
+---
-## 📄 开源协议 (License)
+## ☕ 赏杯咖啡 (Donation)
-本项目遵循 [MIT License](https://mit-license.org/ "null") 开源协议。
+如果您觉得这个工具对您有帮助,阔佬随手赏杯咖啡:
-Created by 青柠·倾城于你
\ No newline at end of file
+- **USDT-TRC20**: `TMBmXngHcKtNo9nLxTCJcGTfNutsbb9rp3`
+- **USDT-Polygon**: `0x846ad38ed7159aee70b0ebd8ec39ad2d7b32835b`
+- **USDT-BSC**: `0x846ad38ed7159aee70b0ebd8ec39ad2d7b32835b`
+- **USDT-ERC20**: `0x846ad38ed7159aee70b0ebd8ec39ad2d7b32835b`
+- **USDT-ArbitrumOne**: `0x846ad38ed7159aee70b0ebd8ec39ad2d7b32835b`
+
+---
+
+
+
+
+## 📄 许可协议
+
+本项目遵循 [MIT License](https://opensource.org/licenses/MIT) 开源协议。
+
+---
+Made with ❤️ for Aliyun Users
+Based on CDT-Monitor
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index 2a88e5b..5133f9a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,15 +1,14 @@
services:
- cdt-monitor:
- image: qninq/cdt-monitor:latest
- # build: .
- container_name: cdt-monitor
+ ecs-controller:
+ image: kori1c/ecs-controller:latest
+ container_name: ecs-controller
restart: always
ports:
- - "43210:80" # 将容器的 80 端口映射到宿主机的 8080 端口
+ - "43211:80"
volumes:
- # 持久化数据目录,确保重启后配置和数据库不丢失
- ./data:/var/www/html/data
- # 可选:如果你想查看 Docker 内部的日志
- # - ./logs:/var/log/nginx
environment:
- - TZ=Asia/Shanghai # 设置时区,对 Cron 任务很重要
\ No newline at end of file
+ - TZ=Asia/Shanghai
+ dns:
+ - 223.5.5.5
+ - 114.114.114.114
\ No newline at end of file
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index bb47272..131099c 100644
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -1,7 +1,7 @@
#!/bin/sh
set -e
-echo "Starting CDT-Monitor..."
+echo "Starting ECS-Controller..."
# 1. 确保数据目录权限正确
# Docker 挂载卷时可能会导致权限归属为 root,这里强制修正为 www-data
diff --git a/image/截屏2026-04-16 11.24.03.png b/image/截屏2026-04-16 11.24.03.png
new file mode 100644
index 0000000..e5391c9
Binary files /dev/null and b/image/截屏2026-04-16 11.24.03.png differ
diff --git a/image/截屏2026-04-16 11.24.14.png b/image/截屏2026-04-16 11.24.14.png
new file mode 100644
index 0000000..f62e763
Binary files /dev/null and b/image/截屏2026-04-16 11.24.14.png differ
diff --git a/image/截屏2026-04-16 11.24.31.png b/image/截屏2026-04-16 11.24.31.png
new file mode 100644
index 0000000..91c9c2d
Binary files /dev/null and b/image/截屏2026-04-16 11.24.31.png differ
diff --git a/image/截屏2026-04-16 12.15.51.png b/image/截屏2026-04-16 12.15.51.png
new file mode 100644
index 0000000..b790901
Binary files /dev/null and b/image/截屏2026-04-16 12.15.51.png differ
diff --git a/image/截屏2026-04-16 12.15.56.png b/image/截屏2026-04-16 12.15.56.png
new file mode 100644
index 0000000..eb26abe
Binary files /dev/null and b/image/截屏2026-04-16 12.15.56.png differ
diff --git a/image/截屏2026-04-16 12.16.05.png b/image/截屏2026-04-16 12.16.05.png
new file mode 100644
index 0000000..8cc34a8
Binary files /dev/null and b/image/截屏2026-04-16 12.16.05.png differ
diff --git a/image/截屏2026-04-16 12.16.12.png b/image/截屏2026-04-16 12.16.12.png
new file mode 100644
index 0000000..183c5b3
Binary files /dev/null and b/image/截屏2026-04-16 12.16.12.png differ
diff --git a/image/截屏2026-04-16 12.16.18.png b/image/截屏2026-04-16 12.16.18.png
new file mode 100644
index 0000000..0e95747
Binary files /dev/null and b/image/截屏2026-04-16 12.16.18.png differ
diff --git a/image/截屏2026-04-16 12.16.27.png b/image/截屏2026-04-16 12.16.27.png
new file mode 100644
index 0000000..f6b77d0
Binary files /dev/null and b/image/截屏2026-04-16 12.16.27.png differ
diff --git a/index.php b/index.php
index 183854b..f883dd0 100644
--- a/index.php
+++ b/index.php
@@ -1,4 +1,9 @@
isInitialized()) {
http_response_code(403);
- echo json_encode(['success' => false, 'message' => 'System already initialized']);
+ echo json_encode(['success' => false, 'message' => '系统已完成初始化']);
exit;
}
@@ -38,7 +43,7 @@ if ($action === 'setup') {
$_SESSION['is_admin'] = true;
echo json_encode(['success' => true]);
} else {
- echo json_encode(['success' => false, 'message' => 'Setup failed']);
+ echo json_encode(['success' => false, 'message' => '初始化失败']);
}
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
@@ -72,7 +77,13 @@ if ($action === 'get_status') {
if ($initError) {
echo json_encode(['error' => $initError]);
} else {
- echo json_encode($app->getStatusForFrontend());
+ $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;
}
@@ -81,7 +92,7 @@ if ($action === 'get_status') {
if ($action !== 'view' && !isset($_SESSION['is_admin'])) {
http_response_code(403);
- echo json_encode(['error' => 'Unauthorized']);
+ echo json_encode(['error' => '请先登录后再操作']);
exit;
}
@@ -126,7 +137,7 @@ if ($action === 'refresh_account') {
$id = $data['id'] ?? 0;
$result = $app->refreshAccount($id);
if ($result === false) {
- echo json_encode(['success' => false, 'message' => 'Refresh failed']);
+ echo json_encode(['success' => false, 'message' => '刷新失败']);
} elseif (is_array($result)) {
// 流量/状态刷新成功,但账单获取失败
echo json_encode($result);
@@ -136,6 +147,111 @@ if ($action === 'refresh_account') {
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 === '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 === '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');
@@ -151,7 +267,7 @@ if ($action === 'clear_logs') {
if ($app->clearSystemLogs($tab)) {
echo json_encode(['success' => true]);
} else {
- echo json_encode(['success' => false, 'message' => 'Clear failed']);
+ echo json_encode(['success' => false, 'message' => '清空失败']);
}
exit;
}
@@ -169,4 +285,38 @@ if ($action === 'logout') {
exit;
}
-echo $app->renderTemplate();
\ No newline at end of file
+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;
+}
+
+echo $app->renderTemplate();
diff --git a/monitor.php b/monitor.php
index 5bd4522..d09fcd7 100644
--- a/monitor.php
+++ b/monitor.php
@@ -8,20 +8,24 @@ header('Content-Type: text/plain; charset=utf-8');
$app = new AliyunTrafficCheck();
-// CLI 模式直接运行,Web 模式需要密码
+// CLI 模式直接运行,Web 模式使用 Bearer Token 鉴权
$isCli = (PHP_SAPI === 'cli');
if (!$isCli) {
- $inputKey = $_GET['key'] ?? '';
- $adminPassword = $app->getAdminPassword();
- if ($inputKey !== $adminPassword) {
+ $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['HTTP_X_MONITOR_TOKEN'] ?? '';
+ if (preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) {
+ $authHeader = $matches[1];
+ }
+
+ $monitorKey = $app->getMonitorKey();
+ if (empty($monitorKey) || !hash_equals($monitorKey, $authHeader)) {
http_response_code(403);
- echo "Access Denied.";
+ echo "访问被拒绝,请使用有效的监控密钥。";
exit;
}
}
// 输出简洁日志
-echo "--- CDT Monitor Start: " . date('Y-m-d H:i:s') . " ---\n";
+echo "--- ECS 服务器管理 开始检测: " . date('Y-m-d H:i:s') . " ---\n";
echo $app->monitor();
-echo "\n--- End ---\n";
\ No newline at end of file
+echo "\n--- 检测结束 ---\n";
diff --git a/template.html b/template.html
index baa1b0f..a58118c 100644
--- a/template.html
+++ b/template.html
@@ -1,969 +1,2568 @@
-
+
- 阿里云 CDT 监控控制台
-
-
+ ECS 服务器管理
+
-
+
+
+
-
-
+
+