exec( "CREATE TABLE IF NOT EXISTS `cloak_site_domains` ( `id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, `hostname` varchar(255) NOT NULL COMMENT '站点域名,不含协议与路径', `config_name` varchar(64) NOT NULL DEFAULT '' COMMENT '关联 check_config 配置名', `cf_zone_id` varchar(64) DEFAULT NULL COMMENT 'Cloudflare Zone ID', `cf_nameservers` text DEFAULT NULL COMMENT 'Cloudflare NS JSON', `cf_status` varchar(32) NOT NULL DEFAULT 'local' COMMENT 'Cloudflare 状态', `cf_error` varchar(500) DEFAULT NULL COMMENT '最近错误', `cf_checked_at` datetime DEFAULT NULL COMMENT '上次检测时间', `created_at` timestamp NOT NULL DEFAULT current_timestamp(), PRIMARY KEY (`id`), UNIQUE KEY `uk_hostname` (`hostname`), KEY `idx_config_name` (`config_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci" ); self::ensureCloudflareColumns($pdo); self::ensureConfigDomainMapTable($pdo); } /** * 配置 → 域名(多配置可共用同一域名) */ public static function ensureConfigDomainMapTable(PDO $pdo) { $pdo->exec( "CREATE TABLE IF NOT EXISTS `cloak_config_domain` ( `config_name` varchar(64) NOT NULL COMMENT 'check_config 配置名', `domain_id` int(11) UNSIGNED NOT NULL COMMENT 'cloak_site_domains.id', `created_at` timestamp NOT NULL DEFAULT current_timestamp(), PRIMARY KEY (`config_name`), KEY `idx_domain_id` (`domain_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci" ); self::migrateLegacyDomainBindings($pdo); } /** * 将 cloak_site_domains.config_name 旧数据迁入 cloak_config_domain(幂等) */ private static function migrateLegacyDomainBindings(PDO $pdo) { $st = $pdo->query( "SELECT id, config_name FROM cloak_site_domains WHERE config_name IS NOT NULL AND config_name != ''" ); if (!$st) { return; } $ins = $pdo->prepare( 'INSERT IGNORE INTO cloak_config_domain (config_name, domain_id) VALUES (?, ?)' ); $clr = $pdo->prepare("UPDATE cloak_site_domains SET config_name = '' WHERE id = ?"); while ($row = $st->fetch()) { $cfg = trim((string) ($row['config_name'] ?? '')); $id = (int) ($row['id'] ?? 0); if ($cfg === '' || $id <= 0) { continue; } $ins->execute([$cfg, $id]); $clr->execute([$id]); } } private static function ensureCloudflareColumns(PDO $pdo) { $columns = [ 'cf_zone_id' => "ADD COLUMN `cf_zone_id` varchar(64) DEFAULT NULL COMMENT 'Cloudflare Zone ID'", 'cf_nameservers' => "ADD COLUMN `cf_nameservers` text DEFAULT NULL COMMENT 'Cloudflare NS JSON'", 'cf_status' => "ADD COLUMN `cf_status` varchar(32) NOT NULL DEFAULT 'local' COMMENT 'Cloudflare 状态'", 'cf_error' => "ADD COLUMN `cf_error` varchar(500) DEFAULT NULL COMMENT '最近错误'", 'cf_checked_at' => "ADD COLUMN `cf_checked_at` datetime DEFAULT NULL COMMENT '上次检测时间'", ]; foreach ($columns as $name => $ddl) { if (!self::columnExists($pdo, 'cloak_site_domains', $name)) { $pdo->exec('ALTER TABLE `cloak_site_domains` ' . $ddl); } } } private static function columnExists(PDO $pdo, $table, $column) { $st = $pdo->prepare( 'SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?' ); $st->execute([DB_NAME, $table, $column]); return (int) $st->fetchColumn() > 0; } private static function selectColumns() { return 'id, hostname, config_name, cf_zone_id, cf_nameservers, cf_status, cf_error, cf_checked_at, created_at'; } public static function pdo() { return new PDO( 'mysql:host=127.0.0.1;dbname=' . DB_NAME . ';charset=utf8mb4', DB_USERNAME, DB_PASSWORD, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC] ); } public static function normalizeHostname($host) { $host = trim((string) $host); if ($host === '') { return ''; } if (strpos($host, '://') !== false) { $parsed = parse_url($host, PHP_URL_HOST); $host = $parsed ?: $host; } else { $host = preg_replace('#/.*$#', '', $host); $host = preg_replace('#:\d+$#', '', $host); } $host = strtolower(trim($host, '.')); if (str_starts_with($host, 'www.')) { $host = substr($host, 4); } return $host; } /** * 从多行文本解析域名列表(每行一个,也支持逗号/分号分隔) * * @return string[] */ public static function parseHostnameBatch($text) { $text = str_replace(["\r\n", "\r"], "\n", trim((string) $text)); if ($text === '') { return []; } $parts = preg_split('/[\n,;]+/', $text); $seen = []; $out = []; foreach ($parts as $part) { $host = self::normalizeHostname(trim($part)); if ($host === '' || isset($seen[$host])) { continue; } if (!preg_match('/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/i', $host)) { continue; } $seen[$host] = true; $out[] = $host; } return $out; } public static function hostToConfigKey($host) { $host = self::normalizeHostname($host); if ($host === '') { return 'index'; } $key = preg_replace('/[^a-z0-9_-]+/i', '_', $host); $key = trim($key, '_'); return $key !== '' ? $key : 'index'; } public static function listAll(PDO $pdo = null) { $pdo = $pdo ?: self::pdo(); self::ensureTable($pdo); $st = $pdo->query('SELECT ' . self::selectColumns() . ' FROM cloak_site_domains ORDER BY hostname ASC'); return $st->fetchAll(); } public static function findById(PDO $pdo, $id) { self::ensureTable($pdo); $st = $pdo->prepare('SELECT ' . self::selectColumns() . ' FROM cloak_site_domains WHERE id = ? LIMIT 1'); $st->execute([(int) $id]); $row = $st->fetch(); return $row ?: null; } public static function updateCloudflareMeta(PDO $pdo, $id, array $fields) { self::ensureTable($pdo); $allowed = ['cf_zone_id', 'cf_nameservers', 'cf_status', 'cf_error', 'cf_checked_at']; $sets = []; $vals = []; foreach ($allowed as $key) { if (array_key_exists($key, $fields)) { $sets[] = "`{$key}` = ?"; $vals[] = $fields[$key]; } } if ($sets === []) { return false; } $vals[] = (int) $id; $sql = 'UPDATE cloak_site_domains SET ' . implode(', ', $sets) . ' WHERE id = ?'; $st = $pdo->prepare($sql); $st->execute($vals); return $st->rowCount() > 0; } public static function findByHostname(PDO $pdo, $hostname) { self::ensureTable($pdo); $host = self::normalizeHostname($hostname); if ($host === '') { return null; } $st = $pdo->prepare('SELECT ' . self::selectColumns() . ' FROM cloak_site_domains WHERE hostname = ? LIMIT 1'); $st->execute([$host]); $row = $st->fetch(); return $row ?: null; } public static function add(PDO $pdo, $hostname) { self::ensureTable($pdo); $host = self::normalizeHostname($hostname); if ($host === '' || !preg_match('/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/i', $host)) { return ['ok' => false, 'error' => '域名格式无效']; } try { $st = $pdo->prepare('INSERT INTO cloak_site_domains (hostname, config_name) VALUES (?, ?)'); $st->execute([$host, '']); return ['ok' => true, 'id' => (int) $pdo->lastInsertId(), 'hostname' => $host]; } catch (PDOException $e) { if ((int) $e->getCode() === 23000) { return ['ok' => false, 'error' => '该域名已存在']; } throw $e; } } public static function deleteById(PDO $pdo, $id) { self::ensureTable($pdo); $id = (int) $id; $pdo->prepare('DELETE FROM cloak_config_domain WHERE domain_id = ?')->execute([$id]); $st = $pdo->prepare('DELETE FROM cloak_site_domains WHERE id = ?'); $st->execute([$id]); return $st->rowCount() > 0; } public static function bindConfigToDomain(PDO $pdo, $configName, $domainId) { self::ensureTable($pdo); $configName = trim($configName); $domainId = (int) $domainId; if ($configName === '' || $domainId <= 0) { return false; } if (self::findById($pdo, $domainId) === null) { return false; } $st = $pdo->prepare( 'INSERT INTO cloak_config_domain (config_name, domain_id) VALUES (?, ?) ON DUPLICATE KEY UPDATE domain_id = VALUES(domain_id)' ); $st->execute([$configName, $domainId]); return true; } public static function clearConfigBinding(PDO $pdo, $configName) { self::ensureTable($pdo); $st = $pdo->prepare('DELETE FROM cloak_config_domain WHERE config_name = ?'); $st->execute([trim($configName)]); } public static function domainIdForConfig(PDO $pdo, $configName) { self::ensureTable($pdo); $st = $pdo->prepare('SELECT domain_id FROM cloak_config_domain WHERE config_name = ? LIMIT 1'); $st->execute([trim($configName)]); $row = $st->fetch(); return $row ? (int) $row['domain_id'] : 0; } public static function primaryHostnameForConfig(PDO $pdo, $configName) { self::ensureTable($pdo); $st = $pdo->prepare( 'SELECT d.hostname FROM cloak_config_domain m INNER JOIN cloak_site_domains d ON d.id = m.domain_id WHERE m.config_name = ? LIMIT 1' ); $st->execute([trim($configName)]); $row = $st->fetch(); return $row ? (string) $row['hostname'] : ''; } /** * @return array */ public static function groupConfigsByDomainId(PDO $pdo) { self::ensureTable($pdo); $st = $pdo->query('SELECT domain_id, config_name FROM cloak_config_domain ORDER BY config_name ASC'); $map = []; while ($row = $st->fetch()) { $did = (int) ($row['domain_id'] ?? 0); if ($did <= 0) { continue; } $map[$did][] = (string) $row['config_name']; } return $map; } /** * @return string[] */ public static function configNamesForDomainId(PDO $pdo, $domainId) { self::ensureTable($pdo); $st = $pdo->prepare('SELECT config_name FROM cloak_config_domain WHERE domain_id = ? ORDER BY config_name ASC'); $st->execute([(int) $domainId]); $names = []; while ($row = $st->fetch()) { $names[] = (string) $row['config_name']; } return $names; } /** * 从全部站点域名中随机选取一个(允许多配置共用同一域名) */ public static function pickRandomDomainId(PDO $pdo, $configName = '') { self::ensureTable($pdo); unset($configName); $all = self::listAll($pdo); if ($all === []) { return 0; } $pick = $all[array_rand($all)]; return (int) ($pick['id'] ?? 0); } public static function resolveConfigNameForHost($host, $baseDir = null) { $baseDir = $baseDir ?: dirname(__DIR__, 2); $norm = self::normalizeHostname($host); if ($norm !== '') { try { $pdo = self::pdo(); self::ensureTable($pdo); $row = self::findByHostname($pdo, $norm); if ($row) { $key = self::hostToConfigKey($norm); if (file_exists($baseDir . '/check_config/' . $key . '_config.php')) { return $key; } return 'index'; } } catch (Throwable $e) { } $key = self::hostToConfigKey($norm); if (file_exists($baseDir . '/check_config/' . $key . '_config.php')) { return $key; } } return 'index'; } }