Files
ecs-controller/Database.php

601 lines
23 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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->ensureColumn('accounts', 'traffic_used', 'REAL DEFAULT 0');
$this->ensureColumn('accounts', 'traffic_billing_month', "TEXT DEFAULT ''");
$this->ensureColumn('accounts', 'instance_status', "TEXT DEFAULT 'Unknown'");
$this->ensureColumn('accounts', 'updated_at', 'INTEGER DEFAULT 0');
$this->ensureColumn('accounts', 'last_keep_alive_at', 'INTEGER DEFAULT 0');
$this->ensureColumn('accounts', 'auto_start_blocked', 'INTEGER DEFAULT 0');
$this->ensureColumn('accounts', 'remark', "TEXT DEFAULT ''");
$this->ensureColumn('accounts', 'site_type', "TEXT DEFAULT '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('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;
}
}