= 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 '
环境不满足
本平台需要 PHP 8.0 及以上,当前版本:' . PHP_VERSION . '
';
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 '缺少 PHP 扩展
请在 php.ini 中启用:' . implode('、', $missing) . '
'
. 'Windows:去掉 extension=' . $missing[0] . ' 前面的分号并重启服务。'
. '(zip 扩展可选,未启用时会用内置解析器读取 xlsx)
';
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'];
}
/**
* 取请求原始 body(仅读一次并缓存)。
* 写接口既要校验 CSRF(从 body 取 _csrf)又要解析业务 JSON,二者都需读 php://input;
* 大多数 SAPI 下 php://input 可重复读,但个别环境只能读一次,因此这里统一缓存避免第二次读为空。
*/
function ny_request_body(): string
{
if (!isset($GLOBALS['__ny_body'])) {
$GLOBALS['__ny_body'] = (string)@file_get_contents('php://input');
}
return $GLOBALS['__ny_body'];
}
/** 校验 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 = ny_request_body();
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', "403 Forbidden403 Forbidden
\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'], '/');
// 账号/密码一律转成字符串:历史数据里若存成 null,不改这里就会表现为"未配置卷王登录账号"
$cfg['username'] = trim((string)($cfg['username'] ?? ''));
$cfg['password'] = (string)($cfg['password'] ?? '');
return $cfg;
}
function ny_save_config(array $cfg): bool
{
// 双写:SQLite 为主(成功即算成功),文件为备份(DB 挂掉时还能读回来)
// 关键:两处都必须做"回读校验"。以前只管写不管是否真落盘,data/ 目录不可写时
// 登录页会照常提示"登录成功",但下一个请求读到的账号仍是空的 → 无限跳登录页。
$GLOBALS['__ny_cfg_save_error'] = '';
$notes = [];
$json = json_encode($cfg, JSON_UNESCAPED_UNICODE);
$ok = false;
if (class_exists('Db') && Db::pdo() !== null) {
try {
if (Db::settingsSet('config', $json)) {
$GLOBALS['__ny_cfg_source'] = 'db';
$ok = true;
} else {
$notes[] = '数据库写入失败:' . (Db::lastError() ?: '未知原因');
}
} catch (Throwable $e) {
$notes[] = '数据库写入异常:' . $e->getMessage();
}
} else {
$notes[] = '数据库不可用(' . (class_exists('Db') ? Db::lastError() : 'Db 类未加载') . ')';
}
$fileOk = ny_write_json(ny_config_file(), $cfg);
if ($fileOk) {
// 回读一致才算真成功
$back = ny_read_json(ny_config_file(), null);
if (!is_array($back) || json_encode($back, JSON_UNESCAPED_UNICODE) !== $json) {
$fileOk = false;
$notes[] = '配置文件回读不一致:' . ny_config_file();
}
} else {
$notes[] = '配置文件写入失败:' . ny_config_file();
}
// 尽量收紧配置文件权限(仅属主可读写),降低明文密码被其它本地用户读取的风险。
// Windows 下 chmod 基本无效(真正的防护是 data/ 目录禁止 Web 访问,见 .htaccess / 部署文档);
// 明文密码仍需保留以便卷王 token 失效时平台自动重新登录,无法改为只存哈希。
if ($fileOk && is_file(ny_config_file())) {
@chmod(ny_config_file(), 0600);
}
if (!$ok) {
$GLOBALS['__ny_cfg_source'] = $fileOk ? 'file' : 'default';
}
if (!$ok && !$fileOk) {
$GLOBALS['__ny_cfg_save_error'] = '配置未落盘:' . implode(';', $notes);
} elseif ($notes !== []) {
// 有一处成功即不影响使用,仅记录降级信息
$GLOBALS['__ny_cfg_save_error'] = implode(';', $notes);
}
return $ok || $fileOk;
}
/** 上一次 ny_save_config 的问题描述(空 = 完全正常) */
function ny_config_save_error(): string
{
return (string)($GLOBALS['__ny_cfg_save_error'] ?? '');
}
/**
* 配置落盘诊断:登录/鉴权失败时优先看这里,用来区分
* 「账号没配」与「账号配了但写不进去 / 读不回来」这两类完全不同的故障。
*/
function ny_config_diag(): array
{
$dir = rtrim((string)NY_DATA, '/\\');
$file = ny_config_file();
$dbOk = class_exists('Db') && Db::pdo() !== null;
$dbWritable = false;
if ($dbOk) {
$probe = '__ny_probe_' . bin2hex(random_bytes(4));
$dbWritable = Db::settingsSet($probe, '1');
Db::settingsDelete($probe);
}
$cfgWritable = false;
$probeFile = $dir . '/.__ny_write_probe';
$fp = @fopen($probeFile, 'wb');
if ($fp) {
$cfgWritable = (fwrite($fp, '1') !== false);
fclose($fp);
@unlink($probeFile);
}
$c = ny_load_config();
return [
'storage' => ny_config_source(),
'data_dir' => $dir,
'dir_writable' => is_dir($dir) && is_writable($dir),
'cfg_file' => $file,
'cfg_writable' => $cfgWritable,
'db_connected' => $dbOk,
'db_writable' => $dbWritable,
'db_error' => (class_exists('Db') && !$dbOk) ? Db::lastError() : '',
'has_username' => trim((string)($c['username'] ?? '')) !== '',
'has_password' => ((string)($c['password'] ?? '')) !== '',
'username' => (string)($c['username'] ?? ''),
'save_error' => ny_config_save_error(),
];
}
/**
* 把接口/表单传入的配置片段合并进已有配置(api.php config_save 用)。
* 抽出来是为了可单测,同时在这里统一守住"账号/密码不能被误清空"。
*
* @return array{config:array,conn_changed:bool}
*/
function ny_apply_config_input(array $old, array $in): array
{
$allowed = ['base_url', 'api_prefix', 'username', 'timeout', 'refresh_interval', 'page_size', 'name_keywords', 'history_display', 'export_sort'];
$new = $old;
foreach ($allowed as $k) {
if (array_key_exists($k, $in)) {
$new[$k] = is_string($in[$k]) ? trim((string)$in[$k]) : $in[$k];
}
}
// 账号不允许被改空:设置页留空视为"不改",否则一旦保存就会陷入"未配置账号"死循环
if (array_key_exists('username', $in)) {
$u = trim((string)($in['username'] ?? ''));
if ($u === '') {
$new['username'] = (string)($old['username'] ?? '');
}
}
// 密码:保留首尾空格的原貌,空 / 占位符 = 不修改
if (array_key_exists('password', $in)) {
$pwdIn = (string)$in['password'];
if ($pwdIn !== '' && $pwdIn !== '******') {
$new['password'] = $pwdIn;
}
}
foreach (['verify_ssl', 'include_temp_save', 'dedupe'] as $k) {
if (array_key_exists($k, $in)) {
$new[$k] = filter_var($in[$k], FILTER_VALIDATE_BOOL);
}
}
// 布局配置
if (array_key_exists('layout', $in)) {
$new['layout'] = ($in['layout'] === 'side') ? 'side' : 'default';
}
// 历史成绩展示方式:click(点击成员弹窗,默认) / inline(明细内全部展开)
if (array_key_exists('history_display', $in)) {
$new['history_display'] = ($in['history_display'] === 'inline') ? 'inline' : 'click';
}
// 成绩导出排序
if (array_key_exists('export_sort', $in)) {
$v = (string)$in['export_sort'];
$new['export_sort'] = in_array($v, ['time','sid','score_desc','score_asc'], true) ? $v : 'time';
}
if (array_key_exists('pass_rate', $in)) {
$pr = (float)$in['pass_rate'];
$new['pass_rate'] = ($pr >= 1 && $pr <= 100) ? $pr : 60;
}
$new['base_url'] = rtrim((string)$new['base_url'], '/');
$new['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/');
$new['timeout'] = (int)$new['timeout'];
$new['refresh_interval'] = (int)$new['refresh_interval'];
if ($new['refresh_interval'] < 3) {
$new['refresh_interval'] = 3;
}
// 默认满分
$fs = (int)($in['full_score'] ?? $new['full_score'] ?? 100);
if ($fs < 1) {
$fs = 100;
}
$new['full_score'] = $fs;
// 账号/密码强制字符串(历史数据里可能存成 null,导致 (string) 后变成空 → 报"未配置账号")
$new['username'] = (string)($new['username'] ?? '');
$new['password'] = (string)($new['password'] ?? '');
// 仅当连接信息真的变了才清 token + 重连;保存满分/布局等不应触发登录卷王
$connKeys = ['base_url', 'api_prefix', 'username', 'password', 'verify_ssl'];
$connChanged = false;
foreach ($connKeys as $k) {
if ((string)($old[$k] ?? '') !== (string)($new[$k] ?? '')) {
$connChanged = true;
break;
}
}
return ['config' => $new, 'conn_changed' => $connChanged];
}
/**
* 解析「本卷满分 + 合格率」:
* 优先取该试卷的独立配置(试卷之间互相隔离),没有则用全局配置。
*
* @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];
}
/**
* PHP 会话目录是否可用(登录状态能不能保持住)。
* 目录不可写时 session_start() 只会告警,登录态存不下来 → 表现为"登录完又被踢回登录页"。
*/
function ny_session_usable(?string &$why = null): bool
{
$dir = (string)@session_save_path();
// save_path 可能是 "N;/path"(多级目录散列),取分号后的真实路径
if (preg_match('/^\d+;(.*)$/', $dir, $m) === 1) {
$dir = $m[1];
}
if ($dir === '') {
$dir = sys_get_temp_dir();
}
if (!is_dir($dir)) {
$why = '会话目录不存在:' . $dir;
return false;
}
if (!is_writable($dir)) {
$why = '会话目录不可写:' . $dir;
return false;
}
return true;
}
/** 安全的字符串化(避免数组/对象转字符串报错) */
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];
}
});