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
+193 -5
View File
@@ -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
{