'sqlite', 'file' => self::dbFile()]; } public static function saveParams(array $p): bool { return true; // SQLite 无需外部配置 } /** 建立/复用连接;失败返回 null 而不抛异常(上层要能优雅降级) */ public static function connect(array $p = []): PDO { if (self::$pdo instanceof PDO) { return self::$pdo; } if (!self::isConfigured()) { self::$err = 'PHP 未启用 pdo_sqlite 扩展,已回退到文件存储'; throw new RuntimeException(self::$err); } $file = self::dbFile(); $dir = dirname($file); if (!is_dir($dir)) { @mkdir($dir, 0777, true); } try { $pdo = new PDO('sqlite:' . $file, null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]); // WAL + 合理超时,避免多人同时刷新时 "database is locked" $pdo->exec('PRAGMA journal_mode=WAL'); $pdo->exec('PRAGMA synchronous=NORMAL'); $pdo->exec('PRAGMA busy_timeout=5000'); $pdo->exec('PRAGMA foreign_keys=ON'); self::$pdo = $pdo; self::$err = ''; self::migrate(); return $pdo; } catch (Throwable $e) { self::$pdo = null; self::$err = $e->getMessage(); throw new RuntimeException('SQLite 连接失败:' . $e->getMessage()); } } /** 获取连接(不可用返回 null) */ public static function pdo(): ?PDO { if (self::$pdo instanceof PDO) { return self::$pdo; } if (self::$initTried) { return null; // 已经试过且失败,不再反复重试(每次请求最多试一次) } self::$initTried = true; try { return self::connect(); } catch (Throwable $e) { return null; } } /** 建表 + 索引(幂等,可重复调用) */ public static function migrate(): int { $pdo = self::$pdo; if (!$pdo) { return 0; } $p = self::prefix(); $pdo->exec("CREATE TABLE IF NOT EXISTS {$p}settings ( k TEXT PRIMARY KEY, v TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL DEFAULT 0 )"); $pdo->exec("CREATE TABLE IF NOT EXISTS {$p}roster ( sid TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', cls TEXT NOT NULL DEFAULT '', grp TEXT NOT NULL DEFAULT '', login TEXT NOT NULL DEFAULT '', sort INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0 )"); $pdo->exec("CREATE TABLE IF NOT EXISTS {$p}project_config ( project_id TEXT PRIMARY KEY, config_json TEXT NOT NULL DEFAULT '{}', updated_at INTEGER NOT NULL DEFAULT 0 )"); $pdo->exec("CREATE INDEX IF NOT EXISTS idx_{$p}roster_cls ON {$p}roster(cls)"); self::ensureRosterLogin(); return 3; } /** 旧库缺少 login 列时补上(幂等) */ public static function ensureRosterLogin(): void { $pdo = self::$pdo; if (!$pdo) { return; } $p = self::prefix(); try { $cols = $pdo->query("PRAGMA table_info({$p}roster)")->fetchAll(PDO::FETCH_ASSOC); $has = false; foreach ($cols as $c) { if (($c['name'] ?? '') === 'login') { $has = true; break; } } if (!$has) { $pdo->exec("ALTER TABLE {$p}roster ADD COLUMN login TEXT NOT NULL DEFAULT ''"); } } catch (Throwable $e) { self::$err = $e->getMessage(); } } // ---------------- 设置 ---------------- public static function settingsAll(): array { $pdo = self::pdo(); if (!$pdo) { return []; } try { $p = self::prefix(); $st = $pdo->query("SELECT k, v FROM {$p}settings"); $out = []; foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) { $out[(string)$r['k']] = (string)$r['v']; } return $out; } catch (Throwable $e) { self::$err = $e->getMessage(); return []; } } /** SQLite 用 ON CONFLICT 做 upsert(其它方言见历史版本) */ public static function settingsSet(string $k, string $v): void { $pdo = self::pdo(); if (!$pdo) { return; } try { $p = self::prefix(); $st = $pdo->prepare( "INSERT INTO {$p}settings (k, v, updated_at) VALUES (?, ?, ?) ON CONFLICT(k) DO UPDATE SET v=excluded.v, updated_at=excluded.updated_at" ); $st->execute([$k, $v, time()]); } catch (Throwable $e) { self::$err = $e->getMessage(); } } public static function settingsGet(string $k, string $def = ''): string { $pdo = self::pdo(); if (!$pdo) { return $def; } try { $p = self::prefix(); $st = $pdo->prepare("SELECT v FROM {$p}settings WHERE k = ?"); $st->execute([$k]); $v = $st->fetchColumn(); return $v === false ? $def : (string)$v; } catch (Throwable $e) { self::$err = $e->getMessage(); return $def; } } // ---------------- 名单 ---------------- public static function rosterAll(): array { $pdo = self::pdo(); if (!$pdo) { return []; } try { $p = self::prefix(); $st = $pdo->query("SELECT sid, name, cls, grp, login FROM {$p}roster ORDER BY sort ASC, sid ASC"); $out = []; foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) { $out[] = [ 'sid' => (string)($r['sid'] ?? ''), 'name' => (string)($r['name'] ?? ''), 'cls' => (string)($r['cls'] ?? ''), 'grp' => (string)($r['grp'] ?? ''), 'login' => (string)($r['login'] ?? ''), ]; } return $out; } catch (Throwable $e) { self::$err = $e->getMessage(); return []; } } /** 全量替换名单(roster_save 语义) */ public static function rosterReplace(array $students): void { $pdo = self::pdo(); if (!$pdo) { return; } try { $p = self::prefix(); $pdo->beginTransaction(); $pdo->exec("DELETE FROM {$p}roster"); $st = $pdo->prepare( "INSERT INTO {$p}roster (sid, name, cls, grp, login, sort, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)" ); $i = 0; foreach ($students as $s) { if (!is_array($s)) { continue; } $st->execute([ (string)($s['sid'] ?? ''), (string)($s['name'] ?? ''), (string)($s['cls'] ?? ''), (string)($s['grp'] ?? ''), (string)($s['login'] ?? ''), $i++, time(), ]); } $pdo->commit(); } catch (Throwable $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } self::$err = $e->getMessage(); } } public static function rosterCount(): int { $pdo = self::pdo(); if (!$pdo) { return 0; } try { $p = self::prefix(); return (int)$pdo->query("SELECT COUNT(*) FROM {$p}roster")->fetchColumn(); } catch (Throwable $e) { self::$err = $e->getMessage(); return 0; } } // ---------------- 每卷独立配置(满分 / 合格率 / 免考名单) ---------------- /** @return array 全部试卷配置 [project_id => cfg] */ public static function projectConfigAll(): array { $pdo = self::pdo(); if (!$pdo) { return []; } try { $p = self::prefix(); $st = $pdo->query("SELECT project_id, config_json FROM {$p}project_config"); $out = []; foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) { $j = json_decode((string)($r['config_json'] ?? '{}'), true); if (is_array($j)) { $out[(string)$r['project_id']] = $j; } } return $out; } catch (Throwable $e) { self::$err = $e->getMessage(); return []; } } /** @return array 单卷配置(缺省返回空结构) */ public static function projectConfigGet(string $projectId): array { $def = ['full_score' => null, 'pass_rate' => null, 'exempt' => []]; if ($projectId === '') { return $def; } $pdo = self::pdo(); if (!$pdo) { return $def; } try { $p = self::prefix(); $st = $pdo->prepare("SELECT config_json FROM {$p}project_config WHERE project_id = ?"); $st->execute([$projectId]); $v = $st->fetchColumn(); if ($v === false) { return $def; } $j = json_decode((string)$v, true); return is_array($j) ? array_merge($def, $j) : $def; } catch (Throwable $e) { self::$err = $e->getMessage(); return $def; } } /** 写入单卷配置(整份覆盖;null 表示"用全局默认") */ public static function projectConfigSet(string $projectId, array $cfg): void { if ($projectId === '') { return; } $pdo = self::pdo(); if (!$pdo) { return; } try { $p = self::prefix(); $st = $pdo->prepare( "INSERT INTO {$p}project_config (project_id, config_json, updated_at) VALUES (?, ?, ?) ON CONFLICT(project_id) DO UPDATE SET config_json=excluded.config_json, updated_at=excluded.updated_at" ); $st->execute([$projectId, json_encode($cfg, JSON_UNESCAPED_UNICODE), time()]); } catch (Throwable $e) { self::$err = $e->getMessage(); } } public static function projectConfigDelete(string $projectId): void { $pdo = self::pdo(); if (!$pdo) { return; } try { $p = self::prefix(); $st = $pdo->prepare("DELETE FROM {$p}project_config WHERE project_id = ?"); $st->execute([$projectId]); } catch (Throwable $e) { self::$err = $e->getMessage(); } } }