dbFile = $dbFile ?: __DIR__ . '/data/data.sqlite'; // 环境安全检查 $this->secureEnvironment(); $this->connect(); $this->initSchema(); } private function secureEnvironment() { $dir = dirname($this->dbFile); $oldFile = __DIR__ . '/data.sqlite'; // 1. 自动创建目录 if (!is_dir($dir)) { if (!@mkdir($dir, 0755, true)) { $this->throwPermissionError($dir); } } // 2. 自动迁移旧数据 if (file_exists($oldFile) && !file_exists($this->dbFile)) { if (!@rename($oldFile, $this->dbFile)) { if (@copy($oldFile, $this->dbFile)) { @unlink($oldFile); } else { throw new Exception("安全迁移失败:无法移动旧数据库。请检查目录权限。"); } } } // 3. 部署 .htaccess $htaccess = $dir . '/.htaccess'; if (!file_exists($htaccess)) { @file_put_contents($htaccess, "Order Deny,Allow\nDeny from all"); } // 4. 部署 index.html $indexHtml = $dir . '/index.html'; if (!file_exists($indexHtml)) { @file_put_contents($indexHtml, ''); } } private function connect() { try { $this->pdo = new PDO('sqlite:' . $this->dbFile); $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); // 开启 WAL 模式,提高并发读写性能 $this->pdo->exec('PRAGMA journal_mode = WAL;'); } catch (PDOException $e) { if (strpos($e->getMessage(), 'unable to open database file') !== false) { $this->throwPermissionError(dirname($this->dbFile)); } throw new Exception("Database Error: " . $e->getMessage()); } } private function throwPermissionError($dir) { $user = get_current_user(); throw new Exception("权限不足:Web用户 ({$user}) 无法读写 {$dir}。
请修复权限:chown -R {$user}:{$user} " . __DIR__ . ""); } private function initSchema() { $this->pdo->exec("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)"); $this->pdo->exec("CREATE TABLE IF NOT EXISTS accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, access_key_id TEXT, access_key_secret TEXT, region_id TEXT, instance_id TEXT, max_traffic REAL, schedule_enabled INTEGER DEFAULT 0, start_time TEXT, stop_time TEXT, traffic_used REAL DEFAULT 0, instance_status TEXT DEFAULT 'Unknown', updated_at INTEGER DEFAULT 0, last_keep_alive_at INTEGER DEFAULT 0 )"); $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, access_key_id TEXT, traffic REAL, recorded_at INTEGER )"); $this->pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_traffic_hourly_unique ON traffic_hourly (access_key_id, recorded_at)"); // 2. 天级表 (30天柱状图) $this->pdo->exec("CREATE TABLE IF NOT EXISTS traffic_daily ( id INTEGER PRIMARY KEY AUTOINCREMENT, access_key_id TEXT, traffic REAL, recorded_at INTEGER )"); $this->pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_traffic_daily_unique ON traffic_daily (access_key_id, recorded_at)"); $this->ensureColumn('accounts', 'traffic_used', 'REAL DEFAULT 0'); $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'); } 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"); } } 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($accessKeyId, $traffic) { $hourTimestamp = floor(time() / 3600) * 3600; $stmt = $this->pdo->prepare("INSERT OR REPLACE INTO traffic_hourly (access_key_id, traffic, recorded_at) VALUES (?, ?, ?)"); $stmt->execute([$accessKeyId, $traffic, $hourTimestamp]); } public function addDailyStat($accessKeyId, $traffic) { $dayTimestamp = strtotime(date('Y-m-d 00:00:00')); $stmt = $this->pdo->prepare("INSERT OR REPLACE INTO traffic_daily (access_key_id, traffic, recorded_at) VALUES (?, ?, ?)"); $stmt->execute([$accessKeyId, $traffic, $dayTimestamp]); } public function getHourlyStats($accessKeyId) { $stmt = $this->pdo->prepare("SELECT traffic, recorded_at FROM traffic_hourly WHERE access_key_id = ? ORDER BY recorded_at DESC LIMIT 25"); $stmt->execute([$accessKeyId]); $data = $stmt->fetchAll(PDO::FETCH_ASSOC); return array_reverse($data); } public function getDailyStats($accessKeyId) { $stmt = $this->pdo->prepare("SELECT traffic, recorded_at FROM traffic_daily WHERE access_key_id = ? ORDER BY recorded_at DESC LIMIT 31"); $stmt->execute([$accessKeyId]); $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"); } }