Files
ny-exam-platform/lib/Db.php
T
nxxy335 8686eb99c7 feat: 接入PostgreSQL/MariaDB + 首次安装向导 + 登录页独立 + 设置重构 + 文件夹识别兜底
- 数据库层 lib/Db.php:统一 PDO 支持 pgsql/mysql/sqlite,连接参数存 data/db.json
  (git-ignored),migrate 建 ny_settings/ny_roster 表(三方言兼容 upsert)
- 配置/名单读写:bootstrap ny_load_config/ny_save_config 与 Roster 均改为
  '数据库可用则走 DB、否则回退 data/config.json/roster.json' 的文件降级模式
- 首次安装向导 install.php:选择存储后端(文件/PostgreSQL/MariaDB),测试连接+
  建表+写入卷王连接信息与默认满分;支持 ?force=1 重新配置
- 登录门分离:新增 login.php 独立登录页(仅需密码,账号预填)+logout.php+
  会话(ny_session_start/ny_is_auth/ny_login/ny_logout);index/dashboard 未安装
  跳 install、未登录跳 login;api.php 加会话保护与 auth_check
- 设置重构:删除卷王系统地址/接口前缀/登录账号/登录密码(连接信息由安装向导
  配置);新增布局配置(默认/成绩明细与缺考名单同行)与各试卷独立满分列表(默认
  分数作用于全部);工具栏布局下拉移除
- 文件夹识别兜底:app.js/dashboard.php 双策略 — mode 含 folder 优先,缺省时用
  parent_id 引用反推(应对部分版本无 mode 字段);空文件夹自动隐藏

验证:php -l 全部通过、node --check app.js 通过;SQLite 等价实测 Db 层
(migrate/config upsert/Roster 读写/ny_load_config)与会话辅助函数全部PASS;
e2e(内置服务器)验证路由:未装→install、装完→login、index/dashboard 未登录
跳转、api 401 保护、?force=1 重配、logout 302 均正确。本机无真实 pgsql/mysql
服务器,DB 连接按部署环境实测。
2026-09-09 07:15:56 +08:00

279 lines
8.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 数据库连接层(统一 PDO)
*
* 支持 PostgreSQL / MariaDB(MySQL) / SQLite(主要用于本地无 DB 时的降级与测试)。
* 连接参数存于 data/db.json;通过 Db::instance() 取单例连接。
* 未配置数据库时返回 null,上层回退到文件存储(config.json / roster.json)。
*/
declare(strict_types=1);
class Db
{
/** @var PDO|null */
private static $pdo = null;
private static string $driver = '';
private static bool $tried = false;
private static bool $failed = false;
/** 数据库连接参数文件 */
public static function dbFile(): string
{
return NY_DATA . '/db.json';
}
/** 是否已配置数据库(db.json 存在且有 driver/host 等) */
public static function isConfigured(): bool
{
$d = self::params();
return !empty($d['driver']);
}
/** 读取连接参数 */
public static function params(): array
{
$d = ny_read_json(self::dbFile(), null);
if (!is_array($d)) {
return [];
}
return $d;
}
/** 写入连接参数 */
public static function saveParams(array $p): bool
{
return ny_write_json(self::dbFile(), $p);
}
/** 当前驱动名(用于 SQL 方言):mysql | pgsql | sqlite | '' */
public static function driver(): string
{
self::check();
return self::$driver;
}
public static function isConnected(): bool
{
self::check();
return self::$pdo !== null;
}
/**
* 获取 PDO 单例;未配置或连不上返回 null。
*/
public static function pdo(): ?PDO
{
self::check();
return self::$pdo;
}
private static function check(): void
{
if (self::$tried) {
return;
}
self::$tried = true;
$p = self::params();
$driver = strtolower((string)($p['driver'] ?? ''));
if ($driver === '') {
return;
}
self::$driver = $driver;
try {
self::$pdo = self::connect($p);
} catch (Throwable $e) {
self::$failed = true;
self::$pdo = null;
}
}
/** 根据参数建 PDO 连接 */
public static function connect(array $p): PDO
{
$driver = strtolower((string)($p['driver'] ?? ''));
$host = (string)($p['host'] ?? '127.0.0.1');
$port = (int)($p['port'] ?? 0);
$db = (string)($p['dbname'] ?? ($p['database'] ?? ''));
$user = (string)($p['user'] ?? ($p['username'] ?? ''));
$pass = (string)($p['password'] ?? '');
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
if ($driver === 'sqlite') {
$dsn = 'sqlite:' . ($p['path'] ?? ($db ?: (NY_DATA . '/app.sqlite')));
} elseif ($driver === 'pgsql') {
$dsn = 'pgsql:host=' . $host;
if ($port > 0) {
$dsn .= ';port=' . $port;
}
$dsn .= ';dbname=' . $db;
$dsn .= ';user=' . $user;
$dsn .= ';password=' . $pass;
} else { // mysql / mariadb
$dsn = 'mysql:host=' . $host;
if ($port > 0) {
$dsn .= ';port=' . $port;
}
$dsn .= ';dbname=' . $db . ';charset=utf8mb4';
$options[PDO::ATTR_PERSISTENT] = false;
}
$pdo = new PDO($dsn, $user, $pass, $options);
if ($driver === 'mysql') {
$pdo->exec("SET NAMES utf8mb4");
}
return $pdo;
}
/** 表名前缀 */
public static function prefix(): string
{
return 'ny_';
}
/**
* 建表(幂等)。返回已执行语句数量。
* 用通用 SQL,三方言均可执行(表名/列名小写、规范命名)。
*/
public static function migrate(): int
{
$pdo = self::pdo();
if ($pdo === null) {
throw new RuntimeException('数据库未连接,无法初始化表结构');
}
$driver = self::$driver;
$n = 0;
// settings(key,value) —— 系统设置键值表
if ($driver === 'sqlite') {
$sql = 'CREATE TABLE IF NOT EXISTS ny_settings (' .
'k VARCHAR(128) PRIMARY KEY, ' .
'v TEXT)';
} elseif ($driver === 'pgsql') {
$sql = 'CREATE TABLE IF NOT EXISTS ny_settings (' .
'k VARCHAR(128) PRIMARY KEY, ' .
'v TEXT)';
} else {
$sql = 'CREATE TABLE IF NOT EXISTS ny_settings (' .
'`k` VARCHAR(128) NOT NULL PRIMARY KEY, ' .
'`v` TEXT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
}
$pdo->exec($sql); $n++;
// roster —— 名单表(学号/姓名/班级/组别)
if ($driver === 'sqlite') {
$sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' .
'id INTEGER PRIMARY KEY AUTOINCREMENT, ' .
'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32))';
} elseif ($driver === 'pgsql') {
$sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' .
'id SERIAL PRIMARY KEY, ' .
'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32))';
} else {
$sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' .
'id INT AUTO_INCREMENT PRIMARY KEY, ' .
'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32)) ' .
'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
}
$pdo->exec($sql); $n++;
return $n;
}
/**
* 把所有 settings 读成数组
*/
public static function settingsAll(): array
{
$pdo = self::pdo();
if ($pdo === null) {
return [];
}
$out = [];
$stmt = $pdo->query('SELECT k, v FROM ny_settings');
foreach ($stmt->fetchAll() as $row) {
$out[$row['k']] = $row['v'];
}
return $out;
}
public static function settingsSet(string $k, string $v): void
{
$pdo = self::pdo();
if ($pdo === null) {
return;
}
$drv = self::$driver;
if ($drv === 'pgsql') {
$sql = 'INSERT INTO ny_settings(k, v) VALUES(?, ?) ' .
'ON CONFLICT (k) DO UPDATE SET v = EXCLUDED.v';
} elseif ($drv === 'sqlite') {
$sql = 'INSERT OR REPLACE INTO ny_settings(k, v) VALUES(?, ?)';
} else {
$sql = 'INSERT INTO ny_settings(k, v) VALUES(?, ?) ' .
'ON DUPLICATE KEY UPDATE v = VALUES(v)';
}
$pdo->prepare($sql)->execute([$k, $v]);
}
/** 名单:全量读 */
public static function rosterAll(): array
{
$pdo = self::pdo();
if ($pdo === null) {
return [];
}
$stmt = $pdo->query('SELECT sid, name, cls, grp FROM ny_roster ORDER BY id');
$rows = [];
foreach ($stmt->fetchAll() as $r) {
$rows[] = [
'sid' => (string)$r['sid'],
'name' => (string)$r['name'],
'cls' => (string)$r['cls'],
'grp' => (string)$r['grp'],
];
}
return $rows;
}
/** 名单:清空后全量写 */
public static function rosterReplace(array $students): void
{
$pdo = self::pdo();
if ($pdo === null) {
return;
}
$pdo->beginTransaction();
try {
$pdo->exec('DELETE FROM ny_roster');
$st = $pdo->prepare('INSERT INTO ny_roster(sid, name, cls, grp) VALUES(?, ?, ?, ?)');
foreach ($students as $s) {
$st->execute([
(string)($s['sid'] ?? ''),
(string)($s['name'] ?? ''),
(string)($s['cls'] ?? ''),
(string)($s['grp'] ?? ''),
]);
}
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
/** 名单:行数 */
public static function rosterCount(): int
{
$pdo = self::pdo();
if ($pdo === null) {
return 0;
}
return (int)$pdo->query('SELECT COUNT(*) FROM ny_roster')->fetchColumn();
}
}