fix(auth): 修复登录后反复报未配置账号并跳回登录页的死循环

根因:ny_save_config 只写不校验,Db::settingsSet 内部吞异常且返回 void。
data/ 目录不可写时登录页照常显示登录成功,但下一请求读到的账号仍是空的
→ 抛未配置卷王登录账号 → 前端 auth 打标 → 跳登录页 → 无限循环。

修复:
- Db::settingsSet 改返回 bool 并回读校验;新增 settingsDelete
- ny_save_config 双写后均做回读校验,失败写入 ny_config_save_error()
- 新增 ny_config_diag()/ny_session_usable(),暴露来源/可写性/是否已存账号
- ny_load_config 把 null 账号强制成空字符串(脏数据不再伪装成未配置)
- login.php 存凭据后回读确认,存不住/会话不可写直接报错而不是假成功
- login.php 账号为空时页面直接给出 data 目录写权限提示
- config_save 合并逻辑抽成 ny_apply_config_input:账号/密码不许被改空
- 鉴权失败响应带 extra.diag,前端 401 也算 auth
- 前端防死循环:刚登录 120s 内或已跳 2 次则停止自动跳转,弹窗给出真实原因+诊断

测试:新增 tests/test-config-persist.php(29) 与 tools/test-login-loop.js(19);
run-all.sh 纳入两者。全套 148+48=196 项通过。
This commit is contained in:
2026-09-14 08:44:09 +08:00
parent 555f500dc7
commit f661862d46
9 changed files with 750 additions and 94 deletions
+42 -3
View File
@@ -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 =
'<div class="empty" style="text-align:left;line-height:1.9">' +
'<b>无法访问卷王</b><br>' + esc(e.message || '') +
(lines.length ? '<pre style="white-space:pre-wrap;margin:10px 0 0;text-align:left;font-size:12px">' + esc(lines.join('\n')) + '</pre>' : '') +
'<div style="margin-top:12px"><a class="btn btn-primary" href="login.php">重新登录</a></div>' +
'</div>';
}
/* ---------- 问卷列表(双策略文件夹识别 + 缓存) ---------- */
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 = '<div class="empty">卷王账号未配置或登录已失效,<a href="login.php">点击重新登录</a></div>';
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 = '<div class="empty">卷王账号未配置或登录已失效,<a href="login.php">点击重新登录</a></div>';
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 {