diff --git a/api.php b/api.php index 246996b..2bdd84e 100644 --- a/api.php +++ b/api.php @@ -171,66 +171,10 @@ try { $in = $_POST; } $old = cfg(); - $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('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'; - } - // 成绩导出排序:time(按提交时间降序,默认)/ sid(按学号)/ score_desc(成绩降序)/ score_asc(成绩升序) - 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'; - } - // 说明:「各试卷独立满分」已移出设置,改为在主页面针对当前试卷配置 - // (project_config / project_config_save),设置里只保留「全局默认满分」。 - 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; - // 仅当连接信息真的变了才清 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; - } - } + // 合并规则统一放在 ny_apply_config_input(含"账号/密码不许被改空"的兜底),便于单测 + $applied = ny_apply_config_input($old, $in); + $new = $applied['config']; + $connChanged = $applied['conn_changed']; ny_save_config($new); if ($connChanged) { @unlink(NY_DATA . '/cache/token.json'); @@ -954,8 +898,11 @@ try { $isAuth = str_contains($msg, '未配置卷王登录账号') || str_contains($msg, '登录卷王失败') || str_contains($msg, '登录失败'); + // 鉴权失败时回传落盘诊断(账号到底有没有存住、data 目录可不可写), + // 否则前端只能给出"账号未配置"这一句空话,用户无法自查,也容易陷入反复跳登录页。 ny_fail($msg, [ 'auth' => $isAuth, + 'diag' => $isAuth ? ny_config_diag() : null, 'trace' => defined('NY_DEBUG') && NY_DEBUG ? $e->getTraceAsString() : null, ]); } diff --git a/assets/app.js b/assets/app.js index e05bd99..2aaaea0 100644 --- a/assets/app.js +++ b/assets/app.js @@ -31,6 +31,8 @@ _sorted: null, // 虚拟滚动:最近一次排序后的完整行数组(缓存,避免重复排序) _kwDebounce: null, // 搜索框防抖定时器 csrf: '', // CSRF token(来自 config_get),写请求自动附带 + _authRedirecting: false, // 鉴权失败已接管(防止并发请求重复跳登录页) + _authStopped: false, // 鉴权失败已停自动刷新(等用户处理,不再定时重试) }; /* ---------------- 本地存储 ---------------- */ @@ -89,7 +91,9 @@ } if (!json.ok) { const err = new Error(json.msg || '请求失败'); - err.auth = !!(json.extra && json.extra.auth); + // 401 = 本系统会话已失效,与"卷王鉴权失败"同样需要重新登录; + // handleAuthError 里的防死循环闸门会保证"刚登录完又失败"时不再反复跳转。 + err.auth = !!(json.extra && json.extra.auth) || res.status === 401; err.extra = json.extra || null; throw err; } @@ -154,6 +158,106 @@ return '已导出' + what + (parts.length ? '(' + parts.join(' / ') + ')' : '(全部班级 / 组别)'); } + /* ---------------- 鉴权失败处理(含防死循环) ---------------- + * 死循环场景:登录页登录成功 → 进主页 → 拉数据仍报"未配置账号/登录失败" → 自动跳登录页 → 再登录…… + * 用户看到的就是"一直让重新登录、根本用不了"。这里加两道闸门: + * 1) 刚登录完(120 秒内)又鉴权失败 → 不再自动跳转,直接把服务端真实原因 + 落盘诊断摊开给用户 + * 2) 同一会话已自动跳转过 2 次 → 同样停在页面报错 + */ + const AUTH_FRESH_MS = 120000; + const AUTH_MAX_JUMPS = 2; + + function authJustLoggedIn() { + try { + const t = parseInt(sessionStorage.getItem('ny_login_at') || '0', 10); + return t > 0 && (Date.now() - t) < AUTH_FRESH_MS; + } catch (e) { return false; } + } + function authJumpCount() { + try { return parseInt(sessionStorage.getItem('ny_auth_jumps') || '0', 10) || 0; } catch (e) { return 0; } + } + function stopAutoRefresh() { + if (state.timer) { clearTimeout(state.timer); state.timer = null; } + if (state.hlTimer) { clearInterval(state.hlTimer); state.hlTimer = null; } + state._authStopped = true; + } + function forceRelogin() { + stopAutoRefresh(); + // 必须先清服务端会话,否则 login.php 发现会话还在会把人踢回 index.php,跳不过去 + fetch('logout.php', { method: 'POST', credentials: 'same-origin' }) + .catch(() => {}) + .finally(() => { window.location.href = 'login.php'; }); + } + + function diagLines(diag) { + if (!diag) return []; + const yn = v => (v ? '是' : '否'); + return [ + '配置来源:' + (diag.storage || '-') + '(配置文件可写=' + yn(diag.cfg_writable) + ')', + '数据目录:' + (diag.data_dir || '-') + '(可写=' + yn(diag.dir_writable) + ')', + '数据库:' + (diag.db_connected ? ('已连接(可写=' + yn(diag.db_writable) + ')') : ('未连接 ' + (diag.db_error || ''))), + '已存账号:' + (diag.has_username ? (diag.username || '已设置') : '空 ← 这就是「未配置账号」的原因'), + '已存密码:' + (diag.has_password ? '已保存' : '空'), + diag.save_error ? ('保存异常:' + diag.save_error) : '', + ].filter(Boolean); + } + + function showAuthFailure(e) { + const lines = diagLines(e && e.extra ? e.extra.diag : null); + const box = document.createElement('div'); + box.className = 'modal open'; + box.innerHTML = + ''; + document.body.appendChild(box); + box.querySelector('[data-auth-retry]').onclick = () => { + box.remove(); + state._authRedirecting = false; + state._authStopped = false; + refresh(false, true); // 非静默 + 强制拉取 + }; + box.querySelector('[data-auth-settings]').onclick = () => { + box.remove(); + const b = document.getElementById('btnSettings'); + if (b) b.click(); + }; + box.querySelector('[data-auth-relogin]').onclick = () => { box.remove(); forceRelogin(); }; + } + + /** + * 统一处理鉴权类错误;返回 true 表示已接管(调用方直接 return)。 + */ + function handleAuthError(e) { + if (state._authRedirecting) return true; + state._authRedirecting = true; + const jumps = authJumpCount(); + if (authJustLoggedIn() || jumps >= AUTH_MAX_JUMPS) { + stopAutoRefresh(); + showAuthFailure(e); + return true; + } + try { sessionStorage.setItem('ny_auth_jumps', String(jumps + 1)); } catch (err) {} + showAlert('卷王账号未配置或登录已失效,正在跳转登录页…', 'error'); + forceRelogin(); + return true; + } + /* ---------------- 问卷列表 ---------------- */ // 把数组渲染到 #projectSel(抽出便于缓存复用) function renderProjects() { @@ -234,6 +338,7 @@ LS.set(LS_PROJECTS, state.projects); renderProjects(); } catch (e) { + if (e.auth) { handleAuthError(e); return; } if (!cached || !cached.length) { setConn('conn-err', '连接失败'); showAlert('无法连接卷王系统:' + e.message + '\n请点击右上角【设置】检查系统地址、接口前缀与账号密码。'); @@ -374,21 +479,7 @@ LS.set(LS_ROSTER, { ts: Date.now(), students: d.roster || [] }); } catch (e) { if (e.auth) { - // 卷王账号未配置或登录已失效:引导重新登录(强制清掉本地会话 + 跳登录页) - // 关键:必须先调 logout.php 把服务端会话清空,否则 login.php 发现会话还在会把人踢回 index.php,形成"跳不过去"的死循环 - if (!state._authRedirecting) { - state._authRedirecting = true; - if (state.timer) { clearTimeout(state.timer); state.timer = null; } - if (state.hlTimer) clearInterval(state.hlTimer); - showAlert('卷王账号未配置或登录已失效,正在跳转登录页…', 'error'); - // 异步清会话,忽略失败;登录页端也有探测兜底(token 过期时主动销毁会话) - fetch('logout.php', { method: 'POST', credentials: 'same-origin' }) - .catch(() => {}) - .finally(() => { - // 200ms 足够让 toast 入场,又不至于让用户干等 - setTimeout(() => { window.location.href = 'login.php'; }, 200); - }); - } + handleAuthError(e); return; } if (!cached) { @@ -989,6 +1080,8 @@ // 用 setTimeout 链而非 setInterval,便于动态调整间隔;后台标签页暂停刷新,回到前台立即补一次。 function schedule() { if (state.timer) { clearTimeout(state.timer); state.timer = null; } + // 鉴权失败已停止自动刷新(handleAuthError),别再排下一轮把用户反复踢走 + if (state._authStopped) return; if (!$('#autoRefresh').checked) return; const base = (parseInt($('#intervalSel').value, 10) || 15) * 1000; const backoff = Math.min(8, Math.pow(2, state.connFail)) * base; diff --git a/bootstrap.php b/bootstrap.php index 33d0241..abbe279 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -282,23 +282,48 @@ function ny_load_config(): array } $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 { - Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE)); - $GLOBALS['__ny_cfg_source'] = 'db'; - $ok = true; + if (Db::settingsSet('config', $json)) { + $GLOBALS['__ny_cfg_source'] = 'db'; + $ok = true; + } else { + $notes[] = '数据库写入失败:' . (Db::lastError() ?: '未知原因'); + } } catch (Throwable $e) { - $ok = false; + $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 失效时平台自动重新登录,无法改为只存哈希。 @@ -306,11 +331,149 @@ function ny_save_config(array $cfg): bool @chmod(ny_config_file(), 0600); } if (!$ok) { - $GLOBALS['__ny_cfg_source'] = 'file'; + $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]; +} + /** * 解析「本卷满分 + 合格率」: * 优先取该试卷的独立配置(试卷之间互相隔离),没有则用全局配置。 @@ -341,6 +504,31 @@ function ny_resolve_score_cfg(string $projectId, array $c): array 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 { diff --git a/dashboard.php b/dashboard.php index 598e573..c1ae78c 100644 --- a/dashboard.php +++ b/dashboard.php @@ -161,10 +161,43 @@ async function api(action, params){ const j = await res.json(); if (j && j.ok) return j.data; const err = new Error((j && (j.msg || j.error)) || '请求失败'); - err.auth = !!(j && j.extra && j.extra.auth); + // 401 = 本系统会话已失效,同样需要重新登录(防死循环闸门见 handleAuthFail) + err.auth = !!(j && j.extra && j.extra.auth) || res.status === 401; + err.extra = (j && j.extra) || null; throw err; } +/* ---------- 鉴权失败:防反复跳登录页的死循环 ---------- + * 若刚登录完(120 秒内)又鉴权失败,说明"重新登录"解决不了问题(多半是凭据没存住), + * 此时不再跳转,直接把服务端原因 + 落盘诊断显示出来,让人能自查。 */ +function authJustLoggedIn(){ + try { + const t = parseInt(sessionStorage.getItem('ny_login_at') || '0', 10); + return t > 0 && (Date.now() - t) < 120000; + } catch (e) { return false; } +} +function authJumpCount(){ + try { return parseInt(sessionStorage.getItem('ny_auth_jumps') || '0', 10) || 0; } catch (e) { return 0; } +} +function showAuthFailureBox(e){ + const d = (e.extra && e.extra.diag) || null; + const yn = v => (v ? '是' : '否'); + const lines = d ? [ + '配置来源:' + (d.storage || '-') + '(配置文件可写=' + yn(d.cfg_writable) + ')', + '数据目录:' + (d.data_dir || '-') + '(可写=' + yn(d.dir_writable) + ')', + '数据库:' + (d.db_connected ? ('已连接(可写=' + yn(d.db_writable) + ')') : ('未连接 ' + (d.db_error || ''))), + '已存账号:' + (d.has_username ? (d.username || '已设置') : '空 ← 这就是「未配置账号」的原因'), + '已存密码:' + (d.has_password ? '已保存' : '空'), + d.save_error ? ('保存异常:' + d.save_error) : '', + ].filter(Boolean) : []; + $('#boardBody').innerHTML = + '
' + + '无法访问卷王
' + esc(e.message || '') + + (lines.length ? '
' + esc(lines.join('\n')) + '
' : '') + + '
重新登录
' + + '
'; +} + /* ---------- 问卷列表(双策略文件夹识别 + 缓存) ---------- */ function renderProjects(list){ const sel = $('#projectSel'); @@ -249,6 +282,9 @@ async function refreshClassSel(){ renderBoard(); } catch (e) { if (e.auth) { + const jumps = authJumpCount(); + if (authJustLoggedIn() || jumps >= 2) { showAuthFailureBox(e); return; } + try { sessionStorage.setItem('ny_auth_jumps', String(jumps + 1)); } catch (err) {} // 卷王账号未配置或登录失效:跳转登录页自愈 $('#boardBody').innerHTML = '
卷王账号未配置或登录已失效,点击重新登录
'; setTimeout(() => { location.href = 'login.php'; }, 1200); @@ -372,7 +408,10 @@ function markStale(msg){ // 具体原因放进 title(鼠标悬停可见),正文保持简短 if (lu) { lu.textContent = '刷新中断(仍显示上次数据)'; if (msg) lu.title = String(msg); } } -function handleAuthFail(){ +function handleAuthFail(e){ + const jumps = authJumpCount(); + if (authJustLoggedIn() || jumps >= 2) { showAuthFailureBox(e || new Error('鉴权失败')); return; } + try { sessionStorage.setItem('ny_auth_jumps', String(jumps + 1)); } catch (err) {} $('#boardBody').innerHTML = '
卷王账号未配置或登录已失效,点击重新登录
'; setTimeout(() => { location.href = 'login.php'; }, 1200); } @@ -524,7 +563,7 @@ async function refreshBoard(manual){ $('#lastUpdate').textContent = fresh.updated_at || '-'; markLive(); } catch (e) { - if (e.auth) { handleAuthFail(); return; } + if (e.auth) { handleAuthFail(e); return; } // 静默续期:保留旧数据,但把实时点变红提示中断 markStale(e.message); } finally { diff --git a/lib/Db.php b/lib/Db.php index a4ca7c5..79eaeb8 100644 --- a/lib/Db.php +++ b/lib/Db.php @@ -170,12 +170,18 @@ class Db // ---------------- 设置 ---------------- - /** SQLite 用 ON CONFLICT 做 upsert(其它方言见历史版本) */ - public static function settingsSet(string $k, string $v): void + /** + * SQLite 用 ON CONFLICT 做 upsert(其它方言见历史版本) + * + * 返回是否真的写成功:以前这里 void + 内部吞异常,导致"配置没落盘但上层以为成功" + * (典型后果:登录成功→下一请求仍报"未配置账号"→反复跳登录页的死循环)。 + * 因此改为回读校验后返回 bool,并把失败原因放进 self::lastError()。 + */ + public static function settingsSet(string $k, string $v): bool { $pdo = self::pdo(); if (!$pdo) { - return; + return false; } try { $p = self::prefix(); @@ -184,6 +190,37 @@ class Db ON CONFLICT(k) DO UPDATE SET v=excluded.v, updated_at=excluded.updated_at" ); $st->execute([$k, $v, time()]); + } catch (Throwable $e) { + self::$err = $e->getMessage(); + return false; + } + // 回读校验:磁盘满 / 目录不可写 / 只读挂载时 execute 可能不报错但实际未落盘 + try { + $p = self::prefix(); + $st = $pdo->prepare("SELECT v FROM {$p}settings WHERE k = ?"); + $st->execute([$k]); + $got = $st->fetchColumn(); + if ($got === false || (string)$got !== $v) { + self::$err = '写入后回读不一致(可能磁盘已满或数据库只读)'; + return false; + } + } catch (Throwable $e) { + self::$err = $e->getMessage(); + return false; + } + return true; + } + + public static function settingsDelete(string $k): void + { + $pdo = self::pdo(); + if (!$pdo) { + return; + } + try { + $p = self::prefix(); + $st = $pdo->prepare("DELETE FROM {$p}settings WHERE k = ?"); + $st->execute([$k]); } catch (Throwable $e) { self::$err = $e->getMessage(); } diff --git a/login.php b/login.php index 47e4f71..232ede2 100644 --- a/login.php +++ b/login.php @@ -10,13 +10,17 @@ require __DIR__ . '/bootstrap.php'; // 先加载配置:下面已登录分支要用 $cfg 构造 SurveyKingClient 探活,必须在前面就准备好 $cfg = ny_load_config(); +$notice = ''; // 非阻断提示(例如"上次会话已失效,请重新登录") + if (ny_is_auth()) { - // 已登录但卷王 token 可能已过期:用当前 token 探一次卷王 /system,能 200 即视为有效,否则清掉本地会话强制重新登录 + // 已登录但卷王 token 可能已过期:用当前 token 探一次卷王,能通即视为有效,否则清掉本地会话强制重新登录 try { $client = new SurveyKingClient($cfg); $client->ping(); } catch (Throwable $e) { // 卷王 token 已失效或配置错误 → 清会话 + 强制显示登录页(避免 login.php 把人踢回 index.php 形成死循环) + // 把失败原因带出来,避免用户只看到登录框却不知道为什么被踢回来 + $notice = '已退出之前的登录(' . $e->getMessage() . ')'; $_SESSION = []; @session_destroy(); } @@ -43,13 +47,36 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { try { $client = new SurveyKingClient($test); $client->getToken(true); + // 登录成功:把账号与密码写回配置(本地单用户工具,持久化密码以便 token // 失效时平台能自动重新登录卷王,避免后台刷新陷入"未配置账号"的死状态) $cfg['username'] = $user; $cfg['password'] = $pwd; - ny_save_config($cfg); - ny_login(); - $ok = true; + $saved = ny_save_config($cfg); + + // 关键:必须回读确认真的存住了。以前这里不看返回值,data 目录不可写时 + // 会显示"登录成功",但下一个请求读到的账号仍是空的 → 无限跳回登录页。 + $back = ny_load_config(); + $persistOk = $saved + && trim((string)($back['username'] ?? '')) === $user + && (string)($back['password'] ?? '') === $pwd; + + if (!$persistOk) { + $d = ny_config_diag(); + $err = "登录卷王成功,但凭据没能保存到本机配置,继续进入会立刻又提示“未配置账号”。\n" + . "请检查 data 目录写权限后重试:\n" + . "· 配置来源:" . $d['storage'] . "(可写=" . ($d['cfg_writable'] ? '是' : '否') . ")\n" + . "· 数据目录:" . $d['data_dir'] . "(可写=" . ($d['dir_writable'] ? '是' : '否') . ")\n" + . "· 数据库:" . ($d['db_connected'] ? '已连接(可写=' . ($d['db_writable'] ? '是' : '否') . ')' : '未连接 ' . $d['db_error']) . "\n" + . "· 保存错误:" . ($d['save_error'] !== '' ? $d['save_error'] : '(无)'); + } elseif (!ny_session_usable($sessWhy)) { + // 会话存不住时同样会"登录成功→立刻被踢回登录页",必须在这一步拦住 + $err = "凭据已保存,但 PHP 会话不可用,登录状态无法保持:\n" . $sessWhy + . "\n请在 php.ini 中把 session.save_path 指向一个可写目录后重试。"; + } else { + ny_login(); + $ok = true; + } } catch (Throwable $e) { $err = '登录失败:' . $e->getMessage(); } @@ -75,9 +102,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {

系统连接信息已配置(/)。请输入账号密码完成登录。

+ +
⚠️ 本机尚未保存卷王账号(配置来源:)。 +如果每次登录成功都回到本页,多半是 data 目录不可写导致账号没存住—— +请让 data/ 目录()对 PHP 进程可写后重试。
+
+ +
@@ -96,7 +130,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
登录成功正在进入数据平台…
- + diff --git a/tests/run-all.sh b/tests/run-all.sh index 9b2f515..0f1e8f8 100644 --- a/tests/run-all.sh +++ b/tests/run-all.sh @@ -18,7 +18,7 @@ echo "PHP = $PHP" echo "NODE = $NODE" echo "================ PHP 自测 ================" -for f in tests/test-aggregate.php tests/test-new-features.php tools/test-xlsxwriter.php; do +for f in tests/test-aggregate.php tests/test-new-features.php tests/test-config-persist.php tools/test-xlsxwriter.php; do echo "" echo ">>> $f" "$PHP" $INI "$f" @@ -30,7 +30,9 @@ echo "================ Node e2e(需 mock-server + PHP 内置服务器) ===== # 彻底避免污染生产 data/ny.db(曾因此把登录页连接信息写成 127.0.0.1)。 NY_DATA_TMP="$(pwd)/.ny-test-data" mkdir -p "$NY_DATA_TMP" -for f in tools/test-config-save.js tools/test-upload-guard.js tools/test-whitelist.js; do +# 说明:test-login-loop.js 会清空数据目录里的账密来复现"未配置账号"现场, +# 即使不传 NY_DATA 它也会自己建临时目录,绝不回退到项目 data/。 +for f in tools/test-config-save.js tools/test-upload-guard.js tools/test-whitelist.js tools/test-login-loop.js; do echo "" echo ">>> $f (NY_DATA=$NY_DATA_TMP)" NY_DATA="$NY_DATA_TMP" "$NODE" "$f" diff --git a/tests/test-config-persist.php b/tests/test-config-persist.php new file mode 100644 index 0000000..3929807 --- /dev/null +++ b/tests/test-config-persist.php @@ -0,0 +1,125 @@ + '']); +check('账号传空 → 保留原账号(防止陷入未配置死循环)', $r['config']['username'] === 'admin', $r['config']['username']); +check('账号传空 → 不算连接信息变化', $r['conn_changed'] === false); + +$r = ny_apply_config_input($old, ['username' => null]); +check('账号传 null → 保留原账号', $r['config']['username'] === 'admin', var_export($r['config']['username'], true)); + +$r = ny_apply_config_input($old, ['password' => '******']); +check('密码占位符 ****** → 不修改', $r['config']['password'] === 'secret'); + +$r = ny_apply_config_input($old, ['password' => '']); +check('密码传空 → 不修改', $r['config']['password'] === 'secret'); + +$r = ny_apply_config_input($old, ['password' => 'newpwd']); +check('密码传新值 → 更新', $r['config']['password'] === 'newpwd'); +check('密码变化 → 标记连接信息已变', $r['conn_changed'] === true); + +$r = ny_apply_config_input($old, ['username' => 'admin2']); +check('账号变化 → 标记连接信息已变', $r['conn_changed'] === true); + +$r = ny_apply_config_input($old, ['full_score' => 90, 'page_size' => 50]); +check('仅改满分/分页 → 不触发重连', $r['conn_changed'] === false); +check('满分已写入', (int)$r['config']['full_score'] === 90); +check('未传的字段保持原值', (int)$r['config']['page_size'] === 50 && $r['config']['username'] === 'admin'); + +$r = ny_apply_config_input($old, ['refresh_interval' => 1]); +check('刷新间隔最小值收敛到 3', (int)$r['config']['refresh_interval'] === 3); + +$r = ny_apply_config_input($old, ['base_url' => 'https://exam.nxxy335.top/']); +check('base_url 去掉末尾斜杠', $r['config']['base_url'] === 'https://exam.nxxy335.top', $r['config']['base_url']); + +echo "\n== 4) 落盘诊断 ==\n"; +$d = ny_config_diag(); +check('诊断含 storage', isset($d['storage'])); +check('诊断含目录可写标记', array_key_exists('dir_writable', $d)); +check('诊断含配置文件可写标记', array_key_exists('cfg_writable', $d)); +check('诊断含数据库可写标记', array_key_exists('db_writable', $d)); +check('诊断含是否已存账号', array_key_exists('has_username', $d)); +check('临时目录可写时 cfg_writable=true', $d['cfg_writable'] === true, json_encode($d, JSON_UNESCAPED_UNICODE)); + +echo "\n== 5) 数据库写入回读校验 ==\n"; +if (class_exists('Db') && Db::pdo() !== null) { + check('Db::settingsSet 成功返回 true', Db::settingsSet('__ny_test_key', 'hello') === true); + check('Db::settingsGet 读回一致', Db::settingsGet('__ny_test_key', '') === 'hello'); + Db::settingsDelete('__ny_test_key'); + check('Db::settingsDelete 生效', Db::settingsGet('__ny_test_key', 'x') === 'x'); +} else { + echo " ⚠️ 无 pdo_sqlite,跳过数据库回读用例\n"; +} + +echo "\n----------------------------------------\n"; +echo "通过 {$pass} 项,失败 {$fail} 项\n"; +exit($fail === 0 ? 0 : 1); diff --git a/tools/test-login-loop.js b/tools/test-login-loop.js new file mode 100644 index 0000000..bfa8d40 --- /dev/null +++ b/tools/test-login-loop.js @@ -0,0 +1,186 @@ +// e2e: 登录后立刻又报"未配置账号"、反复跳登录页的死循环 +// +// 复现前提:本机配置里账号为空(或没存住)。旧代码 login.php 不看 ny_save_config 结果, +// 登录成功页照常显示"登录成功",但下一个请求 ny_load_config() 读到的账号仍是空的 +// → 抛"未配置卷王登录账号" → 前端 auth 打标 → 跳登录页 → 死循环。 +// +// 本测试锁死三件事: +// 1) 登录后凭据必须真的落盘(config_get 能读回账号、has_password=true) +// 2) 登录后立刻拉数据必须成功(不再出现"未配置账号") +// 3) 真出现鉴权失败时,响应必须带 extra.auth + extra.diag(前端据此停止自动跳转并给出可自查信息) +// +// 运行:node tools/test-login-loop.js +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const { spawn } = require('child_process'); + +const ROOT = path.resolve(__dirname, '..'); +const PORT = 18087; +const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.exe'; +const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite'; +const MOCK = { user: 'mock', pass: 'surveyking' }; + +// NY_DATA 指向临时目录:Git Bash 风格 /c/... 需还原成 C:\...,并写回 env 给 PHP 子进程。 +// 注意:绝不能回退到项目下的 data/ —— 本测试会清空账密并重建库,落到真实目录等于破坏本地配置 +// (曾因默认回退到 data/ 把 ny.db 删掉)。未显式指定时一律新建临时目录。 +const os = require('os'); +function resolveDataDir() { + let p = process.env.NY_DATA || path.join(os.tmpdir(), 'ny-login-loop-' + process.pid); + if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + ':' + p.slice(2); + p = path.resolve(p); + process.env.NY_DATA = p; + return p; +} +const DATA = resolveDataDir(); +fs.mkdirSync(DATA, { recursive: true }); +const cfgFile = path.join(DATA, 'config.json'); +const tokenFile = path.join(DATA, 'cache', 'token.json'); +const dbFile = path.join(DATA, 'ny.db'); +const origCfg = fs.existsSync(cfgFile) ? fs.readFileSync(cfgFile, 'utf8') : null; +const hasDb = fs.existsSync(dbFile); +if (hasDb) { try { fs.unlinkSync(dbFile); } catch (e) {} } +try { fs.unlinkSync(tokenFile); } catch (e) {} + +// 关键:模拟"账号没配/没存住"的现场——连接信息是对的,账号密码是空的 +fs.writeFileSync(cfgFile, JSON.stringify({ + base_url: 'http://127.0.0.1:19911', + api_prefix: '/api', + username: '', + password: '', + verify_ssl: false, + timeout: 10, + refresh_interval: 15, + page_size: 50, + dedupe: true, + include_temp_save: false, + name_keywords: '姓名,名字', + full_score: 100, + layout: 'default', +}, null, 2)); + +let cleanedUp = false, mock = null, srv = null; +function cleanup() { + if (cleanedUp) return; + cleanedUp = true; + try { srv && srv.kill('SIGKILL'); } catch (e) {} + try { mock && mock.kill('SIGKILL'); } catch (e) {} + try { + if (origCfg !== null) fs.writeFileSync(cfgFile, origCfg); + else if (fs.existsSync(cfgFile)) fs.unlinkSync(cfgFile); + } catch (e) {} + try { if (fs.existsSync(dbFile)) fs.unlinkSync(dbFile); } catch (e) {} + try { if (fs.existsSync(tokenFile)) fs.unlinkSync(tokenFile); } catch (e) {} +} +process.on('exit', cleanup); +process.on('SIGINT', () => { cleanup(); process.exit(1); }); +process.on('uncaughtException', e => { console.error('FATAL', e); cleanup(); process.exit(2); }); + +mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'pipe' }); +srv = spawn(PHP, INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]), { cwd: ROOT, stdio: 'pipe' }); + +let pass = 0, fail = 0; +function chk(name, cond, extra) { + if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); } + else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); } +} +const cookieJar = []; +let CSRF = ''; +const jarHeader = () => (cookieJar.length ? cookieJar.map(c => c.replace(/;.*$/, '')).join('; ') : ''); +function collect(res) { + for (const sc of (res.headers['set-cookie'] || [])) { + const pair = sc.split(';')[0]; + const name = pair.split('=')[0]; + const i = cookieJar.findIndex(x => x.startsWith(name + '=')); + if (i >= 0) cookieJar[i] = pair; else cookieJar.push(pair); + } +} +function req(method, p, body) { + return new Promise((resolve, reject) => { + if (body && CSRF && !body._csrf) body = Object.assign({}, body, { _csrf: CSRF }); + const data = body ? JSON.stringify(body) : ''; + const h = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }; + if (jarHeader()) h['Cookie'] = jarHeader(); + const r = http.request({ host: '127.0.0.1', port: PORT, method, path: p, headers: h }, res => { + const bufs = []; + res.on('data', c => bufs.push(c)); + res.on('end', () => { + collect(res); + const raw = Buffer.concat(bufs).toString('utf8'); + let j = null; try { j = JSON.parse(raw); } catch (e) {} + resolve({ status: res.statusCode, body: j, raw, loc: res.headers.location }); + }); + }); + r.on('error', reject); + if (data) r.write(data); + r.end(); + }); +} +function formPost(p, fields) { + return new Promise((resolve, reject) => { + const data = new URLSearchParams(fields).toString(); + const h = { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(data) }; + if (jarHeader()) h['Cookie'] = jarHeader(); + const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path: p, headers: h }, res => { + const bufs = []; + res.on('data', c => bufs.push(c)); + res.on('end', () => { collect(res); resolve({ status: res.statusCode, raw: Buffer.concat(bufs).toString('utf8'), loc: res.headers.location }); }); + }); + r.on('error', reject); + r.write(data); r.end(); + }); +} +const delay = ms => new Promise(r => setTimeout(r, ms)); + +(async () => { + await delay(2200); + + console.log('\n--- 1) 登录:凭据必须真的落盘(登录前本机账号为空)---'); + const lg = await formPost('/login.php', { username: MOCK.user, password: MOCK.pass }); + chk('login.php 返回 200/302', lg.status === 200 || lg.status === 302, 'status=' + lg.status); + chk('登录页未出现"没能保存到本机配置"', lg.raw.indexOf('没能保存到本机配置') < 0, (lg.raw.match(/没能保存到本机配置[^<]*/) || [''])[0].slice(0, 160)); + chk('登录页未出现"登录失败"', lg.raw.indexOf('登录失败') < 0, (lg.raw.match(/登录失败[^<]*/) || [''])[0].slice(0, 160)); + chk('拿到会话 cookie', cookieJar.some(c => c.indexOf('NYSESS=') === 0), JSON.stringify(cookieJar)); + chk('成功页写入 ny_login_at(前端防重复跳转依据)', lg.raw.indexOf('ny_login_at') >= 0); + + const ac = await req('GET', '/api.php?action=auth_check'); + chk('auth_check 已登录', ac.body && ac.body.data && ac.body.data.auth === true, ac.raw); + if (!(ac.body && ac.body.data && ac.body.data.auth)) { cleanup(); process.exit(1); } + CSRF = (ac.body && ac.body.data && ac.body.data.csrf_token) || CSRF; + + const g1 = await req('GET', '/api.php?action=config_get'); + CSRF = (g1.body && g1.body.data && g1.body.data.csrf_token) || CSRF; + chk('账号已回读到配置', g1.body && g1.body.data && g1.body.data.username === MOCK.user, JSON.stringify(g1.body && g1.body.data && g1.body.data.username)); + chk('has_password=true(密码已存)', g1.body && g1.body.data && g1.body.data.has_password === true); + chk('响应不含明文密码', !('password' in ((g1.body && g1.body.data) || {}))); + + console.log('\n--- 2) 登录后立刻拉数据:不应再出现"未配置账号" ---'); + const d1 = await req('GET', '/api.php?action=data&projectId=p1&force=1'); + chk('data 成功', d1.body && d1.body.ok === true, (d1.raw || '').slice(0, 200)); + chk('data 未报未配置账号', (d1.raw || '').indexOf('未配置卷王登录账号') < 0, (d1.raw || '').slice(0, 160)); + chk('data 未被标记为鉴权失败', !(d1.body && d1.body.extra && d1.body.extra.auth), JSON.stringify(d1.body && d1.body.extra)); + + console.log('\n--- 3) 鉴权真失败时:必须带 auth 标记 + 可自查的诊断 ---'); + const sv = await req('POST', '/api.php?action=config_save', { password: 'definitely-wrong' }); + chk('改错密码已保存', sv.body && sv.body.ok === true, (sv.raw || '').slice(0, 160)); + const d2 = await req('GET', '/api.php?action=data&projectId=p1&force=1'); + chk('此时 data 失败', d2.body && d2.body.ok === false, (d2.raw || '').slice(0, 160)); + chk('extra.auth=true', !!(d2.body && d2.body.extra && d2.body.extra.auth), JSON.stringify(d2.body && d2.body.extra)); + const diag = d2.body && d2.body.extra && d2.body.extra.diag; + chk('extra.diag 存在', !!diag, JSON.stringify(diag)); + if (diag) { + chk('diag.has_username=true(账号其实存住了)', diag.has_username === true); + chk('diag 含目录/文件/数据库可写标记', 'dir_writable' in diag && 'cfg_writable' in diag && 'db_writable' in diag); + chk('diag 含配置来源', typeof diag.storage === 'string' && diag.storage !== ''); + } + + console.log('\n--- 4) 改回正确密码后应恢复 ---'); + const sv2 = await req('POST', '/api.php?action=config_save', { password: MOCK.pass }); + chk('恢复密码已保存', sv2.body && sv2.body.ok === true); + const d3 = await req('GET', '/api.php?action=data&projectId=p1&force=1'); + chk('data 恢复正常', d3.body && d3.body.ok === true, (d3.raw || '').slice(0, 160)); + + console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败'); + cleanup(); + process.exit(fail === 0 ? 0 : 1); +})();