Files
nxxy335 a3be81b261 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 忽略文档构建产物
2026-09-10 22:42:13 +08:00

160 lines
9.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# NY 问卷考试系统数据平台
基于 **PHP 8 + 卷王问卷系统(SurveyKing)社区版开放 API** 的实时数据统计平台。
选择某个问卷 / 考试后,自动拉取答卷 → 用「姓名」关联学号名单 → 按
**学号、姓名、成绩、提交时间、考试时长** 依次排列展示,并支持定时自动刷新。
## 功能
| 功能 | 说明 |
| --- | --- |
| 问卷/考试切换 | 下拉选择,实时显示该问卷的答卷数据 |
| 学号自动匹配 | 用答卷中的「姓名」题在学号名单中查找对应学号(自动识别姓名题,也可手动指定) |
| 实时刷新 | 5 秒 / 10 秒 / 15 秒 / 30 秒 / 60 秒 / 2 分钟可选,数据变化的行会高亮闪烁;**每次刷新后新增的提交以绿色底纹标出** |
| 统计卡片 | 名单总人数、已提交、缺考、提交率、平均分、**合格率**(≥60% 满分)、最高分、最低分、平均用时;**选择班级后,全部卡片只统计该班级** |
| 缺考名单 | 名单中有、但未提交的人 |
| 未匹配答卷 | 提交了但姓名不在名单中的答卷,方便排查 |
| 名单导入 | 上传 `.xlsx` / `.xlsm` / `.csv` / `.txt`,自动识别「学号」「姓名」「班级」列(班级可选);支持覆盖 / 追加、手动增删改 |
| 导出 | 成绩明细 / 缺考名单 `.xlsx`(零依赖生成,支持多工作表、按最新成绩、按班级分表) |
| 排序 / 班级 | 点击表头排序,**成绩明细默认按提交时间降序**;支持学号/姓名/班级/成绩/提交时间/时长排序;按班级下拉筛选 |
| 全屏 / 自适应 | 右上角「全屏」按钮进入全屏(布局自动铺满、表格更高);页面适配手机 / 平板 / 桌面三端 |
## 环境要求
- PHP ≥ 8.0(需启用 `curl`、`json`、`mbstring`、`openssl`(可选,用于 RSA 加密密码);`zip` 可选,无 zip 扩展时会用内置解析器读 xlsx)
- 推荐启用 `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`。
## 部署
### 方式一:PHP 内置服务器(最快体验)
在本目录执行:
```bash
php -S 0.0.0.0:8080
```
浏览器打开 <http://localhost:8080>。
Windows 也可双击 `启动平台.bat`。
### 方式二:放到 PHP 站点目录
把整个 `ny-exam-platform` 目录复制到 Apache / Nginx + PHP-FPM 的站点目录下,
确保进程对 `data/` 目录有写权限即可。
## 首次配置
打开页面 → 右上角 **设置**:
| 配置项 | 示例 | 说明 |
| --- | --- | --- |
| 卷王系统地址 | `https://exam.nxxy335.top` | 卷王后台地址(默认值),结尾不要带 `/` |
| 接口前缀 | `/api` | 社区版默认 `/api`;企业版可填 `/admin-api` |
| 登录账号 | `admin` | 卷王后台管理员账号 |
| 登录密码 | `123456` | 卷王后台密码 |
| 姓名题关键词 | `姓名,名字,学生姓名` | 用于自动识别哪道题填的是姓名 |
| 满分 | `100` | 用于合格率;**每套试卷可单独设置**——保存时作用于当前选中的试卷,未选试卷时作为所有试卷的默认值 |
| 校验 HTTPS 证书 | 默认勾选 | 访问 https 卷王时校验证书;自签名证书可取消勾选 |
| 默认刷新间隔 | `15` | 秒 |
填完点 **测试连接**,显示「连接成功」后点 **保存**。
> 登录流程(社区版 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` 两种登录方式。
**关于 401 "Full authentication is required to access this resource"**
若你用自己写的客户端对接卷王社区版 v1.13,即使登录成功、拿到了 JWT,调用 `/project/list` 等接口仍可能返回 401。
原因是 v1.13 服务端只认 Cookie 里的 `sk-token`,不认 `Authorization` 头。务必在登录响应里取 `Set-Cookie: sk-token=...`,
并在后续每个请求带上 `Cookie: sk-token=<token>`。本平台已处理此细节。
## 学号名单
- **平台启动时不预设任何名单**,请通过右上角 **名单管理** 上传你自己的名单(`.xlsx` / `.csv`)。
- 名单文件可包含「学号」「姓名」两列,**「班级」为可选列**;含班级时,成绩明细会显示班级列并支持按班级筛选(一份考试涉及多个班级时很实用)。
- 上传方式:名单管理 → 点击或拖拽文件 → 选择覆盖 / 追加 → **保存名单**;也可手动增 / 删 / 改。
- 上传文件仅用于解析,落盘后**立即删除**,不会残留在 `data/uploads/`。
- 名单数据保存在 SQLite `data/ny.db`(无 `pdo_sqlite` 时回落到 `data/roster.json`)。
## 目录结构
```
ny-exam-platform/
├─ index.php 主页面
├─ api.php 后端接口(代理卷王 API)
├─ bootstrap.php 配置 / 公共函数 / 自动加载
├─ lib/
│ ├─ SurveyKingClient.php 卷王 API 客户端(登录、令牌缓存、项目、答卷)
│ ├─ DataService.php 数据组装(姓名→学号匹配、统计、排序)
│ ├─ Roster.php 学号名单管理(xlsx/csv 导入与持久化)
│ └─ XlsxReader.php 零依赖 XLSX 读取器
├─ assets/ 前端样式与脚本
├─ data/ 配置、名单、SQLite 库、令牌缓存(需可写,且必须禁止 Web 直接访问)
├─ docs/ VitePress 文档站
├─ tests/ PHP 自测 + 一键运行脚本
└─ tools/ 开发辅助(mock 服务、e2e 测试)
```
## 统计口径与显示说明
- **班级联动统计**:顶部「班级」下拉选择某一班级后,统计卡片(总人数 / 已提交 / 缺考 / 提交率 / 平均分 / 合格率 / 最高分 / 最低分 / 平均用时)与成绩明细、缺考名单都只统计该班级;选「全部班级」则统计整体。切换班级在本地即时重算,不额外请求服务器。
- **面板计数随班级**:选择班级后,成绩明细标题右侧的「共 X 人已提交 · 名单 X 人」、缺考名单标题右侧的「共 X 人」也都只统计该班级(不再用全部班级的数据)。
- **每套试卷独立满分 / 合格率 / 免考**:在主页面「试卷设置」里为**当前选中的试卷**单独设置,试卷之间互相隔离;未设置时回落到「设置」里的全局默认值。
- **合格率**:成绩 ≥ 满分 × 合格率阈值 视为合格;阈值全局默认 60%,可在「试卷设置」里按卷覆盖,卡片标题会实时显示当前阈值。
- **平均用时不换行**:平均用时(如「9 分 55 秒」)强制单行显示,避免「秒」字换行。
- **成绩明细不再显示每人的「状态」列**:缺考人员统一在「缺考名单」面板查看,成绩明细只保留学号 / 姓名 / 班级 / 成绩 / 提交时间 / 考试时长。
- **新增数据标记**:每次刷新后,相较上一次新出现的提交(按学号判断)整行显示绿色底纹,方便一眼看出谁刚交卷;首次加载不标记。
## 常见问题
**1. 页面提示"无法连接卷王系统"**
检查:地址端口是否正确、服务器防火墙是否放行、接口前缀是否为 `/api`、账号密码是否正确。
可直接在服务器本机执行 `curl http://127.0.0.1:1991/api/system` 验证。
**2. 读到了答卷但学号为空**
说明答卷里的姓名与名单不一致。处理:
- 在「姓名字段」下拉里手动指定正确的题目;
- 或到「设置」里补充姓名题关键词;
- 未匹配的答卷会显示在「未匹配答卷」面板,可据此核对姓名是否写错(如多了空格)。
**3. 成绩列全是 `-`**
说明该问卷不是考试模式或没有开启自动评分,卷王未返回 `examScore`。
**4. 考试时长显示 `-`**
答卷的 `metaInfo.answerInfo` 中没有开始/结束时间(例如是后台手动录入的答卷)。
**5. 想让同一学号只保留最新提交**
设置里勾选「同一学号只保留最新一份答卷」(默认开启);取消勾选则保留全部提交记录。