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 忽略文档构建产物
@@ -5,5 +5,9 @@ data/
|
||||
node_modules/
|
||||
vendor/
|
||||
*.log
|
||||
|
||||
# VitePress 文档站构建产物与缓存
|
||||
docs/.vitepress/dist/
|
||||
docs/.vitepress/cache/
|
||||
.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 秒」)强制单行显示,避免「秒」字换行。
|
||||
- **成绩明细不再显示每人的「状态」列**:缺考人员统一在「缺考名单」面板查看,成绩明细只保留学号 / 姓名 / 班级 / 成绩 / 提交时间 / 考试时长。
|
||||
- **新增数据标记**:每次刷新后,相较上一次新出现的提交(按学号判断)整行显示绿色底纹,方便一眼看出谁刚交卷;首次加载不标记。
|
||||
|
||||
@@ -472,9 +472,25 @@ try {
|
||||
if (empty($_FILES['file']) || !is_uploaded_file($_FILES['file']['tmp_name'])) {
|
||||
ny_fail('请选择要导入的文件');
|
||||
}
|
||||
$f = $_FILES['file'];
|
||||
$ext = strtolower(pathinfo((string)$f['name'], PATHINFO_EXTENSION));
|
||||
$dest = NY_DATA . '/uploads/roster_' . date('Ymd_His') . '.' . ($ext !== '' ? $ext : 'dat');
|
||||
$f = $_FILES['file'];
|
||||
$err = (int)($f['error'] ?? UPLOAD_ERR_OK);
|
||||
if ($err !== UPLOAD_ERR_OK) {
|
||||
ny_fail('上传失败(错误码 ' . $err . ')');
|
||||
}
|
||||
if ((int)($f['size'] ?? 0) > 20 * 1024 * 1024) {
|
||||
ny_fail('文件过大(上限 20MB)');
|
||||
}
|
||||
// 扩展名白名单:杜绝上传 .php/.phtml 等可执行后缀到 Web 目录
|
||||
$ext = strtolower(pathinfo((string)$f['name'], PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, ['xlsx', 'xlsm', 'csv', 'txt'], true)) {
|
||||
ny_fail('仅支持 .xlsx / .xlsm / .csv / .txt 文件');
|
||||
}
|
||||
// 随机文件名:杜绝按时间枚举(原 roster_YYYYMMDD_HHMMSS.ext 可被遍历)
|
||||
$dir = NY_DATA . '/uploads';
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0777, true);
|
||||
}
|
||||
$dest = $dir . '/' . bin2hex(random_bytes(16)) . '.' . $ext;
|
||||
if (!@move_uploaded_file($f['tmp_name'], $dest)) {
|
||||
ny_fail('文件保存失败,请检查 data/uploads 目录权限');
|
||||
}
|
||||
@@ -484,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']);
|
||||
|
||||
@@ -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)
|
||||
if (!file_exists(NY_DATA . '/.htaccess')) {
|
||||
@file_put_contents(NY_DATA . '/.htaccess', "Deny from all\n");
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 默认配置 ----------------
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||