Files
ny-exam-platform/lib/Db.php
T
nxxy335 6772aeae11 feat: 抽取页分组均衡5:5 / 重置清累计 / 历史记录 / 排除管理 / 登陆账号字段 / side等高
随机抽取页:
- 分组均衡抽取:有组别(≥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)
2026-09-09 12:29:49 +08:00

314 lines
9.7 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), 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();
}
}