- 修复:仅当连接信息(base_url/api_prefix/username/password/verify_ssl)真变化时才清 token + 重连; 保存满分/布局等不应触发登录卷王(bug:之前一律 unlink token.json,导致保存后所有用户都被踢) - 修复:默认满分不再误写当前卷;改为显式 apply_pid + apply_score 才会改 full_scores - 配置双写(DB+文件):避免 DB 不可用时静默回退到出厂默认 admin/123456; ny_save_config 同时写文件+DB;ny_load_config 在 DB 可用但无记录时从文件自动迁回 - 设置弹窗新增:登录账号可编辑、密码 type=password 不回显、留空表示不修改; 保存后根据 ping 自动给出成功/失败反馈(错误密码不再静默) - 抽取比例改为按组人数加权(默认)+ 各组均分 + 自定义比例 + 不分组,最大余额法守恒分配 - 抽出比例 UI:删除 5:5 复选框、改 #balanceMode 下拉 + 按组渲染比例输入框 - Db::lastError() 暴露 DB 错误用于诊断(config_get 返回 db_driver / db_connected / db_error) - e2e:test-draw-alloc 18→29 测试用例(加权/自定义/守恒 fuzz); 新增 test-config-save e2e 27 用例全过(连接信息变化检测 + 自动 ping + 密码不回显 + apply_pid) - mock-server:账户校验从 'admin' 改为 'mock' 接受 e2e 账号
323 lines
10 KiB
PHP
323 lines
10 KiB
PHP
<?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;
|
||
private static string $lastError = '';
|
||
|
||
/** 数据库连接参数文件 */
|
||
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;
|
||
}
|
||
|
||
/** 上一次连接失败的原因(连接正常时为空) */
|
||
public static function lastError(): string
|
||
{
|
||
self::check();
|
||
return self::$lastError;
|
||
}
|
||
|
||
/**
|
||
* 获取 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;
|
||
self::$lastError = $e->getMessage();
|
||
}
|
||
}
|
||
|
||
/** 根据参数建 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), login VARCHAR(64))';
|
||
} 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), login VARCHAR(64))';
|
||
} 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), login VARCHAR(64)) ' .
|
||
'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
}
|
||
$pdo->exec($sql); $n++;
|
||
|
||
// 旧版本升级:补 login 列
|
||
self::ensureRosterLogin();
|
||
|
||
return $n;
|
||
}
|
||
|
||
/** 旧表升级:ny_roster 缺 login 列时自动补(三方言兼容) */
|
||
public static function ensureRosterLogin(): void
|
||
{
|
||
$pdo = self::pdo();
|
||
if ($pdo === null) {
|
||
return;
|
||
}
|
||
$has = false;
|
||
if (self::$driver === 'sqlite') {
|
||
foreach ($pdo->query('PRAGMA table_info(ny_roster)')->fetchAll() as $c) {
|
||
if (strtolower((string)$c['name']) === 'login') {
|
||
$has = true;
|
||
break;
|
||
}
|
||
}
|
||
} elseif (self::$driver === 'pgsql') {
|
||
$st = $pdo->prepare("SELECT COUNT(*) FROM information_schema.columns " .
|
||
"WHERE table_name = 'ny_roster' AND column_name = 'login'");
|
||
$st->execute();
|
||
$has = (int)$st->fetchColumn() > 0;
|
||
} else {
|
||
$has = $pdo->query("SHOW COLUMNS FROM ny_roster LIKE 'login'")->fetch() !== false;
|
||
}
|
||
if (!$has) {
|
||
$pdo->exec('ALTER TABLE ny_roster ADD COLUMN login VARCHAR(64)');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 把所有 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 [];
|
||
}
|
||
self::ensureRosterLogin();
|
||
$stmt = $pdo->query('SELECT sid, name, cls, grp, login 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'],
|
||
'login' => (string)($r['login'] ?? ''),
|
||
];
|
||
}
|
||
return $rows;
|
||
}
|
||
|
||
/** 名单:清空后全量写 */
|
||
public static function rosterReplace(array $students): void
|
||
{
|
||
$pdo = self::pdo();
|
||
if ($pdo === null) {
|
||
return;
|
||
}
|
||
self::ensureRosterLogin();
|
||
$pdo->beginTransaction();
|
||
try {
|
||
$pdo->exec('DELETE FROM ny_roster');
|
||
$st = $pdo->prepare('INSERT INTO ny_roster(sid, name, cls, grp, login) VALUES(?, ?, ?, ?, ?)');
|
||
foreach ($students as $s) {
|
||
$st->execute([
|
||
(string)($s['sid'] ?? ''),
|
||
(string)($s['name'] ?? ''),
|
||
(string)($s['cls'] ?? ''),
|
||
(string)($s['grp'] ?? ''),
|
||
(string)($s['login'] ?? ''),
|
||
]);
|
||
}
|
||
$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();
|
||
}
|
||
}
|