Files
ny-exam-platform/api.php
T
nxxy335 f661862d46 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 项通过。
2026-09-14 08:44:09 +08:00

909 lines
41 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* NY 问卷考试系统数据平台 - 后端接口
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
$action = (string)($_GET['action'] ?? $_POST['action'] ?? '');
function cfg(): array
{
return ny_load_config();
}
/**
* 按成绩排序(null 成绩恒排末尾,同分按提交时间降序)
* @param bool $desc true=降序
*/
function ny_sort_by_score(array $rows, bool $desc): array
{
$dir = $desc ? -1 : 1;
usort($rows, static function (array $x, array $y) use ($dir): int {
$xn = ($x['score'] ?? null) === null;
$yn = ($y['score'] ?? null) === null;
if ($xn && $yn) {
return 0;
}
if ($xn) {
return 1;
}
if ($yn) {
return -1;
}
$d = ((float)$x['score'] <=> (float)$y['score']) * $dir;
if ($d !== 0) {
return $d;
}
return ((int)($y['submit_ts'] ?? 0)) <=> ((int)($x['submit_ts'] ?? 0));
});
return $rows;
}
/** 缺考名单工作表行(按学号升序) */
function ny_absent_sheet_rows(array $absent): array
{
usort($absent, static function ($x, $y): int {
$a = trim((string)($x['sid'] ?? ''));
$b = trim((string)($y['sid'] ?? ''));
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
return strnatcmp($a, $b);
});
$out = [['学号', '姓名', '班级', '组别']];
foreach ($absent as $r) {
$out[] = [
(string)($r['sid'] ?? ''),
(string)($r['name'] ?? ''),
(string)($r['cls'] ?? ''),
(string)($r['grp'] ?? ''),
];
}
return $out;
}
/**
* 带短期缓存的数据构建(含并发锁,避免多人同时刷新时重复打卷王造成惊群)
*
* 缓存 key 由「项目 + 姓名字段 + 连接/去重等配置 + 名单指纹」组成:
* 名单一变指纹就变,因此不会出现"改了名单还显示旧数据"。
* force=true 时跳过缓存(前端"立即刷新"按钮使用)。
*
* @return array{result:array,cached:bool,at:int}
*/
function ny_build_data(array $c, string $projectId, string $nameQid, bool $force = false): array
{
$ttl = (int)($c['cache_ttl'] ?? 15);
if ($ttl < 0) {
$ttl = 0;
}
$key = DataCache::key([
$projectId, $nameQid,
(string)$c['base_url'], (string)$c['api_prefix'], (string)$c['username'],
!empty($c['dedupe']) ? 1 : 0,
!empty($c['include_temp_save']) ? 1 : 0,
(string)($c['name_keywords'] ?? ''),
(int)($c['page_size'] ?? 100),
Roster::fingerprint(),
// 本卷独立配置(免考名单/满分/合格率)变化也应让缓存失效,否则改免考后可能短暂看到旧数据
json_encode(DataService::projectConfig($projectId) ?? []),
]);
if ($ttl > 0 && !$force) {
$hit = DataCache::read($key, $ttl);
if ($hit !== null) {
return ['result' => $hit['result'], 'cached' => true, 'at' => $hit['at']];
}
}
// 并发锁:同一 key 只允许一个请求真正去拉卷王
$lock = DataCache::acquireLock($key);
if ($lock === null) {
// 已有别的请求在构建:短暂等待其落盘后直接复用(允许略旧,总比同时压垮卷王好)
for ($i = 0; $i < 15; $i++) {
usleep(200000); // 200ms
$hit = DataCache::read($key, max($ttl, 60));
if ($hit !== null) {
return ['result' => $hit['result'], 'cached' => true, 'at' => $hit['at']];
}
}
// 等不到(构建很慢或锁不被支持)就自己构建,保证可用性
}
// try/finally:无论 build 是否抛异常都释放构建锁,避免异常时锁句柄挂到进程退出造成惊群
$result = null;
try {
$result = DataService::build($c, $projectId, $nameQid);
if ($ttl > 0) {
DataCache::write($key, $result);
}
} finally {
DataCache::releaseLock($lock);
}
return ['result' => $result, 'cached' => false, 'at' => time()];
}
// 会话保护:除 auth_check 外,未登录一律拒访
if ($action !== 'auth_check' && !ny_is_auth()) {
ny_json(['ok' => false, 'msg' => '未登录或会话已过期,请重新登录'], 401);
exit;
}
// CSRF 防护:所有写操作必须携带会话绑定的 token(GET 读取类接口不受影响)
$WRITE_ACTIONS = [
'config_save', 'roster_save', 'roster_import', 'roster_clear',
'unmatched_map', 'project_config_save',
];
if (in_array($action, $WRITE_ACTIONS, true) && !ny_csrf_ok()) {
ny_json(['ok' => false, 'msg' => '安全校验失败(CSRF token 缺失或不匹配),请刷新页面后重试'], 403);
exit;
}
try {
switch ($action) {
// 登录状态检查
case 'auth_check':
ny_ok(['auth' => ny_is_auth(), 'installed' => ny_is_installed()]);
// ---------------- 配置 ----------------
case 'config_get':
$c = cfg();
$c['has_password'] = ((string)$c['password']) !== '';
unset($c['password']);
$c['csrf_token'] = ny_csrf_token();
// 诊断信息:配置存在哪里、数据库通不通(登录卷王失败时优先看这里)
$c['storage'] = ny_config_source();
$c['db_driver'] = class_exists('Db') ? Db::driver() : '';
$c['db_configured'] = class_exists('Db') && Db::isConfigured();
$c['db_connected'] = class_exists('Db') && Db::isConnected();
// 仅在未连接时回传错误详情(供前端诊断连接失败原因);
// 已连接时一律不回传 lastError,避免把 SQL/文件路径等内部信息泄露给客户端。
$c['db_error'] = (class_exists('Db') && !Db::isConnected()) ? Db::lastError() : '';
ny_ok($c);
case 'config_save':
$raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST;
}
$old = cfg();
// 合并规则统一放在 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');
}
// 返回的 config 不再带明文密码
$outCfg = ny_load_config();
$outCfg['has_password'] = ((string)$outCfg['password']) !== '';
unset($outCfg['password']);
$res = [
'saved' => true,
'config' => $outCfg,
'conn_changed' => $connChanged,
'storage' => ny_config_source(),
];
// 连接信息修改后自动测一次,给出明确结果(避免用户配置对错无从知晓)
if ($connChanged) {
try {
$client = new SurveyKingClient(ny_load_config());
$p = $client->ping();
$res['ping'] = ['ok' => true, 'projects' => $p['projects'], 'elapsed' => $p['elapsed']];
} catch (Throwable $e) {
$res['ping'] = ['ok' => false, 'msg' => $e->getMessage()];
}
}
ny_ok($res);
// ---------------- 用弹窗内(未保存)的凭据测试连接 ----------------
case 'config_test':
$raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST;
}
// 以已保存配置为基底,仅覆盖弹窗中填写的字段,避免漏填导致误测
$test = ny_load_config();
foreach (['base_url', 'api_prefix', 'username', 'verify_ssl', 'timeout'] as $k) {
if (array_key_exists($k, $in)) {
$test[$k] = is_string($in[$k]) ? trim((string)$in[$k]) : $in[$k];
}
}
if (array_key_exists('password', $in)) {
$pwdIn = (string)$in['password'];
if ($pwdIn !== '' && $pwdIn !== '******') {
$test['password'] = $pwdIn;
}
}
$test['base_url'] = rtrim((string)($test['base_url'] ?? ''), '/');
$test['api_prefix'] = '/' . trim((string)($test['api_prefix'] ?? '/api'), '/');
try {
$client = new SurveyKingClient($test);
$p = $client->ping();
ny_ok(['connected' => true, 'projects' => $p['projects'], 'elapsed' => $p['elapsed']]);
} catch (Throwable $e) {
ny_ok(['connected' => false, 'msg' => $e->getMessage()]);
}
// ---------------- 连接测试 ----------------
case 'ping':
$client = new SurveyKingClient(cfg());
$res = $client->ping();
$res['session'] = $client->sessionInfo();
ny_ok($res);
case 'diag':
$client = new SurveyKingClient(cfg());
ny_ok($client->diagnose());
// ---------------- 问卷列表 ----------------
case 'projects':
$client = new SurveyKingClient(cfg());
$list = $client->listProjects();
$out = [];
foreach ($list as $p) {
if (!is_array($p)) {
continue;
}
$out[] = [
'id' => ny_str($p['id'] ?? ''),
'name' => ny_str($p['name'] ?? '') ?: '未命名',
'mode' => ny_str($p['mode'] ?? ''),
'total' => (int)($p['total'] ?? 0),
'status' => (int)($p['status'] ?? 0),
'parent_id' => (string)($p['parentId'] ?? '0'),
];
}
ny_ok($out);
// ---------------- 数据 ----------------
case 'data':
$projectId = (string)($_GET['projectId'] ?? '');
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
$nameQid = (string)($_GET['nameQid'] ?? '');
$c = cfg();
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
$result = $built['result'];
// 满分:本卷独立优先,其次全局默认
[$fs, $passRate] = ny_resolve_score_cfg($projectId, $c);
$result['config'] = [
'refresh_interval' => (int)$c['refresh_interval'],
'base_url' => $c['base_url'],
'full_score' => $fs,
'pass_rate' => $passRate,
];
// 缓存来源标记:便于前端/排查时知道这次是实时拉取还是命中短期缓存
$result['_cache'] = ['cached' => $built['cached'], 'at' => $built['at']];
ny_ok($result);
// ---------------- 数据看板(按班级聚合,面向班主任/学生整体情况) ----------------
case 'dashboard':
$projectId = (string)($_GET['projectId'] ?? '');
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
$nameQid = (string)($_GET['nameQid'] ?? '');
$c = cfg();
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
$result = $built['result'];
[$fs, $passRate] = ny_resolve_score_cfg($projectId, $c);
$students = $result['roster'] ?: [];
$rows = $result['rows'] ?: [];
$byCls = [];
foreach ($students as $s) {
$k = trim((string)($s['cls'] ?? '')) ?: '(未分班)';
$byCls[$k][] = $s;
}
$rowsByCls = [];
foreach ($rows as $r) {
$k = trim((string)($r['cls'] ?? '')) ?: '(未分班)';
$rowsByCls[$k][] = $r;
}
// 通用统计器:给定学生集合 + 答卷集合,算一组指标
// 合格率阈值可配(全局 pass_rate 或本卷独立),不再写死 60%
$statOf = static function (array $stu, array $rs, int $fs) use ($passRate): array {
$total = count($stu);
$submitted = count($rs);
$attempts = array_sum(array_map(static fn($r) => (int)($r['attempts'] ?? 1), $rs));
$scores = array_values(array_filter(array_map(static fn($r) => $r['score'] ?? null, $rs), static fn($v) => $v !== null));
$avg = $scores !== [] ? round(array_sum($scores) / count($scores), 2) : null;
$pass = count(array_filter($rs, static fn($r) => ($r['score'] ?? null) !== null && ($r['score'] >= ($passRate / 100) * $fs)));
$passRate = $submitted > 0 ? round($pass / $submitted * 100, 1) : 0;
$durs = array_values(array_filter(array_map(static fn($r) => $r['duration'] ?? null, $rs), static fn($v) => $v !== null));
$avgDur = $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null;
return [
'total' => $total,
'submitted' => $submitted,
'attempts' => $attempts,
'absent' => max(0, $total - $submitted),
'rate' => $total > 0 ? round($submitted / $total * 100, 1) : 0,
'avg' => $avg,
'pass_rate' => $passRate,
'max' => $scores !== [] ? max($scores) : null,
'min' => $scores !== [] ? min($scores) : null,
'avg_duration' => $avgDur,
];
};
// 已提交成员榜:按成绩从高到低,缺成绩者排最后,并给出排名
// 透传 attempts / attempts_list,供前端展示"同人多刷卷"
$membersOf = static function (array $rs): array {
$ms = [];
foreach ($rs as $r) {
$ms[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'score' => ($r['score'] ?? null),
'attempts' => (int)($r['attempts'] ?? 1),
'attempts_list' => $r['attempts_list'] ?? [],
];
}
usort($ms, static function (array $a, array $b): int {
$as = $a['score'];
$bs = $b['score'];
if ($as === null && $bs === null) {
return 0;
}
if ($as === null) {
return 1; // 无成绩排最后
}
if ($bs === null) {
return -1;
}
return $bs <=> $as; // 成绩降序
});
foreach ($ms as $i => $m) {
$ms[$i]['rank'] = $i + 1;
}
return $ms;
};
$classes = [];
foreach ($byCls as $k => $stu) {
$rs = $rowsByCls[$k] ?? [];
$row = $statOf($stu, $rs, $fs);
$row['class'] = $k;
$row['members'] = $membersOf($rs);
// 班级内按组别(A/B 等)再聚合,用于看板下钻对比
$stuByGrp = [];
foreach ($stu as $s) {
$g = trim((string)($s['grp'] ?? '')) ?: '(未分组)';
$stuByGrp[$g][] = $s;
}
$rowsByGrp = [];
foreach ($rs as $r) {
$g = trim((string)($r['grp'] ?? '')) ?: '(未分组)';
$rowsByGrp[$g][] = $r;
}
$groups = [];
foreach ($stuByGrp as $g => $gstu) {
$gs = $statOf($gstu, $rowsByGrp[$g] ?? [], $fs);
$gs['group'] = $g;
$gs['members'] = $membersOf($rowsByGrp[$g] ?? []);
$groups[] = $gs;
}
usort($groups, static fn(array $a, array $b): int => strcmp((string)$a['group'], (string)$b['group']));
$row['groups'] = $groups;
$classes[] = $row;
}
usort($classes, static function (array $a, array $b): int {
$an = is_numeric($a['class']) ? (int)$a['class'] : $a['class'];
$bn = is_numeric($b['class']) ? (int)$b['class'] : $b['class'];
if (is_int($an) && is_int($bn)) {
return $an <=> $bn;
}
return strcmp((string)$a['class'], (string)$b['class']);
});
ny_ok([
'project' => $result['project'],
'full_score' => $fs,
'pass_rate' => $passRate,
'overall' => $result['stats'],
'classes' => $classes,
'updated_at' => $result['updated_at'],
]);
// ---------------- 名单 ----------------
case 'roster':
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta()]);
case 'roster_import':
if (empty($_FILES['file']) || !is_uploaded_file($_FILES['file']['tmp_name'])) {
ny_fail('请选择要导入的文件');
}
$f = $_FILES['file'];
$err = (int)($f['error'] ?? UPLOAD_ERR_OK);
if ($err !== UPLOAD_ERR_OK) {
ny_fail('上传失败(错误码 ' . $err . ')');
}
if ((int)($f['size'] ?? 0) > 20 * 1024 * 1024) {
ny_fail('文件过大(上限 20MB)');
}
// 扩展名白名单:杜绝上传 .php/.phtml 等可执行后缀到 Web 目录
$ext = strtolower(pathinfo((string)$f['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ['xlsx', 'xlsm', 'csv', 'txt'], true)) {
ny_fail('仅支持 .xlsx / .xlsm / .csv / .txt 文件');
}
// 随机文件名:杜绝按时间枚举(原 roster_YYYYMMDD_HHMMSS.ext 可被遍历)
$dir = NY_DATA . '/uploads';
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
$dest = $dir . '/' . bin2hex(random_bytes(16)) . '.' . $ext;
if (!@move_uploaded_file($f['tmp_name'], $dest)) {
ny_fail('文件保存失败,请检查 data/uploads 目录权限');
}
try {
$res = Roster::importFile($dest, $ext);
} catch (Throwable $e) {
@unlink($dest);
ny_fail($e->getMessage());
}
// 解析完立即删除:Web 目录下不再留存任何上传文件
@unlink($dest);
// 默认追加:与「导入名单默认为追加」一致(学号名单管理导入)
$mode = (string)($_POST['mode'] ?? 'append');
if ($mode === 'append') {
// 追加:仅加入名单中尚不存在的成员(无学号时按姓名判重),避免重复行。
// 学号与姓名分两套判重键:带学号者以 sid 判重,无学号者以姓名判重,
// 避免「已存在带学号 A 的行」被「同名但空学号的导入行」误判为不重复而重复插入。
$existing = Roster::all();
$seenSid = [];
$seenName = [];
foreach ($existing as $s) {
$sid = strtolower(trim((string)($s['sid'] ?? '')));
$name = strtolower(trim((string)($s['name'] ?? '')));
if ($sid !== '') {
$seenSid[$sid] = true;
} elseif ($name !== '') {
$seenName[$name] = true;
}
}
$added = [];
foreach ($res['students'] as $s) {
$sid = strtolower(trim((string)($s['sid'] ?? '')));
$name = strtolower(trim((string)($s['name'] ?? '')));
$dup = false;
if ($sid !== '') {
if (isset($seenSid[$sid])) { $dup = true; }
else { $seenSid[$sid] = true; }
} elseif ($name !== '') {
if (isset($seenName[$name])) { $dup = true; }
else { $seenName[$name] = true; }
}
if ($dup) {
continue;
}
$added[] = $s;
}
if (!Roster::save(array_merge($existing, $added))) {
ny_fail('名单追加失败:' . (class_exists('Db') ? Db::lastError() : '未知错误'));
}
$res['count'] = count($added); // 仅计入真正新增的人数
} else {
if (!Roster::save($res['students'])) {
ny_fail('名单导入失败:' . (class_exists('Db') ? Db::lastError() : '未知错误'));
}
}
ny_ok([
'students' => Roster::all(),
'meta' => Roster::meta(),
'imported' => $res['count'],
'skipped' => $res['skipped'] ?? 0,
'duplicates' => $res['duplicates'] ?? 0,
]);
// ---------------- 从卷王白名单导入待考名单(已移除)----------------
// 现改为:DataService::build 自动读取每套试卷卷内设置的「答题白名单」,
// 白名单成员即「应考名单」,未设置时回退到全名单(按现在的方案显示)。
// 名单管理里的「⬇ 从白名单导入」按钮已删除。
case 'roster_save':
$raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in) || !isset($in['students']) || !is_array($in['students'])) {
ny_fail('参数错误');
}
$students = [];
foreach ($in['students'] as $s) {
if (!is_array($s)) {
continue;
}
$students[] = [
'sid' => (string)($s['sid'] ?? ''),
'name' => (string)($s['name'] ?? ''),
'cls' => trim((string)($s['cls'] ?? '')),
'grp' => trim((string)($s['grp'] ?? '')),
'login' => trim((string)($s['login'] ?? '')),
];
}
if (!Roster::save($students)) {
ny_fail('名单保存失败:' . (class_exists('Db') ? Db::lastError() : '未知错误'));
}
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta()]);
case 'roster_clear':
Roster::clear();
ny_ok(['students' => [], 'meta' => Roster::meta()]);
// ---------------- 未匹配答卷 → 加入名单 ----------------
case 'unmatched_map':
$raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST;
}
$name = trim((string)($in['name'] ?? ''));
$sid = trim((string)($in['sid'] ?? ''));
if ($name === '' || $sid === '') {
ny_fail('姓名与学号必填');
}
$cls = trim((string)($in['cls'] ?? ''));
$grp = trim((string)($in['grp'] ?? ''));
$login = trim((string)($in['login'] ?? ''));
$roster = Roster::all();
// 若学号已存在则更新其姓名为该答卷姓名(映射到已有学生);否则新增一行
$found = false;
foreach ($roster as &$s) {
if (trim((string)($s['sid'] ?? '')) === $sid) {
$s['name'] = $name;
if ($cls !== '') { $s['cls'] = $cls; }
if ($grp !== '') { $s['grp'] = $grp; }
if ($login !== '') { $s['login'] = $login; }
$found = true;
break;
}
}
unset($s);
if (!$found) {
$roster[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'login' => $login];
}
if (!Roster::save($roster)) {
ny_fail('映射保存失败:' . (class_exists('Db') ? Db::lastError() : '未知错误'));
}
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta(), 'mapped' => $name]);
// ---------------- 试卷独立配置(满分 / 合格率 / 免考名单)——仅对当前试卷生效 ----------------
case 'project_config':
$projectId = (string)($_GET['projectId'] ?? '');
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
$c = cfg();
[$fs, $pr] = ny_resolve_score_cfg($projectId, $c);
$pc = DataService::projectConfig($projectId);
ny_ok([
'project_id' => $projectId,
'cfg' => $pc,
// 解析后的生效值(前端回显用;null 表示"跟随全局")
'effective' => ['full_score' => $fs, 'pass_rate' => $pr],
'global' => [
'full_score' => (int)($c['full_score'] ?? 100),
'pass_rate' => (float)($c['pass_rate'] ?? 60),
],
]);
case 'project_config_save':
$projectId = (string)($_GET['projectId'] ?? $_POST['projectId'] ?? '');
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
$raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST;
}
$old = DataService::projectConfig($projectId);
// 满分:留空/0 = 清除(跟随全局)
$fsIn = $in['full_score'] ?? null;
$fs = ($fsIn === null || $fsIn === '' || (int)$fsIn < 1) ? null : (int)$fsIn;
// 合格率:留空 = 清除(跟随全局);否则夹到 1~100
$prIn = $in['pass_rate'] ?? null;
$pr = ($prIn === null || $prIn === '') ? null : max(1, min(100, (float)$prIn));
// 免考名单:支持数组或逗号/换行分隔的字符串
$exIn = $in['exempt'] ?? null;
$exempt = [];
if (is_array($exIn)) {
foreach ($exIn as $v) {
$v = trim((string)$v);
if ($v !== '') {
$exempt[] = $v;
}
}
} elseif (is_string($exIn) && trim($exIn) !== '') {
foreach (preg_split('/[,,\s]+/u', trim($exIn)) ?: [] as $v) {
$v = trim($v);
if ($v !== '') {
$exempt[] = $v;
}
}
}
$new = [
'full_score' => $fs,
'pass_rate' => $pr,
'exempt' => array_values(array_unique($exempt)),
];
DataService::projectConfigSave($projectId, $new);
$c2 = cfg();
[$effFs, $effPr] = ny_resolve_score_cfg($projectId, $c2);
ny_ok([
'cfg' => $new,
'effective' => ['full_score' => $effFs, 'pass_rate' => $effPr],
]);
// ---------------- 导出(支持按班级 / 组别筛选,与页面当前筛选保持一致) ----------------
case 'export':
case 'export_absent':
$projectId = (string)($_GET['projectId'] ?? '');
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
$nameQid = (string)($_GET['nameQid'] ?? '');
$clsFilter = trim((string)($_GET['cls'] ?? ''));
$grpFilter = trim((string)($_GET['grp'] ?? ''));
// 命中筛选:未选班级/组别时全部通过
$hit = static function (array $r) use ($clsFilter, $grpFilter): bool {
if ($clsFilter !== '' && trim((string)($r['cls'] ?? '')) !== $clsFilter) {
return false;
}
if ($grpFilter !== '' && trim((string)($r['grp'] ?? '')) !== $grpFilter) {
return false;
}
return true;
};
$c = cfg();
// 复用与 data 接口相同的结果缓存:导出不必再全量拉一次卷王
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
$result = $built['result'];
$rows = array_values(array_filter((array)($result['rows'] ?? []), $hit));
// 缺考口径与页面完全保持一致:筛选后名单 − 筛选后已提交(按学号匹配)
$roster = array_values(array_filter((array)($result['roster'] ?? []), $hit));
if ($roster !== []) {
$submitted = [];
foreach ($rows as $r) {
$sid = trim((string)($r['sid'] ?? ''));
if ($sid !== '') {
$submitted[$sid] = true;
}
}
$absent = array_values(array_filter(
$roster,
static fn(array $s): bool => !isset($submitted[trim((string)($s['sid'] ?? ''))])
));
} else {
// 未导入名单时退回后端自带的缺考数组(同样按筛选过滤)
$absent = array_values(array_filter((array)($result['absent'] ?? []), $hit));
}
// 文件名附带班级 / 组别,便于分辨
$safe = static fn(string $s): string => preg_replace('/[\\\\\/:*?"<>|]/u', '_', $s) ?? '';
$clsLabel = $clsFilter === '' ? ''
: (mb_strpos($clsFilter, '班') === false ? $clsFilter . '班' : $clsFilter);
$grpLabel = $grpFilter === '' ? ''
: (mb_strpos($grpFilter, '组') === false ? $grpFilter . '组' : $grpFilter);
$tag = ($clsLabel !== '' ? '_' . $safe($clsLabel) : '')
. ($grpLabel !== '' ? '_' . $safe($grpLabel) : '');
$mode = ((string)($_GET['mode'] ?? '') === 'latest') ? 'latest' : 'all';
$splitCls = ((string)($_GET['split'] ?? '') === 'cls');
$isLatest = ($mode === 'latest');
$filename = ($action === 'export_absent' ? '缺考名单' : '成绩明细')
. '_' . $safe((string)$result['project']['name'])
. $tag
. ($isLatest ? '_最新成绩' : '')
. '_' . date('Ymd_His') . '.xlsx';
// ---------------- 列定义(可通过 cols= 参数挑选) ----------------
$colLabels = [
'sid' => '学号',
'name' => '姓名',
'cls' => '班级',
'grp' => '组别',
'score' => $isLatest ? '最新成绩' : '成绩',
'best_score' => '最高分',
'attempts' => '考试次数',
'idx' => '第几次',
'submit_at' => $isLatest ? '最近提交时间' : '提交时间',
'duration_txt' => '考试时长',
'duration' => '考试时长(秒)',
];
$defaultCols = $isLatest
? ['sid', 'name', 'cls', 'grp', 'score', 'best_score', 'attempts', 'submit_at', 'duration_txt', 'duration']
: ['sid', 'name', 'cls', 'grp', 'score', 'attempts', 'idx', 'submit_at', 'duration_txt', 'duration'];
$want = array_values(array_filter(
array_map('trim', explode(',', (string)($_GET['cols'] ?? ''))),
static fn($v): bool => $v !== ''
));
$cols = $want !== [] ? array_values(array_intersect($defaultCols, $want)) : $defaultCols;
if ($cols === []) {
$cols = $defaultCols;
}
// ---------------- 数据行 ----------------
$sortMode = (string)($_GET['sort'] ?? '');
if ($isLatest) {
// 每人一行:成绩取「最近一次真实提交」的分数(不受 dedupe 影响),并附最高分与考试次数。
// 注意:include_temp_save 开启时 attempts_list 可能含暂存草稿,取最新成绩应跳过 temp_save,
// 否则导出的"最新成绩"可能是一份草稿分。
$all = [];
foreach ($rows as $r) {
$list = array_values((array)($r['attempts_list'] ?? []));
$last = null;
for ($i = count($list) - 1; $i >= 0; $i--) {
if (empty($list[$i]['temp_save'])) {
$last = $list[$i];
break;
}
}
if ($last === null && $list !== []) {
$last = $list[count($list) - 1]; // 全是暂存时退回最后一份
}
$all[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'cls' => (string)($r['cls'] ?? ''),
'grp' => (string)($r['grp'] ?? ''),
'score' => $last !== null ? ($last['score'] ?? null) : ($r['score'] ?? null),
'best_score' => $r['best_score'] ?? null,
'attempts' => (int)($r['attempts'] ?? 1),
'idx' => (int)($r['attempts'] ?? 1),
'submit_ts' => (int)($r['last_submit_ts'] ?? 0),
'submit_at' => (string)($r['last_submit_at'] ?? ($last['submit_at'] ?? ($r['submit_at'] ?? '-'))),
'duration_txt' => (string)($last['duration_txt'] ?? ($r['duration_txt'] ?? '-')),
'duration' => $last['duration'] ?? ($r['duration'] ?? null),
];
}
if ($sortMode === 'score_desc' || $sortMode === 'score_asc') {
$all = ny_sort_by_score($all, $sortMode === 'score_desc');
} else {
$all = DataService::sortForExport($all, ($sortMode === 'sid') ? 'sid' : 'time');
}
} else {
// 每次考试一行(同一学号考了 N 次就有 N 行)
$all = DataService::expandAttempts($rows, true);
if ($sortMode === 'score_desc' || $sortMode === 'score_asc') {
$all = ny_sort_by_score($all, $sortMode === 'score_desc');
} else {
$all = DataService::sortForExport($all, ($sortMode === 'sid') ? 'sid' : 'time');
}
}
// ---------------- 组装工作表 ----------------
$toRows = static function (array $list) use ($cols, $colLabels): array {
$head = [];
foreach ($cols as $c) {
$head[] = $colLabels[$c];
}
$out = [$head];
foreach ($list as $r) {
$line = [];
foreach ($cols as $c) {
$v = $r[$c] ?? null;
$line[] = ($v === null || $v === '') ? '' : (string)$v;
}
$out[] = $line;
}
return $out;
};
$widths = [];
foreach ($cols as $c) {
$widths[] = in_array($c, ['sid', 'submit_at'], true) ? 20 : ($c === 'name' ? 14 : 11);
}
$sheets = [];
if ($action === 'export_absent') {
$sheets[] = ['name' => '缺考名单', 'rows' => ny_absent_sheet_rows($absent), 'widths' => [16, 14, 16, 10]];
} else {
if ($splitCls) {
// 按班级分表(班级名即工作表名)
$byCls = [];
foreach ($all as $r) {
$byCls[trim((string)($r['cls'] ?? '')) ?: '(未分班)'][] = $r;
}
ksort($byCls, SORT_NATURAL | SORT_FLAG_CASE);
foreach ($byCls as $clsName => $list) {
$sheets[] = ['name' => (string)$clsName, 'rows' => $toRows($list), 'widths' => $widths];
}
} else {
$sheets[] = ['name' => $isLatest ? '成绩明细(最新)' : '成绩明细', 'rows' => $toRows($all), 'widths' => $widths];
}
// 附加:缺考名单 / 未匹配答卷(仅非空时输出)
if ($absent !== []) {
$sheets[] = ['name' => '缺考名单', 'rows' => ny_absent_sheet_rows($absent), 'widths' => [16, 14, 16, 10]];
}
$unmatched = array_values((array)($result['unmatched'] ?? []));
if ($unmatched !== []) {
$um = [['姓名', '成绩', '提交时间']];
foreach ($unmatched as $u) {
$um[] = [
(string)($u['name'] ?? ''),
($u['score'] ?? null) === null ? '' : (string)$u['score'],
(string)($u['submit_at'] ?? ''),
];
}
$sheets[] = ['name' => '未匹配答卷', 'rows' => $um, 'widths' => [16, 10, 20]];
}
}
// 说明:所有单元格按「共享字符串 + 文本格式」写入,
// 既不会把长学号变成科学计数法,也不会把 = + - @ 开头的内容当公式执行(规避 CSV 公式注入)
$bin = XlsxWriter::renderMulti($sheets);
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
header('Content-Disposition: attachment; filename="export.xlsx"; filename*=UTF-8\'\'' . rawurlencode($filename));
header('Content-Length: ' . strlen($bin));
header('Cache-Control: no-store');
echo $bin;
exit;
// ---------------- 随机抽取:导出白名单(xlsx,列:姓名 / 登陆账号) ----------------
case 'draw_export':
$raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST + $_GET;
}
// type=import → 白名单导入模板.xlsx;type=list → 白名单列表.xlsx
$type = (($in['type'] ?? '') === 'import') ? 'import' : 'list';
$members = [];
foreach ((array)($in['members'] ?? []) as $m) {
if (!is_array($m)) {
continue;
}
$name = trim((string)($m['name'] ?? ''));
// 登陆账号优先(名单导入的"登陆账号"列),没有则回退学号
$acct = trim((string)($m['login'] ?? ''));
$sid = trim((string)($m['sid'] ?? ''));
if ($name === '' && $acct === '' && $sid === '') {
continue;
}
$members[] = [$name, $acct !== '' ? $acct : $sid];
}
$bin = XlsxWriter::render(
array_merge([['姓名', '登陆账号']], $members),
['widths' => [16, 24], 'title' => '白名单']
);
$filename = $type === 'import' ? '白名单导入模板.xlsx' : '白名单列表.xlsx';
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
header('Content-Disposition: attachment; filename="whitelist.xlsx"; filename*=UTF-8\'\'' . rawurlencode($filename));
header('Content-Length: ' . strlen($bin));
header('Cache-Control: no-store');
echo $bin;
exit;
default:
ny_fail('未知操作:' . $action);
}
} catch (Throwable $e) {
$msg = $e->getMessage();
// 鉴权类错误(账号未配置 / 登录卷王失败)打标,前端据此跳登录页自愈
$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,
]);
}