Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58f9c8433a | ||
|
|
a3be81b261 | ||
|
|
157f257728 |
@@ -5,5 +5,10 @@ data/
|
||||
node_modules/
|
||||
vendor/
|
||||
*.log
|
||||
|
||||
# VitePress 文档站构建产物与缓存
|
||||
docs/.vitepress/dist/
|
||||
docs/.vitepress/cache/
|
||||
docs/.vitepress/config.mjs.timestamp-*.mjs
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -14,15 +14,44 @@
|
||||
| 统计卡片 | 名单总人数、已提交、缺考、提交率、平均分、**合格率**(≥60% 满分)、最高分、最低分、平均用时;**选择班级后,全部卡片只统计该班级** |
|
||||
| 缺考名单 | 名单中有、但未提交的人 |
|
||||
| 未匹配答卷 | 提交了但姓名不在名单中的答卷,方便排查 |
|
||||
| 名单导入 | 上传 `.xlsx` / `.csv`,自动识别「学号」「姓名」「班级」列(班级可选);支持覆盖 / 追加、手动增删改 |
|
||||
| 导出 | 成绩明细 CSV(学号/姓名/班级/成绩/提交时间/考试时长)、缺考名单 CSV(学号/姓名/班级) |
|
||||
| 名单导入 | 上传 `.xlsx` / `.xlsm` / `.csv` / `.txt`,自动识别「学号」「姓名」「班级」列(班级可选);支持覆盖 / 追加、手动增删改 |
|
||||
| 导出 | 成绩明细 / 缺考名单 `.xlsx`(零依赖生成,支持多工作表、按最新成绩、按班级分表) |
|
||||
| 排序 / 班级 | 点击表头排序,**成绩明细默认按提交时间降序**;支持学号/姓名/班级/成绩/提交时间/时长排序;按班级下拉筛选 |
|
||||
| 全屏 / 自适应 | 右上角「全屏」按钮进入全屏(布局自动铺满、表格更高);页面适配手机 / 平板 / 桌面三端 |
|
||||
|
||||
## 环境要求
|
||||
|
||||
- PHP ≥ 8.0(需启用 `curl`、`json`、`mbstring`、`openssl`(可选,用于 RSA 加密密码);`zip` 可选,无 zip 扩展时会用内置解析器读 xlsx)
|
||||
- 无需数据库、无需 Composer 依赖,数据以 JSON 存放在 `data/`
|
||||
- 推荐启用 `pdo_sqlite`(用于 SQLite 存储);未启用时自动降级为 JSON 文件存储
|
||||
- 无需数据库服务、无需 Composer 依赖
|
||||
|
||||
## 文档
|
||||
|
||||
完整文档见 `docs/`(VitePress):
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
npm install
|
||||
npm run docs:dev # 本地预览
|
||||
npm run docs:build # 构建静态站到 docs/.vitepress/dist
|
||||
```
|
||||
|
||||
## 安全
|
||||
|
||||
- 登录凭据即卷王后台账密;连续 5 次失败锁定 10 分钟
|
||||
- 所有写接口强制校验会话绑定的 CSRF 令牌
|
||||
- 上传文件:扩展名白名单(`.xlsx` / `.xlsm` / `.csv` / `.txt`)+ 随机文件名 + **解析后立即删除**
|
||||
- **部署时必须屏蔽 `data/` 目录**,否则配置、名单、卷王账密可被直接下载:
|
||||
|
||||
```nginx
|
||||
# nginx 不读 .htaccess,必须显式配置
|
||||
location ^~ /data/ {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
```
|
||||
|
||||
详见 `docs/security/hardening.md`。
|
||||
|
||||
## 部署
|
||||
|
||||
@@ -76,7 +105,8 @@ Windows 也可双击 `启动平台.bat`。
|
||||
- **平台启动时不预设任何名单**,请通过右上角 **名单管理** 上传你自己的名单(`.xlsx` / `.csv`)。
|
||||
- 名单文件可包含「学号」「姓名」两列,**「班级」为可选列**;含班级时,成绩明细会显示班级列并支持按班级筛选(一份考试涉及多个班级时很实用)。
|
||||
- 上传方式:名单管理 → 点击或拖拽文件 → 选择覆盖 / 追加 → **保存名单**;也可手动增 / 删 / 改。
|
||||
- 名单数据保存在 `data/roster.json`。
|
||||
- 上传文件仅用于解析,落盘后**立即删除**,不会残留在 `data/uploads/`。
|
||||
- 名单数据保存在 SQLite `data/ny.db`(无 `pdo_sqlite` 时回落到 `data/roster.json`)。
|
||||
|
||||
## 目录结构
|
||||
|
||||
@@ -91,16 +121,18 @@ ny-exam-platform/
|
||||
│ ├─ Roster.php 学号名单管理(xlsx/csv 导入与持久化)
|
||||
│ └─ XlsxReader.php 零依赖 XLSX 读取器
|
||||
├─ assets/ 前端样式与脚本
|
||||
├─ data/ 配置、名单、令牌缓存(需可写)
|
||||
└─ tools/ 开发辅助(PHP 语法校验脚本)
|
||||
├─ data/ 配置、名单、SQLite 库、令牌缓存(需可写,且必须禁止 Web 直接访问)
|
||||
├─ docs/ VitePress 文档站
|
||||
├─ tests/ PHP 自测 + 一键运行脚本
|
||||
└─ tools/ 开发辅助(mock 服务、e2e 测试)
|
||||
```
|
||||
|
||||
## 统计口径与显示说明
|
||||
|
||||
- **班级联动统计**:顶部「班级」下拉选择某一班级后,统计卡片(总人数 / 已提交 / 缺考 / 提交率 / 平均分 / 合格率 / 最高分 / 最低分 / 平均用时)与成绩明细、缺考名单都只统计该班级;选「全部班级」则统计整体。切换班级在本地即时重算,不额外请求服务器。
|
||||
- **面板计数随班级**:选择班级后,成绩明细标题右侧的「共 X 人已提交 · 名单 X 人」、缺考名单标题右侧的「共 X 人」也都只统计该班级(不再用全部班级的数据)。
|
||||
- **每套试卷独立满分**:不同试卷满分不同,可在「设置 → 满分」中按当前选中的试卷单独设置,保存后仅作用于该试卷;未选中试卷时设置的满分作为所有试卷的默认值。合格率按「成绩 ≥ 满分 × 60%」计算。
|
||||
- **合格率**:成绩 ≥ 满分 × 60% 视为合格(满分见上)。
|
||||
- **每套试卷独立满分 / 合格率 / 免考**:在主页面「试卷设置」里为**当前选中的试卷**单独设置,试卷之间互相隔离;未设置时回落到「设置」里的全局默认值。
|
||||
- **合格率**:成绩 ≥ 满分 × 合格率阈值 视为合格;阈值全局默认 60%,可在「试卷设置」里按卷覆盖,卡片标题会实时显示当前阈值。
|
||||
- **平均用时不换行**:平均用时(如「9 分 55 秒」)强制单行显示,避免「秒」字换行。
|
||||
- **成绩明细不再显示每人的「状态」列**:缺考人员统一在「缺考名单」面板查看,成绩明细只保留学号 / 姓名 / 班级 / 成绩 / 提交时间 / 考试时长。
|
||||
- **新增数据标记**:每次刷新后,相较上一次新出现的提交(按学号判断)整行显示绿色底纹,方便一眼看出谁刚交卷;首次加载不标记。
|
||||
|
||||
@@ -128,7 +128,7 @@ if ($action !== 'auth_check' && !ny_is_auth()) {
|
||||
// CSRF 防护:所有写操作必须携带会话绑定的 token(GET 读取类接口不受影响)
|
||||
$WRITE_ACTIONS = [
|
||||
'config_save', 'roster_save', 'roster_import', 'roster_clear',
|
||||
'snapshot_save', 'snapshot_delete', 'unmatched_map',
|
||||
'snapshot_save', 'snapshot_delete', 'unmatched_map', 'project_config_save',
|
||||
];
|
||||
if (in_array($action, $WRITE_ACTIONS, true) && !ny_csrf_ok()) {
|
||||
ny_json(['ok' => false, 'msg' => '安全校验失败(CSRF token 缺失或不匹配),请刷新页面后重试'], 403);
|
||||
@@ -194,9 +194,11 @@ try {
|
||||
$v = (string)$in['export_sort'];
|
||||
$new['export_sort'] = in_array($v, ['time','sid','score_desc','score_asc'], true) ? $v : 'time';
|
||||
}
|
||||
// 各试卷独立满分(覆盖整个 full_scores 映射)
|
||||
if (array_key_exists('full_scores', $in) && is_array($in['full_scores'])) {
|
||||
$new['full_scores'] = $in['full_scores'];
|
||||
// 说明:「各试卷独立满分」已移出设置,改为在主页面针对当前试卷配置
|
||||
// (project_config / project_config_save),设置里只保留「全局默认满分」。
|
||||
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'], '/');
|
||||
@@ -211,18 +213,6 @@ try {
|
||||
$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;
|
||||
@@ -329,15 +319,13 @@ try {
|
||||
$c = cfg();
|
||||
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
|
||||
$result = $built['result'];
|
||||
$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;
|
||||
}
|
||||
// 满分:本卷独立优先,其次全局默认
|
||||
[$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']];
|
||||
@@ -353,11 +341,7 @@ try {
|
||||
$c = cfg();
|
||||
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
|
||||
$result = $built['result'];
|
||||
$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;
|
||||
}
|
||||
[$fs, $passRate] = ny_resolve_score_cfg($projectId, $c);
|
||||
|
||||
$students = $result['roster'] ?: [];
|
||||
$rows = $result['rows'] ?: [];
|
||||
@@ -374,13 +358,14 @@ try {
|
||||
}
|
||||
|
||||
// 通用统计器:给定学生集合 + 答卷集合,算一组指标
|
||||
$statOf = static function (array $stu, array $rs, int $fs): array {
|
||||
// 合格率阈值可配(全局 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'] >= 0.6 * $fs)));
|
||||
$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;
|
||||
@@ -473,6 +458,7 @@ try {
|
||||
ny_ok([
|
||||
'project' => $result['project'],
|
||||
'full_score' => $fs,
|
||||
'pass_rate' => $passRate,
|
||||
'overall' => $result['stats'],
|
||||
'classes' => $classes,
|
||||
'updated_at' => $result['updated_at'],
|
||||
@@ -487,8 +473,24 @@ try {
|
||||
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));
|
||||
$dest = NY_DATA . '/uploads/roster_' . date('Ymd_His') . '.' . ($ext !== '' ? $ext : 'dat');
|
||||
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 目录权限');
|
||||
}
|
||||
@@ -498,6 +500,8 @@ try {
|
||||
@unlink($dest);
|
||||
ny_fail($e->getMessage());
|
||||
}
|
||||
// 解析完立即删除:Web 目录下不再留存任何上传文件
|
||||
@unlink($dest);
|
||||
$mode = (string)($_POST['mode'] ?? 'replace');
|
||||
if ($mode === 'append') {
|
||||
$merged = array_merge(Roster::all(), $res['students']);
|
||||
@@ -632,6 +636,74 @@ try {
|
||||
}
|
||||
ny_ok(Snapshot::trend($snaps));
|
||||
|
||||
// ---------------- 试卷独立配置(满分 / 合格率 / 免考名单)——仅对当前试卷生效 ----------------
|
||||
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 = (string)file_get_contents('php://input');
|
||||
$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':
|
||||
|
||||
@@ -261,6 +261,7 @@
|
||||
state.roster = d.roster || [];
|
||||
state.anomalies = d.anomalies || { fast_high: [], frequent: [], identical: [] };
|
||||
state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100;
|
||||
state.passRate = (d.config && d.config.pass_rate != null) ? (parseFloat(d.config.pass_rate) || 60) : 60;
|
||||
|
||||
// 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据"
|
||||
// (纳入 attempts 与 last_submit_ts 才能发现"重考但分数更低"这种提交)
|
||||
@@ -456,7 +457,8 @@
|
||||
const avg = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 100) / 100 : null;
|
||||
|
||||
const full = state.fullScore || 100;
|
||||
const passCount = rows.filter(r => r.score !== null && r.score !== undefined && r.score >= 0.6 * full).length;
|
||||
const pr = (state.passRate == null ? 60 : state.passRate); // 合格率阈值可配
|
||||
const passCount = rows.filter(r => r.score !== null && r.score !== undefined && r.score >= (pr / 100) * full).length;
|
||||
const passRate = submitted > 0 ? Math.round(passCount / submitted * 1000) / 10 : 0;
|
||||
|
||||
let maxScore = null, minScore = null;
|
||||
@@ -490,6 +492,9 @@
|
||||
set('#sMax', st.maxScore == null ? '-' : st.maxScore);
|
||||
set('#sMin', st.minScore == null ? '-' : st.minScore);
|
||||
set('#sPass', st.passRate + '%');
|
||||
// 合格率阈值可配(全局或本卷独立),卡片标题实时反映当前生效值
|
||||
const lbl = $('#sPassLabel');
|
||||
if (lbl) lbl.textContent = '(≥' + (state.passRate == null ? 60 : state.passRate) + '%满分)';
|
||||
set('#sDur', st.avgDur == null ? '-' : fmtDur(st.avgDur));
|
||||
}
|
||||
|
||||
@@ -854,10 +859,20 @@
|
||||
const el = document.documentElement;
|
||||
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
|
||||
const req = el.requestFullscreen || el.webkitRequestFullscreen;
|
||||
if (req) req.call(el);
|
||||
if (req) {
|
||||
const p = req.call(el);
|
||||
// 部分浏览器返回 Promise,被拒绝时静默吞掉会让用户以为"点了没反应"
|
||||
if (p && typeof p.catch === 'function') {
|
||||
p.catch(() => { showAlert('当前浏览器/环境不允许进入全屏,请按 F11 手动切换', 'warn'); });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// exitFullscreen 挂在 document 上(documentElement.exitFullscreen 恒为 undefined)
|
||||
const exit = document.exitFullscreen || document.webkitExitFullscreen;
|
||||
if (exit) exit.call(document);
|
||||
if (exit) {
|
||||
const p = exit.call(document);
|
||||
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
function onFsChange() {
|
||||
@@ -890,9 +905,9 @@
|
||||
// 布局
|
||||
const layout = c.layout || LS.get(LS_LAYOUT, 'default');
|
||||
(document.querySelector('input[name=cfgLayout][value="' + layout + '"]') || {}).checked = true;
|
||||
// 满分:默认 + 各试卷独立
|
||||
// 全局默认满分 / 合格率(每卷的独立配置移到主页面「试卷设置」)
|
||||
$('#cfgFull').value = c.full_score || 100;
|
||||
renderFullScores((c.full_scores && typeof c.full_scores === 'object') ? c.full_scores : {});
|
||||
if ($('#cfgPassRate')) $('#cfgPassRate').value = (c.pass_rate == null ? 60 : c.pass_rate);
|
||||
// 历史成绩展示方式 + 导出排序
|
||||
state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click';
|
||||
state.exportSort = (c.export_sort === 'sid') ? 'sid' : ((c.export_sort === 'score_desc') ? 'score_desc' : ((c.export_sort === 'score_asc') ? 'score_asc' : 'time'));
|
||||
@@ -914,39 +929,6 @@
|
||||
$('#settingsModal').classList.add('open');
|
||||
}
|
||||
|
||||
// 渲染"各试卷独立满分"列表
|
||||
// 注意:#fullScoresHint 必须位于 #fullScoresBox 之外(见 index.php),
|
||||
// 否则 box.innerHTML 覆盖会销毁它,下面的 textContent 赋值会抛 "Cannot set properties of null"。
|
||||
// 这里仍保留 null 守卫,避免任何 DOM 结构变动导致整个设置弹窗打不开。
|
||||
function setFullScoresHint(text) {
|
||||
const hint = $('#fullScoresHint');
|
||||
if (hint) hint.textContent = text;
|
||||
}
|
||||
function renderFullScores(scores) {
|
||||
const box = $('#fullScoresBox');
|
||||
if (!box) return;
|
||||
if (!state.projects.length) {
|
||||
box.innerHTML = '<span class="hint">暂无试卷,请先连接卷王系统并选择问卷</span>';
|
||||
setFullScoresHint('给某卷填入数字即可单独覆盖默认满分;留空=用默认');
|
||||
return;
|
||||
}
|
||||
// 排除文件夹项:模式为文件夹的无可答卷
|
||||
const surveys = state.projects.filter(p => !((p.mode || '').toLowerCase().includes('folder')));
|
||||
if (!surveys.length) {
|
||||
box.innerHTML = '<span class="hint">尚未获取到可答卷</span>';
|
||||
setFullScoresHint('给某卷填入数字即可单独覆盖默认满分;留空=用默认');
|
||||
return;
|
||||
}
|
||||
box.innerHTML = surveys.map(p => {
|
||||
const val = scores[p.id] != null ? scores[p.id] : '';
|
||||
return '<div class="row">' +
|
||||
'<label title="' + esc(p.name) + '">' + esc(p.name) + '</label>' +
|
||||
'<input type="number" min="1" max="1000" data-pid="' + esc(p.id) + '" value="' + esc(val) + '" placeholder="默认">' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
setFullScoresHint('默认分值为空时使用上方"默认满分";填写即单独覆盖该卷');
|
||||
}
|
||||
|
||||
/* ---------------- 名单 ---------------- */
|
||||
let rosterDraft = [];
|
||||
const rosterSel = new Set(); // 勾选待删除的行下标(对应 rosterDraft 索引)
|
||||
@@ -1163,8 +1145,11 @@
|
||||
include_temp_save: $('#cfgTemp').checked,
|
||||
verify_ssl: $('#cfgSsl').checked,
|
||||
full_score: parseInt($('#cfgFull').value, 10) || 100,
|
||||
pass_rate: (function () {
|
||||
const v = $('#cfgPassRate') ? parseFloat($('#cfgPassRate').value) : NaN;
|
||||
return (isNaN(v) || v < 1 || v > 100) ? 60 : v;
|
||||
})(),
|
||||
layout: (document.querySelector('input[name=cfgLayout]:checked') || {}).value || 'default',
|
||||
full_scores: collectFullScores(),
|
||||
username: $('#cfgUser').value.trim(),
|
||||
history_display: $('#cfgHistoryDisplay').value,
|
||||
export_sort: $('#cfgExportSort').value,
|
||||
@@ -1245,15 +1230,6 @@
|
||||
$('#btnTestConn').addEventListener('click', testConnection);
|
||||
|
||||
// 收集"各试卷独立满分" -> {projectId: score}
|
||||
function collectFullScores() {
|
||||
const out = {};
|
||||
$$('#fullScoresBox input[data-pid]').forEach(inp => {
|
||||
const v = parseInt(inp.value, 10);
|
||||
if (inp.dataset.pid && !isNaN(v) && v > 0) out[inp.dataset.pid] = v;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/* 名单上传 */
|
||||
const zone = $('#dropZone'), fileInput = $('#rosterFile');
|
||||
zone.addEventListener('click', () => fileInput.click());
|
||||
@@ -1503,6 +1479,167 @@
|
||||
$('#trendModal').classList.add('open');
|
||||
});
|
||||
|
||||
/* 成绩明细打印:默认按成绩降序,可选按班级/组别分组对比 */
|
||||
function printTable() {
|
||||
const rows = (state._sorted || []).slice();
|
||||
if (!rows.length) return showAlert('当前没有可打印的成绩', 'warn');
|
||||
|
||||
const sortMode = $('#ptSort').value;
|
||||
const groupBy = $('#ptGroup').value; // none | cls | grp
|
||||
const showRank = $('#ptRank').checked;
|
||||
const showAtt = $('#ptAttempts').checked;
|
||||
const full = state.fullScore || 100;
|
||||
const passRate = (state.passRate == null ? 60 : state.passRate);
|
||||
|
||||
// 排序(打印独立于页面排序,默认成绩降序;null 成绩恒排末尾)
|
||||
const cmp = (a, b) => {
|
||||
const an = (a.score == null), bn = (b.score == null);
|
||||
if (an && bn) return 0;
|
||||
if (an) return 1;
|
||||
if (bn) return -1;
|
||||
if (sortMode === 'score_desc') return (b.score - a.score) || ((b.submit_ts || 0) - (a.submit_ts || 0));
|
||||
if (sortMode === 'score_asc') return (a.score - b.score) || ((b.submit_ts || 0) - (a.submit_ts || 0));
|
||||
if (sortMode === 'sid') return String(a.sid).localeCompare(String(b.sid), 'zh', { numeric: true });
|
||||
return ((b.submit_ts || 0) - (a.submit_ts || 0)); // time
|
||||
};
|
||||
rows.sort(cmp);
|
||||
|
||||
const passOf = r => (r.score != null && r.score >= (passRate / 100) * full);
|
||||
const statOf = list => {
|
||||
const sc = list.map(r => r.score).filter(v => v != null);
|
||||
const avg = sc.length ? (sc.reduce((x, y) => x + y, 0) / sc.length) : null;
|
||||
const pass = list.filter(passOf).length;
|
||||
return {
|
||||
n: list.length,
|
||||
avg: avg == null ? '—' : avg.toFixed(1),
|
||||
passRate: list.length ? Math.round(pass / list.length * 1000) / 10 : 0,
|
||||
};
|
||||
};
|
||||
|
||||
const head = '<tr><th>' + (showRank ? '名次' : '') + '</th><th>学号</th><th>姓名</th>' +
|
||||
(groupBy === 'none' ? '<th>班级</th><th>组别</th>' : '') +
|
||||
'<th>成绩</th>' + (showAtt ? '<th>考试次数</th>' : '') + '<th>提交时间</th></tr>';
|
||||
|
||||
let html = '';
|
||||
let rank = 0;
|
||||
const renderRows = (list, offsetRank) => list.map((r, i) => {
|
||||
const rk = (r.score == null) ? '' : (offsetRank + i + 1);
|
||||
return '<tr>' + (showRank ? '<td class="c">' + rk + '</td>' : '') +
|
||||
'<td>' + esc(r.sid || '') + '</td>' +
|
||||
'<td>' + esc(r.name || '') + '</td>' +
|
||||
(groupBy === 'none' ? '<td>' + esc(r.cls || '') + '</td><td>' + esc(r.grp || '') + '</td>' : '') +
|
||||
'<td class="c"><b>' + (r.score == null ? '—' : r.score) + '</b></td>' +
|
||||
(showAtt ? '<td class="c">' + (r.attempts || 1) + '</td>' : '') +
|
||||
'<td>' + esc(r.submit_at || '') + '</td></tr>';
|
||||
}).join('');
|
||||
|
||||
if (groupBy === 'none') {
|
||||
html = '<table><thead>' + head + '</thead><tbody>' + renderRows(rows, 0) + '</tbody></table>';
|
||||
} else {
|
||||
const keyOf = (groupBy === 'cls') ? (r => (r.cls || '未分班')) : (r => (r.grp || '未分组'));
|
||||
const groups = {};
|
||||
rows.forEach(r => { const k = keyOf(r); (groups[k] = groups[k] || []).push(r); });
|
||||
Object.keys(groups).sort((a, b) => String(a).localeCompare(String(b), 'zh', { numeric: true }))
|
||||
.forEach(k => {
|
||||
const list = groups[k];
|
||||
const st = statOf(list);
|
||||
html += '<h3 class="grp">' + esc(k) +
|
||||
' <small>(' + st.n + ' 人 · 平均 ' + st.avg + ' · 合格率 ' + st.passRate + '%)</small></h3>';
|
||||
html += '<table><thead>' + head + '</thead><tbody>' + renderRows(list, 0) + '</tbody></table>';
|
||||
});
|
||||
}
|
||||
|
||||
const all = statOf(rows);
|
||||
const title = (state.projectName ? state.projectName + ' · ' : '') + '成绩明细';
|
||||
const cls = $('#clsSel').value, grp = $('#grpSel').value;
|
||||
const scope = (cls ? cls + '班 ' : '') + (grp ? grp + '组' : '');
|
||||
|
||||
const w = window.open('', '_blank');
|
||||
if (!w) return showAlert('浏览器拦截了弹窗,请允许后重试', 'error');
|
||||
w.document.write('<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title>' + esc(title) + '</title>' +
|
||||
'<style>' +
|
||||
'body{font-family:"Microsoft YaHei",sans-serif;margin:20px;color:#000}' +
|
||||
'h2{margin:0 0 4px;font-size:18px}' +
|
||||
'.meta{font-size:12px;color:#555;margin-bottom:14px}' +
|
||||
'h3.grp{font-size:14px;margin:18px 0 6px;padding:6px 8px;background:#f0f0f0;border-left:4px solid #333}' +
|
||||
'h3.grp small{font-weight:400;color:#555}' +
|
||||
'table{border-collapse:collapse;width:100%;margin-bottom:10px;font-size:12px}' +
|
||||
'th,td{border:1px solid #666;padding:5px 8px;text-align:left}' +
|
||||
'th{background:#e8e8e8;font-weight:700}' +
|
||||
'td.c{text-align:center}' +
|
||||
'@media print{body{margin:0}h3.grp{page-break-after:avoid}table{page-break-inside:auto}tr{page-break-inside:avoid}}' +
|
||||
'</style></head><body>' +
|
||||
'<h2>' + esc(title) + '</h2>' +
|
||||
'<div class="meta">' + esc(scope) + ' 共 ' + all.n + ' 人 · 平均 ' + all.avg +
|
||||
' · 合格率 ' + all.passRate + '%(合格线:满分的 ' + passRate + '%,即 ' +
|
||||
(Math.round(passRate / 100 * full * 10) / 10) + ' 分) · 打印时间:' + new Date().toLocaleString() + '</div>' +
|
||||
html +
|
||||
'<scr' + 'ipt>window.onload=function(){setTimeout(function(){window.print()},300)}</scr' + 'ipt>' +
|
||||
'</body></html>');
|
||||
w.document.close();
|
||||
}
|
||||
|
||||
$('#btnPrintTable').addEventListener('click', () => {
|
||||
if (!curPid()) return showAlert('请先选择问卷 / 考试');
|
||||
const n = (state._sorted || []).length;
|
||||
$('#ptHint').textContent = n ? ('当前将打印 ' + n + ' 条记录') : '暂无数据';
|
||||
$('#printTableModal').classList.add('open');
|
||||
});
|
||||
$('#btnPtPrint').addEventListener('click', () => {
|
||||
$('#printTableModal').classList.remove('open');
|
||||
printTable();
|
||||
});
|
||||
|
||||
/* 试卷独立设置(满分 / 合格率 / 免考名单)——仅对当前选中试卷生效 */
|
||||
async function openProjCfg() {
|
||||
const pid = curPid();
|
||||
if (!pid) return showAlert('请先选择问卷 / 考试');
|
||||
const pname = (($('#projectSel').selectedOptions && $('#projectSel').selectedOptions[0]) || {}).textContent || '';
|
||||
$('#projCfgSub').textContent = pname ? ('· ' + pname) : '';
|
||||
try {
|
||||
const d = await api('project_config', { params: { projectId: pid } });
|
||||
const cfg = d.cfg || {}, eff = d.effective || {}, g = d.global || {};
|
||||
$('#pcFull').value = (cfg.full_score == null ? '' : cfg.full_score);
|
||||
$('#pcPassRate').value = (cfg.pass_rate == null ? '' : cfg.pass_rate);
|
||||
$('#pcExempt').value = (cfg.exempt || []).join('\n');
|
||||
$('#pcFullHint').textContent = '当前生效:' + (eff.full_score || '-') +
|
||||
'(全局默认 ' + (g.full_score || '-') + ')';
|
||||
$('#pcPassHint').textContent = '当前生效:' + (eff.pass_rate || '-') +
|
||||
'%(全局默认 ' + (g.pass_rate || '-') + '%)';
|
||||
} catch (e) { showAlert(e.message); return; }
|
||||
$('#projCfgModal').classList.add('open');
|
||||
}
|
||||
$('#btnProjCfg').addEventListener('click', openProjCfg);
|
||||
|
||||
$('#btnPcSave').addEventListener('click', async () => {
|
||||
const pid = curPid();
|
||||
if (!pid) return showAlert('请先选择问卷 / 考试');
|
||||
try {
|
||||
await api('project_config_save', { method: 'POST', params: { projectId: pid }, body: {
|
||||
full_score: $('#pcFull').value.trim(),
|
||||
pass_rate: $('#pcPassRate').value.trim(),
|
||||
exempt: $('#pcExempt').value,
|
||||
}});
|
||||
$('#projCfgModal').classList.remove('open');
|
||||
showAlert('本卷设置已保存(仅对当前试卷生效)', 'info');
|
||||
refresh(false, true); // 免考会改变名单/缺考口径,需强制重拉
|
||||
} catch (e) { showAlert(e.message); }
|
||||
});
|
||||
|
||||
$('#btnPcReset').addEventListener('click', async () => {
|
||||
const pid = curPid();
|
||||
if (!pid) return;
|
||||
if (!confirm('确定清除本卷的全部独立设置(恢复跟随全局默认)?')) return;
|
||||
try {
|
||||
await api('project_config_save', { method: 'POST', params: { projectId: pid }, body: {
|
||||
full_score: '', pass_rate: '', exempt: '',
|
||||
}});
|
||||
$('#projCfgModal').classList.remove('open');
|
||||
showAlert('本卷设置已清除', 'info');
|
||||
refresh(false, true);
|
||||
} catch (e) { showAlert(e.message); }
|
||||
});
|
||||
|
||||
/* 异常检测线索 */
|
||||
function renderAnomaly() {
|
||||
const a = state.anomalies || { fast_high: [], frequent: [], identical: [] };
|
||||
|
||||
@@ -53,7 +53,12 @@ body {
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: 0 2px 12px rgba(20, 30, 60, .05);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 12px; }
|
||||
.brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
/* 导航栏主标题(已移除 Logo 与副标题,只保留单一主标题) */
|
||||
.brand-title {
|
||||
margin: 0; font-size: 16px; font-weight: 700; letter-spacing: .3px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.logo {
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 10px;
|
||||
@@ -469,32 +474,47 @@ body.fs-on .card b { font-size: 28px; }
|
||||
body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
||||
|
||||
/* ---------- 三端自适应(桌面 / 平板 / 手机) ---------- */
|
||||
|
||||
/* 大屏笔记本:略收边距,统计卡片保持一行 */
|
||||
@media (max-width: 1180px) {
|
||||
.wrap { padding: 16px 16px 40px; }
|
||||
.topbar { padding: 10px 16px; }
|
||||
}
|
||||
|
||||
/* 平板横屏(769~1024):卡片改 4 列、工具栏字段占满整行 */
|
||||
@media (max-width: 1024px) {
|
||||
.grid-2 { grid-template-columns: 1fr; }
|
||||
.field.grow, .field.grow-2 { flex: 1 1 100%; min-width: 0; }
|
||||
.cards { grid-template-columns: repeat(4, 1fr); }
|
||||
.toolbar { gap: 14px; }
|
||||
}
|
||||
|
||||
/* 平板竖屏 / 大手机(<=768):整体收紧,工具栏与按钮自动换行 */
|
||||
@media (max-width: 768px) {
|
||||
body { font-size: 13px; }
|
||||
.topbar { flex-wrap: wrap; gap: 10px; }
|
||||
.brand-text small { display: none; }
|
||||
.brand { flex: 1 1 100%; }
|
||||
.brand-title { font-size: 15px; }
|
||||
.top-right { flex-wrap: wrap; gap: 8px; }
|
||||
.top-right .btn { padding: 6px 10px; font-size: 12px; }
|
||||
.btn b { font-size: 10px; }
|
||||
.wrap { padding: 12px 12px 36px; }
|
||||
.toolbar { padding: 12px; gap: 10px; }
|
||||
.toolbar { padding: 12px; gap: 10px 12px; }
|
||||
.field { flex: 1 1 100%; min-width: 0; }
|
||||
.field.btns { flex: 1 1 100%; }
|
||||
.field.btns { flex: 1 1 100%; flex-wrap: wrap; row-gap: 8px; }
|
||||
.field.btns select { width: 100%; }
|
||||
/* 导出区的复选标签在小屏独占一行,避免和按钮挤成一团 */
|
||||
.field.btns > label[style*="cursor:pointer"] { flex: 1 1 auto; }
|
||||
.cards { grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
.card { padding: 10px 12px; }
|
||||
.card b { font-size: 20px; }
|
||||
.panel-head { flex-direction: column; align-items: flex-start; gap: 6px; }
|
||||
.card span { font-size: 11px; }
|
||||
/* 面板头部(含按钮)竖排,按钮自动换行 */
|
||||
.panel-head { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
/* 竖排时 .spacer(flex:1)会把内容上下撑开,必须隐藏 */
|
||||
.panel-head > .spacer { display: none; }
|
||||
.panel-head .updated { flex-wrap: wrap; width: 100%; }
|
||||
.panel-head .btn, .panel-head .updated .btn { flex: 0 0 auto; }
|
||||
.table-scroll { max-height: 56vh; }
|
||||
table.grid { font-size: 12px; }
|
||||
table.grid th { padding: 8px 10px; white-space: nowrap; }
|
||||
@@ -503,19 +523,36 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
||||
.modal-box, .modal-box.wide { width: 100%; max-height: 92vh; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.modal-body { padding: 14px; }
|
||||
/* 弹窗底栏在小屏换行,按钮可点满宽 */
|
||||
.modal-foot { flex-wrap: wrap; padding: 10px 14px; }
|
||||
.modal-foot > .spacer { display: none; }
|
||||
.modal-foot .btn { flex: 1 1 auto; }
|
||||
.roster-bar { flex-wrap: wrap; }
|
||||
.roster-bar .chk { font-size: 11px; }
|
||||
/* 名单表在窄屏可横向滚动,避免 6 列挤爆 */
|
||||
#rosterTable { min-width: 560px; }
|
||||
}
|
||||
|
||||
/* 手机(<=480):卡片两列,进一步压缩字号与内边距 */
|
||||
@media (max-width: 480px) {
|
||||
.cards { grid-template-columns: repeat(2, 1fr); }
|
||||
.brand-text strong { font-size: 14px; }
|
||||
.topbar .logo { width: 32px; height: 32px; font-size: 13px; }
|
||||
.cards { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
.brand-title { font-size: 14px; }
|
||||
.btn { padding: 6px 9px; font-size: 12px; }
|
||||
.field label { font-size: 11px; }
|
||||
.roster-bar { gap: 8px; }
|
||||
.roster-bar input[type=text] { width: 120px; }
|
||||
.panel-head h2 { font-size: 14px; }
|
||||
#rosterBody input { font-size: 12px; }
|
||||
.modal-box { max-height: 94vh; }
|
||||
.modal-body { padding: 12px; }
|
||||
/* 工具条按钮在小屏铺满,便于点按 */
|
||||
.field.btns .btn { flex: 1 1 auto; }
|
||||
}
|
||||
|
||||
/* 触摸设备:增大点按热区,去掉 hover 残留高亮 */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.btn { min-height: 34px; }
|
||||
table.grid tbody tr:hover td { background: inherit; }
|
||||
}
|
||||
|
||||
/* ---------- 数据看板 ---------- */
|
||||
@@ -525,8 +562,7 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.dash-hero .brand strong { color: #fff; font-size: 17px; }
|
||||
.dash-hero .brand small { color: rgba(255, 255, 255, .72); }
|
||||
.dash-hero .brand-title { color: #fff; font-size: 17px; }
|
||||
.dash-hero a.back { color: #fff; opacity: .9; font-size: 13px; text-decoration: none; font-weight: 600; }
|
||||
.dash-hero a.back:hover { opacity: 1; text-decoration: underline; }
|
||||
.dash-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
@@ -200,15 +200,19 @@ function ny_is_installed(): bool
|
||||
}
|
||||
|
||||
// ---------------- 目录初始化 ----------------
|
||||
foreach (['', '/cache', '/uploads'] as $sub) {
|
||||
foreach (['', '/cache', '/uploads', '/snapshots'] as $sub) {
|
||||
$dir = NY_DATA . $sub;
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0777, true);
|
||||
}
|
||||
// Apache:禁止直接访问(nginx 不读 .htaccess,需在 server 配置中额外 deny,见 docs/部署与安全)
|
||||
if (!file_exists($dir . '/.htaccess')) {
|
||||
@file_put_contents($dir . '/.htaccess', "Deny from all\n");
|
||||
}
|
||||
// 兜底:即使目录被索引也不暴露文件清单
|
||||
if (!file_exists($dir . '/index.html')) {
|
||||
@file_put_contents($dir . '/index.html', "<!doctype html><meta charset=\"utf-8\"><title>403 Forbidden</title><h1>403 Forbidden</h1>\n");
|
||||
}
|
||||
// 保护数据目录(Apache)
|
||||
if (!file_exists(NY_DATA . '/.htaccess')) {
|
||||
@file_put_contents(NY_DATA . '/.htaccess', "Deny from all\n");
|
||||
}
|
||||
|
||||
// ---------------- 默认配置 ----------------
|
||||
@@ -227,7 +231,9 @@ function ny_default_config(): array
|
||||
'dedupe' => true,
|
||||
'name_keywords' => '姓名,名字,学生姓名,考生姓名,您的姓名,姓名:,姓名:',
|
||||
'full_score' => 100,
|
||||
'full_scores' => [],
|
||||
// 全局默认合格率(百分比,如 60 表示"≥满分的 60%"算合格)。
|
||||
// 每份试卷可在主页面单独覆盖(project_config),试卷之间互相隔离。
|
||||
'pass_rate' => 60,
|
||||
'layout' => 'default',
|
||||
// 历史成绩展示方式:click=点击成员弹窗查看(默认);inline=成绩明细内直接展开每次考试
|
||||
'history_display' => 'click',
|
||||
@@ -258,16 +264,45 @@ function ny_load_config(): array
|
||||
{
|
||||
$GLOBALS['__ny_cfg_source'] = 'default';
|
||||
|
||||
// 默认配置 + 文件覆盖(DB 后端本批次已下线,统一走文件存储)
|
||||
$cfg = ny_default_config();
|
||||
$saved = ny_read_json(ny_config_file(), null);
|
||||
$file = ny_config_file();
|
||||
$saved = null;
|
||||
|
||||
// 1) 优先读 SQLite(data/ny.db 的 ny_settings.config)
|
||||
if (class_exists('Db') && Db::pdo() !== null) {
|
||||
$raw = Db::settingsGet('config', '');
|
||||
if ($raw !== '') {
|
||||
$j = json_decode($raw, true);
|
||||
if (is_array($j)) {
|
||||
$saved = $j;
|
||||
$GLOBALS['__ny_cfg_source'] = 'db';
|
||||
}
|
||||
}
|
||||
// DB 可用但尚无记录 → 把已有 config.json 迁进来(老数据不丢,只迁一次)
|
||||
if ($saved === null) {
|
||||
$fromFile = ny_read_json($file, null);
|
||||
if (is_array($fromFile)) {
|
||||
Db::settingsSet('config', json_encode($fromFile, JSON_UNESCAPED_UNICODE));
|
||||
$saved = $fromFile;
|
||||
$GLOBALS['__ny_cfg_source'] = 'db';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) DB 不可用时退回文件
|
||||
if ($saved === null) {
|
||||
$saved = ny_read_json($file, null);
|
||||
if (is_array($saved)) {
|
||||
$GLOBALS['__ny_cfg_source'] = 'file';
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($saved)) {
|
||||
foreach ($cfg as $k => $v) {
|
||||
if (array_key_exists($k, $saved)) {
|
||||
$cfg[$k] = is_bool($v) ? (bool)$saved[$k] : $saved[$k];
|
||||
}
|
||||
}
|
||||
$GLOBALS['__ny_cfg_source'] = 'file';
|
||||
}
|
||||
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
|
||||
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
|
||||
@@ -276,8 +311,52 @@ function ny_load_config(): array
|
||||
|
||||
function ny_save_config(array $cfg): bool
|
||||
{
|
||||
// 双写:SQLite 为主(成功即算成功),文件为备份(DB 挂掉时还能读回来)
|
||||
$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;
|
||||
} catch (Throwable $e) {
|
||||
$ok = false;
|
||||
}
|
||||
}
|
||||
$fileOk = ny_write_json(ny_config_file(), $cfg);
|
||||
if (!$ok) {
|
||||
$GLOBALS['__ny_cfg_source'] = 'file';
|
||||
return ny_write_json(ny_config_file(), $cfg);
|
||||
}
|
||||
return $ok || $fileOk;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析「本卷满分 + 合格率」:
|
||||
* 优先取该试卷的独立配置(试卷之间互相隔离),没有则用全局配置。
|
||||
*
|
||||
* @return array{0:int,1:float} [满分, 合格率百分比(如 60 表示 60%)]
|
||||
*/
|
||||
function ny_resolve_score_cfg(string $projectId, array $c): array
|
||||
{
|
||||
$pCfg = class_exists('DataService') ? DataService::projectConfig($projectId) : [];
|
||||
|
||||
$fs = (int)($pCfg['full_score'] ?? 0);
|
||||
if ($fs < 1) {
|
||||
$fs = (int)($c['full_score'] ?? 100);
|
||||
}
|
||||
if ($fs < 1) {
|
||||
$fs = 100;
|
||||
}
|
||||
|
||||
$pr = $pCfg['pass_rate'] ?? null;
|
||||
if ($pr === null || $pr === '') {
|
||||
$pr = $c['pass_rate'] ?? 60;
|
||||
}
|
||||
$pr = (float)$pr;
|
||||
if ($pr <= 0 || $pr > 100) {
|
||||
$pr = 60;
|
||||
}
|
||||
|
||||
return [$fs, $pr];
|
||||
}
|
||||
|
||||
/** 安全的字符串化(避免数组/对象转字符串报错) */
|
||||
|
||||
@@ -25,11 +25,7 @@ if (!ny_is_auth()) {
|
||||
|
||||
<div class="dash-hero">
|
||||
<div class="brand">
|
||||
<span class="logo">NY</span>
|
||||
<div class="brand-text">
|
||||
<strong id="heroTitle">班级看板</strong>
|
||||
<small id="heroSub">选择一个问卷与班级查看考试整体情况</small>
|
||||
</div>
|
||||
<h1 class="brand-title">NY问卷考试系统数据平台</h1>
|
||||
</div>
|
||||
<div class="dash-controls">
|
||||
<select id="projectSel" style="min-width:160px"><option value="">请选择问卷 / 考试</option></select>
|
||||
@@ -47,7 +43,9 @@ if (!ny_is_auth()) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:14px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;">
|
||||
<div style="margin-top:14px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
|
||||
<span id="heroSub">选择一个问卷与班级查看考试整体情况</span>
|
||||
<span class="spacer" style="flex:1"></span>
|
||||
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +58,7 @@ if (!ny_is_auth()) {
|
||||
<div class="card warn accent-orange"><span>缺考</span><b id="bAbsent">-</b></div>
|
||||
<div class="card accent-purple"><span>提交率</span><b id="bRate">-</b></div>
|
||||
<div class="card accent-teal"><span>平均分</span><b id="bAvg">-</b></div>
|
||||
<div class="card accent-indigo"><span>合格率(≥60%满分)</span><b id="bPass">-</b></div>
|
||||
<div class="card accent-indigo"><span>合格率<span id="bPassLabel">(≥60%满分)</span></span><b id="bPass">-</b></div>
|
||||
</section>
|
||||
|
||||
<div class="sec-title">分组对比 / 考试成员</div>
|
||||
@@ -200,6 +198,9 @@ async function refreshClassSel(){
|
||||
try {
|
||||
const data = await api('dashboard', { projectId: pid });
|
||||
DASH = data;
|
||||
// 合格率阈值可配(全局或本卷独立),卡片标题实时反映
|
||||
const bl = document.getElementById('bPassLabel');
|
||||
if (bl) bl.textContent = '(≥' + (data.pass_rate == null ? 60 : data.pass_rate) + '%满分)';
|
||||
const classes = (data.classes || []).map(c => c.class);
|
||||
sel.innerHTML = classes.length
|
||||
? classes.map(c => '<option value="' + esc(c) + '">' + esc(c) + '</option>').join('')
|
||||
@@ -285,8 +286,9 @@ function renderPlaceholderBoard(){
|
||||
$('#boardSummary').innerHTML = cards.map(c =>
|
||||
'<div class="card"><span>' + c[0] + '</span><b>' + c[1] + '</b></div>').join('');
|
||||
$('#boardBody').innerHTML = '<div class="empty">请选择问卷与班级查看考试整体情况</div>';
|
||||
$('#heroTitle').textContent = '班级看板';
|
||||
$('#heroSub').textContent = '选择一个问卷与班级查看考试整体情况';
|
||||
// 注意:heroTitle 已随导航栏精简移除,只保留 heroSub(承载当前问卷/班级提示)
|
||||
const _hs = document.getElementById('heroSub');
|
||||
if (_hs) _hs.textContent = '选择一个问卷与班级查看考试整体情况';
|
||||
const d = $('#liveDot'); if (d) { d.classList.remove('on','stale'); }
|
||||
}
|
||||
|
||||
@@ -327,9 +329,8 @@ function renderBoard(){
|
||||
return;
|
||||
}
|
||||
|
||||
// 顶部品牌区:动态标题
|
||||
// 顶部提示:当前班级 / 问卷(heroTitle 已移除,只更新 heroSub)
|
||||
const projectName = ($('#projectSel').selectedOptions[0] || {}).textContent || '';
|
||||
$('#heroTitle').textContent = cls + ' · 班级看板';
|
||||
|
||||
// 构建"学号 -> 多次刷题明细"映射(班级级 members 已含全班所有人的 attempts_list)
|
||||
const MAP = {};
|
||||
@@ -342,7 +343,8 @@ function renderBoard(){
|
||||
|
||||
const overall = DASH.overall || {};
|
||||
const totalAttempts = (overall.attempts_total || 0);
|
||||
$('#heroSub').textContent = '问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' +
|
||||
const _hs2 = document.getElementById('heroSub');
|
||||
if (_hs2) _hs2.textContent = cls + ' · 问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' +
|
||||
'总 ' + c.total + ' / 已交 ' + c.submitted + ' / 缺考 ' + c.absent +
|
||||
(totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : '');
|
||||
|
||||
@@ -354,7 +356,7 @@ function renderBoard(){
|
||||
'<div class="card warn accent-orange"><span>缺考</span><b>' + c.absent + '</b></div>' +
|
||||
'<div class="card accent-purple"><span>提交率</span><b style="color:' + color + '">' + c.rate + '%</b></div>' +
|
||||
'<div class="card accent-teal"><span>平均分</span><b>' + (c.avg != null ? c.avg : '-') + '</b></div>' +
|
||||
'<div class="card accent-indigo"><span>合格率</span><b>' + c.pass_rate + '%</b></div>';
|
||||
'<div class="card accent-indigo"><span>合格率(≥' + (DASH && DASH.pass_rate != null ? DASH.pass_rate : 60) + '%满分)</span><b>' + c.pass_rate + '%</b></div>';
|
||||
|
||||
const groups = (c.groups || []).filter(g => g.group !== '(未分组)');
|
||||
const box = $('#boardBody');
|
||||
@@ -392,10 +394,21 @@ function toggleFullscreen(){
|
||||
const el = document.documentElement;
|
||||
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
|
||||
const req = el.requestFullscreen || el.webkitRequestFullscreen;
|
||||
if (req) req.call(el);
|
||||
if (req) {
|
||||
const p = req.call(el);
|
||||
// 部分浏览器(尤其 Safari)会返回 Promise,被拒绝时静默吞掉会让人以为"点了没反应"
|
||||
if (p && typeof p.catch === 'function') {
|
||||
p.catch(() => { alert('当前浏览器/环境不允许进入全屏,请按 F11 手动切换'); });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const ex = el.exitFullscreen || el.webkitExitFullscreen;
|
||||
if (ex) ex.call(document);
|
||||
// 注意:exitFullscreen 挂在 document 上,不是 documentElement 上
|
||||
// (写成 el.exitFullscreen 恒为 undefined → 退出全屏点了没反应)
|
||||
const ex = document.exitFullscreen || document.webkitExitFullscreen;
|
||||
if (ex) {
|
||||
const p = ex.call(document);
|
||||
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
function onFsChange(){
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { defineConfig } from 'vitepress'
|
||||
|
||||
// 部署到子路径时,构建前设置环境变量即可:
|
||||
// DOCS_BASE=/ny-exam-docs/ npm run docs:build
|
||||
const base = process.env.DOCS_BASE || '/'
|
||||
|
||||
export default defineConfig({
|
||||
base,
|
||||
lang: 'zh-CN',
|
||||
title: 'NY 问卷考试系统数据平台',
|
||||
description: '基于 PHP 8 + 卷王(SurveyKing)社区版开放 API 的零依赖考试数据统计平台',
|
||||
cleanUrls: true,
|
||||
ignoreDeadLinks: true,
|
||||
|
||||
head: [
|
||||
['meta', { name: 'theme-color', content: '#2563eb' }],
|
||||
['meta', { name: 'referrer', content: 'no-referrer' }]
|
||||
],
|
||||
|
||||
themeConfig: {
|
||||
// outlineTitle: '本页目录',
|
||||
outline: [2, 3],
|
||||
lastUpdated: { text: '最后更新于' },
|
||||
|
||||
search: {
|
||||
provider: 'local',
|
||||
options: {
|
||||
locales: {
|
||||
zh: {
|
||||
translations: {
|
||||
button: { buttonText: '搜索文档', buttonAriaLabel: '搜索文档' },
|
||||
modal: {
|
||||
noResultsText: '未找到相关结果',
|
||||
resetButtonTitle: '清除查询条件',
|
||||
footer: {
|
||||
selectText: '选择',
|
||||
navigateText: '切换',
|
||||
closeText: '关闭'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
nav: [
|
||||
{ text: '首页', link: '/' },
|
||||
{ text: '使用指南', link: '/guide/introduction', activeMatch: '/guide/' },
|
||||
{ text: '安全', link: '/security/', activeMatch: '/security/' },
|
||||
{ text: '开发', link: '/dev/architecture', activeMatch: '/dev/' }
|
||||
],
|
||||
|
||||
sidebar: {
|
||||
'/guide/': [
|
||||
{
|
||||
text: '开始',
|
||||
items: [
|
||||
{ text: '平台简介', link: '/guide/introduction' },
|
||||
{ text: '环境要求与部署', link: '/guide/install' },
|
||||
{ text: '快速上手', link: '/guide/quickstart' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '核心功能',
|
||||
items: [
|
||||
{ text: '系统设置', link: '/guide/settings' },
|
||||
{ text: '名单管理', link: '/guide/roster' },
|
||||
{ text: '成绩明细与统计口径', link: '/guide/scores' },
|
||||
{ text: '试卷设置(满分/合格率/免考)', link: '/guide/project-config' },
|
||||
{ text: '快照、趋势与异常检测', link: '/guide/snapshot' },
|
||||
{ text: '导出与打印', link: '/guide/export-print' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '其他页面',
|
||||
items: [
|
||||
{ text: '数据看板', link: '/guide/dashboard' },
|
||||
{ text: '随机抽取', link: '/guide/draw' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '帮助',
|
||||
items: [
|
||||
{ text: '常见问题', link: '/guide/faq' }
|
||||
]
|
||||
}
|
||||
],
|
||||
'/security/': [
|
||||
{
|
||||
text: '安全',
|
||||
items: [
|
||||
{ text: '安全总览', link: '/security/' },
|
||||
{ text: '部署加固清单', link: '/security/hardening' }
|
||||
]
|
||||
}
|
||||
],
|
||||
'/dev/': [
|
||||
{
|
||||
text: '开发',
|
||||
items: [
|
||||
{ text: '架构与目录', link: '/dev/architecture' },
|
||||
{ text: '接口一览', link: '/dev/api' },
|
||||
{ text: '数据存储(SQLite)', link: '/dev/database' },
|
||||
{ text: '测试', link: '/dev/testing' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
socialLinks: [],
|
||||
|
||||
footer: {
|
||||
message: '基于 PHP 8 + 卷王(SurveyKing)社区版开放 API 构建,零第三方运行依赖。',
|
||||
copyright: 'Copyright © NY 问卷考试系统数据平台'
|
||||
},
|
||||
|
||||
docFooter: {
|
||||
prev: '上一页',
|
||||
next: '下一页'
|
||||
},
|
||||
|
||||
returnToTopLabel: '回到顶部',
|
||||
sidebarMenuLabel: '菜单',
|
||||
darkModeSwitchLabel: '主题',
|
||||
lightModeSwitchTitle: '切换到浅色模式',
|
||||
darkModeSwitchTitle: '切换到深色模式'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
# 接口一览
|
||||
|
||||
所有接口统一走 `api.php?action=<name>`,响应为 JSON:
|
||||
|
||||
```jsonc
|
||||
// 成功
|
||||
{ "ok": true, "data": { ... } }
|
||||
// 失败
|
||||
{ "ok": false, "error": "错误信息" }
|
||||
```
|
||||
|
||||
- 未登录时返回 `{ "ok": false, "error": "...", "auth": false }`,前端会清会话并跳登录页;
|
||||
- **写操作**必须在 JSON 体里带 `_csrf` 字段(或 `X-CSRF-Token` 头),
|
||||
令牌从 `config_get` 接口获取。
|
||||
|
||||
## 写操作白名单
|
||||
|
||||
`config_save`、`roster_save`、`roster_import`、`roster_clear`、
|
||||
`snapshot_save`、`snapshot_delete`、`unmatched_map`、`project_config_save`
|
||||
|
||||
## 接口清单
|
||||
|
||||
### 认证与配置
|
||||
|
||||
| Action | 方法 | 参数 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `auth_check` | GET | — | 是否已登录 |
|
||||
| `config_get` | GET | — | 返回配置(密码不回显)+ `csrf_token` + `storage` / `db_driver` / `db_connected` |
|
||||
| `config_save` | POST | JSON 配置对象 | 保存;返回 `conn_changed` 与 `ping` 结果 |
|
||||
| `config_test` | POST | 连接信息 | 测试连接,不落库 |
|
||||
| `ping` | GET | — | 探测卷王是否可达 |
|
||||
| `diag` | GET | — | 诊断信息(存储后端、DB 状态等) |
|
||||
|
||||
> **只有连接信息(地址 / 前缀 / 账号 / 密码 / 证书校验)变化时**,`config_save` 才会清除令牌并重新登录。
|
||||
|
||||
### 数据
|
||||
|
||||
| Action | 方法 | 参数 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `projects` | GET | — | 卷王项目(问卷 / 考试)列表 |
|
||||
| `data` | GET | `projectId`、`nameQid`、`force` | 主系统数据:统计、成绩行、名单、缺考、未匹配、异常 |
|
||||
| `dashboard` | GET | `projectId`、`nameQid`、`force` | 按班级 / 组别聚合的看板数据 |
|
||||
|
||||
`data` 响应关键字段:
|
||||
|
||||
```
|
||||
stats { total, submitted, absent, rate, avg, pass_rate, max, min, avg_duration }
|
||||
rows[] { sid, name, cls, grp, score, best_score, attempts, attempts_list[],
|
||||
submit_ts, submit_at, last_submit_ts, duration, duration_txt }
|
||||
roster[] { sid, name, cls, grp, login }
|
||||
absent[] 缺考名单
|
||||
unmatched[] 未匹配答卷
|
||||
anomalies { fast_high[], frequent[], identical[] }
|
||||
project_cfg 本卷配置
|
||||
_cache { cached, at }
|
||||
```
|
||||
|
||||
### 名单
|
||||
|
||||
| Action | 方法 | 参数 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `roster` | GET | — | 名单与元信息 |
|
||||
| `roster_import` | POST | `multipart`:`file`、`mode=replace\|append` | 导入(扩展名白名单 + 随机名 + 解析后即删) |
|
||||
| `roster_save` | POST | JSON `{ students: [...] }` | 全量替换(DELETE + INSERT) |
|
||||
| `roster_clear` | POST | — | 清空 |
|
||||
| `unmatched_map` | POST | JSON `{ name, sid, cls, grp, login }` | 未匹配答卷补入名单(学号已存在则更新姓名) |
|
||||
|
||||
### 每卷配置
|
||||
|
||||
| Action | 方法 | 参数 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `project_config` | GET | `projectId` | 返回 `cfg`、`effective`(生效值)、`global`(全局默认) |
|
||||
| `project_config_save` | POST | JSON `{ full_score, pass_rate, exempt[] }` | 留空/0 = 清除该项,回落全局 |
|
||||
|
||||
### 快照
|
||||
|
||||
| Action | 方法 | 参数 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `snapshot_save` | POST | `projectId`、JSON `{ label }` | 保存当前成绩快照 |
|
||||
| `snapshot_list` | GET | `projectId` | 列表 |
|
||||
| `snapshot_delete` | POST | `projectId`、JSON `{ file }` | 删除 |
|
||||
| `snapshot_trend` | GET | `projectId`(可逗号分隔多个,跨卷对比) | 趋势 |
|
||||
|
||||
### 导出
|
||||
|
||||
| Action | 方法 | 参数 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `export` | GET | `projectId`、`cls`、`grp`、`mode=latest`、`split=cls`、`sort`、`cols` | 成绩明细 `.xlsx` |
|
||||
| `export_absent` | GET | 同上 | 缺考名单 `.xlsx` |
|
||||
| `draw_export` | GET | `type=import\|list` | 白名单导入模板 / 白名单列表 |
|
||||
|
||||
`sort` 取值:`time` / `sid` / `score_desc` / `score_asc`。
|
||||
|
||||
## 前端调用约定
|
||||
|
||||
```js
|
||||
// 读
|
||||
const d = await api('data', { params: { projectId, force: 1 } })
|
||||
// 写:api() 会自动注入 _csrf
|
||||
await api('roster_save', { json: { students } })
|
||||
```
|
||||
|
||||
## 相关
|
||||
|
||||
- [架构与目录](./architecture.md)
|
||||
- [测试](./testing.md)
|
||||
@@ -0,0 +1,110 @@
|
||||
# 架构与目录
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**:PHP 8(过程式 + 少量静态类),零 Composer 依赖
|
||||
- **前端**:原生 HTML / CSS / JavaScript(无框架、无构建步骤)
|
||||
- **存储**:SQLite(PDO),缺失 `pdo_sqlite` 时自动降级为 JSON 文件
|
||||
- **外部依赖**:仅卷王(SurveyKing)开放 API
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
ny-exam-platform/
|
||||
├─ index.php 主系统页面(服务端渲染骨架 + 前端挂载)
|
||||
├─ dashboard.php 数据看板页面
|
||||
├─ draw.php 随机抽取页面
|
||||
├─ login.php / logout.php 登录与登出
|
||||
├─ api.php 所有后端接口(单一入口,?action=xxx)
|
||||
├─ bootstrap.php 常量、自动加载、配置读写、CSRF、限流、公共函数
|
||||
├─ lib/
|
||||
│ ├─ SurveyKingClient.php 卷王 API 客户端(登录 / 令牌 / 项目 / 答卷)
|
||||
│ ├─ DataService.php 数据组装:姓名→学号匹配、统计、去重、异常检测
|
||||
│ ├─ Roster.php 名单管理(xlsx/csv 导入、列识别、持久化)
|
||||
│ ├─ Db.php SQLite 封装(三方言兼容的 DDL 与 upsert)
|
||||
│ ├─ DataCache.php 结果缓存(TTL + flock 防惊群)
|
||||
│ ├─ Snapshot.php 成绩快照与趋势
|
||||
│ ├─ XlsxReader.php 零依赖 XLSX 读取
|
||||
│ └─ XlsxWriter.php 零依赖 XLSX 写入(支持多工作表)
|
||||
├─ assets/ style.css / app.js / draw.js
|
||||
├─ data/ 运行时数据(需可写,已 gitignore)
|
||||
│ ├─ ny.db SQLite 主库
|
||||
│ ├─ config.json 配置(文件备份)
|
||||
│ ├─ roster.json 名单(文件备份)
|
||||
│ ├─ project_config.json 每卷配置(文件备份)
|
||||
│ ├─ cache/ 卷王令牌 + 结果缓存
|
||||
│ ├─ uploads/ 导入临时目录(解析后即删)
|
||||
│ └─ snapshots/ 成绩快照
|
||||
├─ tests/ PHP 自测 + 一键运行脚本
|
||||
├─ tools/ mock 服务与 Node e2e
|
||||
└─ docs/ 本文档站(VitePress)
|
||||
```
|
||||
|
||||
## 请求流程
|
||||
|
||||
```
|
||||
浏览器 ──► api.php?action=data
|
||||
│
|
||||
├─► ny_is_auth() 未登录 → 401 + {auth:false}
|
||||
├─► 写操作 → ny_csrf_ok() 校验
|
||||
├─► ny_build_data()(带结果缓存)
|
||||
│ └─► DataService::build()
|
||||
│ ├─► SurveyKingClient::listAnswers() 拉卷王答卷
|
||||
│ ├─► Roster::all() 读名单
|
||||
│ ├─► 姓名 → 学号匹配
|
||||
│ ├─► dedupe(决定代表成绩)
|
||||
│ └─► 统计 / 异常检测
|
||||
└─► ny_resolve_score_cfg() 解析本卷满分与合格率
|
||||
└─► ny_ok(JSON)
|
||||
```
|
||||
|
||||
## 关键设计
|
||||
|
||||
### 单一入口 + 白名单
|
||||
|
||||
`api.php` 用 `switch ($action)` 分发。所有写操作集中在 `$WRITE_ACTIONS` 白名单里统一校验 CSRF,
|
||||
新增写接口时**必须**把 action 加进该数组。
|
||||
|
||||
### 配置双写
|
||||
|
||||
`ny_save_config()` 同时写 SQLite 与 `data/config.json`;`ny_load_config()` 优先读库,
|
||||
库为空时自动从文件迁回。这样即使数据库不可用,也不会静默回落到出厂的 `admin/123456`。
|
||||
|
||||
### 只在连接信息变化时才重新登录
|
||||
|
||||
`config_save` 会比较 `base_url / api_prefix / username / password / verify_ssl` 五项,
|
||||
只有真变化才删除令牌缓存并重新 ping。修改满分、布局、刷新间隔不会打断数据刷新。
|
||||
|
||||
### 结果缓存
|
||||
|
||||
`DataCache` 以「配置 + 名单指纹 + 试卷 ID」为键缓存构建结果,默认 TTL 15 秒;
|
||||
用 `flock` 防止并发惊群。名单指纹(`Roster::fingerprint()`)参与缓存键,改名单会自动失效。
|
||||
点「立即刷新」带 `force=1` 跳过缓存。
|
||||
|
||||
### 内存防护
|
||||
|
||||
拉取卷王答卷时单页条数被夹在 20–200 之间,避免单页响应过大导致
|
||||
`json_decode` 一次性撑爆内存(曾出现过 128M 耗尽的问题)。
|
||||
|
||||
### 零依赖 XLSX
|
||||
|
||||
- 读取:`XlsxReader` 直接解析 ZIP(优先 `ZipArchive`,无 `zip` 扩展时用内置实现)与 XML;
|
||||
- 写入:`XlsxWriter` 手写 ZIP(stored 模式),单元格统一写共享字符串 +
|
||||
`numFmtId=49` 文本样式,天然防公式注入、防长学号变科学计数法;
|
||||
- `renderMulti()` 支持多工作表,表名做 31 字截断与 `(2)` 去重。
|
||||
|
||||
## 前端要点
|
||||
|
||||
| 主题 | 实现 |
|
||||
| --- | --- |
|
||||
| 大表格 | 虚拟滚动(占位行 + 可视区渲染),`ROW_H=41`,overscan 12 |
|
||||
| 自动刷新 | `setTimeout` 链 + 失败指数退避;`visibilitychange` 时后台暂停 |
|
||||
| 提示 | 顶部 Toast 系统,`showAlert(msg, type)` 4 种颜色,最多 5 条 |
|
||||
| 全屏 | `document.exitFullscreen()`(注意不是 `documentElement`) |
|
||||
| 事件绑定 | 表格行点击用**事件委托**挂在 `#tbody` 上,避免内联 `onclick` 作用域问题 |
|
||||
|
||||
## 相关
|
||||
|
||||
- [接口一览](./api.md)
|
||||
- [数据存储(SQLite)](./database.md)
|
||||
- [测试](./testing.md)
|
||||
@@ -0,0 +1,113 @@
|
||||
# 数据存储(SQLite)
|
||||
|
||||
平台使用 **SQLite 单文件数据库** `data/ny.db`,零配置、无需额外服务。
|
||||
|
||||
## 启用条件与降级
|
||||
|
||||
```php
|
||||
Db::isConfigured() // 是否有 pdo_sqlite 扩展
|
||||
Db::isConnected() // 是否真的连上了
|
||||
Db::lastError() // 失败原因
|
||||
```
|
||||
|
||||
| 环境 | 行为 |
|
||||
| --- | --- |
|
||||
| 有 `pdo_sqlite` | 使用 `data/ny.db`(同时继续双写 JSON 文件作为备份) |
|
||||
| 无 `pdo_sqlite` | 整体降级为 JSON 文件存储,功能不受影响 |
|
||||
|
||||
> 前端可通过 `config_get` 返回的 `storage`(`db` / `file`)判断当前用的是哪种。
|
||||
|
||||
## 连接参数
|
||||
|
||||
连接建立时施加的 PRAGMA:
|
||||
|
||||
```sql
|
||||
PRAGMA journal_mode = WAL; -- 读写并发
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA busy_timeout = 5000; -- 写锁等待 5 秒
|
||||
PRAGMA foreign_keys = ON;
|
||||
```
|
||||
|
||||
连接**每请求只尝试一次**,失败后返回 `null` 而非抛异常,保证上层能优雅降级。
|
||||
|
||||
## 表结构
|
||||
|
||||
所有建表 / 加列都是**幂等**的(`CREATE TABLE IF NOT EXISTS` + `PRAGMA table_info` 探测后 `ALTER TABLE`),可重复执行。
|
||||
|
||||
### `ny_settings` — 全局配置
|
||||
|
||||
| 列 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `k` | TEXT PK | 键,目前只有 `config` |
|
||||
| `v` | TEXT | 整份配置的 JSON |
|
||||
| `updated_at` | — | 更新时间 |
|
||||
|
||||
### `ny_roster` — 学号名单
|
||||
|
||||
| 列 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `sid` | TEXT PK | 学号 |
|
||||
| `name` | TEXT | 姓名 |
|
||||
| `cls` | TEXT | 班级 |
|
||||
| `grp` | TEXT | 组别 |
|
||||
| `login` | TEXT | 登陆账号(仅用于抽取页白名单导出) |
|
||||
| `sort` | INTEGER | 排序 |
|
||||
|
||||
> `login` 列是后期加入的,旧库会在启动时自动 `ALTER TABLE ADD COLUMN`(幂等)。
|
||||
|
||||
### `ny_project_config` — 每卷独立配置
|
||||
|
||||
| 列 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `project_id` | TEXT PK | 卷王试卷 ID |
|
||||
| `config_json` | TEXT | `{ full_score, pass_rate, exempt[] }` |
|
||||
| `updated_at` | — | 更新时间 |
|
||||
|
||||
## 迁移策略
|
||||
|
||||
首次在有 SQLite 的环境运行时:
|
||||
|
||||
1. `ny_settings` 为空 → 从 `data/config.json` 迁入;
|
||||
2. `ny_roster` 为空 → 从 `data/roster.json` 迁入(`Roster::migrateFileToDbOnce()`);
|
||||
3. 每卷配置同理从 `data/project_config.json` 迁入。
|
||||
|
||||
**迁移只发生在目标表为空时**,不会覆盖已入库的数据;重复执行安全。
|
||||
|
||||
## 读写路径
|
||||
|
||||
| 数据 | 主存储 | 备份 |
|
||||
| --- | --- | --- |
|
||||
| 配置 | `ny_settings['config']` | `data/config.json` |
|
||||
| 名单 | `ny_roster` | `data/roster.json` |
|
||||
| 每卷配置 | `ny_project_config` | `data/project_config.json` |
|
||||
| 成绩快照 | 文件系统 `data/snapshots/` | — |
|
||||
| 结果缓存 | 文件系统 `data/cache/` | — |
|
||||
| 卷王令牌 | 文件系统 `data/cache/token.json` | — |
|
||||
|
||||
`ny_save_config()` 双写:先写库,再写文件。这样即使数据库突然不可用,也不会静默回落到出厂默认值。
|
||||
|
||||
## 维护
|
||||
|
||||
```bash
|
||||
# 备份(推荐:直接复制文件)
|
||||
cp data/ny.db data/ny.db.bak
|
||||
|
||||
# 完整性检查
|
||||
php -r '$p=new PDO("sqlite:data/ny.db"); var_dump($p->query("PRAGMA integrity_check")->fetchAll());'
|
||||
|
||||
# 手动查看名单
|
||||
php -r '$p=new PDO("sqlite:data/ny.db"); print_r($p->query("SELECT * FROM ny_roster LIMIT 5")->fetchAll(PDO::FETCH_ASSOC));'
|
||||
```
|
||||
|
||||
::: tip 命令行加载扩展
|
||||
PHP CLI 常常不加载 Web 端已启用的扩展,需要显式指定:
|
||||
|
||||
```bash
|
||||
php -d extension_dir="C:/php/ext" -d extension=pdo_sqlite script.php
|
||||
```
|
||||
:::
|
||||
|
||||
## 相关
|
||||
|
||||
- [架构与目录](./architecture.md)
|
||||
- [接口一览](./api.md)
|
||||
@@ -0,0 +1,98 @@
|
||||
# 测试
|
||||
|
||||
回归测试分两类:**PHP 自测**(纯逻辑断言)与 **Node e2e**(起 mock 服务 + PHP 内置服务器,走真实 HTTP)。
|
||||
|
||||
## 一键运行
|
||||
|
||||
```bash
|
||||
bash tests/run-all.sh
|
||||
```
|
||||
|
||||
脚本会依次执行:
|
||||
|
||||
| 文件 | 类型 | 覆盖 |
|
||||
| --- | --- | --- |
|
||||
| `tests/test-aggregate.php` | PHP | 聚合、去重、考试次数、排序 |
|
||||
| `tests/test-new-features.php` | PHP | 异常检测、快照、趋势、XLSX 多表 |
|
||||
| `tools/test-xlsxwriter.php` | PHP | XLSX 写入与 ZIP 打包 |
|
||||
| `tools/test-config-save.js` | Node e2e | 配置保存、连接变化检测、每卷配置隔离、CSRF |
|
||||
| `tools/test-draw-alloc.js` | Node e2e | 随机抽取名额分配(加权 / 自定义 / 守恒 fuzz) |
|
||||
|
||||
可通过环境变量覆盖运行时路径:
|
||||
|
||||
```bash
|
||||
PHP_BIN=/usr/bin/php NODE_BIN=$(which node) bash tests/run-all.sh
|
||||
```
|
||||
|
||||
## 单独运行 PHP 自测
|
||||
|
||||
```bash
|
||||
php -d extension_dir="C:/php/ext" -d extension=pdo_sqlite tests/test-aggregate.php
|
||||
php tests/test-new-features.php
|
||||
```
|
||||
|
||||
输出为逐条 `✓ / ✗` 断言,结尾给出通过数 / 总数,失败时非零退出。
|
||||
|
||||
## 单独运行 Node e2e
|
||||
|
||||
e2e 需要两个进程:
|
||||
|
||||
1. **mock 卷王服务**(必须在 `tools/` 目录下启动,内部用 `__dirname` 解析相对路径):
|
||||
|
||||
```bash
|
||||
cd tools
|
||||
node mock-server.js # 默认监听 19911
|
||||
```
|
||||
|
||||
2. **PHP 内置服务器**(端口 18081):
|
||||
|
||||
```bash
|
||||
php -S 127.0.0.1:18081
|
||||
```
|
||||
|
||||
然后:
|
||||
|
||||
```bash
|
||||
node tools/test-config-save.js
|
||||
node tools/test-draw-alloc.js
|
||||
```
|
||||
|
||||
::: warning 测试会污染 `data/`
|
||||
e2e 会备份并修改 `data/ny.db`、`data/config.json`、`data/roster.json`,
|
||||
并在结束后恢复。若中途异常中断,请手动检查并还原:
|
||||
|
||||
```bash
|
||||
git status data/ # data/ 已被 gitignore,需手工核对
|
||||
```
|
||||
|
||||
另外测试前请删除 `data/login_fail.json`,否则可能触发登录限流。
|
||||
:::
|
||||
|
||||
## 写新测试时注意
|
||||
|
||||
| 陷阱 | 说明 |
|
||||
| --- | --- |
|
||||
| CSRF | 写接口必须带 `_csrf`,e2e 里先从 `config_get` 取全局 token |
|
||||
| DB 优先 | 配置读的是 **DB 优先**,只备份 `config.json` 不够,必须一起备份 `data/ny.db` |
|
||||
| `/tmp` 路径 | Node 在 Windows 下会把 `/tmp/x.js` 解析成 `C:\tmp\x.js`,脚本请写在项目目录内 |
|
||||
| `json_decode` | 必须传 `true` 得到关联数组,否则 `is_array(stdClass)` 为 `false` 会静默跳过 |
|
||||
| mock 账号 | mock-server 接受账号 `mock` |
|
||||
|
||||
## 手工冒烟清单
|
||||
|
||||
改完代码建议至少过一遍:
|
||||
|
||||
- [ ] 登录 → 主页面三张卡片有数字
|
||||
- [ ] 切换班级 → 卡片与表格联动
|
||||
- [ ] 点某一行 → 能看到该学生的每次提交
|
||||
- [ ] 导入含 `.php` 后缀的文件 → 被拒绝
|
||||
- [ ] 导入正常 `.xlsx` → 成功,且 `data/uploads/` 为空
|
||||
- [ ] 导出成绩 / 导出缺考 → 能打开,学号不是科学计数法
|
||||
- [ ] 打印预览 → 默认成绩降序
|
||||
- [ ] 数据看板全屏进入 / 退出都正常
|
||||
- [ ] 随机抽取 → 名额守恒
|
||||
|
||||
## 相关
|
||||
|
||||
- [架构与目录](./architecture.md)
|
||||
- [接口一览](./api.md)
|
||||
@@ -0,0 +1,48 @@
|
||||
# 数据看板
|
||||
|
||||
数据看板(`dashboard.php`)是**面向投屏 / 大屏**的汇总视图,适合考试进行中实时监控提交进度。
|
||||
|
||||
从主页面顶部导航 **📊 数据看板** 打开(新标签页)。
|
||||
|
||||
## 顶部操作
|
||||
|
||||
| 控件 | 说明 |
|
||||
| --- | --- |
|
||||
| 问卷 / 考试 | 选择要展示的试卷 |
|
||||
| 班级 | 选择后只展示该班级(需先选试卷) |
|
||||
| 自动刷新开关 + 间隔 | 与主页面一致的 5 / 10 / 15 / 30 / 60 / 120 秒 |
|
||||
| 刷新 | 立即拉取一次(跳过缓存) |
|
||||
| ⛶ 全屏 | 进入 / 退出全屏,布局自动铺满 |
|
||||
|
||||
## 汇总卡片
|
||||
|
||||
`总人数 / 已提交 / 缺考 / 提交率 / 平均分 / 合格率(≥X%满分)`
|
||||
|
||||
合格率卡片的阈值标签会随 [每卷配置](./project-config.md) 实时变化。
|
||||
|
||||
## 班级 / 组别下钻
|
||||
|
||||
看板主体按**班级**分块,每块内再按**组别**对比,指标与统计口径完全相同:
|
||||
总人数、已提交、缺考、提交率、平均分、合格率、最高分、最低分、平均用时。
|
||||
|
||||
点击班级内任意成员可查看其**每一次提交**(时间、成绩、时长),最高分有标记。
|
||||
|
||||
> 未分班的人归入「(未分班)」,未分组的人归入「(未分组)」,不会丢失。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 全屏按钮点了没反应
|
||||
|
||||
- 浏览器要求全屏必须由**用户手势**触发,脚本自动调用 `requestFullscreen()` 会被拒绝;
|
||||
- 退出全屏调用的是 `document.exitFullscreen()`(注意不是 `document.documentElement`),
|
||||
平台已修正过这一点;
|
||||
- 若页面被嵌在 `<iframe>` 中,需要给 iframe 加 `allowfullscreen` 属性。
|
||||
|
||||
### 数据不更新
|
||||
|
||||
确认自动刷新开关打开;若结果缓存时间较长,点 **刷新** 会带 `force=1` 跳过缓存。
|
||||
|
||||
## 相关
|
||||
|
||||
- [成绩明细与统计口径](./scores.md)
|
||||
- [系统设置](./settings.md)
|
||||
@@ -0,0 +1,70 @@
|
||||
# 随机抽取
|
||||
|
||||
随机抽取(`draw.php`)用于从名单中抽人——抽查、抽考、抽签、抽取上台展示等。
|
||||
|
||||
从主页面顶部导航 **🎲 随机抽取** 打开(新标签页)。
|
||||
|
||||
## 抽取来源
|
||||
|
||||
| 来源 | 说明 |
|
||||
| --- | --- |
|
||||
| 学号名单(全部) | 从完整名单中抽 |
|
||||
| 某试卷已提交人员 | 只从交过卷的人里抽(需要先选试卷) |
|
||||
|
||||
可再用 **班级 / 组别** 下拉缩小候选池。
|
||||
|
||||
## 抽取人数
|
||||
|
||||
输入框直接填,或用快捷按钮 `1` / `5` / `10` / `全部`。
|
||||
|
||||
## 分组比例
|
||||
|
||||
决定有分组时名额如何分配:
|
||||
|
||||
| 模式 | 说明 |
|
||||
| --- | --- |
|
||||
| 按人数加权(默认) | 人多的组分到更多名额,采用**最大余额法**,保证总数守恒 |
|
||||
| 各组均分(5:5) | 名额均分到组(不是按人数加权),余数给排在前面的小组 |
|
||||
| 自定义比例 | 为每个组手动指定比例 |
|
||||
| 不分组 | 忽略组别,整池随机 |
|
||||
|
||||
::: tip 5:5 的含义
|
||||
「各组均分」是**名额均分到组**,不等于每组抽到的人数与其人数成正比。
|
||||
如果你想"人多的班多抽几个",请用默认的**按人数加权**。
|
||||
:::
|
||||
|
||||
## 去重与排除
|
||||
|
||||
| 功能 | 说明 |
|
||||
| --- | --- |
|
||||
| 排除已抽中 | 本次会话内已抽中的人不再被抽到;**重置结果**后清零 |
|
||||
| 排除管理 | 手动勾选不参与抽取的人(如请假、已抽过);支持清空排除 |
|
||||
|
||||
## 结果区
|
||||
|
||||
顶部卡片:`候选人数 / 本次抽取 / 累计已抽取 / 已排除 / 抽取来源`。
|
||||
|
||||
结果表格:`序号 / 学号 / 姓名 / 班级 / 组别 / 操作`。
|
||||
|
||||
| 按钮 | 说明 |
|
||||
| --- | --- |
|
||||
| 📜 历史 | 查看历次抽取批次。**重置结果不会清空历史** |
|
||||
| 📋 复制名单 | 复制姓名 / 登陆账号,可直接粘贴到微信或 Excel |
|
||||
| ⬇ 白名单导入模板 | 导出为卷王白名单导入模板 `.xlsx` |
|
||||
| ⬇ 白名单列表 | 导出为白名单列表 `.xlsx` |
|
||||
| 重置结果 | 清空本次结果与累计已抽取(历史保留) |
|
||||
| 清空历史 | 清空全部历史记录 |
|
||||
|
||||
## 登陆账号说明
|
||||
|
||||
名单中的**登陆账号**列只在这里用于导出白名单,主系统界面任何位置都不展示。
|
||||
若名单里没有这一列,导出的白名单该字段为空。
|
||||
|
||||
::: warning 模板文件名
|
||||
卷王模板中该列表头为「登陆账号」(不是"登录账号"),导出时已按此命名。
|
||||
:::
|
||||
|
||||
## 相关
|
||||
|
||||
- [名单管理](./roster.md)
|
||||
- [系统设置](./settings.md)
|
||||
@@ -0,0 +1,72 @@
|
||||
# 导出与打印
|
||||
|
||||
## 导出成绩明细
|
||||
|
||||
工具栏 **导出成绩**,生成 `.xlsx`(零依赖,仓库内自行实现 XLSX 写入,不需要 `zip` 扩展也能用)。
|
||||
|
||||
### 三种可选模式
|
||||
|
||||
| 选项 | 效果 |
|
||||
| --- | --- |
|
||||
| 导出排序 | `按提交时间` / `按学号` / `按成绩降序` / `按成绩升序` |
|
||||
| 按最新成绩 | 每人**只导一行**:最近一次成绩 + 最高分 + 考试次数 |
|
||||
| 按班级分表 | 一个班级一个工作表(xlsx 多工作表) |
|
||||
|
||||
### 列
|
||||
|
||||
默认列(全部记录模式):
|
||||
|
||||
`学号 / 姓名 / 班级 / 组别 / 成绩 / 考试次数 / 第几次 / 提交时间 / 考试时长 / 考试时长(秒)`
|
||||
|
||||
按最新成绩模式:
|
||||
|
||||
`学号 / 姓名 / 班级 / 组别 / 最新成绩 / 最高分 / 考试次数 / 最近提交时间 / 考试时长 / 考试时长(秒)`
|
||||
|
||||
> 学号等长数字列统一按**文本格式**写入,不会被 Excel 转成科学计数法,也不会被当作公式执行。
|
||||
|
||||
### 文件名
|
||||
|
||||
```
|
||||
成绩明细_<试卷名>[_<班级>][_<组别>][_最新成绩]_<YYYYMMDD_HHMMSS>.xlsx
|
||||
```
|
||||
|
||||
### 跟随筛选
|
||||
|
||||
导出**完全跟随页面当前的班级 / 组别筛选**,缺考口径也与页面一致
|
||||
(筛选后名单 − 筛选后已提交,按学号匹配),不会出现"页面上 30 人、导出 50 人"的情况。
|
||||
|
||||
## 导出缺考名单
|
||||
|
||||
工具栏 **导出缺考**,列为 `学号 / 姓名 / 班级 / 组别`,按学号升序。
|
||||
|
||||
## 打印成绩明细
|
||||
|
||||
成绩明细面板右上角 **打印**:
|
||||
|
||||
- 默认按**成绩降序**;
|
||||
- 可选「按班级 / 组别分组对比」,每组段首给出小计;
|
||||
- 走浏览器打印通道,可直接另存为 PDF。
|
||||
|
||||
缺考名单面板右上角 **打印** 同理。
|
||||
|
||||
## 直接调用导出接口
|
||||
|
||||
需要对接其它系统时,可以带参数直接 GET(需已登录):
|
||||
|
||||
```
|
||||
api.php?action=export&projectId=xxx&cls=1&grp=A&mode=latest&split=cls&sort=score_desc&cols=sid,name,score
|
||||
```
|
||||
|
||||
| 参数 | 取值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `projectId` | 试卷 ID | 必填 |
|
||||
| `cls` / `grp` | 班级 / 组别 | 与页面筛选一致 |
|
||||
| `mode` | `latest` | 每人一行最新成绩;缺省导出全部记录 |
|
||||
| `split` | `cls` | 按班级拆分多工作表 |
|
||||
| `sort` | `time` / `sid` / `score_desc` / `score_asc` | 排序 |
|
||||
| `cols` | 逗号分隔列名 | 从默认列中挑选 |
|
||||
|
||||
## 相关
|
||||
|
||||
- [成绩明细与统计口径](./scores.md)
|
||||
- [接口一览](../dev/api.md)
|
||||
@@ -0,0 +1,111 @@
|
||||
# 常见问题
|
||||
|
||||
## 连接与登录
|
||||
|
||||
**Q:提示"无法连接卷王系统"**
|
||||
|
||||
依次检查:
|
||||
|
||||
1. 卷王地址与端口是否正确、是否带多余的路径或结尾 `/`;
|
||||
2. 服务器防火墙 / 安全组是否放行;
|
||||
3. 接口前缀是否为 `/api`(企业版可能是 `/admin-api`);
|
||||
4. 账号密码是否正确;
|
||||
5. 在**平台所在服务器本机**执行 `curl http://127.0.0.1:19911/api/system` 验证卷王本身是否可达。
|
||||
|
||||
**Q:自己写的客户端登录成功却返回 401 "Full authentication is required to access this resource"**
|
||||
|
||||
卷王社区版 v1.13 的 `JwtTokenFilter` **只从 Cookie `sk-token` 读取令牌,不读 `Authorization` 头**。
|
||||
务必从登录响应的 `Set-Cookie` 中取出 `sk-token`,并在后续每个请求带上
|
||||
`Cookie: sk-token=<token>`。本平台已处理此细节。
|
||||
|
||||
**Q:登录一直失败并被锁住**
|
||||
|
||||
连续 5 次失败会锁定 10 分钟。等待即可;如需立即解锁,删除 `data/login_fail.json`。
|
||||
|
||||
**Q:卷王改了密码后平台一直提示未登录**
|
||||
|
||||
到 **设置** 里重新填写密码并保存(连接信息变化会自动清除本地令牌并重新登录)。
|
||||
|
||||
## 匹配与数据
|
||||
|
||||
**Q:读到了答卷但学号为空**
|
||||
|
||||
说明答卷里的姓名与名单不一致:
|
||||
|
||||
- 到 **姓名字段** 下拉里手动指定正确的题目;
|
||||
- 或到 **设置 → 姓名题关键词** 里补充关键词;
|
||||
- **未匹配答卷** 面板会列出这些人,可据此核对(常见原因是姓名前后有空格、用了别名)。
|
||||
|
||||
**Q:成绩列全是 `-`**
|
||||
|
||||
该问卷不是考试模式,或没有开启自动评分,卷王未返回 `examScore`。
|
||||
|
||||
**Q:考试时长全是 `-`**
|
||||
|
||||
答卷的 `metaInfo.answerInfo` 中没有开始 / 结束时间(例如后台手动录入的答卷)。
|
||||
|
||||
**Q:同一个人交了多次,看板里的成绩取哪次?**
|
||||
|
||||
由 **设置 → 同一学号去重** 决定代表成绩取「最新」还是「最高」。
|
||||
但**考试次数始终是全部提交次数**,点击成员可查看每一次提交。
|
||||
|
||||
**Q:我想让某个学生不计入某张试卷的缺考**
|
||||
|
||||
用 [试卷设置 → 免考名单](./project-config.md#免考名单),只对该试卷生效,
|
||||
不要从名单里删除(否则其它试卷也会少人)。
|
||||
|
||||
**Q:合格率是按 60 分算吗?**
|
||||
|
||||
不是写死的 60 分,而是「成绩 ≥ 满分 × 合格率阈值」。阈值全局默认 60%,
|
||||
可在 [试卷设置](./project-config.md) 里为单张试卷覆盖。卡片标题会显示当前阈值。
|
||||
|
||||
**Q:切换班级后数字变了,是不是重新请求了服务器?**
|
||||
|
||||
没有。切班级是本地即时重算,不额外打卷王。
|
||||
|
||||
## 性能
|
||||
|
||||
**Q:人多的时候页面卡 / 后台刷新失败**
|
||||
|
||||
已内置以下优化:结果缓存(默认 15 秒)、虚拟滚动、搜索防抖、页面切到后台自动暂停刷新、
|
||||
分页大小夹在 20–200。仍有问题时可适当调大「结果缓存(秒)」并调小刷新频率。
|
||||
|
||||
**Q:后台刷新报错 "Allowed memory size exhausted"**
|
||||
|
||||
拉取大量答卷时 PHP 内存不足。程序已把单页条数夹在 20–200,并在入口尝试把
|
||||
`memory_limit` 提到 512M。若主机用 `php_admin_value memory_limit` 硬锁,
|
||||
`ini_set` 会静默失效,需要改 `php.ini` 或 FPM 池配置。
|
||||
|
||||
## 存储与迁移
|
||||
|
||||
**Q:数据存在哪?**
|
||||
|
||||
启用 `pdo_sqlite` 时存在 `data/ny.db`(表 `ny_settings` / `ny_roster` / `ny_project_config`);
|
||||
未启用时回落到 `data/config.json`、`data/roster.json`、`data/project_config.json`。
|
||||
两者始终双写,切换不会丢数据。
|
||||
|
||||
**Q:可以把平台搬到另一台服务器吗?**
|
||||
|
||||
可以。整个目录复制过去,并把 `data/` 一起带上(里面有配置、名单、快照),确保 `data/` 可写。
|
||||
|
||||
## 安全
|
||||
|
||||
**Q:上传的名单文件会被别人访问到吗?**
|
||||
|
||||
不会。上传文件落在 `data/uploads/`,扩展名白名单(`.xlsx` / `.xlsm` / `.csv` / `.txt`)、
|
||||
随机文件名,且**解析完成后立刻删除**。另外请确保按
|
||||
[部署加固清单](../security/hardening.md) 在 Web 服务器上屏蔽 `/data/` 目录。
|
||||
|
||||
**Q:为什么配置文件能被直接下载?**
|
||||
|
||||
说明 `/data/` 没有被屏蔽。Apache 下仓库会自动生成 `.htaccess`;
|
||||
**nginx 不读 `.htaccess`**,必须显式加:
|
||||
|
||||
```nginx
|
||||
location ^~ /data/ {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
```
|
||||
|
||||
详见 [部署加固清单](../security/hardening.md)。
|
||||
@@ -0,0 +1,133 @@
|
||||
# 环境要求与部署
|
||||
|
||||
## 环境要求
|
||||
|
||||
| 项目 | 要求 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| PHP | ≥ 8.0 | 推荐 8.1+ |
|
||||
| `curl` | 必需 | 调用卷王 API |
|
||||
| `json` / `mbstring` | 必需 | 数据编解码、中文处理 |
|
||||
| `openssl` | 推荐 | 登录时对密码做 RSA 加密;缺失时降级为明文提交 |
|
||||
| `zip` | 可选 | 缺失时用内置解析器读取 xlsx(写入 xlsx 内置实现无需此扩展) |
|
||||
| `pdo_sqlite` | 推荐 | 启用 SQLite 存储;缺失时自动降级为 JSON 文件存储 |
|
||||
| 数据库 | 无 | 不需要 MySQL / PostgreSQL |
|
||||
| Composer | 无 | 没有任何第三方依赖 |
|
||||
|
||||
> 用 `php -m` 可以查看已加载的扩展。
|
||||
|
||||
## 部署方式
|
||||
|
||||
### 方式一:PHP 内置服务器(体验 / 内网临时使用)
|
||||
|
||||
```bash
|
||||
cd ny-exam-platform
|
||||
php -S 0.0.0.0:8080
|
||||
```
|
||||
|
||||
浏览器打开 <http://localhost:8080>。Windows 下也可双击 `启动平台.bat`。
|
||||
|
||||
::: warning 仅用于内网体验
|
||||
PHP 内置服务器是单进程、无并发能力的开发服务器,**不要**用于生产。
|
||||
并且它**不解析 `.htaccess`**,`data/` 目录会可被直接访问,请务必参考
|
||||
[安全加固清单](../security/hardening.md)。
|
||||
:::
|
||||
|
||||
### 方式二:Nginx + PHP-FPM(推荐生产)
|
||||
|
||||
假设站点根目录为 `/www/sites/sj.exam.nxxy335.top/index`,那么把仓库内容放在该目录下即可。
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name sj.example.com;
|
||||
root /www/sites/sj.exam.nxxy335.top/index;
|
||||
index index.php index.html;
|
||||
|
||||
charset utf-8;
|
||||
client_max_body_size 20m; # 名单文件导入上限,与后端 20MB 校验一致
|
||||
|
||||
# ---- 安全:禁止直接访问数据目录(nginx 不读 .htaccess,必须显式配置)----
|
||||
location ^~ /data/ {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass 127.0.0.1:9000;
|
||||
fastcgi_index index.php;
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
}
|
||||
|
||||
# 不要暴露隐藏文件
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
校验并重载:
|
||||
|
||||
```bash
|
||||
nginx -t && nginx -s reload
|
||||
```
|
||||
|
||||
### 方式三:Apache
|
||||
|
||||
仓库会在 `data/` 及其子目录自动生成 `.htaccess`(内容 `Deny from all`),
|
||||
Apache 下 `data/` 默认即被保护。仍需确认:
|
||||
|
||||
- 虚拟主机 `AllowOverride` 不为 `None`(否则 `.htaccess` 被忽略);
|
||||
- `mod_rewrite` 若启用,确认没有规则把 `/data/` 放行。
|
||||
|
||||
### 方式四:宝塔面板
|
||||
|
||||
1. 新建站点 → 根目录指向仓库目录;
|
||||
2. PHP 版本选 8.0+,安装 `fileinfo`、`opcache`(可选);
|
||||
3. 站点 → 配置文件,在 `server {}` 内加入上面的 `location ^~ /data/` 段;
|
||||
4. 站点 → 目录权限,确保 `data/` 可写(运行用户通常为 `www`);
|
||||
5. 若使用反向代理,确认 `/api.php` 的 `action` 参数未被缓存。
|
||||
|
||||
## 目录权限
|
||||
|
||||
| 路径 | 需要 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `data/` | 可写 | 配置、名单、令牌、快照的根目录 |
|
||||
| `data/ny.db` | 可写 | SQLite 主库(启用 `pdo_sqlite` 时) |
|
||||
| `data/uploads/` | 可写 | 导入名单时的临时落盘,解析后立即删除 |
|
||||
| `data/cache/` | 可写 | 卷王令牌缓存、结果缓存 |
|
||||
| `data/snapshots/` | 可写 | 成绩快照 |
|
||||
|
||||
```bash
|
||||
chown -R www:www data
|
||||
chmod -R 755 data
|
||||
```
|
||||
|
||||
Windows 下给 `IIS_IUSRS` 或站点应用程序池身份赋予 `data` 的「修改」权限。
|
||||
|
||||
## 升级
|
||||
|
||||
仓库是纯文件部署,升级即覆盖文件:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
```
|
||||
|
||||
覆盖后 `data/` 保留(其中存放配置与名单,并且在 `.gitignore` 中),
|
||||
程序会在首次访问时自动完成目录初始化与数据迁移。
|
||||
|
||||
::: tip 关于 SQLite 迁移
|
||||
首次在有 `pdo_sqlite` 的环境运行时,`data/config.json` 与 `data/roster.json`
|
||||
会被自动迁移进 `data/ny.db`,之后以数据库为准,文件作为备份继续双写。
|
||||
迁移是幂等的,重复执行不会覆盖已入库的数据。
|
||||
:::
|
||||
|
||||
## 下一步
|
||||
|
||||
- [快速上手](./quickstart.md)
|
||||
- [安全加固清单](../security/hardening.md)
|
||||
@@ -0,0 +1,61 @@
|
||||
# 平台简介
|
||||
|
||||
NY 问卷考试系统数据平台是一个**零第三方运行依赖**的 PHP 8 应用,直接对接
|
||||
[卷王问卷系统(SurveyKing)社区版 v1.13](https://gitee.com/surveyking/surveyking) 的开放 API。
|
||||
|
||||
它不改变卷王本身的任何数据,只读拉取答卷,然后在本地做「姓名 → 学号」关联、
|
||||
统计聚合、导出与展示。
|
||||
|
||||
## 它能解决什么问题
|
||||
|
||||
卷王自带的考试统计偏"问卷视角",而实际教学/培训场景通常需要的是"名单视角":
|
||||
|
||||
- 谁**没考**(而不是谁考了)—— 需要拿完整名单去比对;
|
||||
- 某个班 / 某个组的成绩分布如何 —— 需要按班级、组别切片;
|
||||
- 同一个人考了多次,该看哪一次 —— 需要去重策略 + 完整的考试次数;
|
||||
- 不同试卷满分不同 —— 需要每卷独立的满分与合格率;
|
||||
- 成绩要交给教务系统 —— 需要干净的 `.xlsx` 导出;
|
||||
- 要抽查 / 抽考 —— 需要可去重、可留痕的随机抽取。
|
||||
|
||||
## 功能总览
|
||||
|
||||
### 主系统 `index.php`
|
||||
|
||||
| 模块 | 说明 |
|
||||
| --- | --- |
|
||||
| 试卷切换 | 下拉选择卷王中的问卷 / 考试,自动拉取该卷全部答卷 |
|
||||
| 姓名关联 | 自动识别答卷中的「姓名」题,也可手动指定字段;未匹配答卷单独面板 + 一键补入名单 |
|
||||
| 统计卡片 | 名单总人数、已提交、缺考、平均分、最高分、最低分、合格率、平均用时;选择班级后全部联动 |
|
||||
| 成绩明细 | 虚拟滚动(无分页)、点击表头排序、搜索、考试次数、新提交绿色高亮(30 秒自动清除) |
|
||||
| 缺考名单 | 名单中有但未提交的人,支持复制学号 / 姓名、打印 |
|
||||
| 试卷设置 | 为当前选中的试卷单独设置满分 / 合格率 / 免考名单,试卷间互相隔离 |
|
||||
| 快照 / 趋势 | 保存带标签的时间点快照,横向对比每名学生成绩变化 |
|
||||
| 异常检测 | 短时长高分、高频重考、雷同成绩三类线索 |
|
||||
| 导出 / 打印 | 成绩明细、缺考名单 `.xlsx`;成绩明细按成绩降序打印,可选分组对比 |
|
||||
|
||||
### 数据看板 `dashboard.php`
|
||||
|
||||
大屏看板,适合投屏。支持全屏切换与自动刷新。
|
||||
|
||||
### 随机抽取 `draw.php`
|
||||
|
||||
从「学号名单」或「某试卷已提交人员」中随机抽取,支持:
|
||||
|
||||
- 按班级 / 组别筛选;
|
||||
- 名额分配四种模式:按人数加权(默认)、各组均分、自定义比例、不分组;
|
||||
- 排除已抽中(本次会话内去重)、排除管理(手动指定不参与的人);
|
||||
- 历史批次留痕、白名单导入模板 / 白名单列表导出。
|
||||
|
||||

|
||||
|
||||
## 设计约束
|
||||
|
||||
- **零 Composer 依赖**:不使用任何 PHP 第三方库,XLSX 读写、ZIP 打包均由仓库内代码完成;
|
||||
- **不修改卷王数据**:全程只读(登录 + 项目列表 + 答卷列表 + 答卷详情);
|
||||
- **可降级**:没有 `zip` 扩展时用内置解析器读 xlsx;没有 `pdo_sqlite` 时自动回落到 JSON 文件存储;
|
||||
- **单目录部署**:整个目录复制到站点目录即可,只需保证 `data/` 可写。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [环境要求与部署](./install.md)
|
||||
- [快速上手](./quickstart.md)
|
||||
@@ -0,0 +1,67 @@
|
||||
# 试卷设置(满分 / 合格率 / 免考)
|
||||
|
||||
不同试卷满分往往不同(100 分制、150 分制、50 道题每题 2 分……),合格率要求也可能不同。
|
||||
所以**满分的每卷配置放在主页面「试卷设置」里,而不是系统设置**——设置里只保留全局默认值。
|
||||
|
||||
## 打开方式
|
||||
|
||||
主页面工具栏 → **试卷设置**(按钮为高亮主色)。配置**只对当前选中的试卷生效**。
|
||||
|
||||
## 优先级
|
||||
|
||||
```
|
||||
本卷配置 > 全局默认(系统设置)
|
||||
```
|
||||
|
||||
未设置某项时显示"跟随全局(X)",保存后卡片标题与统计口径立即更新。
|
||||
|
||||
## 满分
|
||||
|
||||
- 用于合格率阈值计算和卡片展示;
|
||||
- 留空或填 `0` = **清除本卷配置**,回落全局默认;
|
||||
- 全局默认满分在 **设置 → 全局默认满分** 中修改。
|
||||
|
||||
## 合格率
|
||||
|
||||
- 百分比,含义是「成绩 ≥ 满分 × X%」记合格。例如满分 150、合格率 60 → 90 分及格;
|
||||
- 留空 = 跟随全局;录入范围会被夹到 `1 ~ 100`;
|
||||
- 保存后统计卡片标题会变成 `合格率 (≥60%满分)`,与实际口径一致。
|
||||
|
||||
::: tip 为什么不再写死 60%
|
||||
早期版本把「≥60% 满分」硬编码在代码里,一旦满分不是 100 就会产生误导。
|
||||
现在阈值可配、可每卷覆盖,且界面上直接显示当前阈值。
|
||||
:::
|
||||
|
||||
## 免考名单
|
||||
|
||||
某些人因故不参加本卷考试(缓考、免修、已单独补考等),如果直接把他们从名单里删除,
|
||||
其它试卷的统计也会受影响。免考名单解决的就是这个:
|
||||
|
||||
- 填写**学号**列表,支持逗号、中文逗号、空格、换行分隔;
|
||||
- 生效范围:仅当前试卷;
|
||||
- 效果:这些人**不计入名单总人数,也不计入缺考**,彻底从本卷的分母中移除;
|
||||
- 若他们实际上也交了卷,答卷仍会出现在成绩明细中。
|
||||
|
||||
示例输入:
|
||||
|
||||
```
|
||||
2024012001, 2024012002
|
||||
2024012015
|
||||
```
|
||||
|
||||
## 清除本卷设置
|
||||
|
||||
弹窗左下角 **清除本卷设置** 会删除该试卷的全部独立配置(满分、合格率、免考),
|
||||
之后完全跟随全局。
|
||||
|
||||
## 存储
|
||||
|
||||
| 环境 | 位置 |
|
||||
| --- | --- |
|
||||
| 启用 `pdo_sqlite` | `data/ny.db` 表 `ny_project_config`(主键 `project_id`) |
|
||||
| 未启用 | `data/project_config.json` |
|
||||
|
||||
## 相关
|
||||
|
||||
- [系统设置](./settings.md)(全局默认值)
|
||||
- [成绩明细与统计口径](./scores.md)
|
||||
@@ -0,0 +1,82 @@
|
||||
# 快速上手
|
||||
|
||||
从零到看到第一行成绩,一共四步。
|
||||
|
||||
## 第 1 步:登录
|
||||
|
||||
浏览器打开站点根路径,会直接进入登录页。
|
||||
|
||||
::: info 本平台没有独立账号体系
|
||||
登录时填写的**就是卷王后台的管理员账号与密码**——平台用这组账密向卷王换取令牌。
|
||||
连接信息(卷王地址、接口前缀)已在 `data/config.json` 中预置,登录页只要求账号与密码。
|
||||
:::
|
||||
|
||||
- 账号:卷王后台管理员账号(默认带出配置中的值,可现场改)
|
||||
- 密码:卷王后台密码
|
||||
|
||||
登录成功后账密会写回配置并持久化,这样令牌失效时平台能自动重新登录。
|
||||
连续失败 5 次会锁定 10 分钟。
|
||||
|
||||
## 第 2 步:配置连接信息
|
||||
|
||||
右上角 **设置** → 填写:
|
||||
|
||||
| 配置项 | 示例 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 卷王系统地址 | `https://exam.nxxy335.top` | 结尾不要带 `/` |
|
||||
| 接口前缀 | `/api` | 社区版默认 `/api` |
|
||||
| 登录账号 / 密码 | — | 保存后不会回显密码,留空表示不修改 |
|
||||
| 姓名题关键词 | `姓名,名字,学生姓名` | 用于自动识别哪道题填的是姓名 |
|
||||
| 全局默认满分 | `100` | **每套试卷的满分请在主页面「试卷设置」里单独设** |
|
||||
| 全局默认合格率 | `60` | 百分比,60 表示"≥满分的 60%"为合格 |
|
||||
| 校验 HTTPS 证书 | 勾选 | 自签名证书可取消勾选 |
|
||||
| 默认刷新间隔 | `15` | 秒 |
|
||||
|
||||
先点 **测试连接**,出现「连接成功」后再点 **保存**。
|
||||
|
||||

|
||||
|
||||
::: warning 保存时机
|
||||
只有**连接信息**(地址 / 前缀 / 账号 / 密码 / 证书校验)发生变化时,保存才会清除本地令牌并重新登录卷王。
|
||||
修改满分、合格率、布局等其它项不会打断当前数据刷新。
|
||||
:::
|
||||
|
||||
## 第 3 步:导入学号名单
|
||||
|
||||
右上角 **名单管理 <b>0</b>** → 点击或拖拽文件 → 选择 **覆盖 / 追加** → **保存名单**。
|
||||
|
||||
名单文件支持 `.xlsx` / `.xlsm` / `.csv` / `.txt`,至少包含:
|
||||
|
||||
| 学号 | 姓名 | 班级(可选) | 组别(可选) |
|
||||
| --- | --- | --- | --- |
|
||||
| 2023001 | 张三 | 1班 | A |
|
||||
| 2023002 | 李四 | 1班 | B |
|
||||
|
||||
- 表头名称不严格要求,程序按关键词识别(如「学号 / 学籍号 / 编号」均可);
|
||||
- **班级**列存在时,成绩明细会多出班级列,并支持按班级筛选与联动统计;
|
||||
- **组别**列用于分组打印与随机抽取的分组比例;
|
||||
- 也可以直接在弹窗里 **+ 新增** 逐条录入,勾选后 **删除选中**(二次确认)。
|
||||
|
||||
| 安全说明 |
|
||||
| --- |
|
||||
| 上传文件会先落盘到 `data/uploads/` 后立即解析再删除,扩展名白名单 + 随机文件名,不会残留可供 URL 直连访问的文件。详见 [安全总览](../security/)。 |
|
||||
|
||||
## 第 4 步:选择试卷,开始看数据
|
||||
|
||||
回到主页面,左上角 **问卷 / 考试** 下拉选择目标试卷,数据会自动拉取并按设定的间隔刷新。
|
||||
|
||||
- **姓名字段**默认为「自动识别」,若学号大面积为空,可手动指定正确的题目;
|
||||
- 顶部 **班级 / 组别** 下拉可切片,统计卡片会同步联动;
|
||||
- 新提交的答卷整行绿色高亮,30 秒后自动清除,也可手动「清除高亮」;
|
||||
- 若某份试卷满分不是 100,点 **试卷设置** 单独配置。
|
||||
|
||||
## 常见问题速查
|
||||
|
||||
| 现象 | 排查方向 |
|
||||
| --- | --- |
|
||||
| 提示"无法连接卷王系统" | 地址 / 端口 / 防火墙 / 接口前缀 / 账密;在服务器本机 `curl http://127.0.0.1:1991/api/system` 验证 |
|
||||
| 学号列为空 | 姓名不一致(空格、别名);手动指定姓名字段;在设置中补充关键词 |
|
||||
| 成绩全是 `-` | 该问卷不是考试模式或未开启自动评分,卷王未返回 `examScore` |
|
||||
| 时长全是 `-` | 答卷 `metaInfo.answerInfo` 中没有起止时间(例如后台手动录入) |
|
||||
|
||||
完整列表见 [常见问题](./faq.md)。
|
||||
@@ -0,0 +1,85 @@
|
||||
# 名单管理
|
||||
|
||||
名单是平台的"参照系":所有统计都以名单为分母,缺考名单 = 名单中有、但未提交的人。
|
||||
|
||||
## 打开方式
|
||||
|
||||
主页面右上角 **名单管理 <b>N</b>**(N 为当前人数)。也可以在随机抽取页点击 **名单管理** 跳转回来。
|
||||
|
||||

|
||||
|
||||
## 导入文件
|
||||
|
||||
支持 `.xlsx` / `.xlsm` / `.csv` / `.txt`,单次上限 20 MB。
|
||||
|
||||
1. 点击上传区或把文件拖进来;
|
||||
2. 选择 **覆盖**(替换现有名单)或 **追加**(合并到现有名单);
|
||||
3. 点 **保存名单**。
|
||||
|
||||
> 导入后弹窗内会立即显示解析结果,此时**尚未落库**,检查无误再点保存。
|
||||
|
||||
### 列识别规则
|
||||
|
||||
程序先找表头行:某一行同时命中「学号类」与「姓名类」关键词,就把它当作表头,从下一行开始读数据。
|
||||
|
||||
| 字段 | 识别关键词(正则,容错空格) | 缺失时 |
|
||||
| --- | --- | --- |
|
||||
| 学号 | `学号` `编号` `序号` `学员号` `号码` `no` `id` | 取第 0 列 |
|
||||
| 姓名 | `姓名` `名字` `学生姓名` `姓氏` `成员` | 取第 1 列(若学号占了第 0 列) |
|
||||
| 班级 | `班级` `class` | 留空,界面不显示该列 |
|
||||
| 组别 | `组别` `分组` `组次` `组` `group` | 留空 |
|
||||
| 登陆账号 | `登陆账号` `登录账号` `账号` `手机号` `手机` | 留空 |
|
||||
|
||||
其它说明:
|
||||
|
||||
- 学号会自动去掉 Excel 数字尾巴(如 `2024012001.0` → `2024012001`);
|
||||
- 同一学号重复出现时保留首次,其余记为「重复跳过」;
|
||||
- 学号与姓名均为空的行记为「跳过」;
|
||||
- CSV 自动识别并剥离 BOM,非 UTF-8 编码(GBK 等)会自动转码。
|
||||
|
||||
### 示例文件
|
||||
|
||||
| 学号 | 姓名 | 班级 | 组别 | 登陆账号 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 2024012001 | 张三 | 1班 | A | stu001 |
|
||||
| 2024012002 | 李四 | 1班 | B | stu002 |
|
||||
| 2024012003 | 王五 | 2班 | A | stu003 |
|
||||
|
||||
**登陆账号** 只用于随机抽取页导出「白名单导入模板 / 白名单列表」,界面任何位置都不显示。
|
||||
|
||||
## 手动维护
|
||||
|
||||
| 操作 | 说明 |
|
||||
| --- | --- |
|
||||
| 新增 | 点 **+ 新增** 弹出录入框,填学号 / 姓名 / 班级 / 组别 / 登陆账号 |
|
||||
| 编辑 | 直接在表格里改单元格 |
|
||||
| 删除 | 勾选行 → **删除选中** → 二次确认 |
|
||||
| 清空 | **清空** → 二次确认,清空全部名单 |
|
||||
|
||||
::: warning
|
||||
**保存名单**是全量替换(后端 `DELETE` + `INSERT`),请确认弹窗内列表无误再保存。
|
||||
:::
|
||||
|
||||
## 从未匹配答卷补入名单
|
||||
|
||||
主页面 **未匹配答卷** 面板列出"提交了但姓名不在名单中"的答卷。点 **加入名单**,
|
||||
填写学号 / 班级 / 组别后即可补入,避免下次仍然匹配不上。
|
||||
|
||||
## 试卷级免考
|
||||
|
||||
只想让某几个人在**某一张试卷**中不计入统计?用 [试卷设置 → 免考名单](./project-config.md#免考名单),
|
||||
而不是把他们从名单里删掉——免考只在当前试卷生效,不会影响其它试卷。
|
||||
|
||||
## 存储位置
|
||||
|
||||
| 环境 | 位置 |
|
||||
| --- | --- |
|
||||
| 启用 `pdo_sqlite`(默认) | `data/ny.db` 表 `ny_roster` |
|
||||
| 未启用 `pdo_sqlite` | `data/roster.json` |
|
||||
|
||||
首次在有 SQLite 的环境运行时,`roster.json` 会自动迁移进数据库(仅当库为空时)。
|
||||
|
||||
## 相关
|
||||
|
||||
- [成绩明细与统计口径](./scores.md)
|
||||
- [试卷设置](./project-config.md)
|
||||
@@ -0,0 +1,85 @@
|
||||
# 成绩明细与统计口径
|
||||
|
||||
这一页说明**每个数字到底是怎么算出来的**,以及表格里的每一列是什么含义。
|
||||
|
||||
## 统计卡片
|
||||
|
||||
| 卡片 | 计算方式 |
|
||||
| --- | --- |
|
||||
| 名单总人数 | 名单条目数;被当前试卷设为**免考**的人不计入 |
|
||||
| 已提交 | 匹配到名单、且提交了答卷的人数(去重后的代表成绩行数) |
|
||||
| 缺考 | `max(0, 名单总人数 − 已提交)` |
|
||||
| 提交率 | `已提交 ÷ 名单总人数 × 100%` |
|
||||
| 平均分 | 所有**有成绩**的答卷成绩平均值;无成绩者不计入分母 |
|
||||
| 最高分 / 最低分 | 同上,取自所有有成绩的答卷 |
|
||||
| 合格率 | `成绩 ≥ 满分 × 合格率阈值` 的人数 ÷ **已提交人数**。阈值全局默认 60%,可在 [试卷设置](./project-config.md) 针对单卷覆盖 |
|
||||
| 平均用时 | 所有**有时长**的答卷时长平均值(秒),格式化为「X 分 Y 秒」 |
|
||||
|
||||
::: tip 卡片标题会跟着配置变
|
||||
合格率卡片的标题会实时显示当前阈值,例如「合格率 (≥70%满分)」,避免你误以为还是 60%。
|
||||
:::
|
||||
|
||||
## 班级 / 组别联动
|
||||
|
||||
选择顶部 **班级** 下拉后:
|
||||
|
||||
- 全部统计卡片只统计该班级;
|
||||
- 成绩明细只显示该班级;
|
||||
- 缺考名单只显示该班级,标题右侧计数同步变化;
|
||||
- 切换班级是**本地即时重算**,不会额外请求服务器。
|
||||
|
||||
再选 **组别** 可做二次切片。
|
||||
|
||||
## 成绩明细列
|
||||
|
||||
| 列 | 说明 |
|
||||
| --- | --- |
|
||||
| 学号 / 姓名 / 班级 / 组别 | 来自名单;未匹配到的答卷不会进入本表 |
|
||||
| 成绩 | 卷王返回的 `examScore`;该问卷不是考试模式时为 `-` |
|
||||
| 考试次数 | 该学生**全部提交次数**,与「去重」开关无关 |
|
||||
| 提交时间 | **最近一次**提交时间(重考低分也会让这行按最新时间排序到最上面) |
|
||||
| 考试时长 | 最近一次答卷的时长;后台手动录入等没有起止时间的答卷为 `-` |
|
||||
|
||||
点击任意一行可查看该学生的**每一次提交**(时间、成绩、时长),最高分有标记。
|
||||
|
||||
## 排序与搜索
|
||||
|
||||
- 点击表头即可排序,支持学号 / 姓名 / 班级 / 组别 / 成绩 / 考试次数 / 提交时间 / 时长;
|
||||
- 默认排序键是**最近提交时间**,所以刚交卷的人一定在最上面;
|
||||
- 搜索框支持学号 / 姓名模糊匹配,200 ms 防抖;
|
||||
- 成绩明细**无分页**,采用虚拟滚动,几千行也不卡;页面切到后台时自动暂停刷新。
|
||||
|
||||
## 新数据高亮
|
||||
|
||||
每次刷新时,与上一次相比新出现的提交(按学号判断)整行显示绿色底纹,
|
||||
**30 秒后自动清除**;也可以点 **清除高亮** 立刻去掉。首屏加载不标记。
|
||||
|
||||
## 去重策略
|
||||
|
||||
设置里的「同一学号去重」只决定**代表成绩**取哪一份(最新 / 最高):
|
||||
|
||||
| 开关 | 成绩明细 | 考试次数 | 点击行内的历史 |
|
||||
| --- | --- | --- | --- |
|
||||
| 开启 | 一人一行 | 仍是全部次数 | 仍是全部提交 |
|
||||
| 关闭 | 一人多行 | 仍是全部次数 | 仍是全部提交 |
|
||||
|
||||
`best_score` 字段始终是跨全部尝试的真实最高分。
|
||||
|
||||
## 未匹配答卷
|
||||
|
||||
提交了、但姓名不在名单中的答卷会单独成面板。常见原因:
|
||||
|
||||
- 姓名前后有空格、写了别名、用了繁体字;
|
||||
- 姓名题选错了(到 **姓名字段** 下拉里手动指定);
|
||||
- 名单里根本没有这个人。
|
||||
|
||||
可直接在面板里点 **加入名单** 补录。
|
||||
|
||||
## 缺考名单
|
||||
|
||||
名单中有、但未提交的人。支持 **复制学号**(逗号分隔)、**复制姓名**、**打印**。
|
||||
|
||||
## 相关
|
||||
|
||||
- [试卷设置(满分 / 合格率 / 免考)](./project-config.md)
|
||||
- [导出与打印](./export-print.md)
|
||||
@@ -0,0 +1,70 @@
|
||||
# 系统设置
|
||||
|
||||
点击右上角 **设置** 打开。设置保存到 `data/ny.db`(SQLite);无 `pdo_sqlite` 时落到 `data/config.json`,两者始终双写。
|
||||
|
||||
## 连接
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 卷王系统地址 | `https://exam.nxxy335.top` | 结尾不带 `/` |
|
||||
| 接口前缀 | `/api` | 社区版 `/api`;企业版可能用 `/admin-api` |
|
||||
| 登录账号 | `admin` | 卷王后台管理员账号 |
|
||||
| 登录密码 | `123456` | 不回显;留空表示不修改 |
|
||||
| 校验 HTTPS 证书 | 勾选 | 自签名证书取消勾选 |
|
||||
| 超时(秒) | `20` | 单次 HTTP 超时 |
|
||||
|
||||
::: tip 登录流程(社区版 v1.13)
|
||||
`GET /api/system` 取 RSA 公钥 → 公钥加密密码 → `POST /api/public/login` 换取 JWT。
|
||||
|
||||
注意 v1.13 服务端的 `JwtTokenFilter` **只从 Cookie `sk-token` 读取令牌,不读 `Authorization` 头**,
|
||||
所以客户端会把登录响应 `Set-Cookie` 中的 `sk-token` 原样带在后续每个请求的 `Cookie` 头里
|
||||
(同时带 `Authorization` 头以兼容旧版)。平台已内置该流程与令牌缓存,并向下兼容
|
||||
`/system/auth/login`、`/admin-api/oauth2/token` 两种登录方式。
|
||||
|
||||
这也是"自己写的客户端明明拿到 JWT 却仍返回 401 *Full authentication is required*"的根因。
|
||||
:::
|
||||
|
||||
点击 **测试连接** 会立即做一次登录 + 项目列表探测,返回明确的成功 / 失败原因。
|
||||
|
||||
::: warning 保存时会自动重新登录的情况
|
||||
仅当 **地址、接口前缀、账号、密码、证书校验** 五项中任意一项发生变化时,保存才会清除本地令牌缓存
|
||||
(`data/cache/token.json`)并重新登录卷王。其余项(满分、合格率、刷新间隔、布局等)保存不会打断刷新。
|
||||
:::
|
||||
|
||||
## 数据
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 姓名题关键词 | `姓名,名字,学生姓名,…` | 逗号分隔,用于自动识别答卷中的姓名题 |
|
||||
| 全局默认满分 | `100` | 试卷未单独设置时使用;每卷满分在主页面「试卷设置」设置 |
|
||||
| 全局默认合格率 | `60` | 百分比。60 表示「成绩 ≥ 满分 × 60%」记合格 |
|
||||
| 同一学号去重 | 勾选 | 决定**代表成绩**取最新还是最高分;不影响考试次数与历史完整性 |
|
||||
| 包含暂存答卷 | 不勾选 | 是否统计未正式提交的答卷 |
|
||||
| 每页拉取条数 | `200` | 拉取卷王答卷的分页大小,内部会夹在 20–200 之间 |
|
||||
| 结果缓存(秒) | `15` | 0 = 每次实时拉取。点「立即刷新」会带 `force=1` 跳过缓存 |
|
||||
|
||||
::: details 去重到底影响什么
|
||||
- **代表成绩**:勾选去重后,同一学号只展示一行,取「最新」或「最高」(由去重策略决定);
|
||||
- **考试次数**:始终是**全部提交次数**,与去重开关无关;
|
||||
- **历史**:始终完整,点击任意一行可查看该学生的每一次提交。
|
||||
:::
|
||||
|
||||
## 界面
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 默认刷新间隔 | `15` 秒 | 可选 5 / 10 / 15 / 30 / 60 / 120 秒 |
|
||||
| 布局 | `default` | 成绩明细与侧栏面板的排列方式,适配手机 / 平板 / 桌面三端 |
|
||||
| 历史成绩展示 | `click` | `click` = 点击成员弹窗查看;`inline` = 成绩明细内直接展开每次考试 |
|
||||
| 成绩导出排序 | `time` | `time` 提交时间 / `sid` 学号 / `score_desc` 成绩降序 / `score_asc` 成绩升序 |
|
||||
|
||||
## 保存与诊断
|
||||
|
||||
- 保存后会返回明确结果;若连接信息变化,会附带 `ping` 结果告诉你是否真的连上了卷王;
|
||||
- 接口 `action=diag` 与 `action=ping` 可用于排障(需已登录);
|
||||
- 若页面提示 `storage=file`,说明 `pdo_sqlite` 未启用,当前使用 JSON 文件存储,功能不受影响但并发写安全性略低。
|
||||
|
||||
## 相关
|
||||
|
||||
- [试卷设置(每卷独立)](./project-config.md)
|
||||
- [数据存储(SQLite)](../dev/database.md)
|
||||
@@ -0,0 +1,72 @@
|
||||
# 快照、趋势与异常检测
|
||||
|
||||
这一组功能用于**跨时间**看成绩,而不是只看当前这一刻。
|
||||
|
||||
## 快照
|
||||
|
||||
工具栏 **存快照** 打开快照面板,填写标签(留空自动用「快照 MM-DD HH:II」)后保存。
|
||||
|
||||
一份快照包含:
|
||||
|
||||
- 保存时间与标签;
|
||||
- 试卷 ID 与名称;
|
||||
- 当时的整体统计(总人数、已提交、平均分、合格率……);
|
||||
- 每名学生的 `学号 / 姓名 / 班级 / 组别 / 成绩 / 最高分 / 考试次数 / 最近提交时间`(按学号排序)。
|
||||
|
||||
存储位置:`data/snapshots/<projectId>/<unix秒>_<md5(标签)>.json`。
|
||||
|
||||
快照**按试卷分目录**,所以不同试卷的快照互不干扰。
|
||||
|
||||
| 操作 | 位置 |
|
||||
| --- | --- |
|
||||
| 保存当前快照 | 快照弹窗 → 保存当前快照 |
|
||||
| 刷新列表 | 快照弹窗 → 刷新列表 |
|
||||
| 删除某份 | 列表每行的删除按钮 |
|
||||
|
||||
## 趋势
|
||||
|
||||
**趋势** 面板把同一试卷的多份快照横向铺开,按学号对齐,可以直接看出每名学生
|
||||
在几个时间点上的分数变化(上升 / 下降 / 缺考)。
|
||||
|
||||
典型用法:
|
||||
|
||||
1. 月考卷 A 考完 → 存快照「10月月考」;
|
||||
2. 期中卷 B 考完 → 存快照「期中」;
|
||||
3. 打开趋势,逐人对比两次成绩。
|
||||
|
||||
## 异常检测
|
||||
|
||||
工具栏 **异常** 打开异常线索面板。它**不是判定作弊**,只是把值得人工复核的对象挑出来,
|
||||
避免在上千行数据里肉眼找。
|
||||
|
||||
### 短时长高分
|
||||
|
||||
命中条件:
|
||||
|
||||
```
|
||||
有时长记录
|
||||
且 时长 < max(60秒, 平均时长 × 40%)
|
||||
且 成绩 ≥ 满分提示值 × 85%
|
||||
```
|
||||
|
||||
> 「满分提示值」取当前数据中的最高分(`max_score`),至少为 1。
|
||||
|
||||
### 高频重考
|
||||
|
||||
命中条件:`考试次数 ≥ max(4, ceil(平均考试次数 + 3))`
|
||||
|
||||
即:至少 4 次才算高频,且要明显高于整体平均水平。
|
||||
|
||||
### 雷同成绩
|
||||
|
||||
同一分数出现 **≥ 3 人** 时列为疑似,按人数降序排列并列出成员。
|
||||
|
||||
::: warning 请人工复核
|
||||
异常检测基于统计特征,误报很正常(例如题目全是选择题时同分很常见)。
|
||||
请把它当作"值得看一眼"的线索,不要直接作为处理依据。
|
||||
:::
|
||||
|
||||
## 相关
|
||||
|
||||
- [成绩明细与统计口径](./scores.md)
|
||||
- [导出与打印](./export-print.md)
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: NY 问卷考试系统数据平台
|
||||
text: 考试成绩实时统计与名单管理
|
||||
tagline: 基于 PHP 8 + 卷王(SurveyKing)社区版开放 API · 零 Composer 依赖 · 开箱即用
|
||||
image:
|
||||
src: /images/home.png
|
||||
alt: NY 问卷考试系统数据平台
|
||||
actions:
|
||||
- theme: brand
|
||||
text: 快速上手
|
||||
link: /guide/quickstart
|
||||
- theme: alt
|
||||
text: 环境要求与部署
|
||||
link: /guide/install
|
||||
- theme: alt
|
||||
text: 安全加固清单
|
||||
link: /security/hardening
|
||||
|
||||
features:
|
||||
- icon: 🔗
|
||||
title: 直连卷王
|
||||
details: 自动完成 RSA 加密登录、JWT / sk-token 令牌缓存,选择试卷即拉取全部答卷。
|
||||
- icon: 🧩
|
||||
title: 姓名自动关联学号
|
||||
details: 用答卷中的「姓名」题匹配名单,未匹配答卷单独成面板,支持一键补入名单。
|
||||
- icon: 📊
|
||||
title: 多维度统计
|
||||
details: 总人数 / 已提交 / 缺考 / 提交率 / 平均分 / 合格率 / 最高最低分 / 平均用时,按班级即时联动。
|
||||
- icon: ⚙️
|
||||
title: 每卷独立配置
|
||||
details: 满分、合格率、免考名单按试卷隔离设置,互不干扰;未设置时回落到全局默认值。
|
||||
- icon: 📈
|
||||
title: 快照与趋势
|
||||
details: 随时保存成绩快照,横向对比每名学生在多个时间点的成绩变化,并给出异常线索。
|
||||
- icon: 🎲
|
||||
title: 随机抽取
|
||||
details: 按班级 / 组别均衡或加权抽取,累计去重、排除管理、历史批次与白名单导出。
|
||||
- icon: 📤
|
||||
title: 导出与打印
|
||||
details: 零依赖生成 .xlsx,支持多工作表、最新成绩视图、成绩明细与缺考名单打印。
|
||||
- icon: 🔒
|
||||
title: 安全内建
|
||||
details: 会话登录、CSRF 令牌、登录失败限流,上传文件白名单 + 随机名 + 解析后即删。
|
||||
---
|
||||
|
||||
## 三分钟了解它
|
||||
|
||||
平台由三个页面组成,顶部导航栏可自由切换:
|
||||
|
||||
| 页面 | 入口 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| 主系统 | `index.php` | 成绩明细、统计卡片、名单管理、快照 / 趋势 / 异常、导出与打印 |
|
||||
| 数据看板 | `dashboard.php` | 大屏展示,适合投屏实时监控提交进度 |
|
||||
| 随机抽取 | `draw.php` | 从名单或某试卷已提交人员中随机抽取,支持白名单导出 |
|
||||
|
||||

|
||||
|
||||
::: tip 还没部署?
|
||||
先看 [环境要求与部署](/guide/install),再按 [快速上手](/guide/quickstart) 完成连接配置与名单导入,全程不需要写一行代码。
|
||||
:::
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "ny-exam-platform-docs",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "NY 问卷考试系统数据平台 · 使用文档",
|
||||
"scripts": {
|
||||
"docs:dev": "vitepress dev .",
|
||||
"docs:build": "vitepress build .",
|
||||
"docs:preview": "vitepress preview ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitepress": "^1.6.3"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
@@ -0,0 +1,156 @@
|
||||
# 部署加固清单
|
||||
|
||||
按 **P0 → P2** 顺序执行。P0 不做等于把配置和名单暴露在公网上。
|
||||
|
||||
## P0:屏蔽数据目录(必做)
|
||||
|
||||
`data/` 位于 Web 根目录内,里面有 `ny.db`(配置 / 名单 / 卷王账号密码)、
|
||||
`config.json`、`roster.json`、令牌缓存与快照。**必须禁止直接访问。**
|
||||
|
||||
### nginx(生产环境默认,必做)
|
||||
|
||||
```nginx
|
||||
location ^~ /data/ {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
```
|
||||
|
||||
若站点部署在子路径下(例如 `/index/`),改为对应的前缀:
|
||||
|
||||
```nginx
|
||||
location ^~ /index/data/ {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
nginx -t && nginx -s reload
|
||||
```
|
||||
|
||||
验证(应返回 404 或 403,而不是文件内容):
|
||||
|
||||
```bash
|
||||
curl -I https://你的域名/data/config.json
|
||||
```
|
||||
|
||||
### Apache
|
||||
|
||||
仓库已自动生成 `.htaccess`。仍需确认:
|
||||
|
||||
```apache
|
||||
<Directory "/path/to/ny-exam-platform/data">
|
||||
Require all denied
|
||||
</Directory>
|
||||
```
|
||||
|
||||
且虚拟主机的 `AllowOverride` 不为 `None`。
|
||||
|
||||
### PHP 内置服务器(开发环境)
|
||||
|
||||
**内置服务器既不解析 `.htaccess`,也没有 location 规则**,开发时不要把 8080 端口暴露到公网。
|
||||
|
||||
## P0:排查是否已被上传过 Webshell
|
||||
|
||||
如果你运行过修复前的版本,请立即检查:
|
||||
|
||||
```bash
|
||||
ls -la data/uploads/
|
||||
```
|
||||
|
||||
- 正常情况:目录为空(新版本解析后立即删除);
|
||||
- 若存在 `.php` / `.phtml` / `.phar` / `.htaccess` 之外的可疑文件,立即删除;
|
||||
- 检查 nginx access 日志中是否有对 `/data/uploads/*.php` 的 `POST` 请求;
|
||||
- 如确认被利用,请**轮换卷王后台密码**并重新部署干净的目录。
|
||||
|
||||
## P1:全站 HTTPS
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name sj.example.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
登录密码经 RSA 加密后再提交,但令牌 Cookie 在 HTTP 下可被中间人截获。
|
||||
|
||||
## P1:限制上传体积
|
||||
|
||||
与后端 20 MB 校验保持一致:
|
||||
|
||||
```nginx
|
||||
client_max_body_size 20m;
|
||||
```
|
||||
|
||||
PHP 侧:
|
||||
|
||||
```ini
|
||||
upload_max_filesize = 20M
|
||||
post_max_size = 24M
|
||||
```
|
||||
|
||||
## P1:关闭信息泄漏
|
||||
|
||||
```nginx
|
||||
server_tokens off;
|
||||
```
|
||||
|
||||
```ini
|
||||
expose_php = Off
|
||||
display_errors = Off
|
||||
log_errors = On
|
||||
```
|
||||
|
||||
## P1:PHP-FPM 最小权限
|
||||
|
||||
- PHP-FPM 进程用户(如 `www`)只对 `data/` 有写权限,其它目录只读;
|
||||
- 禁止在 Web 根目录放 `.git`:
|
||||
|
||||
```nginx
|
||||
location ~ /\.git {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
```
|
||||
|
||||
## P2:来源 IP 限制(内网场景)
|
||||
|
||||
```nginx
|
||||
allow 10.0.0.0/8;
|
||||
allow 192.168.0.0/16;
|
||||
deny all;
|
||||
```
|
||||
|
||||
## P2:定期备份
|
||||
|
||||
备份 `data/` 目录即可,包含:
|
||||
|
||||
| 文件 | 内容 |
|
||||
| --- | --- |
|
||||
| `data/ny.db` | 配置、名单、每卷配置(SQLite) |
|
||||
| `data/config.json` | 配置(文件备份) |
|
||||
| `data/roster.json` | 名单(文件备份) |
|
||||
| `data/snapshots/` | 成绩快照 |
|
||||
|
||||
## P2:改掉卷王默认口令
|
||||
|
||||
卷王后台默认 `admin / 123456` 请务必修改,并在平台 **设置** 中同步更新。
|
||||
|
||||
## 加固自检清单
|
||||
|
||||
- [ ] `curl -I https://域名/data/config.json` 返回 404 / 403
|
||||
- [ ] `curl -I https://域名/data/ny.db` 返回 404 / 403
|
||||
- [ ] 上传一个 `.php` 后缀文件被拒绝
|
||||
- [ ] 正常导入 `.xlsx` 后 `data/uploads/` 为空
|
||||
- [ ] 全站 HTTPS,HTTP 自动跳转
|
||||
- [ ] `server_tokens off`、`expose_php = Off`
|
||||
- [ ] `data/` 可写,其它目录只读
|
||||
- [ ] 卷王后台已改默认密码
|
||||
- [ ] `data/` 已纳入定期备份
|
||||
|
||||
## 相关
|
||||
|
||||
- [安全总览](./index.md)
|
||||
- [环境要求与部署](../guide/install.md)
|
||||
@@ -0,0 +1,80 @@
|
||||
# 安全总览
|
||||
|
||||
平台内建了多层防护,但**部署方式决定了大部分安全性**——请务必同时阅读
|
||||
[部署加固清单](./hardening.md)。
|
||||
|
||||
## 认证
|
||||
|
||||
- 采用 PHP Session 鉴权,未登录访问任意页面会被重定向到 `login.php`;
|
||||
- 会话 Cookie 为 `NYSESS`,`HttpOnly` + `SameSite=Lax`;
|
||||
- **登录凭据就是卷王后台账密**:平台用它向卷王换取令牌,不维护独立账号体系;
|
||||
- 卷王令牌缓存在 `data/cache/token.json`;令牌失效时平台会自动重新登录;
|
||||
若卷王侧也认证失败,前端会先调用 `logout.php` 清会话再跳转登录页,避免出现跳转死循环。
|
||||
|
||||
## 登录限流
|
||||
|
||||
连续 **5 次**登录失败 → 锁定 **10 分钟**。状态记录在 `data/login_fail.json`。
|
||||
|
||||
应急解锁:删除 `data/login_fail.json`。
|
||||
|
||||
## CSRF 防护
|
||||
|
||||
- 会话绑定 CSRF 令牌:`ny_csrf_token()` 生成,`ny_csrf_ok()` 校验;
|
||||
- 令牌通过 `config_get` 接口下发给前端,前端在写请求的 JSON 体 `_csrf` 字段与
|
||||
`X-CSRF-Token` 头中自动携带;
|
||||
- 后端对写操作维护**白名单** `$WRITE_ACTIONS`,命中白名单的请求一律校验:
|
||||
`config_save` / `roster_save` / `roster_import` / `roster_clear` /
|
||||
`snapshot_save` / `snapshot_delete` / `unmatched_map` / `project_config_save`。
|
||||
|
||||
## 文件上传
|
||||
|
||||
::: danger 历史风险(已修复)
|
||||
早期版本中上传文件按 `roster_YYYYMMDD_HHMMSS.<原始扩展名>` 保存在 `data/uploads/`,
|
||||
存在两个问题:
|
||||
|
||||
1. **扩展名未校验**——上传 `.php` 会被存成 `roster_20260910_180400.php`;
|
||||
2. **文件名可枚举**——按时间 1 秒粒度即可遍历。
|
||||
|
||||
在 nginx + PHP-FPM 下(`.htaccess` 无效),这构成**任意文件上传 → 远程代码执行(RCE)**。
|
||||
:::
|
||||
|
||||
当前实现(四层防护):
|
||||
|
||||
| 层 | 措施 |
|
||||
| --- | --- |
|
||||
| 1 | **扩展名白名单**:仅允许 `.xlsx` / `.xlsm` / `.csv` / `.txt`,其余直接拒绝 |
|
||||
| 2 | **随机文件名**:`bin2hex(random_bytes(16))`,无法枚举 |
|
||||
| 3 | **解析后立即删除**:`@unlink($dest)`,Web 目录下不留存任何上传文件 |
|
||||
| 4 | **大小限制**:单个文件 ≤ 20 MB;同时建议 nginx 配置 `client_max_body_size 20m` |
|
||||
|
||||
另外,上传接口属于写操作,同样受 CSRF 保护。
|
||||
|
||||
## XSS / 注入
|
||||
|
||||
- 所有输出到 HTML 的值均经过 `htmlspecialchars()`;
|
||||
- 导出 XLSX 时单元格统一写入**共享字符串**并施加 `numFmtId=49` 文本样式,
|
||||
天然防止 CSV / Excel 公式注入,也避免长学号被转成科学计数法;
|
||||
- 数据访问全部使用 PDO 预处理语句 / 参数绑定。
|
||||
|
||||
## 目录保护
|
||||
|
||||
程序启动时会在 `data/`、`data/cache/`、`data/uploads/`、`data/snapshots/`
|
||||
自动生成 `.htaccess`(`Deny from all`)与 `index.html` 占位。
|
||||
|
||||
> **nginx 不读取 `.htaccess`**,必须显式配置。见 [部署加固清单](./hardening.md)。
|
||||
|
||||
## 建议的额外措施
|
||||
|
||||
| 措施 | 说明 |
|
||||
| --- | --- |
|
||||
| 全站 HTTPS | 登录密码经 RSA 加密,但仍建议全站 TLS |
|
||||
| IP 白名单 | 内网使用时在 nginx 层限制来源 IP |
|
||||
| 隐藏版本号 | 关闭 `expose_php`、隐藏 nginx 版本号 |
|
||||
| 定期备份 | 备份 `data/`(配置、名单、快照、SQLite 库) |
|
||||
| 最小权限 | PHP-FPM 进程只对 `data/` 可写,其它目录只读 |
|
||||
| 改默认密码 | 卷王后台默认 `admin/123456` 务必修改 |
|
||||
|
||||
## 相关
|
||||
|
||||
- [部署加固清单](./hardening.md)
|
||||
- [环境要求与部署](../guide/install.md)
|
||||
@@ -26,11 +26,7 @@ $rosterCount = Roster::count();
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="logo">NY</span>
|
||||
<div class="brand-text">
|
||||
<strong>随机抽取</strong>
|
||||
<small>名单 / 已提交人员 → 卷王白名单</small>
|
||||
</div>
|
||||
<h1 class="brand-title">NY问卷考试系统数据平台</h1>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<a class="btn" href="index.php#roster" title="前往主系统管理学号名单">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></a>
|
||||
|
||||
@@ -22,11 +22,7 @@ $rosterCount = Roster::count();
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="logo">NY</span>
|
||||
<div class="brand-text">
|
||||
<strong>NY问卷考试系统数据平台</strong>
|
||||
<small>卷王问卷系统 · 实时数据统计</small>
|
||||
</div>
|
||||
<h1 class="brand-title">NY问卷考试系统数据平台</h1>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<div id="conn" class="conn conn-off"><i></i><span>未连接</span></div>
|
||||
@@ -99,6 +95,7 @@ $rosterCount = Roster::count();
|
||||
<button class="btn" id="btnSnapshot" title="保存当前成绩为一份带标签的时间点快照">存快照</button>
|
||||
<button class="btn" id="btnTrend" title="查看本卷各快照间每名学生的成绩趋势">趋势</button>
|
||||
<button class="btn" id="btnAnomaly" title="异常检测线索:短时长高分 / 高频重考 / 雷同成绩">异常</button>
|
||||
<button class="btn btn-primary" id="btnProjCfg" title="为当前选中的试卷单独设置满分 / 合格率 / 免考名单">试卷设置</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -109,7 +106,7 @@ $rosterCount = Roster::count();
|
||||
<div class="card accent-teal"><span>平均分</span><b id="sAvg">-</b></div>
|
||||
<div class="card accent-green"><span>最高分</span><b id="sMax">-</b></div>
|
||||
<div class="card accent-red"><span>最低分</span><b id="sMin">-</b></div>
|
||||
<div class="card accent-indigo"><span>合格率(≥60%满分)</span><b id="sPass">-</b></div>
|
||||
<div class="card accent-indigo"><span>合格率<span id="sPassLabel">(≥60%满分)</span></span><b id="sPass">-</b></div>
|
||||
<div class="card accent-gray"><span>平均用时</span><b id="sDur">-</b></div>
|
||||
</section>
|
||||
|
||||
@@ -119,6 +116,7 @@ $rosterCount = Roster::count();
|
||||
<h2>成绩明细 <small id="tableHint"></small></h2>
|
||||
<div class="updated">
|
||||
<button class="btn sm" id="btnClearHl" hidden title="清除新数据的绿色高亮标记(每 30 秒自动清除一次)">清除高亮 <b id="hlBadge">0</b></button>
|
||||
<button class="btn sm" id="btnPrintTable" title="打印成绩明细(默认按成绩降序,可选分组对比)">打印</button>
|
||||
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,9 +195,14 @@ $rosterCount = Roster::count();
|
||||
<p class="hint">实际生效上限 200,超出会自动夹到 200(防止单页内存耗尽)</p>
|
||||
</div>
|
||||
<div class="fi">
|
||||
<label>默认满分(用于合格率)</label>
|
||||
<label>全局默认满分</label>
|
||||
<input type="number" id="cfgFull" min="1" max="1000">
|
||||
<p class="hint" id="cfgFullHint">未选中试卷时的默认值</p>
|
||||
<p class="hint" id="cfgFullHint">全局默认;每份试卷可在主页面「试卷设置」中单独覆盖</p>
|
||||
</div>
|
||||
<div class="fi">
|
||||
<label>全局默认合格率(%)</label>
|
||||
<input type="number" id="cfgPassRate" min="1" max="100" step="1">
|
||||
<p class="hint">达到满分的该百分比即算合格;每份试卷可单独覆盖</p>
|
||||
</div>
|
||||
<div class="fi">
|
||||
<label>历史成绩展示方式</label>
|
||||
@@ -224,15 +227,6 @@ $rosterCount = Roster::count();
|
||||
<input type="text" id="cfgKeywords">
|
||||
<p class="hint">英文逗号分隔,用于自动识别"姓名"题目</p>
|
||||
</div>
|
||||
<div class="fi full">
|
||||
<label>各试卷独立满分</label>
|
||||
<div id="fullScoresBox" class="fullscores">
|
||||
<span class="hint">未选中试卷时,此处为空;选中某套试卷后保存即在设置中为该卷单独设定满分</span>
|
||||
</div>
|
||||
<!-- 注意:#fullScoresHint 必须放在 #fullScoresBox 之外,
|
||||
否则 renderFullScores() 的 box.innerHTML 覆盖会把它销毁,导致 textContent 赋值报 null -->
|
||||
<p class="hint" id="fullScoresHint">给某卷填入数字即可单独覆盖默认满分;留空=用默认</p>
|
||||
</div>
|
||||
<div class="fi full checks">
|
||||
<label><input type="checkbox" id="cfgDedupe"> 同一学号只保留最新一份答卷</label>
|
||||
<label><input type="checkbox" id="cfgTemp"> 包含"暂存"未提交的答卷</label>
|
||||
@@ -435,6 +429,74 @@ $rosterCount = Roster::count();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成绩明细打印设置 -->
|
||||
<div class="modal" id="printTableModal">
|
||||
<div class="modal-box">
|
||||
<div class="modal-head"><h3>打印成绩明细</h3><button class="x" data-close>×</button></div>
|
||||
<div class="modal-body">
|
||||
<div class="fi">
|
||||
<label>排序方式</label>
|
||||
<select id="ptSort">
|
||||
<option value="score_desc" selected>按成绩降序(默认)</option>
|
||||
<option value="score_asc">按成绩升序</option>
|
||||
<option value="sid">按学号</option>
|
||||
<option value="time">按提交时间</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fi">
|
||||
<label>分组对比</label>
|
||||
<select id="ptGroup">
|
||||
<option value="none" selected>不分组(连续打印)</option>
|
||||
<option value="cls">按班级分组</option>
|
||||
<option value="grp">按组别分组</option>
|
||||
</select>
|
||||
<p class="hint">分组后每个班/组单独一段,段首显示小计(人数/平均分/合格率)</p>
|
||||
</div>
|
||||
<div class="fi checks">
|
||||
<label><input type="checkbox" id="ptRank" checked> 显示名次</label>
|
||||
<label><input type="checkbox" id="ptAttempts" checked> 显示考试次数</label>
|
||||
</div>
|
||||
<p class="hint" id="ptHint"></p>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<span class="spacer"></span>
|
||||
<button class="btn" data-close>取消</button>
|
||||
<button class="btn btn-primary" id="btnPtPrint">打印</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 试卷独立设置(满分 / 合格率 / 免考名单)——仅对当前选中试卷生效 -->
|
||||
<div class="modal" id="projCfgModal">
|
||||
<div class="modal-box">
|
||||
<div class="modal-head"><h3>试卷设置 <small id="projCfgSub"></small></h3><button class="x" data-close>×</button></div>
|
||||
<div class="modal-body">
|
||||
<p class="hint" style="margin:0 0 12px">以下设置<b>仅对当前选中的这份试卷生效</b>,各试卷之间互不影响。留空表示跟随全局默认。</p>
|
||||
<div class="fi">
|
||||
<label>本卷满分</label>
|
||||
<input type="number" id="pcFull" min="1" max="1000" placeholder="留空=用全局默认">
|
||||
<p class="hint" id="pcFullHint"></p>
|
||||
</div>
|
||||
<div class="fi">
|
||||
<label>本卷合格率(%)</label>
|
||||
<input type="number" id="pcPassRate" min="1" max="100" step="1" placeholder="留空=用全局默认">
|
||||
<p class="hint" id="pcPassHint"></p>
|
||||
</div>
|
||||
<div class="fi">
|
||||
<label>免考学生(学号)</label>
|
||||
<textarea id="pcExempt" rows="4" placeholder="每行一个学号,也可用逗号分隔"></textarea>
|
||||
<p class="hint">免考学生不计入「名单总人数」与「缺考名单」</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn danger" id="btnPcReset">清除本卷设置</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn" data-close>取消</button>
|
||||
<button class="btn btn-primary" id="btnPcSave">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 异常检测线索 -->
|
||||
<div class="modal" id="anomalyModal">
|
||||
<div class="modal-box wide">
|
||||
|
||||
@@ -79,11 +79,73 @@ class DataService
|
||||
/**
|
||||
* 组装结果
|
||||
*/
|
||||
/**
|
||||
* 读取某份试卷的独立配置(满分 / 合格率 / 免考名单)。
|
||||
* 优先 SQLite(ny_project_config 表);不可用时退回 config.json 里的 project_config 字段,
|
||||
* 保证没有 pdo_sqlite 的环境也能用(只是隔离粒度仍在,只是落到文件)。
|
||||
*
|
||||
* @return array{full_score:?int,pass_rate:?float,exempt:array<int,string>}
|
||||
*/
|
||||
public static function projectConfig(string $projectId): array
|
||||
{
|
||||
$def = ['full_score' => null, 'pass_rate' => null, 'exempt' => []];
|
||||
if ($projectId === '') {
|
||||
return $def;
|
||||
}
|
||||
if (class_exists('Db') && Db::pdo() !== null) {
|
||||
return Db::projectConfigGet($projectId);
|
||||
}
|
||||
// 文件回退
|
||||
$all = [];
|
||||
$saved = ny_read_json(NY_DATA . '/project_config.json', null);
|
||||
if (is_array($saved)) {
|
||||
$all = $saved;
|
||||
}
|
||||
$one = $all[$projectId] ?? null;
|
||||
return is_array($one) ? array_merge($def, $one) : $def;
|
||||
}
|
||||
|
||||
/** 写入某份试卷的独立配置 */
|
||||
public static function projectConfigSave(string $projectId, array $cfg): void
|
||||
{
|
||||
if ($projectId === '') {
|
||||
return;
|
||||
}
|
||||
if (class_exists('Db') && Db::pdo() !== null) {
|
||||
Db::projectConfigSet($projectId, $cfg);
|
||||
return;
|
||||
}
|
||||
$all = [];
|
||||
$saved = ny_read_json(NY_DATA . '/project_config.json', null);
|
||||
if (is_array($saved)) {
|
||||
$all = $saved;
|
||||
}
|
||||
$all[$projectId] = $cfg;
|
||||
ny_write_json(NY_DATA . '/project_config.json', $all);
|
||||
}
|
||||
|
||||
public static function build(array $cfg, string $projectId, string $nameQid = ''): array
|
||||
{
|
||||
$client = new SurveyKingClient($cfg);
|
||||
|
||||
// 每卷独立配置(满分 / 合格率 / 免考名单):试卷之间互相隔离
|
||||
$pCfg = self::projectConfig($projectId);
|
||||
$exempt = [];
|
||||
foreach ((array)($pCfg['exempt'] ?? []) as $sid) {
|
||||
$sid = trim((string)$sid);
|
||||
if ($sid !== '') {
|
||||
$exempt[$sid] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$students = Roster::all();
|
||||
// 免考:从名单中剔除,使其既不计入「总人数」也不计入「缺考」
|
||||
if ($exempt !== []) {
|
||||
$students = array_values(array_filter(
|
||||
$students,
|
||||
static fn(array $s): bool => !isset($exempt[trim((string)($s['sid'] ?? ''))])
|
||||
));
|
||||
}
|
||||
// 姓名 -> 学号 / 班级(规范化)
|
||||
$studentInfo = [];
|
||||
foreach ($students as $s) {
|
||||
@@ -313,6 +375,12 @@ class DataService
|
||||
'anomalies' => $anomalies,
|
||||
'roster' => $students,
|
||||
'roster_count' => count($students),
|
||||
// 本卷独立配置(前端据此回显满分/合格率/免考);full_score 已按本卷解析
|
||||
'project_cfg' => [
|
||||
'full_score' => $pCfg['full_score'] ?? null,
|
||||
'pass_rate' => $pCfg['pass_rate'] ?? null,
|
||||
'exempt' => array_values(array_map('strval', array_keys($exempt))),
|
||||
],
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,83 +1,411 @@
|
||||
<?php
|
||||
/**
|
||||
* 数据库适配层(旧:pgsql/mysql/sqlite 三方言支持)
|
||||
* 本批次已下线,仅保留文件存储。如未来需要接入 DB,请在此文件实现 PDO 单例。
|
||||
* 数据库适配层 —— SQLite 单文件后端(零配置、无需额外服务)
|
||||
*
|
||||
* 设计要点:
|
||||
* 1. 数据文件固定为 data/ny.db(SQLite),不需要用户配置任何连接信息。
|
||||
* 2. 若环境没有 pdo_sqlite 扩展,整个层自动降级为「不可用」,
|
||||
* 上层(Roster / 配置)会回退到 JSON 文件存储 —— 保证老环境不会挂。
|
||||
* 3. 所有建表/加列都是幂等的(IF NOT EXISTS / 探测列后 ALTER),可重复执行。
|
||||
* 4. 首次启用时自动把已有的 data/roster.json、data/config.json 迁移进来,
|
||||
* 老数据不丢(迁移只发生在目标表为空时)。
|
||||
*
|
||||
* 表结构:
|
||||
* ny_settings (k TEXT PK, v TEXT) 全局配置(键 'config' 存整份配置 JSON)
|
||||
* ny_roster (sid TEXT PK, name, cls, grp, login)
|
||||
* ny_project_config (project_id TEXT PK, config_json TEXT) 每份试卷独立配置(满分/合格率/免考,试卷间隔离)
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
class Db
|
||||
{
|
||||
/** @var PDO|null 当前不可用,始终返回 null */
|
||||
private static ?PDO $pdo = null;
|
||||
|
||||
public static function isConfigured(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function isConnected(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function driver(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public static function lastError(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public static function params(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function saveParams(array $p): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function connect(array $p): PDO
|
||||
{
|
||||
throw new RuntimeException('数据库后端已下线,请使用文件存储(data/config.json / data/roster.json)');
|
||||
}
|
||||
|
||||
public static function pdo(): ?PDO
|
||||
{
|
||||
return null;
|
||||
}
|
||||
private static string $err = '';
|
||||
private static bool $initTried = false;
|
||||
|
||||
public static function prefix(): string
|
||||
{
|
||||
return 'ny_';
|
||||
}
|
||||
|
||||
public static function migrate(): int
|
||||
/** 数据文件路径 */
|
||||
public static function dbFile(): string
|
||||
{
|
||||
return 0;
|
||||
return NY_DATA . '/ny.db';
|
||||
}
|
||||
|
||||
public static function ensureRosterLogin(): void {}
|
||||
/** 是否具备启用条件(有 pdo_sqlite 扩展) */
|
||||
public static function isConfigured(): bool
|
||||
{
|
||||
return extension_loaded('pdo_sqlite');
|
||||
}
|
||||
|
||||
/** 是否已真正连上 */
|
||||
public static function isConnected(): bool
|
||||
{
|
||||
return self::pdo() !== null;
|
||||
}
|
||||
|
||||
public static function driver(): string
|
||||
{
|
||||
return self::pdo() !== null ? 'sqlite' : '';
|
||||
}
|
||||
|
||||
public static function lastError(): string
|
||||
{
|
||||
return self::$err;
|
||||
}
|
||||
|
||||
/** 连接参数(SQLite 固定,保留接口以兼容既有调用) */
|
||||
public static function params(): array
|
||||
{
|
||||
return ['driver' => 'sqlite', 'file' => self::dbFile()];
|
||||
}
|
||||
|
||||
public static function saveParams(array $p): bool
|
||||
{
|
||||
return true; // SQLite 无需外部配置
|
||||
}
|
||||
|
||||
/** 建立/复用连接;失败返回 null 而不抛异常(上层要能优雅降级) */
|
||||
public static function connect(array $p = []): PDO
|
||||
{
|
||||
if (self::$pdo instanceof PDO) {
|
||||
return self::$pdo;
|
||||
}
|
||||
if (!self::isConfigured()) {
|
||||
self::$err = 'PHP 未启用 pdo_sqlite 扩展,已回退到文件存储';
|
||||
throw new RuntimeException(self::$err);
|
||||
}
|
||||
$file = self::dbFile();
|
||||
$dir = dirname($file);
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0777, true);
|
||||
}
|
||||
try {
|
||||
$pdo = new PDO('sqlite:' . $file, null, null, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
// WAL + 合理超时,避免多人同时刷新时 "database is locked"
|
||||
$pdo->exec('PRAGMA journal_mode=WAL');
|
||||
$pdo->exec('PRAGMA synchronous=NORMAL');
|
||||
$pdo->exec('PRAGMA busy_timeout=5000');
|
||||
$pdo->exec('PRAGMA foreign_keys=ON');
|
||||
self::$pdo = $pdo;
|
||||
self::$err = '';
|
||||
self::migrate();
|
||||
return $pdo;
|
||||
} catch (Throwable $e) {
|
||||
self::$pdo = null;
|
||||
self::$err = $e->getMessage();
|
||||
throw new RuntimeException('SQLite 连接失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取连接(不可用返回 null) */
|
||||
public static function pdo(): ?PDO
|
||||
{
|
||||
if (self::$pdo instanceof PDO) {
|
||||
return self::$pdo;
|
||||
}
|
||||
if (self::$initTried) {
|
||||
return null; // 已经试过且失败,不再反复重试(每次请求最多试一次)
|
||||
}
|
||||
self::$initTried = true;
|
||||
try {
|
||||
return self::connect();
|
||||
} catch (Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 建表 + 索引(幂等,可重复调用) */
|
||||
public static function migrate(): int
|
||||
{
|
||||
$pdo = self::$pdo;
|
||||
if (!$pdo) {
|
||||
return 0;
|
||||
}
|
||||
$p = self::prefix();
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$p}settings (
|
||||
k TEXT PRIMARY KEY,
|
||||
v TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$p}roster (
|
||||
sid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
cls TEXT NOT NULL DEFAULT '',
|
||||
grp TEXT NOT NULL DEFAULT '',
|
||||
login TEXT NOT NULL DEFAULT '',
|
||||
sort INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$p}project_config (
|
||||
project_id TEXT PRIMARY KEY,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_{$p}roster_cls ON {$p}roster(cls)");
|
||||
self::ensureRosterLogin();
|
||||
return 3;
|
||||
}
|
||||
|
||||
/** 旧库缺少 login 列时补上(幂等) */
|
||||
public static function ensureRosterLogin(): void
|
||||
{
|
||||
$pdo = self::$pdo;
|
||||
if (!$pdo) {
|
||||
return;
|
||||
}
|
||||
$p = self::prefix();
|
||||
try {
|
||||
$cols = $pdo->query("PRAGMA table_info({$p}roster)")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$has = false;
|
||||
foreach ($cols as $c) {
|
||||
if (($c['name'] ?? '') === 'login') {
|
||||
$has = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$has) {
|
||||
$pdo->exec("ALTER TABLE {$p}roster ADD COLUMN login TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 设置 ----------------
|
||||
|
||||
public static function settingsAll(): array
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
$st = $pdo->query("SELECT k, v FROM {$p}settings");
|
||||
$out = [];
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$out[(string)$r['k']] = (string)$r['v'];
|
||||
}
|
||||
return $out;
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public static function settingsSet(string $k, string $v): void {}
|
||||
/** SQLite 用 ON CONFLICT 做 upsert(其它方言见历史版本) */
|
||||
public static function settingsSet(string $k, string $v): void
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
$st = $pdo->prepare(
|
||||
"INSERT INTO {$p}settings (k, v, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(k) DO UPDATE SET v=excluded.v, updated_at=excluded.updated_at"
|
||||
);
|
||||
$st->execute([$k, $v, time()]);
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
public static function settingsGet(string $k, string $def = ''): string
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return $def;
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
$st = $pdo->prepare("SELECT v FROM {$p}settings WHERE k = ?");
|
||||
$st->execute([$k]);
|
||||
$v = $st->fetchColumn();
|
||||
return $v === false ? $def : (string)$v;
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
return $def;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 名单 ----------------
|
||||
|
||||
public static function rosterAll(): array
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
$st = $pdo->query("SELECT sid, name, cls, grp, login FROM {$p}roster ORDER BY sort ASC, sid ASC");
|
||||
$out = [];
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$out[] = [
|
||||
'sid' => (string)($r['sid'] ?? ''),
|
||||
'name' => (string)($r['name'] ?? ''),
|
||||
'cls' => (string)($r['cls'] ?? ''),
|
||||
'grp' => (string)($r['grp'] ?? ''),
|
||||
'login' => (string)($r['login'] ?? ''),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public static function rosterReplace(array $students): void {}
|
||||
/** 全量替换名单(roster_save 语义) */
|
||||
public static function rosterReplace(array $students): void
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
$pdo->beginTransaction();
|
||||
$pdo->exec("DELETE FROM {$p}roster");
|
||||
$st = $pdo->prepare(
|
||||
"INSERT INTO {$p}roster (sid, name, cls, grp, login, sort, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$i = 0;
|
||||
foreach ($students as $s) {
|
||||
if (!is_array($s)) {
|
||||
continue;
|
||||
}
|
||||
$st->execute([
|
||||
(string)($s['sid'] ?? ''),
|
||||
(string)($s['name'] ?? ''),
|
||||
(string)($s['cls'] ?? ''),
|
||||
(string)($s['grp'] ?? ''),
|
||||
(string)($s['login'] ?? ''),
|
||||
$i++,
|
||||
time(),
|
||||
]);
|
||||
}
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
self::$err = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
public static function rosterCount(): int
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
return (int)$pdo->query("SELECT COUNT(*) FROM {$p}roster")->fetchColumn();
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 每卷独立配置(满分 / 合格率 / 免考名单) ----------------
|
||||
|
||||
/** @return array<string,array> 全部试卷配置 [project_id => cfg] */
|
||||
public static function projectConfigAll(): array
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
$st = $pdo->query("SELECT project_id, config_json FROM {$p}project_config");
|
||||
$out = [];
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$j = json_decode((string)($r['config_json'] ?? '{}'), true);
|
||||
if (is_array($j)) {
|
||||
$out[(string)$r['project_id']] = $j;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array 单卷配置(缺省返回空结构) */
|
||||
public static function projectConfigGet(string $projectId): array
|
||||
{
|
||||
$def = ['full_score' => null, 'pass_rate' => null, 'exempt' => []];
|
||||
if ($projectId === '') {
|
||||
return $def;
|
||||
}
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return $def;
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
$st = $pdo->prepare("SELECT config_json FROM {$p}project_config WHERE project_id = ?");
|
||||
$st->execute([$projectId]);
|
||||
$v = $st->fetchColumn();
|
||||
if ($v === false) {
|
||||
return $def;
|
||||
}
|
||||
$j = json_decode((string)$v, true);
|
||||
return is_array($j) ? array_merge($def, $j) : $def;
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
return $def;
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入单卷配置(整份覆盖;null 表示"用全局默认") */
|
||||
public static function projectConfigSet(string $projectId, array $cfg): void
|
||||
{
|
||||
if ($projectId === '') {
|
||||
return;
|
||||
}
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
$st = $pdo->prepare(
|
||||
"INSERT INTO {$p}project_config (project_id, config_json, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(project_id) DO UPDATE SET config_json=excluded.config_json, updated_at=excluded.updated_at"
|
||||
);
|
||||
$st->execute([$projectId, json_encode($cfg, JSON_UNESCAPED_UNICODE), time()]);
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
public static function projectConfigDelete(string $projectId): void
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
$st = $pdo->prepare("DELETE FROM {$p}project_config WHERE project_id = ?");
|
||||
$st->execute([$projectId]);
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,49 @@ class Roster
|
||||
return class_exists('Db') && Db::pdo() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次启用 SQLite 时,把已有的 data/roster.json 迁进数据库(老数据不丢)。
|
||||
* 只在「DB 名单为空 且 文件有数据」时执行一次,之后由 DB 全权负责。
|
||||
*/
|
||||
private static function migrateFileToDbOnce(): void
|
||||
{
|
||||
if (!self::useDb()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (Db::rosterCount() > 0) {
|
||||
return; // DB 已有数据,不覆盖
|
||||
}
|
||||
$d = ny_read_json(self::file(), null);
|
||||
if (!is_array($d) || empty($d['students']) || !is_array($d['students'])) {
|
||||
return;
|
||||
}
|
||||
$clean = [];
|
||||
foreach ($d['students'] as $s) {
|
||||
if (!is_array($s)) {
|
||||
continue;
|
||||
}
|
||||
$clean[] = [
|
||||
'sid' => trim((string)($s['sid'] ?? '')),
|
||||
'name' => trim((string)($s['name'] ?? '')),
|
||||
'cls' => trim((string)($s['cls'] ?? '')),
|
||||
'grp' => trim((string)($s['grp'] ?? '')),
|
||||
'login' => trim((string)($s['login'] ?? '')),
|
||||
];
|
||||
}
|
||||
if ($clean !== []) {
|
||||
Db::rosterReplace($clean);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// 迁移失败不影响主流程:仍可退回文件读取
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<int,array{sid:string,name:string,cls:string}> */
|
||||
public static function all(): array
|
||||
{
|
||||
if (self::useDb()) {
|
||||
self::migrateFileToDbOnce();
|
||||
return Db::rosterAll();
|
||||
}
|
||||
$d = ny_read_json(self::file(), null);
|
||||
@@ -105,7 +144,10 @@ class Roster
|
||||
public static function fingerprint(): string
|
||||
{
|
||||
if (self::useDb()) {
|
||||
return 'db:' . self::count();
|
||||
// 人数 + 内容哈希:名单内容变了指纹才变(只靠人数会在"换人"时误命中缓存)
|
||||
$n = self::count();
|
||||
$hash = md5(json_encode(self::all(), JSON_UNESCAPED_UNICODE));
|
||||
return 'db:' . $n . ':' . $hash;
|
||||
}
|
||||
$f = self::file();
|
||||
$mt = is_file($f) ? (string)filemtime($f) : '0';
|
||||
|
||||
@@ -17,7 +17,7 @@ done
|
||||
|
||||
echo ""
|
||||
echo "================ Node e2e(需 mock-server + PHP 内置服务器) ================"
|
||||
for f in tools/test-config-save.js tools/test-draw-alloc.js; do
|
||||
for f in tools/test-config-save.js tools/test-draw-alloc.js tools/test-upload-guard.js; do
|
||||
echo ""
|
||||
echo ">>> $f"
|
||||
"$NODE" "$f"
|
||||
|
||||
@@ -20,6 +20,17 @@ const hasToken = fs.existsSync(tokenFile);
|
||||
const tokenSnap = hasToken ? fs.readFileSync(tokenFile, 'utf8') : null;
|
||||
if (hasToken) fs.renameSync(tokenFile, tokenBak);
|
||||
|
||||
// SQLite 优先:若不先移走 data/ny.db,里面的旧配置会盖掉本测试写入的 config.json,
|
||||
// 导致登录用的是上一次遗留的账号密码(表现为"登录成功但 auth_check=false")。
|
||||
// 同时清掉登录限流记录,避免上一轮失败把本轮锁住。
|
||||
const dbFile = path.join(ROOT, 'data', 'ny.db');
|
||||
const dbBak = dbFile + '.bak';
|
||||
const hasDb = fs.existsSync(dbFile);
|
||||
if (hasDb) { try { fs.renameSync(dbFile, dbBak); } catch (e) {} }
|
||||
|
||||
const throttleFile = path.join(ROOT, 'data', 'login_fail.json');
|
||||
if (fs.existsSync(throttleFile)) { try { fs.unlinkSync(throttleFile); } catch (e) {} }
|
||||
|
||||
fs.writeFileSync(cfgFile, JSON.stringify({
|
||||
base_url: 'http://127.0.0.1:19911',
|
||||
api_prefix: '/api',
|
||||
@@ -51,6 +62,14 @@ function cleanup() {
|
||||
try {
|
||||
if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile);
|
||||
} catch(e) { console.error('cleanup token failed:', e.message); }
|
||||
// 恢复 SQLite(本轮测试新建的 ny.db 丢弃,换回原有的)
|
||||
try {
|
||||
if (fs.existsSync(dbFile)) fs.unlinkSync(dbFile);
|
||||
if (hasDb && fs.existsSync(dbBak)) fs.renameSync(dbBak, dbFile);
|
||||
} catch(e) { console.error('cleanup db failed:', e.message); }
|
||||
try {
|
||||
if (fs.existsSync(throttleFile)) fs.unlinkSync(throttleFile);
|
||||
} catch(e) {}
|
||||
}
|
||||
process.on('exit', cleanup);
|
||||
process.on('SIGINT', () => { cleanup(); process.exit(1); });
|
||||
@@ -235,35 +254,44 @@ async function login() {
|
||||
const after = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
|
||||
chk('token 保持未变', before === after, 'changed=' + (before !== after));
|
||||
|
||||
console.log('\n--- 4) 各试卷独立满分:仅当 apply_pid 显式指定时写 ---');
|
||||
// 不传 apply_pid,也不传 full_scores → full_scores 应保留原值(保持空映射)
|
||||
const cs2 = await req('POST', '/api.php?action=config_save', {
|
||||
full_score: 90,
|
||||
console.log('\n--- 4) 试卷独立配置(满分/合格率/免考)已从设置移出,改走 project_config ---');
|
||||
// 每卷满分已不在 config_save 里;改为 project_config_save(试卷间隔离)
|
||||
const pc1 = await req('POST', '/api.php?action=project_config_save&projectId=p1', {
|
||||
full_score: 150, pass_rate: 70, exempt: 'S1,S2',
|
||||
});
|
||||
const _cs2fs = cs2.body && cs2.body.data && cs2.body.data.config
|
||||
? cs2.body.data.config.full_scores : undefined;
|
||||
chk('未传 apply_pid → full_scores 保持空映射(不被覆盖为非空)',
|
||||
_cs2fs === undefined || Object.keys(_cs2fs).length === 0,
|
||||
JSON.stringify(_cs2fs));
|
||||
// 显式 apply_pid='p1', apply_score=85 → 写入
|
||||
const cs3 = await req('POST', '/api.php?action=config_save', {
|
||||
full_score: 90,
|
||||
apply_pid: 'p1',
|
||||
apply_score: 85,
|
||||
chk('project_config_save 200', pc1.status === 200, 'status=' + pc1.status + ' ' + JSON.stringify(pc1.body));
|
||||
chk('生效满分=150', pc1.body && pc1.body.data && pc1.body.data.effective
|
||||
&& pc1.body.data.effective.full_score === 150,
|
||||
JSON.stringify(pc1.body && pc1.body.data && pc1.body.data.effective));
|
||||
chk('生效合格率=70', pc1.body && pc1.body.data && pc1.body.data.effective
|
||||
&& Number(pc1.body.data.effective.pass_rate) === 70,
|
||||
JSON.stringify(pc1.body && pc1.body.data && pc1.body.data.effective));
|
||||
|
||||
// 隔离性:p2 未配置 → 跟随全局(注:上面的 config_save 已把全局满分改成 95,
|
||||
// 所以这里不能写死 100,必须与响应里的 global 比对)
|
||||
const pc2 = await req('GET', '/api.php?action=project_config&projectId=p2');
|
||||
const gFs = pc2.body && pc2.body.data && pc2.body.data.global ? pc2.body.data.global.full_score : null;
|
||||
const gPr = pc2.body && pc2.body.data && pc2.body.data.global ? pc2.body.data.global.pass_rate : null;
|
||||
chk('p2 隔离:满分跟随全局(=' + gFs + ')', pc2.body && pc2.body.data && pc2.body.data.effective
|
||||
&& pc2.body.data.effective.full_score === gFs,
|
||||
JSON.stringify(pc2.body && pc2.body.data && pc2.body.data.effective) + ' global=' + gFs);
|
||||
chk('p2 隔离:合格率跟随全局(=' + gPr + ')', pc2.body && pc2.body.data && pc2.body.data.effective
|
||||
&& Number(pc2.body.data.effective.pass_rate) === Number(gPr),
|
||||
JSON.stringify(pc2.body && pc2.body.data && pc2.body.data.effective) + ' global=' + gPr);
|
||||
|
||||
// 免考名单往返
|
||||
const pc3 = await req('GET', '/api.php?action=project_config&projectId=p1');
|
||||
const ex = pc3.body && pc3.body.data && pc3.body.data.cfg && pc3.body.data.cfg.exempt;
|
||||
chk('免考名单往返 = [S1,S2]', Array.isArray(ex) && ex.length === 2 && ex[0] === 'S1' && ex[1] === 'S2',
|
||||
JSON.stringify(ex));
|
||||
|
||||
// 清除本卷设置 → 回到全局
|
||||
const pc4 = await req('POST', '/api.php?action=project_config_save&projectId=p1', {
|
||||
full_score: '', pass_rate: '', exempt: '',
|
||||
});
|
||||
chk('apply_pid=85 → full_scores.p1=85', cs3.body && cs3.body.data && cs3.body.data.config
|
||||
&& cs3.body.data.config.full_scores && cs3.body.data.config.full_scores.p1 === 85,
|
||||
JSON.stringify(cs3.body.data && cs3.body.data.config && cs3.body.data.config.full_scores));
|
||||
// apply_pid='p1', apply_score=0 → 清除
|
||||
const cs4 = await req('POST', '/api.php?action=config_save', {
|
||||
full_score: 90,
|
||||
apply_pid: 'p1',
|
||||
apply_score: 0,
|
||||
});
|
||||
chk('apply_pid=0 → 清除 p1',
|
||||
cs4.body && cs4.body.data && cs4.body.data.config
|
||||
&& (!cs4.body.data.config.full_scores || cs4.body.data.config.full_scores.p1 === undefined),
|
||||
JSON.stringify(cs4.body.data && cs4.body.data.config && cs4.body.data.config.full_scores));
|
||||
chk('清除后 p1 回到全局满分(=' + gFs + ')', pc4.body && pc4.body.data && pc4.body.data.effective
|
||||
&& pc4.body.data.effective.full_score === gFs,
|
||||
JSON.stringify(pc4.body && pc4.body.data && pc4.body.data.effective) + ' global=' + gFs);
|
||||
|
||||
console.log('\n--- 5) 修改连接信息:应清 token + 重新登录(ping)---');
|
||||
const tokBefore = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// e2e: 上传文件防护(扩展名白名单 + 随机文件名 + 解析后即删)
|
||||
// 运行:node tools/test-upload-guard.js (NY_DEBUG=1 可打印调试信息)
|
||||
//
|
||||
// 覆盖的漏洞:早期版本按 roster_YYYYMMDD_HHMMSS.<原始扩展名> 存在 data/uploads/,
|
||||
// 既不做扩展名校验(可上传 .php 造成 RCE),文件名又可枚举;且 nginx 下 .htaccess 无效。
|
||||
const http = require('http');
|
||||
const net = require('net');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const PHP_PORT = 18081;
|
||||
const MOCK_PORT = 19911;
|
||||
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.exe';
|
||||
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
|
||||
const DEBUG = !!process.env.NY_DEBUG;
|
||||
|
||||
const DATA = path.join(ROOT, 'data');
|
||||
const UPLOADS = path.join(DATA, 'uploads');
|
||||
const throttleFile = path.join(DATA, 'login_fail.json');
|
||||
|
||||
// ---------- 备份 / 还原运行时数据 ----------
|
||||
// ny.db 必须移走:配置是 DB 优先,留着库里的真实配置会盖掉本测试写入的 config.json。
|
||||
// roster.json 只能复制不能移走:mock-server 启动时要读它,移走会让 mock 直接崩。
|
||||
const moved = []; // [路径, 备份路径] —— 移走
|
||||
const copied = []; // [路径, 备份路径] —— 复制
|
||||
for (const f of ['ny.db', 'cache/token.json']) {
|
||||
const p = path.join(DATA, f);
|
||||
if (fs.existsSync(p)) {
|
||||
const bak = p + '.nybak';
|
||||
try { fs.renameSync(p, bak); moved.push([p, bak]); }
|
||||
catch (e) { console.error(' 备份失败(移走)' + f + ':' + e.message); }
|
||||
}
|
||||
}
|
||||
for (const f of ['config.json', 'roster.json', 'project_config.json']) {
|
||||
const p = path.join(DATA, f);
|
||||
if (fs.existsSync(p)) {
|
||||
const bak = p + '.nybak';
|
||||
try { fs.copyFileSync(p, bak); copied.push([p, bak]); }
|
||||
catch (e) { console.error(' 备份失败(复制)' + f + ':' + e.message); }
|
||||
}
|
||||
}
|
||||
// 清空登录限流记录:用覆盖写而不是删除(部分环境下删除会被安全策略拦掉)
|
||||
function clearThrottle() {
|
||||
try { fs.writeFileSync(throttleFile, '{}'); } catch (e) {}
|
||||
try { if (fs.existsSync(throttleFile)) fs.unlinkSync(throttleFile); } catch (e) {}
|
||||
}
|
||||
clearThrottle();
|
||||
|
||||
const cfgFile = path.join(DATA, 'config.json');
|
||||
fs.writeFileSync(cfgFile, JSON.stringify({
|
||||
base_url: 'http://127.0.0.1:' + MOCK_PORT,
|
||||
api_prefix: '/api',
|
||||
username: 'mock',
|
||||
password: 'surveyking',
|
||||
verify_ssl: false,
|
||||
timeout: 10,
|
||||
refresh_interval: 15,
|
||||
page_size: 50,
|
||||
include_temp_save: false,
|
||||
dedupe: true,
|
||||
name_keywords: '姓名,名字',
|
||||
full_score: 100,
|
||||
pass_rate: 60,
|
||||
layout: 'default',
|
||||
}, null, 2));
|
||||
|
||||
let mock = null, srv = null, cleanedUp = false;
|
||||
function killAll() {
|
||||
for (const p of [srv, mock]) {
|
||||
try { p && p.kill('SIGKILL'); } catch (e) {}
|
||||
}
|
||||
}
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
killAll();
|
||||
// 丢弃本次测试产生的运行时数据,换回备份
|
||||
for (const f of ['ny.db', 'ny.db-wal', 'ny.db-shm', 'config.json', 'cache/token.json']) {
|
||||
const p = path.join(DATA, f);
|
||||
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch (e) {}
|
||||
}
|
||||
clearThrottle();
|
||||
// 统一用「覆盖写回 + 尽力删除备份」的方式还原,避免删除被拦导致还原失败
|
||||
for (const [p, bak] of moved.concat(copied)) {
|
||||
try { if (fs.existsSync(bak)) fs.copyFileSync(bak, p); } catch (e) {}
|
||||
try { if (fs.existsSync(bak)) fs.unlinkSync(bak); } catch (e) {}
|
||||
}
|
||||
}
|
||||
process.on('exit', cleanup);
|
||||
process.on('SIGINT', () => { cleanup(); process.exit(1); });
|
||||
process.on('uncaughtException', (e) => { console.error('FATAL', e); cleanup(); process.exit(2); });
|
||||
|
||||
// ---------- 启动依赖服务 ----------
|
||||
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: DEBUG ? 'inherit' : 'ignore' });
|
||||
mock.on('exit', (code) => { if (DEBUG) console.log(' [debug] mock 退出,code=' + code); });
|
||||
const phpArgs = INI.split(/\s+/).filter(Boolean)
|
||||
.concat(['-S', '127.0.0.1:' + PHP_PORT, '-t', ROOT]);
|
||||
srv = spawn(PHP, phpArgs, { cwd: ROOT, stdio: DEBUG ? 'inherit' : 'ignore' });
|
||||
|
||||
/** 轮询直到端口可连接(避免使用固定 sleep 导致的偶发失败) */
|
||||
function waitPort(port, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
return new Promise((resolve) => {
|
||||
(function attempt() {
|
||||
const s = net.connect({ host: '127.0.0.1', port });
|
||||
const done = (ok) => { s.destroy(); resolve(ok); };
|
||||
s.setTimeout(800, () => done(false));
|
||||
s.on('connect', () => done(true));
|
||||
s.on('error', () => {
|
||||
if (Date.now() > deadline) return done(false);
|
||||
setTimeout(attempt, 300);
|
||||
});
|
||||
s.on('timeout', () => {
|
||||
if (Date.now() > deadline) return done(false);
|
||||
setTimeout(attempt, 300);
|
||||
});
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- HTTP 辅助 ----------
|
||||
const cookieJar = [];
|
||||
let CSRF = '';
|
||||
function baseHeaders() {
|
||||
const h = {};
|
||||
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
|
||||
return h;
|
||||
}
|
||||
function absorb(res) {
|
||||
for (const sc of res.headers['set-cookie'] || []) {
|
||||
const pair = sc.split(';')[0];
|
||||
const name = pair.split('=')[0];
|
||||
const i = cookieJar.findIndex(x => x.startsWith(name + '='));
|
||||
if (i >= 0) cookieJar[i] = pair; else cookieJar.push(pair);
|
||||
}
|
||||
}
|
||||
function request(method, urlPath, headers, bodyBuf) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const h = Object.assign({}, headers || {});
|
||||
if (bodyBuf) h['Content-Length'] = bodyBuf.length;
|
||||
const r = http.request({ host: '127.0.0.1', port: PHP_PORT, method, path: urlPath, headers: h }, res => {
|
||||
const chunks = [];
|
||||
res.on('data', c => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
absorb(res);
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
let j = null;
|
||||
try { j = JSON.parse(raw); } catch (e) {}
|
||||
resolve({ status: res.statusCode, body: j, raw });
|
||||
});
|
||||
});
|
||||
r.on('error', reject);
|
||||
if (bodyBuf) r.write(bodyBuf);
|
||||
r.end();
|
||||
});
|
||||
}
|
||||
const get = (p) => request('GET', p, baseHeaders());
|
||||
function formPost(p, fields) {
|
||||
const body = Buffer.from(new URLSearchParams(fields).toString());
|
||||
return request('POST', p, Object.assign({ 'Content-Type': 'application/x-www-form-urlencoded' }, baseHeaders()), body);
|
||||
}
|
||||
function upload(urlPath, fields, file) {
|
||||
const boundary = '----nyUpload' + Math.random().toString(16).slice(2);
|
||||
const parts = [];
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
|
||||
}
|
||||
parts.push(Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${file.filename}"\r\n` +
|
||||
'Content-Type: application/octet-stream\r\n\r\n'));
|
||||
parts.push(Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content));
|
||||
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
|
||||
const body = Buffer.concat(parts);
|
||||
return request('POST', urlPath,
|
||||
Object.assign({ 'Content-Type': 'multipart/form-data; boundary=' + boundary }, baseHeaders()), body);
|
||||
}
|
||||
function listUploads() {
|
||||
if (!fs.existsSync(UPLOADS)) return [];
|
||||
return fs.readdirSync(UPLOADS).filter(f => f !== '.htaccess' && f !== 'index.html');
|
||||
}
|
||||
|
||||
// ---------- 断言 ----------
|
||||
let pass = 0, fail = 0;
|
||||
function chk(name, cond, extra) {
|
||||
if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
|
||||
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const mockUp = await waitPort(MOCK_PORT, 15000);
|
||||
const phpUp = await waitPort(PHP_PORT, 15000);
|
||||
chk('mock 卷王服务已就绪', mockUp);
|
||||
chk('PHP 内置服务器已就绪', phpUp);
|
||||
if (!mockUp || !phpUp) {
|
||||
console.log(' 服务未就绪,终止测试');
|
||||
cleanup();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 登录(失败会触发 5 次锁定,所以每次尝试前清掉限流记录)
|
||||
for (let i = 0; i < 3 && CSRF === ''; i++) {
|
||||
clearThrottle(); // 上一次尝试若失败会累计限流,这里先清零
|
||||
const lg = await formPost('/login.php', { username: 'mock', password: 'surveyking' });
|
||||
const ac = await get('/api.php?action=auth_check');
|
||||
if (DEBUG) {
|
||||
const m = (lg.raw || '').match(/登录失败[\s\S]{0,160}?<\/div>|尝试次数[^<]*/);
|
||||
console.log(' [debug] login ' + lg.status + ' auth=' + JSON.stringify(ac.body && ac.body.data) +
|
||||
' err=' + (m ? m[0].replace(/\s+/g, ' ').slice(0, 160) : '(none)'));
|
||||
}
|
||||
if (ac.body && ac.body.data && ac.body.data.auth === true) {
|
||||
const cg = await get('/api.php?action=config_get');
|
||||
CSRF = (cg.body && cg.body.data && cg.body.data.csrf_token) || '';
|
||||
}
|
||||
}
|
||||
chk('已登录并取到 CSRF token', CSRF !== '');
|
||||
if (CSRF === '') {
|
||||
console.log(' 登录失败,终止测试');
|
||||
cleanup();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n--- 1) 危险扩展名必须被拒绝 ---');
|
||||
const evil = await upload('/api.php?action=roster_import', { mode: 'replace', _csrf: CSRF }, {
|
||||
filename: 'shell.php', content: '<?php system($_GET["c"]); ?>',
|
||||
});
|
||||
// 失败响应字段为 msg(ny_fail 输出 {"ok":false,"msg":...})
|
||||
const evilMsg = (evil.body && (evil.body.error || evil.body.msg)) || '';
|
||||
chk('上传 .php 被拒绝', !!(evil.body && evil.body.ok === false), evilMsg || evil.raw.slice(0, 120));
|
||||
chk('拒绝原因是扩展名白名单', /仅支持/.test(evilMsg), evilMsg);
|
||||
|
||||
const phtml = await upload('/api.php?action=roster_import', { mode: 'replace', _csrf: CSRF }, {
|
||||
filename: 'a.phtml', content: 'x',
|
||||
});
|
||||
chk('上传 .phtml 被拒绝', !!(phtml.body && phtml.body.ok === false));
|
||||
|
||||
console.log('\n--- 2) 被拒绝后不留残留文件 ---');
|
||||
chk('data/uploads 无残留', listUploads().length === 0, JSON.stringify(listUploads()));
|
||||
|
||||
console.log('\n--- 3) 合法 csv 正常导入且文件即删 ---');
|
||||
const csv = '学号,姓名,班级\n90001,测试甲,1班\n90002,测试乙,1班\n';
|
||||
const good = await upload('/api.php?action=roster_import', { mode: 'replace', _csrf: CSRF }, {
|
||||
filename: 'roster.csv', content: Buffer.from('\xEF\xBB\xBF' + csv, 'utf8'),
|
||||
});
|
||||
chk('合法 .csv 导入成功', !!(good.body && good.body.ok === true),
|
||||
(good.body && (good.body.error || good.body.msg)) || '');
|
||||
chk('导入人数为 2', !!(good.body && good.body.data && good.body.data.imported === 2),
|
||||
good.body ? String(good.body.data && good.body.data.imported) : '');
|
||||
chk('导入后 data/uploads 已清空', listUploads().length === 0, JSON.stringify(listUploads()));
|
||||
|
||||
console.log('\n--- 4) 文件名不可枚举 ---');
|
||||
const leaked = listUploads().filter(f => /^roster_\d{8}_\d{6}\./.test(f));
|
||||
chk('不存在可枚举的 roster_时间戳 文件', leaked.length === 0, JSON.stringify(leaked));
|
||||
|
||||
console.log('');
|
||||
console.log(`结果:${pass} 通过 / ${fail} 失败(共 ${pass + fail})`);
|
||||
cleanup();
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
})();
|
||||