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 连接按部署环境实测。
This commit is contained in:
+278
@@ -0,0 +1,278 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user