fix(security): 修复上传名单文件可被 URL 直连访问(nginx 下可 getshell)

严重漏洞:roster_import 使用 $_FILES 的原始扩展名拼 roster_YYYYMMDD_HHMMSS.<ext>
落在 Web 根目录内的 data/uploads/,既无扩展名白名单(可上传 .php),文件名又
可按秒枚举;而 bootstrap 只写了 data/.htaccess,nginx 不读 .htaccess,
生产环境(nginx + PHP-FPM)下可直接远程代码执行。

修复(api.php roster_import 四层防护):
- 扩展名白名单:仅 xlsx / xlsm / csv / txt
- 随机文件名:bin2hex(random_bytes(16)),杜绝枚举
- 解析完成后 @unlink 立即删除,Web 目录下不留存任何上传文件
- 20MB 大小限制 + UPLOAD_ERR 检查

加固(bootstrap.php):data 及 cache/uploads/snapshots 每个目录都写
.htaccess(Deny from all)+ index.html 占位;README 与文档补充 nginx
location ^~ /data/ { deny all; return 404; } 规则。

测试:新增 e2e tools/test-upload-guard.js(11 断言,覆盖 .php/.phtml 被拒、
无残留、合法 csv 导入成功且即删、无可枚举文件名),并入 tests/run-all.sh。
全套 90 项通过。

docs: 新增 VitePress 文档站(18 页)
- 使用指南 12 页:简介/部署/快速上手/系统设置/名单管理/统计口径/试卷设置/
  快照趋势异常/导出打印/数据看板/随机抽取/常见问题
- 安全 2 页:安全总览(含本次漏洞复盘)与部署加固清单(P0-P2)
- 开发 4 页:架构与目录/接口一览/SQLite 存储/测试
- 配图迁移至 docs/public/images/;.gitignore 忽略文档构建产物
This commit is contained in:
2026-09-10 22:42:13 +08:00
parent 157f257728
commit a3be81b261
33 changed files with 4802 additions and 17 deletions
+48
View File
@@ -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)
+70
View File
@@ -0,0 +1,70 @@
# 随机抽取
随机抽取(`draw.php`)用于从名单中抽人——抽查、抽考、抽签、抽取上台展示等。
从主页面顶部导航 **🎲 随机抽取** 打开(新标签页)。
## 抽取来源
| 来源 | 说明 |
| --- | --- |
| 学号名单(全部) | 从完整名单中抽 |
| 某试卷已提交人员 | 只从交过卷的人里抽(需要先选试卷) |
可再用 **班级 / 组别** 下拉缩小候选池。
## 抽取人数
输入框直接填,或用快捷按钮 `1` / `5` / `10` / `全部`。
## 分组比例
决定有分组时名额如何分配:
| 模式 | 说明 |
| --- | --- |
| 按人数加权(默认) | 人多的组分到更多名额,采用**最大余额法**,保证总数守恒 |
| 各组均分(5:5) | 名额均分到组(不是按人数加权),余数给排在前面的小组 |
| 自定义比例 | 为每个组手动指定比例 |
| 不分组 | 忽略组别,整池随机 |
::: tip 5:5 的含义
「各组均分」是**名额均分到组**,不等于每组抽到的人数与其人数成正比。
如果你想"人多的班多抽几个",请用默认的**按人数加权**。
:::
## 去重与排除
| 功能 | 说明 |
| --- | --- |
| 排除已抽中 | 本次会话内已抽中的人不再被抽到;**重置结果**后清零 |
| 排除管理 | 手动勾选不参与抽取的人(如请假、已抽过);支持清空排除 |
## 结果区
顶部卡片:`候选人数 / 本次抽取 / 累计已抽取 / 已排除 / 抽取来源`。
结果表格:`序号 / 学号 / 姓名 / 班级 / 组别 / 操作`。
| 按钮 | 说明 |
| --- | --- |
| 📜 历史 | 查看历次抽取批次。**重置结果不会清空历史** |
| 📋 复制名单 | 复制姓名 / 登陆账号,可直接粘贴到微信或 Excel |
| ⬇ 白名单导入模板 | 导出为卷王白名单导入模板 `.xlsx` |
| ⬇ 白名单列表 | 导出为白名单列表 `.xlsx` |
| 重置结果 | 清空本次结果与累计已抽取(历史保留) |
| 清空历史 | 清空全部历史记录 |
## 登陆账号说明
名单中的**登陆账号**列只在这里用于导出白名单,主系统界面任何位置都不展示。
若名单里没有这一列,导出的白名单该字段为空。
::: warning 模板文件名
卷王模板中该列表头为「登陆账号」(不是"登录账号"),导出时已按此命名。
:::
## 相关
- [名单管理](./roster.md)
- [系统设置](./settings.md)
+72
View File
@@ -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)
+111
View File
@@ -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)。
+133
View File
@@ -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)
+61
View File
@@ -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`
从「学号名单」或「某试卷已提交人员」中随机抽取,支持:
- 按班级 / 组别筛选;
- 名额分配四种模式:按人数加权(默认)、各组均分、自定义比例、不分组;
- 排除已抽中(本次会话内去重)、排除管理(手动指定不参与的人);
- 历史批次留痕、白名单导入模板 / 白名单列表导出。
![名单管理](/images/roster.png)
## 设计约束
- **零 Composer 依赖**:不使用任何 PHP 第三方库,XLSX 读写、ZIP 打包均由仓库内代码完成;
- **不修改卷王数据**:全程只读(登录 + 项目列表 + 答卷列表 + 答卷详情);
- **可降级**:没有 `zip` 扩展时用内置解析器读 xlsx;没有 `pdo_sqlite` 时自动回落到 JSON 文件存储;
- **单目录部署**:整个目录复制到站点目录即可,只需保证 `data/` 可写。
## 下一步
- [环境要求与部署](./install.md)
- [快速上手](./quickstart.md)
+67
View File
@@ -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)
+82
View File
@@ -0,0 +1,82 @@
# 快速上手
从零到看到第一行成绩,一共四步。
## 第 1 步:登录
浏览器打开站点根路径,会直接进入登录页。
::: info 本平台没有独立账号体系
登录时填写的**就是卷王后台的管理员账号与密码**——平台用这组账密向卷王换取令牌。
连接信息(卷王地址、接口前缀)已在 `data/config.json` 中预置,登录页只要求账号与密码。
:::
- 账号:卷王后台管理员账号(默认带出配置中的值,可现场改)
- 密码:卷王后台密码
登录成功后账密会写回配置并持久化,这样令牌失效时平台能自动重新登录。
连续失败 5 次会锁定 10 分钟。
## 第 2 步:配置连接信息
右上角 **设置** → 填写:
| 配置项 | 示例 | 说明 |
| --- | --- | --- |
| 卷王系统地址 | `https://exam.nxxy335.top` | 结尾不要带 `/` |
| 接口前缀 | `/api` | 社区版默认 `/api` |
| 登录账号 / 密码 | — | 保存后不会回显密码,留空表示不修改 |
| 姓名题关键词 | `姓名,名字,学生姓名` | 用于自动识别哪道题填的是姓名 |
| 全局默认满分 | `100` | **每套试卷的满分请在主页面「试卷设置」里单独设** |
| 全局默认合格率 | `60` | 百分比,60 表示"≥满分的 60%"为合格 |
| 校验 HTTPS 证书 | 勾选 | 自签名证书可取消勾选 |
| 默认刷新间隔 | `15` | 秒 |
先点 **测试连接**,出现「连接成功」后再点 **保存**。
![系统设置](/images/settings.png)
::: 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)。
+85
View File
@@ -0,0 +1,85 @@
# 名单管理
名单是平台的"参照系":所有统计都以名单为分母,缺考名单 = 名单中有、但未提交的人。
## 打开方式
主页面右上角 **名单管理 <b>N</b>**(N 为当前人数)。也可以在随机抽取页点击 **名单管理** 跳转回来。
![名单管理](/images/roster.png)
## 导入文件
支持 `.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)
+85
View File
@@ -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)
+70
View File
@@ -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)
+72
View File
@@ -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)