Files
ny-exam-platform/bootstrap.php
T
nxxy335 804b04de60 移除登录失败限流;修复连接信息被 e2e 串味为 127.0.0.1
- bootstrap.php/login.php:整体移除「5 次失败锁 10 分钟」限流,登录失败仅即时提示原因
- 新增 tools/fix-config.php:一键双写修正连接地址(默认 exam.nxxy335.top),清空账号密码让用户重输真实卷王凭据
- NY_DATA 隔离落地:run-all.sh 给写配置的 e2e 指向临时目录;修 Windows Git Bash /c/... 路径规范化 bug;.ny-test-data 加 .gitignore
- 清理 dead throttle 引用(e2e 脚本 + docs)
- 修 login.php 真实 bug:ny_is_auth() 分支在加载 $cfg 前引用导致 Undefined variable 警告
- 全量测试 116 通过(PHP 44 + e2e 72)
2026-09-11 17:25:22 +08:00

471 lines
15 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__));
// 数据目录默认指向项目下的 data/;测试可通过 NY_DATA 环境变量整体重定向到临时目录,
// 避免 e2e 把 mock 服务的连接配置写进生产库(曾因此把生产 base_url 改成 127.0.0.1 导致登录失败)。
$nyData = NY_ROOT . '/data';
$nyDataEnv = getenv('NY_DATA');
if ($nyDataEnv !== false && $nyDataEnv !== '') {
$nyData = rtrim(str_replace('\\', '/', $nyDataEnv), '/');
}
define('NY_DATA', $nyData);
// ---------------- 环境预检 ----------------
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');
// ---------------- 内存上限(数据接口可能处理大量答卷)----------------
// 拉取卷王全量答卷做 json_decode 时极易触顶 128M;在未被禁用 / 未被 php_admin_value 硬锁的前提下
// 临时放宽到 512M。若主机用 php_admin_value 锁定了 memory_limit,下面的 ini_set 会静默失效,
// 需要改主机 php.ini(或 fpm 池配置)才能生效。
(function (): void {
$cur = (string) ini_get('memory_limit');
if ($cur === '' || $cur === '-1') {
return; // 未设置 / 已无限制,无需处理
}
$curBytes = (int) $cur;
$unit = strtoupper(substr($cur, -1));
if ($unit === 'G') {
$curBytes *= 1024 * 1024 * 1024;
} elseif ($unit === 'M') {
$curBytes *= 1024 * 1024;
} elseif ($unit === 'K') {
$curBytes *= 1024;
}
$want = 512 * 1024 * 1024;
if ($curBytes > 0 && $curBytes < $want) {
@ini_set('memory_limit', '512M');
}
})();
// ---------------- 会话 ----------------
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();
}
// ---------------- CSRF 防护 ----------------
/**
* 会话绑定的 CSRF token:写操作(POST)必须携带,防止跨站请求伪造。
* 前端从 config_get / auth_check 响应里拿到 csrf_token,后续写请求附在
* X-CSRF-Token 请求头或 body 的 _csrf 字段。
*/
function ny_csrf_token(): string
{
ny_session_start();
if (empty($_SESSION['ny_csrf']) || !is_string($_SESSION['ny_csrf'])) {
$_SESSION['ny_csrf'] = bin2hex(random_bytes(16));
}
return $_SESSION['ny_csrf'];
}
/** 校验 CSRF token(写操作调用)。失败返回 false 并应拒绝请求。 */
function ny_csrf_ok(): bool
{
$given = '';
if (!empty($_SERVER['HTTP_X_CSRF_TOKEN'])) {
$given = (string)$_SERVER['HTTP_X_CSRF_TOKEN'];
} elseif (!empty($_POST['_csrf'])) {
$given = (string)$_POST['_csrf'];
} else {
// JSON body 里的 _csrf(api.php 写接口多走 JSON)
$raw = (string)file_get_contents('php://input');
if ($raw !== '') {
$j = json_decode($raw, true);
if (is_array($j) && isset($j['_csrf'])) {
$given = (string)$j['_csrf'];
}
}
}
$token = ny_csrf_token();
return $given !== '' && hash_equals($token, $given);
}
// ---------------- 登录失败限流(已移除) ----------------
// 注:单机内部工具场景下,5 次失败锁 10 分钟的限制常因配置写错/测环境串味而把正常用户挡在门外,
// 且无实际爆破价值(仅内网使用)。应需求已整体移除:登录不再限流,失败只即时提示原因。
/** 判断是否已安装(只看文件 config:base_url 非空即视为已具备连接配置) */
function ny_is_installed(): bool
{
$cfg = ny_load_config();
if (trim((string)($cfg['base_url'] ?? '')) === '') {
return false;
}
return is_file(ny_config_file());
}
// ---------------- 目录初始化 ----------------
foreach (['', '/cache', '/uploads'] as $sub) {
$dir = NY_DATA . $sub;
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
// Apache:禁止直接访问(nginx 不读 .htaccess,需在 server 配置中额外 deny,见 docs/部署与安全)
if (!file_exists($dir . '/.htaccess')) {
@file_put_contents($dir . '/.htaccess', "Deny from all\n");
}
// 兜底:即使目录被索引也不暴露文件清单
if (!file_exists($dir . '/index.html')) {
@file_put_contents($dir . '/index.html', "<!doctype html><meta charset=\"utf-8\"><title>403 Forbidden</title><h1>403 Forbidden</h1>\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,
// 全局默认合格率(百分比,如 60 表示"≥满分的 60%"算合格)。
// 每份试卷可在主页面单独覆盖(project_config),试卷之间互相隔离。
'pass_rate' => 60,
'layout' => 'default',
// 历史成绩展示方式:click=点击成员弹窗查看(默认);inline=成绩明细内直接展开每次考试
'history_display' => 'click',
// 成绩导出排序:time=按提交时间降序(默认);sid=按学号(同学号内按提交时间降序)
'export_sort' => 'time',
// 数据结果缓存秒数:0=每次实时拉取;建议 10~30。
// 拉取卷王全量答卷是重操作(多次 HTTP + 大量解码),缓存可避免多人同时刷新时重复打卷王。
// 名单一变指纹就变会自动失效;点"立即刷新"也会 force=1 跳过缓存。
'cache_ttl' => 15,
];
}
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';
$cfg = ny_default_config();
$file = ny_config_file();
$saved = null;
// 1) 优先读 SQLite(data/ny.db 的 ny_settings.config)
if (class_exists('Db') && Db::pdo() !== null) {
$raw = Db::settingsGet('config', '');
if ($raw !== '') {
$j = json_decode($raw, true);
if (is_array($j)) {
$saved = $j;
$GLOBALS['__ny_cfg_source'] = 'db';
}
}
// DB 可用但尚无记录 → 把已有 config.json 迁进来(老数据不丢,只迁一次)
if ($saved === null) {
$fromFile = ny_read_json($file, null);
if (is_array($fromFile)) {
Db::settingsSet('config', json_encode($fromFile, JSON_UNESCAPED_UNICODE));
$saved = $fromFile;
$GLOBALS['__ny_cfg_source'] = 'db';
}
}
}
// 2) DB 不可用时退回文件
if ($saved === null) {
$saved = ny_read_json($file, null);
if (is_array($saved)) {
$GLOBALS['__ny_cfg_source'] = 'file';
}
}
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];
}
}
}
$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
{
// 双写:SQLite 为主(成功即算成功),文件为备份(DB 挂掉时还能读回来)
$ok = false;
if (class_exists('Db') && Db::pdo() !== null) {
try {
Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE));
$GLOBALS['__ny_cfg_source'] = 'db';
$ok = true;
} catch (Throwable $e) {
$ok = false;
}
}
$fileOk = ny_write_json(ny_config_file(), $cfg);
if (!$ok) {
$GLOBALS['__ny_cfg_source'] = 'file';
}
return $ok || $fileOk;
}
/**
* 解析「本卷满分 + 合格率」:
* 优先取该试卷的独立配置(试卷之间互相隔离),没有则用全局配置。
*
* @return array{0:int,1:float} [满分, 合格率百分比(如 60 表示 60%)]
*/
function ny_resolve_score_cfg(string $projectId, array $c): array
{
$pCfg = class_exists('DataService') ? DataService::projectConfig($projectId) : [];
$fs = (int)($pCfg['full_score'] ?? 0);
if ($fs < 1) {
$fs = (int)($c['full_score'] ?? 100);
}
if ($fs < 1) {
$fs = 100;
}
$pr = $pCfg['pass_rate'] ?? null;
if ($pr === null || $pr === '') {
$pr = $c['pass_rate'] ?? 60;
}
$pr = (float)$pr;
if ($pr <= 0 || $pr > 100) {
$pr = 60;
}
return [$fs, $pr];
}
/** 安全的字符串化(避免数组/对象转字符串报错) */
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',
'DataCache' => NY_ROOT . '/lib/DataCache.php',
'Db' => NY_ROOT . '/lib/Db.php',
];
if (isset($map[$class]) && is_file($map[$class])) {
require $map[$class];
}
});