随机抽取页: - 分组均衡抽取:有组别(≥2个)时默认按名额均分到组(12/8抽10→5/5), 其次按班级均分,否则整池随机;可勾选关闭 - 名额均分算法 allocQuotas:floor 均分 + 余数小组优先补齐 + 超容量退回 + 差额给剩余容量大者 - 重置结果:同时清零累计已抽取数(历史记录仍保留) - 历史记录:每批次入 localStorage ny_draw_log(上限50), 重置不清,支持查看详情/恢复结果/删除/清空 - 抽取排除:排除管理弹窗(搜索/清空),结果行可"移出并加入排除" - 白名单列表下载:登陆账号优先,缺失回退学号 名单登陆账号: - 模板解析识别"登陆/登录账号/账号/手机号"列,手机号去 .0 尾巴 - Db ny_roster 增 login 列 + 旧表自动升级(三方言) - roster_save 透传 login;draw_export 使用 login - 登陆账号仅用于白名单列表下载,其余界面不展示 布局:side 模式下成绩明细/缺考名单 panel 改 flex 撑满, .table-scroll 取消 320px 限高,两侧等高不再留白 测试:tools/test-draw-alloc.js 18/18 通过(含 1000 组随机 fuzz)
314 lines
9.7 KiB
PHP
314 lines
9.7 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;
|
||
|
||
/** 数据库连接参数文件 */
|
||
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), 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();
|
||
}
|
||
}
|