dbPath = $dbPath; $this->jsonDir = $jsonDir; $this->initDatabase(); } /** * 初始化数据库连接 */ private function initDatabase() { try { // 检查数据库文件是否存在 $dbExists = file_exists($this->dbPath); $this->db = new PDO('sqlite:' . $this->dbPath); $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); // 创建同步日志表 $this->createSyncLogTable(); // 如果数据库文件是新创建的,或者没有任何业务数据表,标记需要同步 if (!$dbExists || $this->isEmptyDatabase()) { $this->needsInitialSync = true; } } catch (PDOException $e) { error_log("数据库连接失败: " . $e->getMessage()); throw $e; } } /** * 检查数据库是否为空(没有业务数据表) */ private function isEmptyDatabase() { try { $sql = "SELECT count(*) as table_count FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != 'json_sync_log'"; $stmt = $this->db->query($sql); $result = $stmt->fetch(); return $result['table_count'] == 0; } catch (Exception $e) { return true; } } /** * 创建同步日志表 */ private function createSyncLogTable() { $sql = "CREATE TABLE IF NOT EXISTS json_sync_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, json_filename TEXT UNIQUE NOT NULL, table_name TEXT NOT NULL, section_no INTEGER DEFAULT 0, last_sync_time DATETIME NOT NULL, json_file_mtime INTEGER NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )"; $this->db->exec($sql); } /** * 检查并同步JSON数据到数据库 */ public function syncJsonToDatabase() { // 如果是初始同步,强制同步所有文件 if ($this->needsInitialSync) { $jsonFiles = glob($this->jsonDir . '*.json'); foreach ($jsonFiles as $file) { $this->syncSingleFile($file); } $this->needsInitialSync = false; return; } // 正常增量同步 $jsonFiles = glob($this->jsonDir . '*.json'); foreach ($jsonFiles as $file) { $this->syncSingleFile($file); } } /** * 同步单个JSON文件到数据库 */ private function syncSingleFile($filePath) { $filename = basename($filePath); $tableName = pathinfo($filename, PATHINFO_FILENAME); // 检查是否需要更新 if (!$this->shouldUpdate($filePath, $tableName)) { return; } // 读取JSON数据 $data = $this->loadJsonData($filePath); if (empty($data)) { return; } // 获取排序号(从第一个数据项的 no 字段) $sectionNo = $data[0]['no'] ?? 0; // 创建或更新表 $this->createTable($tableName, $data[0]); // 清空旧数据 $this->clearTable($tableName); // 插入新数据(跳过第一个section项) $items = array_slice($data, 1); foreach ($items as $item) { $this->insertItem($tableName, $item, $data[0]['section'] ?? $tableName); } // 更新同步日志(包含排序号) $this->updateSyncLog($filename, $tableName, $sectionNo); } /** * 更新同步日志 */ private function updateSyncLog($filename, $tableName, $sectionNo = 0) { $jsonFile = $this->jsonDir . $filename; $jsonModified = filemtime($jsonFile); $syncTime = date('Y-m-d H:i:s'); // 检查是否已存在记录 $sql = "SELECT id FROM json_sync_log WHERE json_filename = :filename"; $stmt = $this->db->prepare($sql); $stmt->execute([':filename' => $filename]); $exists = $stmt->fetch(); if ($exists) { // 更新现有记录 $sql = "UPDATE json_sync_log SET table_name = :table_name, section_no = :section_no, last_sync_time = :sync_time, json_file_mtime = :mtime WHERE json_filename = :filename"; } else { // 插入新记录 $sql = "INSERT INTO json_sync_log (json_filename, table_name, section_no, last_sync_time, json_file_mtime) VALUES (:filename, :table_name, :section_no, :sync_time, :mtime)"; } $stmt = $this->db->prepare($sql); $stmt->execute([ ':filename' => $filename, ':table_name' => $tableName, ':section_no' => $sectionNo, ':sync_time' => $syncTime, ':mtime' => $jsonModified ]); } /** * 判断是否需要更新 */ private function shouldUpdate($jsonFile, $tableName) { $filename = basename($jsonFile); $jsonModified = filemtime($jsonFile); // 查询该JSON文件的同步记录 $sql = "SELECT * FROM json_sync_log WHERE json_filename = :filename"; $stmt = $this->db->prepare($sql); $stmt->execute([':filename' => $filename]); $log = $stmt->fetch(); // 如果没有同步记录,需要更新 if (!$log) { return true; } // 计算时间差(秒) $timeDiff = $jsonModified - $log['json_file_mtime']; // 如果JSON文件修改时间比记录的晚至少5分钟(300秒),则需要更新 if ($timeDiff >= 300) { return true; } // 否则不需要更新 return false; } /** * 加载JSON数据 */ private function loadJsonData($filePath) { if (!file_exists($filePath)) { return []; } $content = file_get_contents($filePath); if ($content === false) { return []; } $data = json_decode($content, true); if (json_last_error() !== JSON_ERROR_NONE) { error_log("JSON解析错误: " . json_last_error_msg()); return []; } return is_array($data) ? $data : []; } /** * 创建数据表 */ private function createTable($tableName, $firstItem) { // 清理表名,只保留字母、数字和下划线 $tableName = preg_replace('/[^a-zA-Z0-9_]/', '_', $tableName); $sql = "CREATE TABLE IF NOT EXISTS {$tableName} ( id INTEGER PRIMARY KEY AUTOINCREMENT, section TEXT NOT NULL, name TEXT NOT NULL, url TEXT, description TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP )"; $this->db->exec($sql); } /** * 清空表数据 */ private function clearTable($tableName) { $tableName = preg_replace('/[^a-zA-Z0-9_]/', '_', $tableName); $this->db->exec("DELETE FROM {$tableName}"); } /** * 插入数据项 */ private function insertItem($tableName, $item, $section) { $tableName = preg_replace('/[^a-zA-Z0-9_]/', '_', $tableName); $sql = "INSERT INTO {$tableName} (section, name, url, description) VALUES (:section, :name, :url, :description)"; $stmt = $this->db->prepare($sql); $stmt->execute([ ':section' => $section, ':name' => $item['name'] ?? '', ':url' => $item['url'] ?? '', ':description' => $item['description'] ?? '' ]); } /** * 全局搜索 */ public function globalSearch($keyword) { if (empty($keyword)) { return []; } $tables = $this->getAllTables(); $results = []; foreach ($tables as $table) { $tableName = $table['name']; $sql = "SELECT *, '{$tableName}' as source_table FROM {$tableName} WHERE name LIKE :keyword OR description LIKE :keyword OR url LIKE :keyword ORDER BY name"; $stmt = $this->db->prepare($sql); $stmt->execute([':keyword' => '%' . $keyword . '%']); $items = $stmt->fetchAll(); if (!empty($items)) { $results[$tableName] = [ 'section' => $items[0]['section'] ?? $tableName, 'items' => $items ]; } } return $results; } /** * 按栏目搜索 */ public function searchBySection($section, $keyword) { if (empty($keyword)) { return []; } $tables = $this->getAllTables(); $results = []; foreach ($tables as $table) { $tableName = $table['name']; $sql = "SELECT * FROM {$tableName} WHERE section = :section AND (name LIKE :keyword OR description LIKE :keyword OR url LIKE :keyword) ORDER BY name"; $stmt = $this->db->prepare($sql); $stmt->execute([ ':section' => $section, ':keyword' => '%' . $keyword . '%' ]); $items = $stmt->fetchAll(); if (!empty($items)) { $results[] = $items; } } return array_merge(...$results); } /** * 获取所有栏目配置 */ public function getSectionsConfig() { $tables = $this->getAllTables(); $sections = []; foreach ($tables as $table) { $tableName = $table['name']; // 从同步日志中获取排序号 $sql = "SELECT section_no FROM json_sync_log WHERE table_name = :table_name LIMIT 1"; $stmt = $this->db->prepare($sql); $stmt->execute([':table_name' => $tableName]); $log = $stmt->fetch(); $sectionNo = $log ? $log['section_no'] : 0; // 获取栏目名称 $sql = "SELECT DISTINCT section FROM {$tableName} LIMIT 1"; $stmt = $this->db->query($sql); $row = $stmt->fetch(); if ($row) { $sections[$tableName] = [ 'title' => $row['section'], 'table' => $tableName, 'no' => $sectionNo ]; } } // 按 no 字段排序 uasort($sections, function($a, $b) { return ($a['no'] ?? 0) - ($b['no'] ?? 0); }); return $sections; } /** * 获取指定栏目的所有项目 */ public function getItemsBySection($tableName) { if (!$this->validateTableName($tableName)) { return []; } $tableName = preg_replace('/[^a-zA-Z0-9_]/', '_', $tableName); $sql = "SELECT * FROM {$tableName} ORDER BY name"; $stmt = $this->db->query($sql); return $stmt->fetchAll(); } /** * 获取所有表名 */ private function getAllTables() { $sql = "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != 'json_sync_log' ORDER BY name"; $stmt = $this->db->query($sql); return $stmt->fetchAll(); } /** * 关闭数据库连接 */ public function close() { $this->db = null; } // ======================== 表名安全验证 ======================== /** * 验证表名是否存在于数据库中(防SQL注入) */ private function validateTableName($tableName) { $tables = $this->getAllTables(); $validNames = array_column($tables, 'name'); return in_array($tableName, $validNames, true); } // ======================== JSON 文件操作 ======================== /** * 读取JSON文件并解析 * @return array|null */ private function readJsonArray($filePath) { if (!file_exists($filePath)) return null; $content = file_get_contents($filePath); if ($content === false) return null; $data = json_decode($content, true); if (json_last_error() !== JSON_ERROR_NONE) return null; return is_array($data) ? $data : null; } /** * 写入JSON文件(保持格式) */ private function writeJsonArray($filePath, $data) { $json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); return file_put_contents($filePath, $json . "\n") !== false; } /** * 从表名获取JSON文件路径 */ private function getJsonFilePath($tableName) { return $this->jsonDir . $tableName . '.json'; } /** * 获取所有栏目带详细信息 * @return array 每个元素包含 name, title, no, item_count, filename */ public function getAllSectionsWithInfo() { $tables = $this->getAllTables(); $sections = []; foreach ($tables as $table) { $info = $this->getSectionInfo($table['name']); if ($info) { $sections[] = $info; } } // 按 no 排序 usort($sections, function($a, $b) { return ($a['no'] ?? 0) - ($b['no'] ?? 0); }); return $sections; } /** * 获取单个栏目配置信息 * @return array|null */ public function getSectionInfo($tableName) { if (!$this->validateTableName($tableName)) return null; $filePath = $this->getJsonFilePath($tableName); $data = $this->readJsonArray($filePath); if (!$data || empty($data)) return null; $meta = $data[0]; return [ 'name' => $tableName, 'title' => $meta['section'] ?? $tableName, 'no' => $meta['no'] ?? 0, 'item_count' => count($data) - 1, 'filename' => $tableName . '.json' ]; } // ======================== 工具条目 CRUD ======================== /** * 在栏目中添加工具条目 */ public function addItemToSection($tableName, $name, $url, $description) { if (!$this->validateTableName($tableName)) { return ['success' => false, 'error' => '无效的栏目']; } $filePath = $this->getJsonFilePath($tableName); $data = $this->readJsonArray($filePath); if (!$data) { return ['success' => false, 'error' => '读取JSON文件失败']; } $data[] = [ 'name' => $name, 'url' => $url, 'description' => $description ]; if (!$this->writeJsonArray($filePath, $data)) { return ['success' => false, 'error' => '写入JSON文件失败']; } $this->syncSingleFile($filePath); return ['success' => true]; } /** * 更新栏目中的工具条目 * @param string $tableName 表名 * @param int $itemIndex 工具在JSON中的索引(0表示第一个工具项) */ public function updateItemInSection($tableName, $itemIndex, $name, $url, $description) { if (!$this->validateTableName($tableName)) { return ['success' => false, 'error' => '无效的栏目']; } $filePath = $this->getJsonFilePath($tableName); $data = $this->readJsonArray($filePath); if (!$data) { return ['success' => false, 'error' => '读取JSON文件失败']; } // itemIndex 从 0 开始,对应 JSON 数组中的 data[1], data[2], ... $arrayIndex = $itemIndex + 1; if (!isset($data[$arrayIndex])) { return ['success' => false, 'error' => '工具条目不存在']; } $data[$arrayIndex]['name'] = $name; $data[$arrayIndex]['url'] = $url; $data[$arrayIndex]['description'] = $description; if (!$this->writeJsonArray($filePath, $data)) { return ['success' => false, 'error' => '写入JSON文件失败']; } $this->syncSingleFile($filePath); return ['success' => true]; } /** * 删除栏目中的工具条目 */ public function deleteItemFromSection($tableName, $itemIndex) { if (!$this->validateTableName($tableName)) { return ['success' => false, 'error' => '无效的栏目']; } $filePath = $this->getJsonFilePath($tableName); $data = $this->readJsonArray($filePath); if (!$data) { return ['success' => false, 'error' => '读取JSON文件失败']; } $arrayIndex = $itemIndex + 1; if (!isset($data[$arrayIndex])) { return ['success' => false, 'error' => '工具条目不存在']; } array_splice($data, $arrayIndex, 1); if (!$this->writeJsonArray($filePath, $data)) { return ['success' => false, 'error' => '写入JSON文件失败']; } $this->syncSingleFile($filePath); return ['success' => true]; } /** * 批量添加工具条目 * @param string $tableName 表名 * @param array $items 条目数组,每个元素包含 [name, url, description] */ public function batchAddItems($tableName, $items) { if (!$this->validateTableName($tableName)) { return ['success' => false, 'error' => '无效的栏目', 'count' => 0, 'total' => count($items)]; } $filePath = $this->getJsonFilePath($tableName); $data = $this->readJsonArray($filePath); if (!$data) { return ['success' => false, 'error' => '读取JSON文件失败', 'count' => 0, 'total' => count($items)]; } $added = 0; foreach ($items as $item) { $name = trim($item[0] ?? ''); $url = trim($item[1] ?? ''); $description = trim($item[2] ?? ''); if (empty($name) && empty($url)) continue; $data[] = [ 'name' => $name, 'url' => $url, 'description' => $description ]; $added++; } if ($added === 0) { return ['success' => false, 'error' => '没有有效的条目可添加', 'count' => 0, 'total' => count($items)]; } if (!$this->writeJsonArray($filePath, $data)) { return ['success' => false, 'error' => '写入JSON文件失败', 'count' => $added, 'total' => count($items)]; } $this->syncSingleFile($filePath); return ['success' => true, 'count' => $added, 'total' => count($items)]; } // ======================== 栏目管理 ======================== /** * 创建新栏目 * @param string $sectionName 栏目显示名称 * @return array */ public function createSection($sectionName) { if (empty(trim($sectionName))) { return ['success' => false, 'error' => '栏目名称不能为空']; } $tableName = $this->generateTableName($sectionName); // 检查JSON文件是否已存在 if (file_exists($this->getJsonFilePath($tableName))) { return ['success' => false, 'error' => '栏目文件已存在: ' . $tableName . '.json']; } // 获取最大排序号 $maxNo = $this->getMaxSectionNo(); // 创建JSON数据 $data = [ ['no' => $maxNo + 1, 'section' => $sectionName] ]; if (!$this->writeJsonArray($this->getJsonFilePath($tableName), $data)) { return ['success' => false, 'error' => '创建JSON文件失败']; } // 同步到数据库 try { $this->syncSingleFile($this->getJsonFilePath($tableName)); } catch (Exception $e) { // 清理已创建的文件 unlink($this->getJsonFilePath($tableName)); return ['success' => false, 'error' => '数据库同步失败: ' . $e->getMessage()]; } return ['success' => true, 'table_name' => $tableName]; } /** * 删除栏目(删除JSON文件 + 数据库表 + 同步日志) */ public function removeSection($tableName) { if (!$this->validateTableName($tableName)) { return ['success' => false, 'error' => '栏目不存在']; } // 删除JSON文件 $filePath = $this->getJsonFilePath($tableName); if (file_exists($filePath)) { if (!unlink($filePath)) { return ['success' => false, 'error' => '删除JSON文件失败']; } } // 删除数据库表 $safeName = preg_replace('/[^a-zA-Z0-9_]/', '_', $tableName); $this->db->exec("DROP TABLE IF EXISTS {$safeName}"); // 删除同步日志 $stmt = $this->db->prepare("DELETE FROM json_sync_log WHERE table_name = :tn"); $stmt->execute([':tn' => $tableName]); return ['success' => true]; } /** * 重命名栏目显示名称 */ public function renameSection($tableName, $newSectionName) { if (empty(trim($newSectionName))) { return ['success' => false, 'error' => '栏目名称不能为空']; } if (!$this->validateTableName($tableName)) { return ['success' => false, 'error' => '栏目不存在']; } $filePath = $this->getJsonFilePath($tableName); $data = $this->readJsonArray($filePath); if (!$data) { return ['success' => false, 'error' => '读取JSON文件失败']; } // 更新元数据中的section名称 $data[0]['section'] = $newSectionName; if (!$this->writeJsonArray($filePath, $data)) { return ['success' => false, 'error' => '写入JSON文件失败']; } $this->syncSingleFile($filePath); return ['success' => true]; } /** * 获取全部栏目数据用于导出 * @return array */ public function exportAllSections() { $tables = $this->getAllTables(); $export = []; foreach ($tables as $table) { $filePath = $this->getJsonFilePath($table['name']); $data = $this->readJsonArray($filePath); if ($data) { $export[$table['name']] = $data; } } return $export; } /** * 获取栏目CSV导出内容 */ public function exportSectionToCsv($tableName) { if (!$this->validateTableName($tableName)) return ''; $items = $this->getItemsBySection($tableName); $info = $this->getSectionInfo($tableName); $csv = "名称,URL,描述\r\n"; foreach ($items as $item) { $name = str_replace('"', '""', $item['name'] ?? ''); $url = str_replace('"', '""', $item['url'] ?? ''); $desc = str_replace('"', '""', $item['description'] ?? ''); $csv .= "\"{$name}\",\"{$url}\",\"{$desc}\"\r\n"; } return $csv; } // ======================== 辅助方法 ======================== /** * 获取最大排序号 */ private function getMaxSectionNo() { $sql = "SELECT MAX(section_no) as max_no FROM json_sync_log"; $stmt = $this->db->query($sql); $row = $stmt->fetch(); return $row['max_no'] ?? 0; } /** * 从栏目名称生成表名/文件名 */ private function generateTableName($name) { // 先做基本清理 $name = preg_replace('/[^a-zA-Z0-9_\x{4e00}-\x{9fff}]/u', '_', $name); $name = preg_replace('/_+/', '_', $name); $name = trim($name, '_'); if (empty($name)) $name = 'section'; // 如果包含中文,提取英文部分+hash if (preg_match('/[\x{4e00}-\x{9fff}]/u', $name)) { $eng = preg_replace('/[\x{4e00}-\x{9fff}]/u', '', $name); $eng = trim(preg_replace('/_+/', '_', $eng), '_'); if (empty($eng)) $eng = 'section'; $hash = substr(md5($name), 0, 4); $name = $eng . '_' . $hash; } return strtolower($name); } }