Files
ny-exam-platform/lib/Db.php
T
nxxy335 6cd17b9cb2 fix: 修复保存满分后清登录;配置双写;设置显示账号/密码;抽取比例改按人数加权
- 修复:仅当连接信息(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 账号
2026-09-09 13:06:47 +08:00

323 lines
10 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;
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();
}
}