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(其它方言见历史版本) * * 返回是否真的写成功:以前这里 void + 内部吞异常,导致"配置没落盘但上层以为成功" * (典型后果:登录成功→下一请求仍报"未配置账号"→反复跳登录页的死循环)。 * 因此改为回读校验后返回 bool,并把失败原因放进 self::lastError()。 */ public static function settingsSet(string $k, string $v): bool { $pdo = self::pdo(); if (!$pdo) { return false; } 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(); return false; } // 回读校验:磁盘满 / 目录不可写 / 只读挂载时 execute 可能不报错但实际未落盘 try { $p = self::prefix(); $st = $pdo->prepare("SELECT v FROM {$p}settings WHERE k = ?"); $st->execute([$k]); $got = $st->fetchColumn(); if ($got === false || (string)$got !== $v) { self::$err = '写入后回读不一致(可能磁盘已满或数据库只读)'; return false; } } catch (Throwable $e) { self::$err = $e->getMessage(); return false; } return true; } public static function settingsDelete(string $k): void { $pdo = self::pdo(); if (!$pdo) { return; } try { $p = self::prefix(); $st = $pdo->prepare("DELETE FROM {$p}settings WHERE k = ?"); $st->execute([$k]); } 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(); } } }