Files
ny-exam-platform/api.php
T
nxxy335 6cd17b9cb2 fix: 修复保存满分后清登录;配置双写;设置显示账号/密码;抽取比例改按人数加权
- 修复:仅当连接信息(base_url/api_prefix/username/password/verify_ssl)真变化时才清 token + 重连;
  保存满分/布局等不应触发登录卷王(bug:之前一律 unlink token.json,导致保存后所有用户都被踢)
- 修复:默认满分不再误写当前卷;改为显式 apply_pid + apply_score 才会改 full_scores
- 配置双写(DB+文件):避免 DB 不可用时静默回退到出厂默认 admin/123456;
  ny_save_config 同时写文件+DB;ny_load_config 在 DB 可用但无记录时从文件自动迁回
- 设置弹窗新增:登录账号可编辑、密码 type=password 不回显、留空表示不修改;
  保存后根据 ping 自动给出成功/失败反馈(错误密码不再静默)
- 抽取比例改为按组人数加权(默认)+ 各组均分 + 自定义比例 + 不分组,最大余额法守恒分配
- 抽出比例 UI:删除 5:5 复选框、改 #balanceMode 下拉 + 按组渲染比例输入框
- Db::lastError() 暴露 DB 错误用于诊断(config_get 返回 db_driver / db_connected / db_error)
- e2e:test-draw-alloc 18→29 测试用例(加权/自定义/守恒 fuzz);
  新增 test-config-save e2e 27 用例全过(连接信息变化检测 + 自动 ping + 密码不回显 + apply_pid)
- mock-server:账户校验从 'admin' 改为 'mock' 接受 e2e 账号
2026-09-09 13:06:47 +08:00

507 lines
22 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();
}
// 会话/安装保护:除 auth_check 外,未安装或未登录一律拒访
if (!ny_is_installed()) {
ny_json(['ok' => false, 'msg' => '系统尚未安装,请先完成安装向导'], 503);
exit;
}
if ($action !== 'auth_check' && !ny_is_auth()) {
ny_json(['ok' => false, 'msg' => '未登录或会话已过期,请重新登录'], 401);
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['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();
$c['db_error'] = class_exists('Db') ? Db::lastError() : '';
ny_ok($c);
case 'config_save':
$raw = (string)file_get_contents('php://input');
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST;
}
$old = cfg();
$allowed = ['base_url', 'api_prefix', 'username', 'timeout', 'refresh_interval', 'page_size', 'name_keywords'];
$new = $old;
foreach ($allowed as $k) {
if (array_key_exists($k, $in)) {
$new[$k] = is_string($in[$k]) ? trim((string)$in[$k]) : $in[$k];
}
}
// 密码:与 install.php 一致不做 trim(保留首尾空格的原貌),空 / 占位符 = 不修改
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';
}
// 各试卷独立满分(覆盖整个 full_scores 映射)
if (array_key_exists('full_scores', $in) && is_array($in['full_scores'])) {
$new['full_scores'] = $in['full_scores'];
}
$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;
// 各试卷独立满分:仅当显式指定 apply_pid 时才写入/清除该卷(修复 bug:之前会误用默认满分覆盖当前卷)
$applyPid = trim((string)($in['apply_pid'] ?? ''));
if ($applyPid !== '') {
$scores = is_array($new['full_scores'] ?? null) ? $new['full_scores'] : [];
$applyScore = (int)($in['apply_score'] ?? 0);
if ($applyScore > 0) {
$scores[$applyPid] = $applyScore;
} else {
unset($scores[$applyPid]);
}
$new['full_scores'] = $scores;
}
// 仅当连接信息真的变了才清 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_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 '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();
$result = DataService::build($c, $projectId, $nameQid);
$fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : [];
$fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100);
if ($fs < 1) {
$fs = 100;
}
$result['config'] = [
'refresh_interval' => (int)$c['refresh_interval'],
'base_url' => $c['base_url'],
'full_score' => $fs,
];
ny_ok($result);
// ---------------- 数据看板(按班级聚合,面向班主任/学生整体情况) ----------------
case 'dashboard':
$projectId = (string)($_GET['projectId'] ?? '');
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
$nameQid = (string)($_GET['nameQid'] ?? '');
$c = cfg();
$result = DataService::build($c, $projectId, $nameQid);
$fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : [];
$fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100);
if ($fs < 1) {
$fs = 100;
}
$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;
}
// 通用统计器:给定学生集合 + 答卷集合,算一组指标
$statOf = static function (array $stu, array $rs, int $fs): array {
$total = count($stu);
$submitted = count($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'] >= 0.6 * $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,
'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,
];
};
// 已提交成员榜:按成绩从高到低,缺成绩者排最后,并给出排名
$membersOf = static function (array $rs): array {
$ms = [];
foreach ($rs as $r) {
$ms[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'score' => ($r['score'] ?? null),
];
}
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,
'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'];
$ext = strtolower(pathinfo((string)$f['name'], PATHINFO_EXTENSION));
$dest = NY_DATA . '/uploads/roster_' . date('Ymd_His') . '.' . ($ext !== '' ? $ext : 'dat');
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());
}
$mode = (string)($_POST['mode'] ?? 'replace');
if ($mode === 'append') {
$merged = array_merge(Roster::all(), $res['students']);
Roster::save($merged);
} else {
Roster::save($res['students']);
}
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta(), 'imported' => $res['count']]);
case 'roster_save':
$raw = (string)file_get_contents('php://input');
$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'] ?? '')),
];
}
Roster::save($students);
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta()]);
case 'roster_clear':
Roster::clear();
ny_ok(['students' => [], 'meta' => Roster::meta()]);
// ---------------- 导出(支持按班级 / 组别筛选,与页面当前筛选保持一致) ----------------
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();
$result = DataService::build($c, $projectId, $nameQid);
$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) : '');
$filename = ($action === 'export_absent' ? '缺考名单' : '成绩明细')
. '_' . $safe((string)$result['project']['name'])
. $tag
. '_' . date('Ymd_His') . '.csv';
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"; filename*=UTF-8\'\'' . rawurlencode($filename));
header('Cache-Control: no-store');
echo "\xEF\xBB\xBF";
$out = fopen('php://output', 'w');
if ($action === 'export_absent') {
fputcsv($out, ['学号', '姓名', '班级', '组别']);
foreach ($absent as $r) {
fputcsv($out, [$r['sid'] ?? '', $r['name'] ?? '', $r['cls'] ?? '', $r['grp'] ?? '']);
}
} else {
fputcsv($out, ['学号', '姓名', '班级', '组别', '成绩', '提交时间', '考试时长', '考试时长(秒)']);
foreach ($rows as $r) {
fputcsv($out, [
$r['sid'] ?? '',
$r['name'] ?? '',
$r['cls'] ?? '',
$r['grp'] ?? '',
$r['score'] === null ? '' : $r['score'],
$r['submit_at'] ?? '',
$r['duration_txt'] ?? '',
$r['duration'] === null ? '' : $r['duration'],
]);
}
}
fclose($out);
exit;
// ---------------- 随机抽取:导出白名单(xlsx,列:姓名 / 登陆账号) ----------------
case 'draw_export':
$raw = (string)file_get_contents('php://input');
$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) {
ny_fail($e->getMessage(), [
'trace' => defined('NY_DEBUG') && NY_DEBUG ? $e->getTraceAsString() : null,
]);
}