mirror of
https://github.com/Kori1c/ecs-controller.git
synced 2026-11-15 21:20:44 +08:00
Remove legacy PHP runtime
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,8 +1,6 @@
|
||||
config.json
|
||||
*.log
|
||||
.env
|
||||
composer.phar
|
||||
composer.lock
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
/.idea
|
||||
|
||||
2086
AliyunService.php
2086
AliyunService.php
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1097
ConfigManager.php
1097
ConfigManager.php
File diff suppressed because it is too large
Load Diff
626
Database.php
626
Database.php
@@ -1,626 +0,0 @@
|
||||
<?php
|
||||
|
||||
class Database
|
||||
{
|
||||
private $pdo;
|
||||
private $dbFile;
|
||||
|
||||
public function __construct($dbFile = null)
|
||||
{
|
||||
// 默认路径修改为 /data/ 子目录
|
||||
$this->dbFile = $dbFile ?: __DIR__ . '/data/data.sqlite';
|
||||
|
||||
// 环境安全检查
|
||||
$this->secureEnvironment();
|
||||
|
||||
$this->connect();
|
||||
$this->initSchema();
|
||||
}
|
||||
|
||||
private function secureEnvironment()
|
||||
{
|
||||
$dir = dirname($this->dbFile);
|
||||
$oldFile = __DIR__ . '/data.sqlite';
|
||||
|
||||
// 1. 自动创建目录
|
||||
if (!is_dir($dir)) {
|
||||
if (!@mkdir($dir, 0755, true)) {
|
||||
$this->throwPermissionError($dir);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 自动迁移旧数据
|
||||
if (file_exists($oldFile) && !file_exists($this->dbFile)) {
|
||||
if (!@rename($oldFile, $this->dbFile)) {
|
||||
if (@copy($oldFile, $this->dbFile)) {
|
||||
@unlink($oldFile);
|
||||
} else {
|
||||
throw new Exception("安全迁移失败:无法移动旧数据库。请检查目录权限。");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 部署 .htaccess
|
||||
$htaccess = $dir . '/.htaccess';
|
||||
if (!file_exists($htaccess)) {
|
||||
@file_put_contents($htaccess, "Order Deny,Allow\nDeny from all");
|
||||
}
|
||||
|
||||
// 4. 部署 index.html
|
||||
$indexHtml = $dir . '/index.html';
|
||||
if (!file_exists($indexHtml)) {
|
||||
@file_put_contents($indexHtml, '');
|
||||
}
|
||||
}
|
||||
|
||||
private function connect()
|
||||
{
|
||||
try {
|
||||
$this->pdo = new PDO('sqlite:' . $this->dbFile);
|
||||
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
// 开启 WAL 模式,提高并发读写性能
|
||||
$this->pdo->exec('PRAGMA journal_mode = WAL;');
|
||||
} catch (PDOException $e) {
|
||||
if (strpos($e->getMessage(), 'unable to open database file') !== false) {
|
||||
$this->throwPermissionError(dirname($this->dbFile));
|
||||
}
|
||||
throw new Exception("Database Error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function throwPermissionError($dir)
|
||||
{
|
||||
$user = get_current_user();
|
||||
throw new Exception("权限不足:Web用户 ({$user}) 无法读写 {$dir}。<br>请修复权限:<code>chown -R {$user}:{$user} " . __DIR__ . "</code>");
|
||||
}
|
||||
|
||||
private function initSchema()
|
||||
{
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)");
|
||||
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
access_key_id TEXT,
|
||||
access_key_secret TEXT,
|
||||
region_id TEXT,
|
||||
instance_id TEXT,
|
||||
max_traffic REAL,
|
||||
schedule_enabled INTEGER DEFAULT 0,
|
||||
start_time TEXT,
|
||||
stop_time TEXT,
|
||||
traffic_used REAL DEFAULT 0,
|
||||
instance_status TEXT DEFAULT 'Unknown',
|
||||
updated_at INTEGER DEFAULT 0,
|
||||
last_keep_alive_at INTEGER DEFAULT 0,
|
||||
is_deleted INTEGER DEFAULT 0
|
||||
)");
|
||||
|
||||
// 向下兼容:自动补充新增字段
|
||||
try {
|
||||
$this->pdo->exec("ALTER TABLE accounts ADD COLUMN is_deleted INTEGER DEFAULT 0");
|
||||
} catch (PDOException $e) {
|
||||
// 忽略字段已存在错误
|
||||
}
|
||||
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, message TEXT, created_at INTEGER)");
|
||||
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT,
|
||||
attempt_time INTEGER
|
||||
)");
|
||||
|
||||
// 1. 小时级表 (24小时折线图)
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS traffic_hourly (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER,
|
||||
traffic REAL,
|
||||
recorded_at INTEGER
|
||||
)");
|
||||
$this->pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_traffic_hourly_unique ON traffic_hourly (account_id, recorded_at)");
|
||||
|
||||
// 2. 天级表 (30天柱状图)
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS traffic_daily (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER,
|
||||
traffic REAL,
|
||||
recorded_at INTEGER
|
||||
)");
|
||||
$this->pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_traffic_daily_unique ON traffic_daily (account_id, recorded_at)");
|
||||
|
||||
// 3. 账单缓存表 (BSS API 结果缓存)
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS billing_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
cache_type TEXT NOT NULL,
|
||||
billing_cycle TEXT DEFAULT '',
|
||||
data TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
UNIQUE(account_id, cache_type, billing_cycle)
|
||||
)");
|
||||
|
||||
// ECS 公网出口流量累计账本。CDT/账单接口有延迟且偏账号聚合,这里按实例和自然月保存云监控分钟采样的累计结果。
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS instance_traffic_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
instance_id TEXT NOT NULL,
|
||||
billing_month TEXT NOT NULL,
|
||||
traffic_bytes REAL DEFAULT 0,
|
||||
last_sample_ms INTEGER DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL,
|
||||
UNIQUE(account_id, instance_id, billing_month)
|
||||
)");
|
||||
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS ecs_create_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT UNIQUE NOT NULL,
|
||||
preview_id TEXT DEFAULT '',
|
||||
account_group_key TEXT NOT NULL,
|
||||
region_id TEXT NOT NULL,
|
||||
zone_id TEXT DEFAULT '',
|
||||
instance_type TEXT NOT NULL,
|
||||
image_id TEXT DEFAULT '',
|
||||
os_label TEXT DEFAULT '',
|
||||
instance_name TEXT DEFAULT '',
|
||||
vpc_id TEXT DEFAULT '',
|
||||
vswitch_id TEXT DEFAULT '',
|
||||
security_group_id TEXT DEFAULT '',
|
||||
internet_max_bandwidth_out INTEGER DEFAULT 0,
|
||||
system_disk_category TEXT DEFAULT '',
|
||||
system_disk_size INTEGER DEFAULT 0,
|
||||
instance_id TEXT DEFAULT '',
|
||||
public_ip TEXT DEFAULT '',
|
||||
public_ip_mode TEXT DEFAULT 'ecs_public_ip',
|
||||
eip_allocation_id TEXT DEFAULT '',
|
||||
eip_address TEXT DEFAULT '',
|
||||
eip_managed INTEGER DEFAULT 0,
|
||||
login_user TEXT DEFAULT '',
|
||||
login_password TEXT DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
step TEXT DEFAULT '',
|
||||
error_message TEXT DEFAULT '',
|
||||
payload TEXT DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)");
|
||||
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS telegram_bot_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)");
|
||||
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS telegram_action_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
chat_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
payload TEXT DEFAULT '',
|
||||
expires_at INTEGER NOT NULL,
|
||||
used_at INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
)");
|
||||
|
||||
$this->ensureColumn('accounts', 'traffic_used', 'REAL DEFAULT 0');
|
||||
$this->ensureColumn('accounts', 'traffic_billing_month', "TEXT DEFAULT ''");
|
||||
$this->ensureColumn('accounts', 'instance_status', "TEXT DEFAULT 'Unknown'");
|
||||
$this->ensureColumn('accounts', 'updated_at', 'INTEGER DEFAULT 0');
|
||||
$this->ensureColumn('accounts', 'last_keep_alive_at', 'INTEGER DEFAULT 0');
|
||||
$this->ensureColumn('accounts', 'auto_start_blocked', 'INTEGER DEFAULT 0');
|
||||
$this->ensureColumn('accounts', 'schedule_start_enabled', 'INTEGER DEFAULT 0');
|
||||
$this->ensureColumn('accounts', 'schedule_stop_enabled', 'INTEGER DEFAULT 0');
|
||||
$this->ensureColumn('accounts', 'schedule_last_start_date', "TEXT DEFAULT ''");
|
||||
$this->ensureColumn('accounts', 'schedule_last_stop_date', "TEXT DEFAULT ''");
|
||||
$this->ensureColumn('accounts', 'schedule_blocked_by_traffic', 'INTEGER DEFAULT 0');
|
||||
$this->ensureColumn('accounts', 'remark', "TEXT DEFAULT ''");
|
||||
$this->ensureColumn('accounts', 'site_type', "TEXT DEFAULT 'international'");
|
||||
$this->ensureColumn('accounts', 'group_key', "TEXT DEFAULT ''");
|
||||
$this->ensureColumn('accounts', 'instance_name', "TEXT DEFAULT ''");
|
||||
$this->ensureColumn('accounts', 'instance_type', "TEXT DEFAULT ''");
|
||||
$this->ensureColumn('accounts', 'internet_max_bandwidth_out', 'INTEGER DEFAULT 0');
|
||||
$this->ensureColumn('accounts', 'public_ip', "TEXT DEFAULT ''");
|
||||
$this->ensureColumn('accounts', 'public_ip_mode', "TEXT DEFAULT 'ecs_public_ip'");
|
||||
$this->ensureColumn('accounts', 'eip_allocation_id', "TEXT DEFAULT ''");
|
||||
$this->ensureColumn('accounts', 'eip_address', "TEXT DEFAULT ''");
|
||||
$this->ensureColumn('accounts', 'eip_managed', 'INTEGER DEFAULT 0');
|
||||
$this->ensureColumn('accounts', 'private_ip', "TEXT DEFAULT ''");
|
||||
$this->ensureColumn('accounts', 'cpu', "INTEGER DEFAULT 0");
|
||||
$this->ensureColumn('accounts', 'memory', "INTEGER DEFAULT 0");
|
||||
$this->ensureColumn('accounts', 'os_name', "TEXT DEFAULT ''");
|
||||
$this->ensureColumn('accounts', 'stopped_mode', "TEXT DEFAULT ''");
|
||||
$this->ensureColumn('accounts', 'health_status', "TEXT DEFAULT 'Unknown'");
|
||||
$this->ensureColumn('accounts', 'traffic_api_status', "TEXT DEFAULT 'ok'");
|
||||
$this->ensureColumn('accounts', 'traffic_api_message', "TEXT DEFAULT ''");
|
||||
$this->ensureColumn('accounts', 'protection_suspended', 'INTEGER DEFAULT 0');
|
||||
$this->ensureColumn('accounts', 'protection_suspend_reason', "TEXT DEFAULT ''");
|
||||
$this->ensureColumn('accounts', 'protection_suspend_notified_at', 'INTEGER DEFAULT 0');
|
||||
$this->ensureColumn('ecs_create_tasks', 'public_ip_mode', "TEXT DEFAULT 'ecs_public_ip'");
|
||||
$this->ensureColumn('ecs_create_tasks', 'eip_allocation_id', "TEXT DEFAULT ''");
|
||||
$this->ensureColumn('ecs_create_tasks', 'eip_address', "TEXT DEFAULT ''");
|
||||
$this->ensureColumn('ecs_create_tasks', 'eip_managed', 'INTEGER DEFAULT 0');
|
||||
|
||||
$this->migrateStatsToAccountId();
|
||||
}
|
||||
|
||||
private function ensureColumn($table, $column, $definition)
|
||||
{
|
||||
try {
|
||||
$this->pdo->query("SELECT $column FROM $table LIMIT 1");
|
||||
} catch (Exception $e) {
|
||||
$this->pdo->exec("ALTER TABLE $table ADD COLUMN $column $definition");
|
||||
}
|
||||
}
|
||||
|
||||
private function migrateStatsToAccountId()
|
||||
{
|
||||
// check if traffic_hourly has access_key_id column
|
||||
$needsMigration = false;
|
||||
try {
|
||||
$this->pdo->query("SELECT access_key_id FROM traffic_hourly LIMIT 1");
|
||||
$needsMigration = true;
|
||||
} catch (Exception $e) {
|
||||
// column does not exist, no migration needed (or already migrated)
|
||||
}
|
||||
|
||||
if ($needsMigration) {
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
// 1. Migrate hourly stats
|
||||
$this->pdo->exec("CREATE TABLE traffic_hourly_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER,
|
||||
traffic REAL,
|
||||
recorded_at INTEGER
|
||||
)");
|
||||
|
||||
// Try to link stats to the first matching account ID for that AK
|
||||
$this->pdo->exec("INSERT INTO traffic_hourly_new (account_id, traffic, recorded_at)
|
||||
SELECT a.id, t.traffic, t.recorded_at
|
||||
FROM traffic_hourly t
|
||||
JOIN accounts a ON t.access_key_id = a.access_key_id
|
||||
GROUP BY a.id, t.recorded_at"); // Group by to avoid duplicates if multiple accounts share AK (though previous schema didn't allow duplicates, this is safety)
|
||||
|
||||
$this->pdo->exec("DROP TABLE traffic_hourly");
|
||||
$this->pdo->exec("ALTER TABLE traffic_hourly_new RENAME TO traffic_hourly");
|
||||
$this->pdo->exec("CREATE UNIQUE INDEX idx_traffic_hourly_unique ON traffic_hourly (account_id, recorded_at)");
|
||||
|
||||
// 2. Migrate daily stats
|
||||
$this->pdo->exec("CREATE TABLE traffic_daily_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER,
|
||||
traffic REAL,
|
||||
recorded_at INTEGER
|
||||
)");
|
||||
|
||||
$this->pdo->exec("INSERT INTO traffic_daily_new (account_id, traffic, recorded_at)
|
||||
SELECT a.id, t.traffic, t.recorded_at
|
||||
FROM traffic_daily t
|
||||
JOIN accounts a ON t.access_key_id = a.access_key_id
|
||||
GROUP BY a.id, t.recorded_at");
|
||||
|
||||
$this->pdo->exec("DROP TABLE traffic_daily");
|
||||
$this->pdo->exec("ALTER TABLE traffic_daily_new RENAME TO traffic_daily");
|
||||
$this->pdo->exec("CREATE UNIQUE INDEX idx_traffic_daily_unique ON traffic_daily (account_id, recorded_at)");
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (Exception $e) {
|
||||
if ($this->pdo->inTransaction())
|
||||
$this->pdo->rollBack();
|
||||
// Log error but don't stop execution, schema might be in mixed state but next run/update might fix or user can reset
|
||||
$this->addLog('error', "Database Migration Failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getPdo()
|
||||
{
|
||||
return $this->pdo;
|
||||
}
|
||||
|
||||
public function addLog($type, $message)
|
||||
{
|
||||
$stmt = $this->pdo->prepare("INSERT INTO logs (type, message, created_at) VALUES (?, ?, ?)");
|
||||
$stmt->execute([$type, $message, time()]);
|
||||
}
|
||||
|
||||
public function getLogs($limit = 100)
|
||||
{
|
||||
$stmt = $this->pdo->prepare("SELECT * FROM logs ORDER BY id DESC LIMIT ?");
|
||||
$stmt->execute([$limit]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getLogsByTypes(array $types, $limit = 20)
|
||||
{
|
||||
$placeholders = implode(',', array_fill(0, count($types), '?'));
|
||||
$sql = "SELECT * FROM logs WHERE type IN ($placeholders) ORDER BY id DESC LIMIT ?";
|
||||
|
||||
$params = $types;
|
||||
$params[] = $limit;
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function clearLogsByTypes(array $types)
|
||||
{
|
||||
$placeholders = implode(',', array_fill(0, count($types), '?'));
|
||||
$stmt = $this->pdo->prepare("DELETE FROM logs WHERE type IN ($placeholders)");
|
||||
return $stmt->execute($types);
|
||||
}
|
||||
|
||||
/**
|
||||
* 优化后的日志清理逻辑
|
||||
* @param int $defaultDays 默认保留天数(用于重要日志)
|
||||
* @param int $heartbeatDays 心跳日志保留天数(建议设置较短,如3天)
|
||||
*/
|
||||
public function pruneLogs($defaultDays = 30, $heartbeatDays = 3)
|
||||
{
|
||||
$now = time();
|
||||
|
||||
// 1. 清理过期心跳日志 (Heartbeat) - 激进清理
|
||||
$stmt = $this->pdo->prepare("DELETE FROM logs WHERE type = 'heartbeat' AND created_at < ?");
|
||||
$stmt->execute([$now - ($heartbeatDays * 86400)]);
|
||||
|
||||
// 2. 清理其他过期日志 (Info, Warning, Error) - 保守清理
|
||||
$stmt = $this->pdo->prepare("DELETE FROM logs WHERE type != 'heartbeat' AND created_at < ?");
|
||||
$stmt->execute([$now - ($defaultDays * 86400)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置 Logs 表的自增 ID 并重新排序
|
||||
* 这是一个较重的操作,但能保证 ID 连续
|
||||
*/
|
||||
public function reorderLogsIds()
|
||||
{
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
// 1. 检查是否有数据
|
||||
$count = $this->pdo->query("SELECT COUNT(*) FROM logs")->fetchColumn();
|
||||
|
||||
if ($count == 0) {
|
||||
// 如果没数据,直接重置序号为 0
|
||||
$this->pdo->exec("DELETE FROM sqlite_sequence WHERE name='logs'");
|
||||
$this->pdo->exec("DELETE FROM logs"); // 确保空
|
||||
} else {
|
||||
// 2. 使用临时表重排数据
|
||||
// 创建临时表保存现有数据,按时间正序排列
|
||||
$this->pdo->exec("CREATE TEMPORARY TABLE logs_temp AS SELECT type, message, created_at FROM logs ORDER BY created_at ASC");
|
||||
|
||||
// 清空原表
|
||||
$this->pdo->exec("DELETE FROM logs");
|
||||
|
||||
// 重置自增序列
|
||||
$this->pdo->exec("DELETE FROM sqlite_sequence WHERE name='logs'");
|
||||
|
||||
// 将数据插回原表,ID 会自动从 1 开始重新生成
|
||||
$this->pdo->exec("INSERT INTO logs (type, message, created_at) SELECT type, message, created_at FROM logs_temp");
|
||||
|
||||
// 删除临时表
|
||||
$this->pdo->exec("DROP TABLE logs_temp");
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
// 记录错误到错误日志文件(如果有),或者忽略,因为这不是关键业务
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 整理数据库碎片 (VACUUM)
|
||||
* 释放已删除数据占用的磁盘空间
|
||||
*/
|
||||
public function vacuum()
|
||||
{
|
||||
$this->pdo->exec("VACUUM");
|
||||
}
|
||||
|
||||
// --- 登录频率限制相关方法 ---
|
||||
|
||||
public function recordLoginAttempt($ip)
|
||||
{
|
||||
$stmt = $this->pdo->prepare("INSERT INTO login_attempts (ip, attempt_time) VALUES (?, ?)");
|
||||
$stmt->execute([$ip, time()]);
|
||||
}
|
||||
|
||||
public function getRecentFailedAttempts($ip, $windowSeconds = 900)
|
||||
{
|
||||
$stmt = $this->pdo->prepare("SELECT COUNT(*) FROM login_attempts WHERE ip = ? AND attempt_time > ?");
|
||||
$stmt->execute([$ip, time() - $windowSeconds]);
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
public function clearLoginAttempts($ip)
|
||||
{
|
||||
$stmt = $this->pdo->prepare("DELETE FROM login_attempts WHERE ip = ?");
|
||||
$stmt->execute([$ip]);
|
||||
}
|
||||
|
||||
// --- 流量记录逻辑 ---
|
||||
|
||||
public function addHourlyStat($accountId, $traffic)
|
||||
{
|
||||
$hourTimestamp = floor(time() / 3600) * 3600;
|
||||
$stmt = $this->pdo->prepare("INSERT OR REPLACE INTO traffic_hourly (account_id, traffic, recorded_at) VALUES (?, ?, ?)");
|
||||
$stmt->execute([$accountId, $traffic, $hourTimestamp]);
|
||||
}
|
||||
|
||||
public function addDailyStat($accountId, $traffic)
|
||||
{
|
||||
$dayTimestamp = strtotime(date('Y-m-d 00:00:00'));
|
||||
$stmt = $this->pdo->prepare("INSERT OR REPLACE INTO traffic_daily (account_id, traffic, recorded_at) VALUES (?, ?, ?)");
|
||||
$stmt->execute([$accountId, $traffic, $dayTimestamp]);
|
||||
}
|
||||
|
||||
public function getHourlyStats($accountId)
|
||||
{
|
||||
$stmt = $this->pdo->prepare("SELECT traffic, recorded_at FROM traffic_hourly WHERE account_id = ? ORDER BY recorded_at DESC LIMIT 25");
|
||||
$stmt->execute([$accountId]);
|
||||
$data = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
return array_reverse($data);
|
||||
}
|
||||
|
||||
public function getDailyStats($accountId)
|
||||
{
|
||||
$stmt = $this->pdo->prepare("SELECT traffic, recorded_at FROM traffic_daily WHERE account_id = ? ORDER BY recorded_at DESC LIMIT 31");
|
||||
$stmt->execute([$accountId]);
|
||||
$data = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
return array_reverse($data);
|
||||
}
|
||||
|
||||
public function pruneStats()
|
||||
{
|
||||
$hourLimit = time() - (48 * 3600);
|
||||
$this->pdo->exec("DELETE FROM traffic_hourly WHERE recorded_at < $hourLimit");
|
||||
|
||||
$dayLimit = time() - (60 * 86400);
|
||||
$this->pdo->exec("DELETE FROM traffic_daily WHERE recorded_at < $dayLimit");
|
||||
|
||||
$monthLimit = date('Y-m', strtotime('-4 months'));
|
||||
$stmt = $this->pdo->prepare("DELETE FROM instance_traffic_usage WHERE billing_month < ?");
|
||||
$stmt->execute([$monthLimit]);
|
||||
}
|
||||
|
||||
public function getInstanceTrafficUsage($accountId, $instanceId, $billingMonth)
|
||||
{
|
||||
$stmt = $this->pdo->prepare("SELECT * FROM instance_traffic_usage WHERE account_id = ? AND instance_id = ? AND billing_month = ? LIMIT 1");
|
||||
$stmt->execute([$accountId, $instanceId, $billingMonth]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public function upsertInstanceTrafficUsage($accountId, $instanceId, $billingMonth, $trafficBytes, $lastSampleMs)
|
||||
{
|
||||
$stmt = $this->pdo->prepare("
|
||||
INSERT INTO instance_traffic_usage (account_id, instance_id, billing_month, traffic_bytes, last_sample_ms, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id, instance_id, billing_month)
|
||||
DO UPDATE SET
|
||||
traffic_bytes = excluded.traffic_bytes,
|
||||
last_sample_ms = excluded.last_sample_ms,
|
||||
updated_at = excluded.updated_at
|
||||
");
|
||||
|
||||
return $stmt->execute([
|
||||
$accountId,
|
||||
$instanceId,
|
||||
$billingMonth,
|
||||
max(0, (float) $trafficBytes),
|
||||
max(0, (int) $lastSampleMs),
|
||||
time()
|
||||
]);
|
||||
}
|
||||
|
||||
// --- 账单缓存相关方法 ---
|
||||
|
||||
/**
|
||||
* 写入/更新账单缓存
|
||||
*/
|
||||
public function setBillingCache($accountId, $cacheType, $billingCycle, $data)
|
||||
{
|
||||
$stmt = $this->pdo->prepare("INSERT OR REPLACE INTO billing_cache (account_id, cache_type, billing_cycle, data, updated_at) VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$accountId, $cacheType, $billingCycle, json_encode($data), time()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取账单缓存 (含过期判断)
|
||||
* @param int $maxAge 最大缓存时间(秒),默认6小时
|
||||
* @return array|null 缓存数据或 null(已过期/不存在)
|
||||
*/
|
||||
public function getBillingCache($accountId, $cacheType, $billingCycle, $maxAge = 21600)
|
||||
{
|
||||
$stmt = $this->pdo->prepare("SELECT data, updated_at FROM billing_cache WHERE account_id = ? AND cache_type = ? AND billing_cycle = ? LIMIT 1");
|
||||
$stmt->execute([$accountId, $cacheType, $billingCycle]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if (!$row) return null;
|
||||
if ((time() - $row['updated_at']) > $maxAge) return null;
|
||||
|
||||
return json_decode($row['data'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理超过90天的历史账单缓存
|
||||
*/
|
||||
public function pruneBillingCache()
|
||||
{
|
||||
$limit = time() - (90 * 86400);
|
||||
$this->pdo->exec("DELETE FROM billing_cache WHERE updated_at < $limit");
|
||||
}
|
||||
|
||||
public function createEcsCreateTask($taskId, $previewId, $groupKey, $regionId, $instanceType, $payload)
|
||||
{
|
||||
$stmt = $this->pdo->prepare("
|
||||
INSERT INTO ecs_create_tasks (
|
||||
task_id, preview_id, account_group_key, region_id, instance_type, status, step, payload, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
|
||||
$now = time();
|
||||
$stmt->execute([
|
||||
$taskId,
|
||||
$previewId,
|
||||
$groupKey,
|
||||
$regionId,
|
||||
$instanceType,
|
||||
'running',
|
||||
'初始化创建任务',
|
||||
json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
$now,
|
||||
$now
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateEcsCreateTask($taskId, array $fields)
|
||||
{
|
||||
if (empty($fields)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$fields['updated_at'] = time();
|
||||
$allowed = [
|
||||
'zone_id', 'image_id', 'os_label', 'instance_name', 'vpc_id', 'vswitch_id',
|
||||
'security_group_id', 'internet_max_bandwidth_out', 'system_disk_category',
|
||||
'system_disk_size', 'instance_id', 'public_ip', 'login_user', 'login_password',
|
||||
'public_ip_mode', 'eip_allocation_id', 'eip_address', 'eip_managed',
|
||||
'status', 'step', 'error_message', 'payload', 'updated_at'
|
||||
];
|
||||
|
||||
$sets = [];
|
||||
$values = [];
|
||||
foreach ($fields as $key => $value) {
|
||||
if (!in_array($key, $allowed, true)) {
|
||||
continue;
|
||||
}
|
||||
$sets[] = "$key = ?";
|
||||
$values[] = is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : $value;
|
||||
}
|
||||
|
||||
if (empty($sets)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$values[] = $taskId;
|
||||
$stmt = $this->pdo->prepare("UPDATE ecs_create_tasks SET " . implode(', ', $sets) . " WHERE task_id = ?");
|
||||
return $stmt->execute($values);
|
||||
}
|
||||
|
||||
public function getEcsCreateTask($taskId)
|
||||
{
|
||||
$stmt = $this->pdo->prepare("SELECT * FROM ecs_create_tasks WHERE task_id = ? LIMIT 1");
|
||||
$stmt->execute([$taskId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ?: null;
|
||||
}
|
||||
}
|
||||
252
DdnsService.php
252
DdnsService.php
@@ -1,252 +0,0 @@
|
||||
<?php
|
||||
|
||||
class DdnsService
|
||||
{
|
||||
private $config;
|
||||
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -1,583 +0,0 @@
|
||||
<?php
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
|
||||
class NotificationService
|
||||
{
|
||||
private $config;
|
||||
|
||||
public function setConfig($config)
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送定时任务通知
|
||||
* @return bool|string 成功返回 true,失败返回错误信息
|
||||
*/
|
||||
public function notifySchedule($actionType, $account, $description = "")
|
||||
{
|
||||
$title = "定时任务: " . $actionType;
|
||||
$maskedKey = substr($account['access_key_id'], 0, 7) . '***';
|
||||
$traffic = isset($account['traffic_used']) ? $this->formatTraffic((float) $account['traffic_used']) : '暂无';
|
||||
$threshold = $this->config['traffic_threshold'] ?? 95;
|
||||
|
||||
$details = [
|
||||
['label' => '账号', 'value' => $maskedKey],
|
||||
['label' => '执行动作', 'value' => $actionType, 'highlight' => true],
|
||||
['label' => '执行时间', 'value' => date('Y-m-d H:i:s')],
|
||||
['label' => '当前流量', 'value' => $traffic],
|
||||
['label' => '设定阈值', 'value' => $threshold . '%'],
|
||||
['label' => '详情说明', 'value' => $description ?: '根据预设时间表自动执行。']
|
||||
];
|
||||
|
||||
$textMsg = "【ECS 服务器管家】{$title}\n" .
|
||||
"账号: {$maskedKey}\n" .
|
||||
"执行动作: {$actionType}\n" .
|
||||
"当前流量: {$traffic}\n" .
|
||||
"设定阈值: {$threshold}%\n" .
|
||||
"执行时间: " . date('Y-m-d H:i:s') . "\n" .
|
||||
"详情说明: " . ($description ?: '根据预设时间表自动执行。');
|
||||
|
||||
return $this->dispatchNotifications($title, "您的实例已执行{$actionType}操作", $details, 'info', $textMsg, $account['access_key_id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送流量告警
|
||||
* @return bool|string
|
||||
*/
|
||||
public function sendTrafficWarning($accessKeyId, $traffic, $percentage, $statusText, $threshold)
|
||||
{
|
||||
$title = "流量告警 - " . $statusText;
|
||||
$trafficText = $this->formatTraffic((float) $traffic);
|
||||
$details = [
|
||||
['label' => '账号', 'value' => substr($accessKeyId, 0, 7) . '***'],
|
||||
['label' => '当前流量', 'value' => $trafficText],
|
||||
['label' => '使用率', 'value' => $percentage . '%', 'highlight' => true],
|
||||
['label' => '设定阈值', 'value' => $threshold . '%'],
|
||||
['label' => '当前状态', 'value' => $statusText]
|
||||
];
|
||||
|
||||
$textMsg = "【ECS 服务器管家】{$title}\n" .
|
||||
"账号: " . substr($accessKeyId, 0, 7) . '***' . "\n" .
|
||||
"当前流量: {$trafficText}\n" .
|
||||
"使用率: {$percentage}%\n" .
|
||||
"设定阈值: {$threshold}%\n" .
|
||||
"当前状态: {$statusText}";
|
||||
|
||||
return $this->dispatchNotifications($title, "检测到流量异常或达到阈值", $details, 'warning', $textMsg, $accessKeyId);
|
||||
}
|
||||
|
||||
public function notifyCredentialInvalid($accessKeyId, $traffic, $percentage, $threshold)
|
||||
{
|
||||
$title = "流量告警 - 账号密钥已失效";
|
||||
$trafficText = $this->formatTraffic((float) $traffic);
|
||||
$maskedAccount = substr($accessKeyId, 0, 7) . '***';
|
||||
$statusText = '检测到 AK 已失效,已暂停自动停机';
|
||||
$details = [
|
||||
['label' => '账号', 'value' => $maskedAccount],
|
||||
['label' => '当前流量', 'value' => $trafficText],
|
||||
['label' => '使用率', 'value' => $percentage . '%', 'highlight' => true],
|
||||
['label' => '设定阈值', 'value' => $threshold . '%'],
|
||||
['label' => '当前状态', 'value' => $statusText],
|
||||
['label' => '处理建议', 'value' => '请更新 AK 后再恢复自动停机保护。']
|
||||
];
|
||||
|
||||
$textMsg = "【ECS 服务器管家】{$title}\n" .
|
||||
"账号: {$maskedAccount}\n" .
|
||||
"当前流量: {$trafficText}\n" .
|
||||
"使用率: {$percentage}%\n" .
|
||||
"设定阈值: {$threshold}%\n" .
|
||||
"当前状态: {$statusText}\n" .
|
||||
"处理建议: 请更新 AK 后再恢复自动停机保护。";
|
||||
|
||||
return $this->dispatchNotifications($title, "检测到账号密钥失效,已暂停自动停机保护,避免重复失败通知。", $details, 'warning', $textMsg, $accessKeyId);
|
||||
}
|
||||
|
||||
public function notifyEcsCreated($accountLabel, array $result, array $preview = [])
|
||||
{
|
||||
$title = 'ECS 创建并启动成功';
|
||||
$instanceId = $result['instanceId'] ?? '';
|
||||
$publicIp = $result['publicIp'] ?? '';
|
||||
$loginUser = $result['loginUser'] ?? '';
|
||||
$loginPassword = $result['loginPassword'] ?? '';
|
||||
$regionId = $preview['regionId'] ?? '';
|
||||
$instanceType = $preview['instanceType'] ?? ($result['instanceType'] ?? '');
|
||||
$instanceName = $preview['instanceName'] ?? ($result['instanceName'] ?? '');
|
||||
|
||||
$details = [
|
||||
['label' => '账号', 'value' => $accountLabel],
|
||||
['label' => '实例名称', 'value' => $instanceName ?: '-'],
|
||||
['label' => '实例编号', 'value' => $instanceId ?: '-', 'highlight' => true],
|
||||
['label' => '区域', 'value' => $regionId ?: '-'],
|
||||
['label' => '实例规格', 'value' => $instanceType ?: '-'],
|
||||
['label' => '公网地址', 'value' => $publicIp ?: '等待阿里云分配'],
|
||||
['label' => '登录用户', 'value' => $loginUser ?: '-'],
|
||||
['label' => '初始密码', 'value' => $loginPassword ?: '-', 'highlight' => true],
|
||||
['label' => '安全提醒', 'value' => '初始密码仅在本次创建完成通知和控制台弹窗展示,请立即保存。']
|
||||
];
|
||||
|
||||
$textMsg = "【ECS 服务器管家】ECS 创建并启动成功\n" .
|
||||
"账号: {$accountLabel}\n" .
|
||||
"实例名称: " . ($instanceName ?: '-') . "\n" .
|
||||
"实例编号: " . ($instanceId ?: '-') . "\n" .
|
||||
"区域: " . ($regionId ?: '-') . "\n" .
|
||||
"规格: " . ($instanceType ?: '-') . "\n" .
|
||||
"公网地址: " . ($publicIp ?: '等待阿里云分配') . "\n" .
|
||||
"登录用户: " . ($loginUser ?: '-') . "\n" .
|
||||
"初始密码: " . ($loginPassword ?: '-') . "\n" .
|
||||
"安全提醒: 初始密码仅在本次创建完成通知和控制台弹窗展示,请立即保存。";
|
||||
|
||||
return $this->dispatchNotifications($title, '实例已创建并启动,请立即保存一次性登录密码。', $details, 'success', $textMsg, $accountLabel);
|
||||
}
|
||||
|
||||
public function notifyInstanceStatusChanged($accountLabel, array $account, $fromStatus, $toStatus, $reason = '')
|
||||
{
|
||||
$fromLabel = $this->statusLabel($fromStatus);
|
||||
$toLabel = $this->statusLabel($toStatus);
|
||||
$instanceName = $account['instance_name'] ?? ($account['remark'] ?? '');
|
||||
$instanceId = $account['instance_id'] ?? '';
|
||||
$region = $account['region_id'] ?? '';
|
||||
$title = $toStatus === 'Running' ? '实例已启动' : ($toStatus === 'Stopped' ? '实例已停机' : "实例状态变化 - {$toLabel}");
|
||||
|
||||
$details = [
|
||||
['label' => '账号', 'value' => $accountLabel],
|
||||
['label' => '实例名称', 'value' => $instanceName ?: '-'],
|
||||
['label' => '实例编号', 'value' => $instanceId ?: '-', 'highlight' => true],
|
||||
['label' => '区域', 'value' => $region ?: '-'],
|
||||
['label' => '原状态', 'value' => $fromLabel],
|
||||
['label' => '新状态', 'value' => $toLabel, 'highlight' => true],
|
||||
['label' => '变化时间', 'value' => date('Y-m-d H:i:s')],
|
||||
['label' => '说明', 'value' => $reason ?: '系统检测到实例运行状态发生变化。']
|
||||
];
|
||||
|
||||
$textMsg = "【ECS 服务器管家】{$title}\n" .
|
||||
"账号: {$accountLabel}\n" .
|
||||
"实例名称: " . ($instanceName ?: '-') . "\n" .
|
||||
"实例编号: " . ($instanceId ?: '-') . "\n" .
|
||||
"区域: " . ($region ?: '-') . "\n" .
|
||||
"原状态: {$fromLabel}\n" .
|
||||
"新状态: {$toLabel}\n" .
|
||||
"变化时间: " . date('Y-m-d H:i:s') . "\n" .
|
||||
"说明: " . ($reason ?: '系统检测到实例运行状态发生变化。');
|
||||
|
||||
return $this->dispatchNotifications($title, '实例已进入最终状态', $details, 'success', $textMsg, $accountLabel);
|
||||
}
|
||||
|
||||
public function notifyInstanceReleased($accountLabel, array $account, $reason = '')
|
||||
{
|
||||
$instanceName = $account['instance_name'] ?? ($account['remark'] ?? '');
|
||||
$instanceId = $account['instance_id'] ?? '';
|
||||
$region = $account['region_id'] ?? '';
|
||||
$publicIp = $account['public_ip'] ?? '';
|
||||
$title = '实例已释放';
|
||||
|
||||
$details = [
|
||||
['label' => '账号', 'value' => $accountLabel],
|
||||
['label' => '实例名称', 'value' => $instanceName ?: '-'],
|
||||
['label' => '实例编号', 'value' => $instanceId ?: '-', 'highlight' => true],
|
||||
['label' => '区域', 'value' => $region ?: '-'],
|
||||
['label' => '公网地址', 'value' => $publicIp ?: '-'],
|
||||
['label' => '释放时间', 'value' => date('Y-m-d H:i:s')],
|
||||
['label' => '说明', 'value' => $reason ?: '实例已从 ECS 控制台释放,本地记录和 DDNS 解析将同步清理。']
|
||||
];
|
||||
|
||||
$textMsg = "【ECS 服务器管家】实例已释放\n" .
|
||||
"账号: {$accountLabel}\n" .
|
||||
"实例名称: " . ($instanceName ?: '-') . "\n" .
|
||||
"实例编号: " . ($instanceId ?: '-') . "\n" .
|
||||
"区域: " . ($region ?: '-') . "\n" .
|
||||
"公网地址: " . ($publicIp ?: '-') . "\n" .
|
||||
"释放时间: " . date('Y-m-d H:i:s') . "\n" .
|
||||
"说明: " . ($reason ?: '实例已从 ECS 控制台释放,本地记录和 DDNS 解析将同步清理。');
|
||||
|
||||
return $this->dispatchNotifications($title, '实例已释放,本地记录和 DDNS 解析将同步清理。', $details, 'warning', $textMsg, $accountLabel);
|
||||
}
|
||||
|
||||
public function notifyPublicIpChanged($accountLabel, array $account, $oldIp, $newIp, $reason = '')
|
||||
{
|
||||
$instanceName = $account['instance_name'] ?? ($account['remark'] ?? '');
|
||||
$instanceId = $account['instance_id'] ?? '';
|
||||
$region = $account['region_id'] ?? '';
|
||||
$title = '公网 IP 已更换';
|
||||
|
||||
$details = [
|
||||
['label' => '账号', 'value' => $accountLabel],
|
||||
['label' => '实例名称', 'value' => $instanceName ?: '-'],
|
||||
['label' => '实例编号', 'value' => $instanceId ?: '-', 'highlight' => true],
|
||||
['label' => '区域', 'value' => $region ?: '-'],
|
||||
['label' => '原公网 IP', 'value' => $oldIp ?: '-'],
|
||||
['label' => '新公网 IP', 'value' => $newIp ?: '-', 'highlight' => true],
|
||||
['label' => '变更时间', 'value' => date('Y-m-d H:i:s')],
|
||||
['label' => '说明', 'value' => $reason ?: '系统已更换托管 EIP,并同步更新 DDNS 解析。']
|
||||
];
|
||||
|
||||
$textMsg = "【ECS 服务器管家】公网 IP 已更换\n" .
|
||||
"账号: {$accountLabel}\n" .
|
||||
"实例名称: " . ($instanceName ?: '-') . "\n" .
|
||||
"实例编号: " . ($instanceId ?: '-') . "\n" .
|
||||
"区域: " . ($region ?: '-') . "\n" .
|
||||
"原公网 IP: " . ($oldIp ?: '-') . "\n" .
|
||||
"新公网 IP: " . ($newIp ?: '-') . "\n" .
|
||||
"变更时间: " . date('Y-m-d H:i:s') . "\n" .
|
||||
"说明: " . ($reason ?: '系统已更换托管 EIP,并同步更新 DDNS 解析。');
|
||||
|
||||
return $this->dispatchNotifications($title, '公网 IP 已成功更换,DDNS 解析已同步更新。', $details, 'success', $textMsg, $accountLabel);
|
||||
}
|
||||
|
||||
private function statusLabel($status)
|
||||
{
|
||||
$map = [
|
||||
'Running' => '已启动',
|
||||
'Starting' => '启动中',
|
||||
'Stopping' => '停机中',
|
||||
'Stopped' => '已停机',
|
||||
'Pending' => '创建中',
|
||||
'Released' => '已释放',
|
||||
'Unknown' => '未知'
|
||||
];
|
||||
return $map[$status] ?? ($status ?: '未知');
|
||||
}
|
||||
|
||||
public function sendTestEmail($to)
|
||||
{
|
||||
$details = [
|
||||
['label' => '测试结果', 'value' => '成功'],
|
||||
['label' => '发送时间', 'value' => date('Y-m-d H:i:s')],
|
||||
['label' => '服务器', 'value' => $_SERVER['SERVER_NAME'] ?? 'localhost']
|
||||
];
|
||||
$html = $this->renderEmailTemplate("测试邮件", "邮件服务配置验证成功", $details, 'success');
|
||||
return $this->sendMail($to, '管理员', 'ECS 服务器管家测试邮件', $html);
|
||||
}
|
||||
|
||||
public function sendTestTelegram($data)
|
||||
{
|
||||
$textMsg = "【ECS 服务器管理】测试推送\n这是一条来自 Telegram 的测试消息。\n发送时间: " . date('Y-m-d H:i:s');
|
||||
return $this->sendTelegram($textMsg, $data);
|
||||
}
|
||||
|
||||
public function sendTestWebhook($data)
|
||||
{
|
||||
$textMsg = "【ECS 服务器管家】测试推送\n这是一条来自接口回调的测试消息。\n发送时间: " . date('Y-m-d H:i:s');
|
||||
$summary = "这是一条来自接口回调的测试消息。";
|
||||
$threshold = $this->config['traffic_threshold'] ?? 95;
|
||||
$details = [
|
||||
['label' => '当前流量', 'value' => '0 MB'],
|
||||
['label' => '设定阈值', 'value' => $threshold . '%']
|
||||
];
|
||||
return $this->sendWebhook($textMsg, "测试推送", $summary, $details, 'test_account_id', $data);
|
||||
}
|
||||
|
||||
private function dispatchNotifications($title, $summary, $details, $type, $textMsg, $accountId = '')
|
||||
{
|
||||
$errors = [];
|
||||
$successCount = 0;
|
||||
$attemptCount = 0;
|
||||
|
||||
// 邮件通知
|
||||
if (($this->config['notify_email_enabled'] ?? '1') === '1' && !empty($this->config['notify_email'])) {
|
||||
$attemptCount++;
|
||||
$html = $this->renderEmailTemplate($title, $summary, $details, $type);
|
||||
$res = $this->sendMail($this->config['notify_email'], '', "ECS 服务器管家通知 - " . $title, $html);
|
||||
if ($res === true)
|
||||
$successCount++;
|
||||
else
|
||||
$errors[] = "邮件通知: " . $res;
|
||||
}
|
||||
|
||||
// Telegram
|
||||
if (($this->config['notify_tg_enabled'] ?? '0') === '1' && !empty($this->config['notify_tg_token']) && !empty($this->config['notify_tg_chat_id'])) {
|
||||
$attemptCount++;
|
||||
$res = $this->sendTelegram($textMsg);
|
||||
if ($res === true)
|
||||
$successCount++;
|
||||
else
|
||||
$errors[] = "Telegram: " . $res;
|
||||
}
|
||||
|
||||
// 接口回调
|
||||
if (($this->config['notify_wh_enabled'] ?? '0') === '1' && !empty($this->config['notify_wh_url'])) {
|
||||
$attemptCount++;
|
||||
$res = $this->sendWebhook($textMsg, $title, $summary, $details, $accountId);
|
||||
if ($res === true)
|
||||
$successCount++;
|
||||
else
|
||||
$errors[] = "接口回调: " . $res;
|
||||
}
|
||||
|
||||
if ($attemptCount == 0)
|
||||
return true;
|
||||
|
||||
if ($successCount == 0 && count($errors) > 0) {
|
||||
return implode(" | ", $errors);
|
||||
} else if (count($errors) > 0) {
|
||||
return "部分完成: " . implode(" | ", $errors);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function renderEmailTemplate($title, $summary, $details, $type = 'info')
|
||||
{
|
||||
$color = '#007AFF';
|
||||
if ($type === 'warning')
|
||||
$color = '#FF3B30';
|
||||
if ($type === 'success')
|
||||
$color = '#34C759';
|
||||
|
||||
$rows = '';
|
||||
foreach ($details as $item) {
|
||||
$valColor = isset($item['highlight']) && $item['highlight'] ? $color : '#1C1C1E';
|
||||
$rows .= "
|
||||
<tr style='border-bottom: 1px solid #F2F2F7;'>
|
||||
<td style='padding: 12px 0; color: #8E8E93; font-size: 14px; width: 40%;'>{$item['label']}</td>
|
||||
<td style='padding: 12px 0; color: {$valColor}; font-size: 14px; font-weight: 600; text-align: right;'>{$item['value']}</td>
|
||||
</tr>";
|
||||
}
|
||||
|
||||
return "
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset='utf-8'></head>
|
||||
<body style='margin: 0; padding: 0; background-color: #F2F2F7; font-family: sans-serif;'>
|
||||
<table width='100%' border='0' cellspacing='0' cellpadding='0'>
|
||||
<tr><td align='center' style='padding: 40px 20px;'>
|
||||
<table width='100%' border='0' cellspacing='0' cellpadding='0' style='max-width: 500px; background-color: #FFFFFF; border-radius: 24px; overflow: hidden;'>
|
||||
<tr><td style='height: 6px; background-color: {$color};'></td></tr>
|
||||
<tr><td style='padding: 40px 30px;'>
|
||||
<div style='color: {$color}; font-size: 12px; font-weight: 700; margin-bottom: 8px;'>ECS 服务器管家</div>
|
||||
<h1 style='margin: 0 0 10px 0; font-size: 24px; color: #1C1C1E;'>{$title}</h1>
|
||||
<p style='margin: 0 0 30px 0; font-size: 15px; color: #8E8E93;'>{$summary}</p>
|
||||
<table width='100%' border='0' cellspacing='0' cellpadding='0' style='border-top: 1px solid #F2F2F7;'>{$rows}</table>
|
||||
</td></tr>
|
||||
<tr><td style='background-color: #FAFAFC; padding: 20px; text-align: center; color: #AEAEB2; font-size: 12px;'>© " . date('Y') . " ECS 服务器管家</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>";
|
||||
}
|
||||
|
||||
private function sendMail($to, $name, $subject, $body)
|
||||
{
|
||||
$mail = new PHPMailer();
|
||||
$mail->CharSet = 'UTF-8';
|
||||
$mail->IsSMTP();
|
||||
$mail->SMTPDebug = 0;
|
||||
$mail->SMTPAuth = true;
|
||||
|
||||
$secure = $this->config['notify_secure'] ?? 'ssl';
|
||||
if (!empty($secure)) {
|
||||
$mail->SMTPSecure = $secure;
|
||||
} else {
|
||||
$mail->SMTPSecure = '';
|
||||
$mail->SMTPAutoTLS = false;
|
||||
}
|
||||
|
||||
$mail->Host = $this->config['notify_host'] ?? '';
|
||||
$mail->Port = $this->config['notify_port'] ?? 465;
|
||||
$mail->Username = $this->config['notify_username'] ?? '';
|
||||
$mail->Password = $this->config['notify_password'] ?? '';
|
||||
|
||||
$mail->SetFrom($mail->Username, 'ECS 服务器管家');
|
||||
$mail->Subject = $subject;
|
||||
$mail->MsgHTML($body);
|
||||
$mail->AddAddress($to, $name);
|
||||
|
||||
// 修改:返回 true 或 错误信息字符串
|
||||
if ($mail->Send()) {
|
||||
return true;
|
||||
} else {
|
||||
return $mail->ErrorInfo;
|
||||
}
|
||||
}
|
||||
|
||||
private function sendTelegram($text, $overrideConfig = null)
|
||||
{
|
||||
$token = $overrideConfig['token'] ?? $this->config['notify_tg_token'] ?? '';
|
||||
$chatId = $overrideConfig['chat_id'] ?? $this->config['notify_tg_chat_id'] ?? '';
|
||||
$proxyType = $overrideConfig['proxy_type'] ?? $this->config['notify_tg_proxy_type'] ?? 'none';
|
||||
|
||||
if (empty($token) || empty($chatId))
|
||||
return "Telegram 的机器人令牌或接收会话编号为空";
|
||||
|
||||
$url = "https://api.telegram.org/bot{$token}/sendMessage";
|
||||
if ($proxyType === 'custom' && !empty($overrideConfig['proxy_url'] ?? $this->config['notify_tg_proxy_url'] ?? '')) {
|
||||
$baseUrl = rtrim($overrideConfig['proxy_url'] ?? $this->config['notify_tg_proxy_url'], '/');
|
||||
$url = "{$baseUrl}/bot{$token}/sendMessage";
|
||||
}
|
||||
|
||||
$postFields = [
|
||||
'chat_id' => $chatId,
|
||||
'text' => $text
|
||||
];
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postFields));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
|
||||
if ($proxyType === 'socks5') {
|
||||
$proxyIp = $overrideConfig['proxy_ip'] ?? $this->config['notify_tg_proxy_ip'] ?? '';
|
||||
$proxyPort = $overrideConfig['proxy_port'] ?? $this->config['notify_tg_proxy_port'] ?? '';
|
||||
if ($proxyIp && $proxyPort) {
|
||||
curl_setopt($ch, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5_HOSTNAME);
|
||||
curl_setopt($ch, CURLOPT_PROXY, "{$proxyIp}:{$proxyPort}");
|
||||
$proxyUser = $overrideConfig['proxy_user'] ?? $this->config['notify_tg_proxy_user'] ?? '';
|
||||
$proxyPass = $overrideConfig['proxy_pass'] ?? $this->config['notify_tg_proxy_pass'] ?? '';
|
||||
if ($proxyUser || $proxyPass) {
|
||||
curl_setopt($ch, CURLOPT_PROXYUSERPWD, "{$proxyUser}:{$proxyPass}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result = curl_exec($ch);
|
||||
$error = curl_error($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error)
|
||||
return "网络请求错误: " . $error;
|
||||
if ($httpCode != 200)
|
||||
return "接口返回错误 {$httpCode}: " . $result;
|
||||
return true;
|
||||
}
|
||||
|
||||
private function sendWebhook($text, $title, $summary, $details, $accountId = '', $overrideConfig = null)
|
||||
{
|
||||
$url = $overrideConfig['url'] ?? $this->config['notify_wh_url'] ?? '';
|
||||
$method = strtoupper($overrideConfig['method'] ?? $this->config['notify_wh_method'] ?? 'GET');
|
||||
$requestType = strtoupper($overrideConfig['request_type'] ?? $this->config['notify_wh_request_type'] ?? 'JSON');
|
||||
$headersStr = $overrideConfig['headers'] ?? $this->config['notify_wh_headers'] ?? '';
|
||||
$bodyTemplate = $overrideConfig['body'] ?? $this->config['notify_wh_body'] ?? '';
|
||||
|
||||
if (empty($url))
|
||||
return "接口回调地址为空";
|
||||
|
||||
// Parse variables
|
||||
$traffic = '暂无';
|
||||
$maxTraffic = '暂无';
|
||||
foreach ($details as $d) {
|
||||
if ($d['label'] === '当前流量')
|
||||
$traffic = str_replace([' GB', ' MB', ' GB', ' MB'], '', $d['value']);
|
||||
if ($d['label'] === '设定阈值')
|
||||
$maxTraffic = str_replace('%', '', $d['value']);
|
||||
}
|
||||
$replacePairs = [
|
||||
'#TITLE#' => $title,
|
||||
'#MSG#' => $summary ?: $text,
|
||||
'#ACCOUNT#' => $accountId,
|
||||
'#TRAFFIC#' => $traffic,
|
||||
'#MAX_TRAFFIC#' => $maxTraffic
|
||||
];
|
||||
|
||||
$ch = curl_init();
|
||||
$customHeaders = [];
|
||||
|
||||
// Parse custom headers
|
||||
if (!empty($headersStr)) {
|
||||
$parsedHeaders = json_decode($headersStr, true);
|
||||
if (is_array($parsedHeaders)) {
|
||||
foreach ($parsedHeaders as $k => $v) {
|
||||
$customHeaders[] = "{$k}: {$v}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($method === 'GET') {
|
||||
$urlReplacePairs = [];
|
||||
foreach ($replacePairs as $k => $v) {
|
||||
$urlReplacePairs[$k] = urlencode((string) $v);
|
||||
}
|
||||
$finalUrl = strtr($url, $urlReplacePairs);
|
||||
|
||||
// 读取请求没有请求体时,将默认参数拼到地址上。
|
||||
if (empty($bodyTemplate) && strpos($finalUrl, '?') === false && strpos($url, '#') === false) {
|
||||
$payload = [
|
||||
'title' => $title,
|
||||
'text' => $text,
|
||||
'time' => date('Y-m-d H:i:s')
|
||||
];
|
||||
$finalUrl .= '?' . http_build_query($payload);
|
||||
}
|
||||
curl_setopt($ch, CURLOPT_URL, $finalUrl);
|
||||
} else {
|
||||
// 发送请求
|
||||
$urlReplacePairs = [];
|
||||
foreach ($replacePairs as $k => $v) {
|
||||
$urlReplacePairs[$k] = urlencode((string) $v);
|
||||
}
|
||||
curl_setopt($ch, CURLOPT_URL, strtr($url, $urlReplacePairs));
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
|
||||
$finalBody = '';
|
||||
if (!empty($bodyTemplate)) {
|
||||
$bodyReplacePairs = $replacePairs;
|
||||
if ($requestType === 'JSON') {
|
||||
foreach ($bodyReplacePairs as $k => $v) {
|
||||
// 数据格式安全转义,避免模板变量破坏 JSON 字符串。
|
||||
$bodyReplacePairs[$k] = substr(json_encode((string) $v, JSON_UNESCAPED_UNICODE), 1, -1);
|
||||
}
|
||||
} else if ($requestType === 'FORM') {
|
||||
foreach ($bodyReplacePairs as $k => $v) {
|
||||
$bodyReplacePairs[$k] = urlencode((string) $v);
|
||||
}
|
||||
}
|
||||
$finalBody = strtr($bodyTemplate, $bodyReplacePairs);
|
||||
|
||||
// Content Type
|
||||
if ($requestType === 'JSON') {
|
||||
$customHeaders[] = 'Content-Type: application/json';
|
||||
} else if ($requestType === 'FORM') {
|
||||
$customHeaders[] = 'Content-Type: application/x-www-form-urlencoded';
|
||||
// 用户误填 JSON 时,尽量转换为表单格式。
|
||||
$decoded = json_decode($finalBody, true);
|
||||
if (is_array($decoded)) {
|
||||
$finalBody = http_build_query($decoded);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 未配置请求体时发送默认内容。
|
||||
$payload = ['title' => $title, 'text' => $text, 'time' => date('Y-m-d H:i:s')];
|
||||
if ($requestType === 'JSON') {
|
||||
$finalBody = json_encode($payload, JSON_UNESCAPED_UNICODE);
|
||||
$customHeaders[] = 'Content-Type: application/json';
|
||||
} else {
|
||||
$finalBody = http_build_query($payload);
|
||||
$customHeaders[] = 'Content-Type: application/x-www-form-urlencoded';
|
||||
}
|
||||
}
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $finalBody);
|
||||
}
|
||||
|
||||
if (!empty($customHeaders)) {
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, array_unique($customHeaders));
|
||||
}
|
||||
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
|
||||
$result = curl_exec($ch);
|
||||
$error = curl_error($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error)
|
||||
return "网络请求错误: " . $error;
|
||||
if ($httpCode >= 400)
|
||||
return "接口返回错误 {$httpCode}: " . $result;
|
||||
return true;
|
||||
}
|
||||
|
||||
private function formatTraffic($trafficGb)
|
||||
{
|
||||
$trafficGb = (float) $trafficGb;
|
||||
if ($trafficGb <= 0) {
|
||||
return '0 MB';
|
||||
}
|
||||
if ($trafficGb < 1) {
|
||||
return round($trafficGb * 1024, 2) . ' MB';
|
||||
}
|
||||
return round($trafficGb, 2) . ' GB';
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
## 🚀 快速部署
|
||||
|
||||
当前默认容器已经切换到 Go 单进程版本:HTTP API、SQLite、任务 worker 和监控 worker 在同一个可优雅关闭的服务中运行,不再需要 PHP-FPM、Nginx 或 Cron。旧 PHP 文件仍保留在仓库中,便于迁移期间对照。
|
||||
当前容器使用 Go 单进程版本:HTTP API、SQLite、任务 worker 和监控 worker 在同一个可优雅关闭的服务中运行,不需要 PHP-FPM、Nginx 或 Cron。
|
||||
|
||||
### 方式一:Docker Compose (推荐)
|
||||
Colima、Docker Desktop 和 Linux Docker 均可使用。Compose 会创建非 root 应用使用的 named volume,并把旧的 `./data` 目录作为只读迁移源;首次启动时若 volume 为空,会自动复制旧 SQLite 数据和加密密钥。
|
||||
@@ -248,10 +248,10 @@ cdt:ListCdtInternetTraffic
|
||||
|
||||
## 🛠️ 技术架构
|
||||
|
||||
- **Backend**: Go 单进程 HTTP 服务,内置 action API、任务 worker、监控 worker 和 Telegram 控制 worker;旧 PHP 文件仅作为迁移对照。
|
||||
- **Backend**: Go 单进程 HTTP 服务,内置 action API、任务 worker、监控 worker 和 Telegram 控制 worker。
|
||||
- **Storage**: SQLite 3 WAL,负责配置、账号、session、任务队列、流量账本、账单缓存和审计日志;敏感字段使用 SecretBox 加密。
|
||||
- **Cloud adapter**: 通过轻量 Alibaba RPC 客户端完成 ECS、VPC、EIP、CMS、CDT 和 BSS 调用,支持 fake/mock client 做离线测试。
|
||||
- **Frontend**: 保留 Vue 3 + 静态资源和原有 `index.php?action=...` 兼容协议,Go 服务直接托管页面。
|
||||
- **Frontend**: Vue 3 + 静态资源;Go 服务直接托管页面,并保留历史 `index.php?action=...` API 路径以兼容现有客户端。
|
||||
- **Runtime**: Docker/Colima/Linux Docker 均可运行;Compose 使用 named volume、非 root 应用用户和 `/healthz` 健康检查。
|
||||
|
||||
---
|
||||
|
||||
@@ -1,804 +0,0 @@
|
||||
<?php
|
||||
|
||||
class TelegramControlService
|
||||
{
|
||||
private $db;
|
||||
private $pdo;
|
||||
private $configManager;
|
||||
private $app;
|
||||
private $settings;
|
||||
|
||||
public function __construct(Database $db, ConfigManager $configManager, $app)
|
||||
{
|
||||
$this->db = $db;
|
||||
$this->pdo = $db->getPdo();
|
||||
$this->configManager = $configManager;
|
||||
$this->app = $app;
|
||||
$this->settings = $configManager->getAllSettings();
|
||||
}
|
||||
|
||||
public function processUpdates()
|
||||
{
|
||||
return $this->processUpdatesWithTimeout(1);
|
||||
}
|
||||
|
||||
public function processUpdatesWithTimeout($timeout = 1)
|
||||
{
|
||||
if (!$this->isConfigured()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$lock = $this->acquireLock();
|
||||
if (!$lock) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->cleanupExpiredTokens();
|
||||
$offset = ((int) $this->getState('last_update_id', '0')) + 1;
|
||||
$response = $this->api('getUpdates', [
|
||||
'offset' => $offset,
|
||||
'limit' => 20,
|
||||
'timeout' => max(0, (int) $timeout),
|
||||
'allowed_updates' => json_encode(['message', 'callback_query'])
|
||||
]);
|
||||
|
||||
if (!$response['ok']) {
|
||||
$message = $response['description'] ?? '未知错误';
|
||||
$this->db->addLog('error', 'Telegram 控制拉取消息失败: ' . $message);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
foreach (($response['result'] ?? []) as $update) {
|
||||
$updateId = (int) ($update['update_id'] ?? 0);
|
||||
if ($updateId > 0) {
|
||||
// 先确认 offset,避免发送消息过程中被重启后重复响应旧指令。
|
||||
$this->setState('last_update_id', (string) $updateId);
|
||||
}
|
||||
|
||||
try {
|
||||
if (isset($update['callback_query'])) {
|
||||
$this->handleCallback($update['callback_query']);
|
||||
} elseif (isset($update['message'])) {
|
||||
$this->handleMessage($update['message']);
|
||||
}
|
||||
$processed++;
|
||||
} catch (\Exception $e) {
|
||||
$this->db->addLog('error', 'Telegram 控制指令处理失败: ' . strip_tags($e->getMessage()));
|
||||
}
|
||||
}
|
||||
return $processed;
|
||||
} finally {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
}
|
||||
|
||||
private function acquireLock()
|
||||
{
|
||||
$dir = __DIR__ . '/data';
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0775, true);
|
||||
}
|
||||
|
||||
$lock = @fopen($dir . '/telegram-control.lock', 'c');
|
||||
if (!$lock) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!flock($lock, LOCK_EX | LOCK_NB)) {
|
||||
fclose($lock);
|
||||
return null;
|
||||
}
|
||||
|
||||
return $lock;
|
||||
}
|
||||
|
||||
private function isConfigured()
|
||||
{
|
||||
return trim((string) ($this->settings['notify_tg_token'] ?? '')) !== ''
|
||||
&& trim((string) ($this->settings['notify_tg_chat_id'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
private function handleMessage(array $message)
|
||||
{
|
||||
$chat = $message['chat'] ?? [];
|
||||
$from = $message['from'] ?? [];
|
||||
$chatId = (string) ($chat['id'] ?? '');
|
||||
$userId = (string) ($from['id'] ?? '');
|
||||
if (!$this->isAllowed($chatId, $userId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$text = trim((string) ($message['text'] ?? ''));
|
||||
$command = strtolower(preg_replace('/@.+$/', '', strtok($text, " \n\t") ?: ''));
|
||||
|
||||
if (in_array($command, ['/traffic', '流量'], true)) {
|
||||
$this->sendMessage($chatId, $this->buildTrafficText(), $this->trafficKeyboard());
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array($command, ['/instances', '实例'], true)) {
|
||||
$this->sendMessage($chatId, $this->buildInstancesText(1), $this->instancesKeyboard(1));
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sendMessage($chatId, $this->mainMenuText(), $this->mainMenuKeyboard());
|
||||
}
|
||||
|
||||
private function handleCallback(array $callback)
|
||||
{
|
||||
$id = (string) ($callback['id'] ?? '');
|
||||
$from = $callback['from'] ?? [];
|
||||
$message = $callback['message'] ?? [];
|
||||
$chat = $message['chat'] ?? [];
|
||||
$chatId = (string) ($chat['id'] ?? '');
|
||||
$userId = (string) ($from['id'] ?? '');
|
||||
$messageId = (int) ($message['message_id'] ?? 0);
|
||||
$data = (string) ($callback['data'] ?? '');
|
||||
|
||||
if (!$this->isAllowed($chatId, $userId)) {
|
||||
$this->answerCallback($id, '没有权限执行该操作');
|
||||
return;
|
||||
}
|
||||
|
||||
$parts = explode(':', $data);
|
||||
if (($parts[0] ?? '') !== 'm') {
|
||||
$this->answerCallback($id);
|
||||
return;
|
||||
}
|
||||
|
||||
$action = $parts[1] ?? 'home';
|
||||
if ($action === 'home') {
|
||||
$this->answerCallback($id);
|
||||
$this->editMessage($chatId, $messageId, $this->mainMenuText(), $this->mainMenuKeyboard());
|
||||
} elseif ($action === 'help') {
|
||||
$this->answerCallback($id);
|
||||
$this->editMessage($chatId, $messageId, $this->helpText(), $this->mainMenuKeyboard());
|
||||
} elseif ($action === 'traffic') {
|
||||
$this->answerCallback($id, '正在刷新流量...');
|
||||
$this->refreshAllData();
|
||||
$this->editMessage($chatId, $messageId, $this->buildTrafficText(), $this->trafficKeyboard());
|
||||
return;
|
||||
} elseif ($action === 'list') {
|
||||
$this->answerCallback($id);
|
||||
$page = max(1, (int) ($parts[2] ?? 1));
|
||||
$this->editMessage($chatId, $messageId, $this->buildInstancesText($page), $this->instancesKeyboard($page));
|
||||
} elseif ($action === 'listrefresh') {
|
||||
$this->answerCallback($id, '正在刷新列表...');
|
||||
$page = max(1, (int) ($parts[2] ?? 1));
|
||||
$this->refreshAllData();
|
||||
$this->editMessage($chatId, $messageId, $this->buildInstancesText($page), $this->instancesKeyboard($page));
|
||||
return;
|
||||
} elseif ($action === 'inst') {
|
||||
$this->answerCallback($id);
|
||||
$accountId = (int) ($parts[2] ?? 0);
|
||||
$this->editMessage($chatId, $messageId, $this->buildInstanceDetailText($accountId), $this->instanceKeyboard($accountId));
|
||||
} elseif ($action === 'refresh') {
|
||||
$this->answerCallback($id, '正在刷新状态...');
|
||||
$accountId = (int) ($parts[2] ?? 0);
|
||||
if ($accountId > 0) {
|
||||
$this->app->refreshAccount($accountId);
|
||||
}
|
||||
$this->editMessage($chatId, $messageId, $this->buildInstanceDetailText($accountId), $this->instanceKeyboard($accountId));
|
||||
return;
|
||||
} elseif ($action === 'refreshall') {
|
||||
$this->answerCallback($id, '正在同步数据...');
|
||||
$this->refreshAllData();
|
||||
$this->editMessage($chatId, $messageId, $this->buildTrafficText(), $this->trafficKeyboard());
|
||||
return;
|
||||
} elseif ($action === 'start') {
|
||||
$this->answerCallback($id, '正在提交开机指令...');
|
||||
$accountId = (int) ($parts[2] ?? 0);
|
||||
$this->startInstance($chatId, $messageId, $accountId);
|
||||
return;
|
||||
} elseif ($action === 'stop') {
|
||||
$this->answerCallback($id, '正在提交停机指令...');
|
||||
$accountId = (int) ($parts[2] ?? 0);
|
||||
$this->stopInstance($chatId, $messageId, $accountId);
|
||||
return;
|
||||
} elseif ($action === 'release') {
|
||||
$this->answerCallback($id);
|
||||
$accountId = (int) ($parts[2] ?? 0);
|
||||
if (!$this->findInstance($accountId)) {
|
||||
$this->editMessage($chatId, $messageId, "释放失败:实例不存在或已被清理。", $this->mainMenuKeyboard());
|
||||
return;
|
||||
}
|
||||
$token = $this->createActionToken($userId, $chatId, 'release', $accountId);
|
||||
$this->editMessage($chatId, $messageId, $this->buildReleaseConfirmText($accountId), $this->releaseConfirmKeyboard($token, $accountId));
|
||||
} elseif ($action === 'confirm') {
|
||||
$this->answerCallback($id, '正在提交释放指令...');
|
||||
$token = (string) ($parts[2] ?? '');
|
||||
$this->confirmRelease($chatId, $messageId, $userId, $token);
|
||||
return;
|
||||
} elseif ($action === 'cancel') {
|
||||
$this->answerCallback($id);
|
||||
$token = (string) ($parts[2] ?? '');
|
||||
$this->useActionToken($token, $userId, $chatId, false);
|
||||
$this->editMessage($chatId, $messageId, "已取消释放操作。", $this->mainMenuKeyboard());
|
||||
} else {
|
||||
$this->answerCallback($id);
|
||||
}
|
||||
}
|
||||
|
||||
private function isAllowed($chatId, $userId)
|
||||
{
|
||||
$configuredChatId = trim((string) ($this->settings['notify_tg_chat_id'] ?? ''));
|
||||
if ($configuredChatId === '' || $chatId !== $configuredChatId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowed = $this->parseCsvIds($this->settings['notify_tg_allowed_user_ids'] ?? '');
|
||||
if (!empty($allowed)) {
|
||||
return in_array($userId, $allowed, true);
|
||||
}
|
||||
|
||||
// 私聊场景下 chat_id 通常等于 from.id;群聊未配置白名单时不允许控制。
|
||||
return $chatId !== '' && $chatId[0] !== '-' && $chatId === $userId;
|
||||
}
|
||||
|
||||
private function parseCsvIds($value)
|
||||
{
|
||||
$items = preg_split('/[\s,;,;]+/', trim((string) $value)) ?: [];
|
||||
return array_values(array_filter(array_map('trim', $items), function ($item) {
|
||||
return $item !== '';
|
||||
}));
|
||||
}
|
||||
|
||||
private function mainMenuText()
|
||||
{
|
||||
return "🛡️ ECS 服务器管家\n\n请选择要执行的操作:";
|
||||
}
|
||||
|
||||
private function helpText()
|
||||
{
|
||||
return "📘 使用说明\n\n"
|
||||
. "配置 Telegram 通知后,当前 Bot 默认支持远程控制。\n\n"
|
||||
. "可用功能:\n"
|
||||
. "📊 查看账号概览\n"
|
||||
. "🖥️ 查看实例列表和详情\n"
|
||||
. "🚀 对已停止实例一键开机\n"
|
||||
. "🗑️ 二次确认后释放实例\n\n"
|
||||
. "⚠️ 释放实例会进入后台安全队列,系统会继续处理停机、托管 EIP 回收、ECS 删除和 DDNS 清理。";
|
||||
}
|
||||
|
||||
private function mainMenuKeyboard()
|
||||
{
|
||||
return [
|
||||
'inline_keyboard' => [
|
||||
[
|
||||
['text' => '📊 账号概览', 'callback_data' => 'm:traffic'],
|
||||
['text' => '🖥️ 实例列表', 'callback_data' => 'm:list:1']
|
||||
],
|
||||
[
|
||||
['text' => '🔄 刷新数据', 'callback_data' => 'm:refreshall'],
|
||||
['text' => '📘 帮助说明', 'callback_data' => 'm:help']
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
private function trafficKeyboard()
|
||||
{
|
||||
return [
|
||||
'inline_keyboard' => [
|
||||
[
|
||||
['text' => '🔄 刷新流量', 'callback_data' => 'm:traffic'],
|
||||
['text' => '🖥️ 查看实例', 'callback_data' => 'm:list:1']
|
||||
],
|
||||
[
|
||||
['text' => '🏠 返回主菜单', 'callback_data' => 'm:home']
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
private function buildTrafficText()
|
||||
{
|
||||
$config = $this->app->getConfigForFrontend();
|
||||
$accounts = $config['Accounts'] ?? [];
|
||||
if (empty($accounts)) {
|
||||
return "📊 账号概览\n\n暂无账号数据,请先在控制台添加账号。";
|
||||
}
|
||||
|
||||
$lines = ["📊 账号概览"];
|
||||
foreach ($accounts as $account) {
|
||||
$used = (float) ($account['usageUsed'] ?? 0);
|
||||
$total = (float) ($account['maxTraffic'] ?? 0);
|
||||
$percent = (float) ($account['usagePercent'] ?? 0);
|
||||
$status = $percent >= 100 ? '已超量' : ($percent >= (float) ($config['traffic_threshold'] ?? 95) ? '接近阈值' : '正常');
|
||||
if (($account['trafficStatus'] ?? 'ok') !== 'ok') {
|
||||
$status = $account['trafficMessage'] ?: '流量同步异常';
|
||||
}
|
||||
|
||||
$lines[] = "";
|
||||
$lines[] = "👤 账号:" . ($account['remark'] ?: '未命名账号');
|
||||
$lines[] = "📍 区域:" . $this->regionName($account['regionId'] ?? '');
|
||||
$lines[] = "📦 已用:" . $this->formatTraffic($used) . " / " . $this->formatTraffic($total);
|
||||
$lines[] = "📈 使用率:" . $percent . "%";
|
||||
$lines[] = $this->trafficStatusIcon($status) . " 状态:" . $status;
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
private function buildInstancesText($page)
|
||||
{
|
||||
$instances = $this->getInstances();
|
||||
if (empty($instances)) {
|
||||
return "🖥️ 实例列表\n\n暂无实例数据。";
|
||||
}
|
||||
|
||||
$pageSize = 6;
|
||||
$totalPages = max(1, (int) ceil(count($instances) / $pageSize));
|
||||
$page = min(max(1, $page), $totalPages);
|
||||
$slice = array_slice($instances, ($page - 1) * $pageSize, $pageSize);
|
||||
|
||||
$lines = ["🖥️ 实例列表 第 {$page}/{$totalPages} 页"];
|
||||
foreach ($slice as $inst) {
|
||||
$lines[] = "";
|
||||
$status = $inst['instanceStatus'] ?? '';
|
||||
$lines[] = "🖥️ " . ($inst['remark'] ?: $inst['instanceName'] ?: $inst['instanceId']);
|
||||
$lines[] = $this->statusIcon($status) . " 状态:" . $this->statusLabel($status);
|
||||
$lines[] = "📦 流量:" . $this->formatTraffic((float) ($inst['flow_used'] ?? 0)) . " / " . $this->formatTraffic((float) ($inst['flow_total'] ?? 0));
|
||||
$lines[] = "🌐 IP:" . (($inst['publicIp'] ?? '') ?: '-');
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
private function refreshAllData()
|
||||
{
|
||||
if (method_exists($this->app, 'getAllManagedInstances')) {
|
||||
$this->app->getAllManagedInstances(true);
|
||||
$this->configManager->load();
|
||||
}
|
||||
}
|
||||
|
||||
private function instancesKeyboard($page)
|
||||
{
|
||||
$instances = $this->getInstances();
|
||||
$pageSize = 6;
|
||||
$totalPages = max(1, (int) ceil(count($instances) / $pageSize));
|
||||
$page = min(max(1, $page), $totalPages);
|
||||
$slice = array_slice($instances, ($page - 1) * $pageSize, $pageSize);
|
||||
|
||||
$keyboard = [];
|
||||
foreach ($slice as $inst) {
|
||||
$label = $this->shortButtonText($this->statusIcon($inst['instanceStatus'] ?? '') . ' ' . ($inst['remark'] ?: $inst['instanceName'] ?: $inst['instanceId']) . ' / ' . $this->statusLabel($inst['instanceStatus'] ?? ''));
|
||||
$keyboard[] = [['text' => $label, 'callback_data' => 'm:inst:' . (int) $inst['accountId']]];
|
||||
}
|
||||
|
||||
$pager = [];
|
||||
if ($page > 1) {
|
||||
$pager[] = ['text' => '⬅️ 上一页', 'callback_data' => 'm:list:' . ($page - 1)];
|
||||
}
|
||||
if ($page < $totalPages) {
|
||||
$pager[] = ['text' => '下一页 ➡️', 'callback_data' => 'm:list:' . ($page + 1)];
|
||||
}
|
||||
if (!empty($pager)) {
|
||||
$keyboard[] = $pager;
|
||||
}
|
||||
|
||||
$keyboard[] = [
|
||||
['text' => '🔄 刷新列表', 'callback_data' => 'm:listrefresh:' . $page],
|
||||
['text' => '🏠 返回主菜单', 'callback_data' => 'm:home']
|
||||
];
|
||||
|
||||
return ['inline_keyboard' => $keyboard];
|
||||
}
|
||||
|
||||
private function buildInstanceDetailText($accountId)
|
||||
{
|
||||
$inst = $this->findInstance($accountId);
|
||||
if (!$inst) {
|
||||
return "🖥️ 实例详情\n\n实例不存在或已被清理。";
|
||||
}
|
||||
|
||||
$status = $inst['instanceStatus'] ?? '';
|
||||
return "🖥️ 实例详情\n\n"
|
||||
. "🏷️ 备注:" . ($inst['remark'] ?: '-') . "\n"
|
||||
. "📍 区域:" . ($inst['regionName'] ?? $this->regionName($inst['regionId'] ?? '')) . "\n"
|
||||
. $this->statusIcon($status) . " 状态:" . $this->statusLabel($status) . "\n"
|
||||
. "🆔 实例 ID:" . ($inst['instanceId'] ?: '-') . "\n"
|
||||
. "🌐 公网 IP:" . (($inst['publicIp'] ?? '') ?: '-') . "\n"
|
||||
. "🔌 公网类型:" . (($inst['publicIpMode'] ?? '') === 'eip' ? 'EIP' : 'ECS 公网') . "\n"
|
||||
. "⚙️ 规格:" . (($inst['instanceType'] ?? '') ?: '-') . "\n"
|
||||
. "📦 出口流量:" . $this->formatTraffic((float) ($inst['flow_used'] ?? 0)) . " / " . $this->formatTraffic((float) ($inst['flow_total'] ?? 0)) . "\n"
|
||||
. "📈 使用率:" . ((float) ($inst['percentageOfUse'] ?? 0)) . "%\n"
|
||||
. "🚧 阈值:" . ((int) ($inst['threshold'] ?? 95)) . "%";
|
||||
}
|
||||
|
||||
private function instanceKeyboard($accountId)
|
||||
{
|
||||
$inst = $this->findInstance($accountId);
|
||||
if (!$inst) {
|
||||
return ['inline_keyboard' => [[['text' => '🖥️ 返回实例列表', 'callback_data' => 'm:list:1']]]];
|
||||
}
|
||||
|
||||
$status = $inst['instanceStatus'] ?? '';
|
||||
$locked = !empty($inst['operationLocked']) || in_array($status, ['Releasing', 'Released'], true);
|
||||
$keyboard = [];
|
||||
if (!$locked && $status === 'Stopped') {
|
||||
$keyboard[] = [['text' => '🚀 开机', 'callback_data' => 'm:start:' . (int) $accountId]];
|
||||
}
|
||||
if (!$locked && $status === 'Running') {
|
||||
$keyboard[] = [['text' => '🛑 停机', 'callback_data' => 'm:stop:' . (int) $accountId]];
|
||||
}
|
||||
if (!$locked && !in_array($status, ['Releasing', 'Released'], true)) {
|
||||
$keyboard[] = [['text' => '🗑️ 释放实例', 'callback_data' => 'm:release:' . (int) $accountId]];
|
||||
}
|
||||
$keyboard[] = [['text' => '🔄 刷新状态', 'callback_data' => 'm:refresh:' . (int) $accountId]];
|
||||
$keyboard[] = [
|
||||
['text' => '🖥️ 返回实例列表', 'callback_data' => 'm:list:1'],
|
||||
['text' => '🏠 返回主菜单', 'callback_data' => 'm:home']
|
||||
];
|
||||
return ['inline_keyboard' => $keyboard];
|
||||
}
|
||||
|
||||
private function buildReleaseConfirmText($accountId)
|
||||
{
|
||||
$inst = $this->findInstance($accountId);
|
||||
if (!$inst) {
|
||||
return "🗑️ 确认释放实例\n\n实例不存在或已被清理。";
|
||||
}
|
||||
|
||||
$ttl = $this->confirmTtl();
|
||||
return "⚠️ 确认释放实例?\n\n"
|
||||
. "🖥️ 实例:" . ($inst['remark'] ?: $inst['instanceName'] ?: '-') . "\n"
|
||||
. "📍 区域:" . ($inst['regionName'] ?? $this->regionName($inst['regionId'] ?? '')) . "\n"
|
||||
. "🆔 实例 ID:" . ($inst['instanceId'] ?: '-') . "\n"
|
||||
. "🔌 公网类型:" . (($inst['publicIpMode'] ?? '') === 'eip' ? 'EIP' : 'ECS 公网') . "\n\n"
|
||||
. "🗑️ 释放后 ECS 会被删除,系统托管 EIP 和 DDNS 解析会同步清理,操作不可恢复。\n\n"
|
||||
. "⏱️ 请在 {$ttl} 秒内确认。";
|
||||
}
|
||||
|
||||
private function releaseConfirmKeyboard($token, $accountId)
|
||||
{
|
||||
return [
|
||||
'inline_keyboard' => [
|
||||
[
|
||||
['text' => '⚠️ 确认释放', 'callback_data' => 'm:confirm:' . $token],
|
||||
['text' => '取消', 'callback_data' => 'm:cancel:' . $token]
|
||||
],
|
||||
[
|
||||
['text' => '🖥️ 返回实例详情', 'callback_data' => 'm:inst:' . (int) $accountId]
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
private function startInstance($chatId, $messageId, $accountId)
|
||||
{
|
||||
$inst = $this->findInstance($accountId);
|
||||
if (!$inst) {
|
||||
$this->editMessage($chatId, $messageId, "❌ 开机失败:实例不存在。", $this->mainMenuKeyboard());
|
||||
return;
|
||||
}
|
||||
|
||||
if (($inst['instanceStatus'] ?? '') !== 'Stopped') {
|
||||
$this->editMessage($chatId, $messageId, "ℹ️ 当前实例不是已停机状态,无需开机。", $this->instanceKeyboard($accountId));
|
||||
return;
|
||||
}
|
||||
|
||||
$success = $this->app->controlInstanceAction($accountId, 'start', 'KeepCharging', false);
|
||||
$this->db->addLog($success ? 'info' : 'error', "Telegram 控制开机" . ($success ? '成功' : '失败') . " [{$inst['remark']}] {$inst['instanceId']}");
|
||||
$this->editMessage(
|
||||
$chatId,
|
||||
$messageId,
|
||||
$success ? "🚀 开机指令已提交。\n\n🖥️ 实例:" . ($inst['remark'] ?: $inst['instanceId']) . "\n🟡 当前状态:启动中" : "❌ 开机失败,请查看系统日志。",
|
||||
$this->instanceKeyboard($accountId)
|
||||
);
|
||||
}
|
||||
|
||||
private function stopInstance($chatId, $messageId, $accountId)
|
||||
{
|
||||
$inst = $this->findInstance($accountId);
|
||||
if (!$inst) {
|
||||
$this->editMessage($chatId, $messageId, "❌ 停机失败:实例不存在。", $this->mainMenuKeyboard());
|
||||
return;
|
||||
}
|
||||
|
||||
if (($inst['instanceStatus'] ?? '') !== 'Running') {
|
||||
$this->editMessage($chatId, $messageId, "ℹ️ 当前实例不是运行中状态,无需停机。", $this->instanceKeyboard($accountId));
|
||||
return;
|
||||
}
|
||||
|
||||
$success = $this->app->controlInstanceAction($accountId, 'stop', 'KeepCharging', false);
|
||||
$this->db->addLog($success ? 'info' : 'error', "Telegram 控制停机" . ($success ? '成功' : '失败') . " [{$inst['remark']}] {$inst['instanceId']}");
|
||||
$this->editMessage(
|
||||
$chatId,
|
||||
$messageId,
|
||||
$success ? "🛑 停机指令已提交。\n\n🖥️ 实例:" . ($inst['remark'] ?: $inst['instanceId']) . "\n🟠 当前状态:停机中" : "❌ 停机失败,请查看系统日志。",
|
||||
$this->instanceKeyboard($accountId)
|
||||
);
|
||||
}
|
||||
|
||||
private function confirmRelease($chatId, $messageId, $userId, $token)
|
||||
{
|
||||
$record = $this->useActionToken($token, $userId, $chatId, true);
|
||||
if (!$record) {
|
||||
$this->editMessage($chatId, $messageId, "⏱️ 释放确认已失效,请重新发起释放操作。", $this->mainMenuKeyboard());
|
||||
return;
|
||||
}
|
||||
|
||||
$accountId = (int) $record['account_id'];
|
||||
$inst = $this->findInstance($accountId);
|
||||
$success = $this->app->deleteInstanceAction($accountId);
|
||||
$label = $inst ? ($inst['remark'] ?: $inst['instanceId']) : ('实例 #' . $accountId);
|
||||
$this->db->addLog($success ? 'warning' : 'error', "Telegram 提交释放" . ($success ? '成功' : '失败') . " [{$label}]");
|
||||
$this->editMessage(
|
||||
$chatId,
|
||||
$messageId,
|
||||
$success
|
||||
? "🗑️ 释放指令已提交。\n\n🖥️ 实例:{$label}\n后台释放队列已接管,会继续处理停机、托管 EIP 回收、ECS 删除和 DDNS 清理。"
|
||||
: "❌ 释放指令提交失败,请查看系统日志。",
|
||||
$this->mainMenuKeyboard()
|
||||
);
|
||||
}
|
||||
|
||||
private function getInstances()
|
||||
{
|
||||
$status = $this->app->getStatusForFrontend(true);
|
||||
$items = $status['data'] ?? [];
|
||||
usort($items, function ($a, $b) {
|
||||
return strcmp(($a['regionName'] ?? '') . ($a['remark'] ?? ''), ($b['regionName'] ?? '') . ($b['remark'] ?? ''));
|
||||
});
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function findInstance($accountId)
|
||||
{
|
||||
foreach ($this->getInstances() as $inst) {
|
||||
if ((int) ($inst['accountId'] ?? 0) === (int) $accountId) {
|
||||
return $inst;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function createActionToken($userId, $chatId, $action, $accountId, array $payload = [])
|
||||
{
|
||||
$token = bin2hex(random_bytes(8));
|
||||
$stmt = $this->pdo->prepare("
|
||||
INSERT INTO telegram_action_tokens
|
||||
(token, user_id, chat_id, action, account_id, payload, expires_at, used_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)
|
||||
");
|
||||
$now = time();
|
||||
$stmt->execute([
|
||||
$token,
|
||||
(string) $userId,
|
||||
(string) $chatId,
|
||||
$action,
|
||||
(int) $accountId,
|
||||
json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
$now + $this->confirmTtl(),
|
||||
$now
|
||||
]);
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function useActionToken($token, $userId, $chatId, $markUsed)
|
||||
{
|
||||
$stmt = $this->pdo->prepare("
|
||||
SELECT *
|
||||
FROM telegram_action_tokens
|
||||
WHERE token = ?
|
||||
AND user_id = ?
|
||||
AND chat_id = ?
|
||||
AND used_at = 0
|
||||
AND expires_at >= ?
|
||||
LIMIT 1
|
||||
");
|
||||
$stmt->execute([(string) $token, (string) $userId, (string) $chatId, time()]);
|
||||
$record = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$update = $this->pdo->prepare("UPDATE telegram_action_tokens SET used_at = ? WHERE id = ?");
|
||||
$update->execute([time(), (int) $record['id']]);
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
private function cleanupExpiredTokens()
|
||||
{
|
||||
$stmt = $this->pdo->prepare("DELETE FROM telegram_action_tokens WHERE expires_at < ? OR (used_at > 0 AND used_at < ?)");
|
||||
$stmt->execute([time() - 3600, time() - 86400]);
|
||||
}
|
||||
|
||||
private function getState($key, $default = '')
|
||||
{
|
||||
$stmt = $this->pdo->prepare("SELECT value FROM telegram_bot_state WHERE key = ? LIMIT 1");
|
||||
$stmt->execute([$key]);
|
||||
$value = $stmt->fetchColumn();
|
||||
return $value === false ? $default : (string) $value;
|
||||
}
|
||||
|
||||
private function setState($key, $value)
|
||||
{
|
||||
$stmt = $this->pdo->prepare("
|
||||
INSERT INTO telegram_bot_state (key, value)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
");
|
||||
$stmt->execute([$key, $value]);
|
||||
}
|
||||
|
||||
private function sendMessage($chatId, $text, array $keyboard = null)
|
||||
{
|
||||
$payload = [
|
||||
'chat_id' => $chatId,
|
||||
'text' => $text
|
||||
];
|
||||
if ($keyboard) {
|
||||
$payload['reply_markup'] = json_encode($keyboard, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
return $this->api('sendMessage', $payload);
|
||||
}
|
||||
|
||||
private function editMessage($chatId, $messageId, $text, array $keyboard = null)
|
||||
{
|
||||
if ($messageId <= 0) {
|
||||
return $this->sendMessage($chatId, $text, $keyboard);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'chat_id' => $chatId,
|
||||
'message_id' => $messageId,
|
||||
'text' => $text
|
||||
];
|
||||
if ($keyboard) {
|
||||
$payload['reply_markup'] = json_encode($keyboard, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
$response = $this->api('editMessageText', $payload);
|
||||
if (!$response['ok']) {
|
||||
return $this->sendMessage($chatId, $text, $keyboard);
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function answerCallback($callbackId, $text = '')
|
||||
{
|
||||
if ($callbackId === '') {
|
||||
return;
|
||||
}
|
||||
$payload = ['callback_query_id' => $callbackId];
|
||||
if ($text !== '') {
|
||||
$payload['text'] = $text;
|
||||
}
|
||||
$this->api('answerCallbackQuery', $payload);
|
||||
}
|
||||
|
||||
private function api($method, array $payload = [])
|
||||
{
|
||||
$token = trim((string) ($this->settings['notify_tg_token'] ?? ''));
|
||||
$proxyType = $this->settings['notify_tg_proxy_type'] ?? 'none';
|
||||
$url = "https://api.telegram.org/bot{$token}/{$method}";
|
||||
if ($proxyType === 'custom' && !empty($this->settings['notify_tg_proxy_url'])) {
|
||||
$url = rtrim($this->settings['notify_tg_proxy_url'], '/') . "/bot{$token}/{$method}";
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($payload));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 8);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||
|
||||
if ($proxyType === 'socks5') {
|
||||
$proxyIp = trim((string) ($this->settings['notify_tg_proxy_ip'] ?? ''));
|
||||
$proxyPort = trim((string) ($this->settings['notify_tg_proxy_port'] ?? ''));
|
||||
if ($proxyIp !== '' && $proxyPort !== '') {
|
||||
curl_setopt($ch, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5_HOSTNAME);
|
||||
curl_setopt($ch, CURLOPT_PROXY, "{$proxyIp}:{$proxyPort}");
|
||||
$proxyUser = $this->settings['notify_tg_proxy_user'] ?? '';
|
||||
$proxyPass = $this->settings['notify_tg_proxy_pass'] ?? '';
|
||||
if ($proxyUser !== '' || $proxyPass !== '') {
|
||||
curl_setopt($ch, CURLOPT_PROXYUSERPWD, "{$proxyUser}:{$proxyPass}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$raw = curl_exec($ch);
|
||||
$error = curl_error($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error) {
|
||||
return ['ok' => false, 'description' => '网络请求错误: ' . $error];
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
return ['ok' => false, 'description' => "接口返回异常 {$httpCode}: " . (string) $raw];
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function confirmTtl()
|
||||
{
|
||||
return max(30, (int) ($this->settings['notify_tg_confirm_ttl'] ?? 60));
|
||||
}
|
||||
|
||||
private function shortButtonText($text)
|
||||
{
|
||||
$text = trim((string) $text);
|
||||
if (function_exists('mb_strlen') && mb_strlen($text, 'UTF-8') > 28) {
|
||||
return mb_substr($text, 0, 25, 'UTF-8') . '...';
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
private function formatTraffic($value)
|
||||
{
|
||||
$value = (float) $value;
|
||||
if ($value <= 0) {
|
||||
return '0 MB';
|
||||
}
|
||||
if ($value < 1) {
|
||||
return round($value * 1024, 2) . ' MB';
|
||||
}
|
||||
return round($value, 2) . ' GB';
|
||||
}
|
||||
|
||||
private function statusLabel($status)
|
||||
{
|
||||
$map = [
|
||||
'Running' => '运行中',
|
||||
'Starting' => '启动中',
|
||||
'Stopping' => '停机中',
|
||||
'Stopped' => '已停止',
|
||||
'Pending' => '创建中',
|
||||
'Releasing' => '释放中',
|
||||
'Released' => '已释放',
|
||||
'Unknown' => '未知'
|
||||
];
|
||||
return $map[$status] ?? ($status ?: '未知');
|
||||
}
|
||||
|
||||
private function statusIcon($status)
|
||||
{
|
||||
$map = [
|
||||
'Running' => '🟢',
|
||||
'Starting' => '🟡',
|
||||
'Stopping' => '🟠',
|
||||
'Stopped' => '🔴',
|
||||
'Pending' => '🟡',
|
||||
'Releasing' => '🗑️',
|
||||
'Released' => '⚫',
|
||||
'Unknown' => '⚪'
|
||||
];
|
||||
return $map[$status] ?? '⚪';
|
||||
}
|
||||
|
||||
private function trafficStatusIcon($status)
|
||||
{
|
||||
if (strpos($status, '超量') !== false || strpos($status, '异常') !== false) {
|
||||
return '🔴';
|
||||
}
|
||||
if (strpos($status, '接近') !== false) {
|
||||
return '🟠';
|
||||
}
|
||||
return '🟢';
|
||||
}
|
||||
|
||||
private function regionName($regionId)
|
||||
{
|
||||
$map = [
|
||||
'cn-hongkong' => '中国香港',
|
||||
'ap-southeast-1' => '新加坡',
|
||||
'ap-northeast-1' => '日本(东京)',
|
||||
'us-west-1' => '美国(硅谷)',
|
||||
'us-east-1' => '美国(弗吉尼亚)',
|
||||
'cn-hangzhou' => '华东 1(杭州)',
|
||||
'cn-shanghai' => '华东 2(上海)',
|
||||
'cn-beijing' => '华北 2(北京)',
|
||||
'cn-shenzhen' => '华南 1(深圳)'
|
||||
];
|
||||
return $map[$regionId] ?? ($regionId ?: '-');
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"require": {
|
||||
"alibabacloud/client": "^1.5",
|
||||
"phpmailer/phpmailer": "^6.9"
|
||||
},
|
||||
"repositories": {
|
||||
"packagist": {
|
||||
"type": "composer",
|
||||
"url": "https://mirrors.aliyun.com/composer/"
|
||||
}
|
||||
}
|
||||
}
|
||||
1264
composer.lock
generated
1264
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ set -eu
|
||||
data_dir="${ECS_DATA_DIR:-/var/lib/ecs-controller}"
|
||||
mkdir -p "$data_dir"
|
||||
|
||||
# Keep old bind-mounted PHP data readable without making the app process root.
|
||||
# Keep legacy bind-mounted data readable without making the app process root.
|
||||
if [ ! -f "$data_dir/data.sqlite" ] && [ -f /migration-data/data.sqlite ]; then
|
||||
cp -p /migration-data/data.sqlite "$data_dir/data.sqlite"
|
||||
if [ ! -f "$data_dir/.secret_encryption.key" ] && [ -f /migration-data/.secret_encryption.key ]; then
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "Starting ECS-Controller..."
|
||||
|
||||
# 1. 确保数据目录权限正确
|
||||
# Docker 挂载卷时可能会导致权限归属为 root,这里强制修正为 www-data
|
||||
if [ -d "/var/www/html/data" ]; then
|
||||
chown -R www-data:www-data /var/www/html/data
|
||||
fi
|
||||
|
||||
# 2. 启动 Cron 服务 (后台运行)
|
||||
# Alpine 使用 dcron,-b 表示后台运行,-L 指定日志级别
|
||||
crond -b -l 8
|
||||
echo "Cron daemon started."
|
||||
|
||||
# 3. 启动 Telegram 控制轮询 (后台运行)
|
||||
# 如果没有配置 Telegram,进程会保持低频等待;配置后按钮控制可秒级响应。
|
||||
su -s /bin/sh www-data -c "php /var/www/html/telegram_worker.php" >/dev/null 2>&1 &
|
||||
echo "Telegram control worker started."
|
||||
|
||||
# 4. 启动 PHP-FPM (后台运行)
|
||||
# -D 表示 Daemonize (守护进程模式)
|
||||
php-fpm -D
|
||||
echo "PHP-FPM started."
|
||||
|
||||
# 5. 启动 Nginx (前台运行)
|
||||
# 保持 Nginx 在前台运行,防止容器退出
|
||||
echo "Nginx started."
|
||||
nginx -g 'daemon off;'
|
||||
@@ -1,56 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /var/www/html;
|
||||
index index.php index.html;
|
||||
|
||||
# 日志输出到 stdout/stderr,由 Docker 收集
|
||||
error_log /var/log/nginx/error.log;
|
||||
access_log /var/log/nginx/access.log;
|
||||
|
||||
# Gzip 压缩
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json text/plain;
|
||||
gzip_min_length 1000;
|
||||
gzip_vary on;
|
||||
|
||||
# 静态资源缓存
|
||||
location /static/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# 处理前端路由
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
# 处理 PHP 请求
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass 127.0.0.1:9000;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
|
||||
# 安全配置:禁止访问 .ht* 文件
|
||||
location ~ /\.ht {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# 安全配置:严禁外部直接访问 SQLite 数据库文件及 data 目录
|
||||
location ^~ /data/ {
|
||||
deny all;
|
||||
return 403;
|
||||
}
|
||||
|
||||
# 禁止访问 git 文件
|
||||
location ~ /\.git {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# 禁止访问 composer 文件
|
||||
location ~ /composer\.(json|lock) {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
[www]
|
||||
pm = dynamic
|
||||
pm.max_children = 12
|
||||
pm.start_servers = 4
|
||||
pm.min_spare_servers = 2
|
||||
pm.max_spare_servers = 6
|
||||
pm.max_requests = 500
|
||||
394
index.php
394
index.php
@@ -1,394 +0,0 @@
|
||||
<?php
|
||||
ini_set('session.cookie_httponly', 1);
|
||||
ini_set('session.cookie_secure', isset($_SERVER['HTTPS']) ? 1 : 0);
|
||||
ini_set('session.cookie_samesite', 'Lax');
|
||||
ini_set('session.use_strict_mode', 1);
|
||||
ini_set('session.use_only_cookies', 1);
|
||||
session_start();
|
||||
|
||||
error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING & ~E_DEPRECATED);
|
||||
ini_set('display_errors', 0);
|
||||
|
||||
require_once 'AliyunTrafficCheck.php';
|
||||
|
||||
header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
|
||||
|
||||
$app = new AliyunTrafficCheck();
|
||||
$action = $_GET['action'] ?? 'view';
|
||||
|
||||
// ---------------- 公开接口 ----------------
|
||||
|
||||
if ($action === 'check_init') {
|
||||
header('Content-Type: application/json');
|
||||
$initError = $app->getInitError();
|
||||
if ($initError) {
|
||||
echo json_encode(['initialized' => false, 'error' => $initError]);
|
||||
} else {
|
||||
echo json_encode([
|
||||
'initialized' => $app->isInitialized(),
|
||||
'brand' => $app->getPublicBrand()
|
||||
]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'setup') {
|
||||
header('Content-Type: application/json');
|
||||
if ($app->isInitialized()) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'message' => '系统已完成初始化']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
try {
|
||||
if ($app->setup($data)) {
|
||||
$_SESSION['is_admin'] = true;
|
||||
echo json_encode(['success' => true]);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => '初始化失败']);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'login') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
try {
|
||||
if ($app->login($data['password'] ?? '')) {
|
||||
$_SESSION['is_admin'] = true;
|
||||
echo json_encode(['success' => true]);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => '密码错误']);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'check_login') {
|
||||
echo json_encode(['logged_in' => isset($_SESSION['is_admin']) && $_SESSION['is_admin'] === true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'brand_logo') {
|
||||
$dir = __DIR__ . '/data';
|
||||
$files = array_merge(
|
||||
glob($dir . '/brand-logo.png') ?: [],
|
||||
glob($dir . '/brand-logo.jpg') ?: [],
|
||||
glob($dir . '/brand-logo.webp') ?: []
|
||||
);
|
||||
$file = $files[0] ?? '';
|
||||
if ($file === '' || !is_file($file)) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
$mimeMap = [
|
||||
'png' => 'image/png',
|
||||
'jpg' => 'image/jpeg',
|
||||
'webp' => 'image/webp'
|
||||
];
|
||||
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
|
||||
header('Content-Type: ' . ($mimeMap[$ext] ?? 'application/octet-stream'));
|
||||
header('Cache-Control: public, max-age=86400');
|
||||
readfile($file);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'get_status') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$initError = $app->getInitError();
|
||||
if ($initError) {
|
||||
echo json_encode(['error' => $initError]);
|
||||
} else {
|
||||
$isAdmin = isset($_SESSION['is_admin']) && $_SESSION['is_admin'] === true;
|
||||
if (!$isAdmin) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => '请先登录后再操作']);
|
||||
} else {
|
||||
echo json_encode($app->getStatusForFrontend(true));
|
||||
}
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// ---------------- 需鉴权接口 ----------------
|
||||
|
||||
if ($action !== 'view' && !isset($_SESSION['is_admin'])) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => '请先登录后再操作']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'get_config') {
|
||||
echo json_encode($app->getConfigForFrontend());
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'save_config') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
if ($app->updateConfig($data)) {
|
||||
echo json_encode(['success' => true]);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => '保存失败']);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'upload_logo') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($app->uploadLogo($_FILES['logo'] ?? []));
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'send_test_email') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$result = $app->sendTestEmail($data['email'] ?? '');
|
||||
echo json_encode(['success' => $result === true, 'message' => $result]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'send_test_telegram') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$result = $app->sendTestTelegram($data['telegram'] ?? []);
|
||||
echo json_encode(['success' => $result === true, 'message' => $result]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'send_test_webhook') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$result = $app->sendTestWebhook($data['webhook'] ?? []);
|
||||
echo json_encode(['success' => $result === true, 'message' => $result]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'refresh_account') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$id = $data['id'] ?? 0;
|
||||
$result = $app->refreshAccount($id);
|
||||
if ($result === false) {
|
||||
echo json_encode(['success' => false, 'message' => '刷新失败']);
|
||||
} elseif (is_array($result)) {
|
||||
// 流量/状态刷新成功,但账单获取失败
|
||||
echo json_encode($result);
|
||||
} else {
|
||||
echo json_encode(['success' => true]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'fetch_instances') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
try {
|
||||
$instances = $app->fetchInstances($data['accessKeyId'] ?? '', $data['accessKeySecret'] ?? '', $data['regionId'] ?? '');
|
||||
echo json_encode(['success' => true, 'data' => $instances]);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'test_account') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
try {
|
||||
$result = $app->testAccountCredentials($data['account'] ?? []);
|
||||
echo json_encode($result);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'sync_account_group') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
|
||||
try {
|
||||
echo json_encode($app->syncAccountGroup($data['groupKey'] ?? ''));
|
||||
} catch (Exception $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'restore_schedule_block') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
|
||||
try {
|
||||
echo json_encode($app->restoreScheduleAfterTrafficBlock($data['groupKey'] ?? ''));
|
||||
} catch (Exception $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'preview_ecs_create') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
|
||||
try {
|
||||
$result = $app->previewEcsCreate($data);
|
||||
$_SESSION['ecs_create_previews'] = $_SESSION['ecs_create_previews'] ?? [];
|
||||
$_SESSION['ecs_create_previews'][$result['previewId']] = [
|
||||
'summary' => $result['summary'],
|
||||
'created_at' => time()
|
||||
];
|
||||
echo json_encode($result);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'get_ecs_disk_options') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
|
||||
try {
|
||||
echo json_encode($app->getEcsDiskOptions($data));
|
||||
} catch (Exception $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'create_ecs') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$previewId = $data['previewId'] ?? '';
|
||||
$confirmed = !empty($data['confirmed']);
|
||||
|
||||
try {
|
||||
if (!$confirmed) {
|
||||
throw new Exception('请先确认配置清单和费用提示');
|
||||
}
|
||||
$previewStore = $_SESSION['ecs_create_previews'][$previewId] ?? null;
|
||||
if (!$previewStore || (time() - ($previewStore['created_at'] ?? 0)) > 900) {
|
||||
throw new Exception('配置清单已过期,请重新预检');
|
||||
}
|
||||
|
||||
$result = $app->createEcsFromPreview($previewId, $previewStore['summary']);
|
||||
unset($_SESSION['ecs_create_previews'][$previewId]);
|
||||
echo json_encode($result);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'get_ecs_create_task') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$taskId = $_GET['taskId'] ?? '';
|
||||
$task = $app->getEcsCreateTask($taskId);
|
||||
if (!$task) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'message' => '任务不存在']);
|
||||
} else {
|
||||
unset($task['login_password']);
|
||||
echo json_encode(['success' => true, 'data' => $task]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// 修改:获取系统日志,支持 Tab
|
||||
if ($action === 'get_logs') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$tab = $_GET['tab'] ?? 'action'; // 默认是动作日志
|
||||
echo json_encode(['data' => $app->getSystemLogs($tab)]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 新增:清空日志
|
||||
if ($action === 'clear_logs') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$tab = $data['tab'] ?? 'action';
|
||||
if ($app->clearSystemLogs($tab)) {
|
||||
echo json_encode(['success' => true]);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => '清空失败']);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'get_history') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$id = $_GET['id'] ?? 0;
|
||||
echo json_encode(['data' => $app->getAccountHistory($id)]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'logout') {
|
||||
session_destroy();
|
||||
echo json_encode(['success' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'get_all_instances') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$sync = ($_GET['sync'] ?? '0') === '1';
|
||||
echo json_encode(['data' => $app->getAllManagedInstances($sync)]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'control_instance') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$accountId = $data['accountId'] ?? 0;
|
||||
$actionType = $data['action'] ?? '';
|
||||
$shutdownMode = $data['shutdownMode'] ?? 'KeepCharging';
|
||||
|
||||
if (!in_array($actionType, ['start', 'stop'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => '无效的操作类型']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$result = $app->controlInstanceAction($accountId, $actionType, $shutdownMode);
|
||||
echo json_encode(['success' => $result]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'delete_instance') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$accountId = $data['accountId'] ?? 0;
|
||||
$forceStop = $data['forceStop'] ?? false;
|
||||
|
||||
$result = $app->deleteInstanceAction($accountId, $forceStop);
|
||||
echo json_encode(['success' => $result]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'replace_instance_ip') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$accountId = $data['accountId'] ?? 0;
|
||||
|
||||
$result = $app->replaceInstanceIpAction($accountId);
|
||||
echo json_encode($result);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo $app->renderTemplate();
|
||||
31
monitor.php
31
monitor.php
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
// 此文件用于 Cron 任务
|
||||
// 输出简洁的文本日志
|
||||
|
||||
require_once 'AliyunTrafficCheck.php';
|
||||
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
|
||||
$app = new AliyunTrafficCheck();
|
||||
|
||||
// CLI 模式直接运行,Web 模式使用 Bearer Token 鉴权
|
||||
$isCli = (PHP_SAPI === 'cli');
|
||||
|
||||
if (!$isCli) {
|
||||
$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 "访问被拒绝,请使用有效的监控密钥。";
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// 输出简洁日志
|
||||
echo "--- ECS 服务器管理 开始检测: " . date('Y-m-d H:i:s') . " ---\n";
|
||||
echo $app->monitor();
|
||||
echo "\n--- 检测结束 ---\n";
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
// Telegram 控制常驻轮询进程。主监控 cron 继续负责流量和实例巡检。
|
||||
|
||||
require_once 'AliyunTrafficCheck.php';
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(403);
|
||||
echo "仅支持 CLI 运行。";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$app = new AliyunTrafficCheck();
|
||||
$ref = new ReflectionClass($app);
|
||||
|
||||
$dbProp = $ref->getProperty('db');
|
||||
$dbProp->setAccessible(true);
|
||||
$db = $dbProp->getValue($app);
|
||||
|
||||
$configProp = $ref->getProperty('configManager');
|
||||
$configProp->setAccessible(true);
|
||||
$configManager = $configProp->getValue($app);
|
||||
|
||||
$service = new TelegramControlService($db, $configManager, $app);
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
$service->processUpdatesWithTimeout(20);
|
||||
} catch (\Throwable $e) {
|
||||
$db->addLog('error', 'Telegram 控制常驻进程异常: ' . strip_tags($e->getMessage()));
|
||||
sleep(5);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user