Files
ny-exam-platform/bootstrap.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

374 lines
11 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
/**
* NY 问卷考试系统数据平台 - 引导 / 公共函数
* PHP >= 8.0
*/
declare(strict_types=1);
const NY_APP_NAME = 'NY问卷考试系统数据平台';
const NY_VERSION = '1.0.0';
define('NY_ROOT', str_replace('\\', '/', __DIR__));
define('NY_DATA', NY_ROOT . '/data');
// ---------------- 环境预检 ----------------
if (PHP_VERSION_ID < 80000) {
http_response_code(500);
header('Content-Type: text/html; charset=utf-8');
echo '<h2>环境不满足</h2><p>本平台需要 <b>PHP 8.0 及以上</b>,当前版本:' . PHP_VERSION . '</p>';
exit;
}
$missing = [];
foreach (['curl' => 'curl', 'json' => 'json', 'mbstring' => 'mbstring'] as $fn => $ext) {
if (!extension_loaded($ext)) {
$missing[] = $ext;
}
}
if ($missing !== []) {
http_response_code(500);
header('Content-Type: text/html; charset=utf-8');
echo '<h2>缺少 PHP 扩展</h2><p>请在 php.ini 中启用:<b>' . implode('、', $missing) . '</b></p>'
. '<p>Windows:去掉 <code>extension=' . $missing[0] . '</code> 前面的分号并重启服务。'
. '(<code>zip</code> 扩展可选,未启用时会用内置解析器读取 xlsx)</p>';
exit;
}
date_default_timezone_set('Asia/Shanghai');
mb_internal_encoding('UTF-8');
// ---------------- 会话 ----------------
function ny_session_start(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
session_name('NYSESS');
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'httponly' => true,
'samesite' => 'Lax',
'secure' => $secure,
]);
@session_start();
}
/** 当前是否已登录(本系统会话) */
function ny_is_auth(): bool
{
ny_session_start();
return !empty($_SESSION['ny_auth']);
}
/** 标记登录 */
function ny_login(): void
{
ny_session_start();
$_SESSION['ny_auth'] = true;
}
/** 登出 */
function ny_logout(): void
{
ny_session_start();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
}
/** 判断是否已安装(有过连接配置且能流转):文件 config 或 DB settings 存在 base_url 且账号非空 */
function ny_is_installed(): bool
{
$cfg = ny_load_config();
if (trim((string)($cfg['base_url'] ?? '')) === '') {
return false;
}
// 有无可用的后端(文件 config 存在 ,或 DB 连得上)
if (class_exists('Db') && Db::isConnected()) {
return true;
}
return is_file(ny_config_file());
}
// ---------------- 目录初始化 ----------------
foreach (['', '/cache', '/uploads'] as $sub) {
$dir = NY_DATA . $sub;
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
}
// 保护数据目录(Apache)
if (!file_exists(NY_DATA . '/.htaccess')) {
@file_put_contents(NY_DATA . '/.htaccess', "Deny from all\n");
}
// ---------------- 默认配置 ----------------
function ny_default_config(): array
{
return [
'base_url' => 'https://exam.nxxy335.top',
'api_prefix' => '/api',
'username' => 'admin',
'password' => '123456',
'verify_ssl' => true,
'timeout' => 20,
'refresh_interval' => 15,
'page_size' => 200,
'include_temp_save' => false,
'dedupe' => true,
'name_keywords' => '姓名,名字,学生姓名,考生姓名,您的姓名,姓名:,姓名:',
'full_score' => 100,
'full_scores' => [],
'layout' => 'default',
];
}
function ny_config_file(): string
{
return NY_DATA . '/config.json';
}
/**
* 配置来源:db | file | default
* (default 表示既没读到 DB 也没读到文件——此时账号密码是出厂默认值,几乎必然连不上卷王)
*/
function ny_config_source(): string
{
return (string)($GLOBALS['__ny_cfg_source'] ?? 'default');
}
function ny_load_config(): array
{
$GLOBALS['__ny_cfg_source'] = 'default';
// 数据库已初始化且能连上 → 从 DB 读,否则回退文件
$dbCfg = ny_db_config();
if ($dbCfg !== null) {
$cfg = ny_default_config();
foreach ($cfg as $k => $v) {
if (array_key_exists($k, $dbCfg)) {
$cfg[$k] = is_bool($v) ? (bool)$dbCfg[$k] : $dbCfg[$k];
}
}
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
$GLOBALS['__ny_cfg_source'] = 'db';
return $cfg;
}
$cfg = ny_default_config();
$saved = ny_read_json(ny_config_file(), null);
if (is_array($saved)) {
foreach ($cfg as $k => $v) {
if (array_key_exists($k, $saved)) {
$cfg[$k] = is_bool($v) ? (bool)$saved[$k] : $saved[$k];
}
}
$GLOBALS['__ny_cfg_source'] = 'file';
// DB 已连上但库里没有配置,而文件里有 —— 迁移一份到 DB,避免两端不一致
if (class_exists('Db') && Db::pdo() !== null && empty($GLOBALS['__ny_cfg_migrated'])) {
$GLOBALS['__ny_cfg_migrated'] = true;
try {
Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE));
} catch (Throwable $e) {
// 迁移失败不影响本次使用
}
}
}
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
return $cfg;
}
function ny_save_config(array $cfg): bool
{
// 先写文件快照:DB 万一不可用(连不上 / 未初始化)时不会静默回退到出厂默认配置
$fileOk = ny_write_json(ny_config_file(), $cfg);
if (class_exists('Db')) {
$db = Db::pdo();
if ($db !== null) {
try {
Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE));
$GLOBALS['__ny_cfg_source'] = 'db';
return true;
} catch (Throwable $e) {
// 落库失败则回退文件
$GLOBALS['__ny_cfg_source'] = 'file';
return $fileOk;
}
}
}
$GLOBALS['__ny_cfg_source'] = 'file';
return $fileOk;
}
/** 数据库初始化后从 DB 读取配置(无则返回 null) */
function ny_db_config(): ?array
{
if (!class_exists('Db')) {
return null;
}
$db = Db::pdo();
if ($db === null) {
return null;
}
try {
$raw = (string)(Db::settingsAll()['config'] ?? '');
} catch (Throwable $e) {
return null;
}
$data = json_decode($raw, true);
return is_array($data) ? $data : null;
}
/** 安全的字符串化(避免数组/对象转字符串报错) */
function ny_str(mixed $v): string
{
if ($v === null || is_array($v) || is_object($v)) {
return '';
}
return trim((string)$v);
}
// ---------------- JSON 文件读写 ----------------
function ny_read_json(string $file, mixed $default = null): mixed
{
if (!is_file($file)) {
return $default;
}
$raw = @file_get_contents($file);
if ($raw === false || trim($raw) === '') {
return $default;
}
$data = json_decode($raw, true);
return $data === null ? $default : $data;
}
function ny_write_json(string $file, mixed $data): bool
{
$tmp = $file . '.tmp';
$ok = @file_put_contents($tmp, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX);
if ($ok === false) {
return false;
}
return @rename($tmp, $file) || @copy($tmp, $file);
}
// ---------------- 输出 ----------------
function ny_json(mixed $data, int $http = 200): void
{
http_response_code($http);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
}
function ny_ok(mixed $data = null): void
{
ny_json(['ok' => true, 'data' => $data]);
exit;
}
function ny_fail(string $msg, mixed $extra = null): void
{
ny_json(['ok' => false, 'msg' => $msg, 'extra' => $extra]);
exit;
}
// ---------------- 工具函数 ----------------
/** 规范化姓名:去空白、去全角空格、统一大小写 */
function ny_norm_name(string $s): string
{
$s = str_replace(["\xC2\xA0", ' '], '', $s);
$s = preg_replace('/\s+/u', '', $s) ?? '';
return mb_strtolower(trim($s));
}
/** 把任意结构的答卷值压平成字符串 */
function ny_flatten_value(mixed $v): string
{
if ($v === null) {
return '';
}
if (is_bool($v)) {
return $v ? '1' : '';
}
if (is_int($v) || is_float($v)) {
return trim((string)$v);
}
if (is_string($v)) {
return trim($v);
}
if (is_array($v)) {
foreach (['value', 'text', 'label', 'name', 'content', 'title'] as $k) {
if (isset($v[$k]) && (is_string($v[$k]) || is_numeric($v[$k]))) {
return trim((string)$v[$k]);
}
}
$parts = [];
foreach ($v as $item) {
$s = ny_flatten_value($item);
if ($s !== '') {
$parts[] = $s;
}
}
return implode(',', $parts);
}
return trim((string)$v);
}
/** 时间字段 -> 时间戳(秒) */
function ny_to_timestamp(mixed $v): int
{
if ($v === null || $v === '') {
return 0;
}
if (is_numeric($v)) {
$n = (int)$v;
if ($n > 1000000000000) { // 毫秒
return intdiv($n, 1000);
}
return $n;
}
$t = strtotime((string)$v);
return $t === false ? 0 : $t;
}
/** 秒 -> 12分34秒 */
function ny_format_duration(?int $sec): string
{
if ($sec === null || $sec < 0) {
return '-';
}
if ($sec < 60) {
return $sec . '秒';
}
$m = intdiv($sec, 60);
$s = $sec % 60;
if ($m < 60) {
return $m . '分' . str_pad((string)$s, 2, '0', STR_PAD_LEFT) . '秒';
}
$h = intdiv($m, 60);
return $h . '小时' . str_pad((string)($m % 60), 2, '0', STR_PAD_LEFT) . '分' . str_pad((string)$s, 2, '0', STR_PAD_LEFT) . '秒';
}
// ---------------- 类自动加载 ----------------
spl_autoload_register(function (string $class): void {
$map = [
'XlsxReader' => NY_ROOT . '/lib/XlsxReader.php',
'XlsxWriter' => NY_ROOT . '/lib/XlsxWriter.php',
'SurveyKingClient' => NY_ROOT . '/lib/SurveyKingClient.php',
'Roster' => NY_ROOT . '/lib/Roster.php',
'DataService' => NY_ROOT . '/lib/DataService.php',
'Db' => NY_ROOT . '/lib/Db.php',
];
if (isset($map[$class]) && is_file($map[$class])) {
require $map[$class];
}
});