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
+44 -5
View File
@@ -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') {
</div>
</div>
<p class="login-tip">系统连接信息已配置(<?= htmlspecialchars(parse_url($cfg['base_url'], PHP_URL_HOST)) ?>/<?= htmlspecialchars(trim($cfg['api_prefix'], '/')) ?>)。请输入账号密码完成登录。</p>
<?php if (trim((string)$cfg['username']) === ''): ?>
<div class="alert" style="white-space:pre-wrap">⚠️ 本机尚未保存卷王账号(配置来源:<?= htmlspecialchars(ny_config_source()) ?>)。
如果每次登录成功都回到本页,多半是 data 目录不可写导致账号没存住——
请让 data/ 目录(<?= htmlspecialchars(NY_DATA) ?>)对 PHP 进程可写后重试。</div>
<?php endif; ?>
<?php if ($err !== ''): ?>
<div class="alert" style="white-space:pre-wrap"><?= htmlspecialchars($err) ?></div>
<?php elseif ($notice !== ''): ?>
<div class="alert" style="white-space:pre-wrap"><?= htmlspecialchars($notice) ?></div>
<?php endif; ?>
<form method="post">
@@ -96,7 +130,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
<div class="login-brand" style="justify-content:center"><span class="logo">NY</span>
<div class="brand-text"><strong>登录成功</strong><small>正在进入数据平台…</small></div>
</div>
<script>setTimeout(function(){ location.href='index.php'; }, 600);</script>
<script>
// 记录"本次登录成功"的时间戳:前端据此判断"刚登录完又报鉴权失败",
// 这种情况不再自动跳回登录页(否则会形成死循环),而是把真实错误显示出来。
try { sessionStorage.setItem('ny_login_at', String(Date.now())); sessionStorage.removeItem('ny_auth_jumps'); } catch (e) {}
setTimeout(function(){ location.href='index.php'; }, 600);
</script>
</div>
</div>
<?php endif; ?>