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(); } } // ---------------- 设置 ---------------- /** 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 语义) * @return bool 成功 true;DB 不可用或事务失败 false(失败原因见 lastError) */ public static function rosterReplace(array $students): bool { $pdo = self::pdo(); if (!$pdo) { self::$err = '数据库未连接'; return false; } 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(); return true; } catch (Throwable $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } self::$err = $e->getMessage(); return false; } } 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 单卷配置(缺省返回空结构) */ 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(); } } }