From a3be81b26110c9962992645104e392279691413a Mon Sep 17 00:00:00 2001 From: nxxy335 Date: Thu, 10 Sep 2026 22:42:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=20=E4=BF=AE=E5=A4=8D=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E5=90=8D=E5=8D=95=E6=96=87=E4=BB=B6=E5=8F=AF=E8=A2=AB?= =?UTF-8?q?=20URL=20=E7=9B=B4=E8=BF=9E=E8=AE=BF=E9=97=AE=EF=BC=88nginx=20?= =?UTF-8?q?=E4=B8=8B=E5=8F=AF=20getshell=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 严重漏洞:roster_import 使用 $_FILES 的原始扩展名拼 roster_YYYYMMDD_HHMMSS. 落在 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 忽略文档构建产物 --- .gitignore | 4 + README.md | 48 +- api.php | 24 +- bootstrap.php | 14 +- docs/.vitepress/config.mjs | 129 + ....timestamp-1789051213693-e9e6067deebec.mjs | 121 + docs/dev/api.md | 106 + docs/dev/architecture.md | 110 + docs/dev/database.md | 113 + docs/dev/testing.md | 98 + docs/guide/dashboard.md | 48 + docs/guide/draw.md | 70 + docs/guide/export-print.md | 72 + docs/guide/faq.md | 111 + docs/guide/install.md | 133 + docs/guide/introduction.md | 61 + docs/guide/project-config.md | 67 + docs/guide/quickstart.md | 82 + docs/guide/roster.md | 85 + docs/guide/scores.md | 85 + docs/guide/settings.md | 70 + docs/guide/snapshot.md | 72 + docs/index.md | 63 + docs/package-lock.json | 2521 +++++++++++++++++ docs/package.json | 14 + .../1-首页.png => public/images/home.png} | Bin .../images/roster.png} | Bin .../images/scores.png} | Bin .../images/settings.png} | Bin docs/security/hardening.md | 156 + docs/security/index.md | 80 + tests/run-all.sh | 2 +- tools/test-upload-guard.js | 260 ++ 33 files changed, 4802 insertions(+), 17 deletions(-) create mode 100644 docs/.vitepress/config.mjs create mode 100644 docs/.vitepress/config.mjs.timestamp-1789051213693-e9e6067deebec.mjs create mode 100644 docs/dev/api.md create mode 100644 docs/dev/architecture.md create mode 100644 docs/dev/database.md create mode 100644 docs/dev/testing.md create mode 100644 docs/guide/dashboard.md create mode 100644 docs/guide/draw.md create mode 100644 docs/guide/export-print.md create mode 100644 docs/guide/faq.md create mode 100644 docs/guide/install.md create mode 100644 docs/guide/introduction.md create mode 100644 docs/guide/project-config.md create mode 100644 docs/guide/quickstart.md create mode 100644 docs/guide/roster.md create mode 100644 docs/guide/scores.md create mode 100644 docs/guide/settings.md create mode 100644 docs/guide/snapshot.md create mode 100644 docs/index.md create mode 100644 docs/package-lock.json create mode 100644 docs/package.json rename docs/{预览图/1-首页.png => public/images/home.png} (100%) rename docs/{预览图/4-名单管理.png => public/images/roster.png} (100%) rename docs/{预览图/2-成绩明细与统计.png => public/images/scores.png} (100%) rename docs/{预览图/3-系统设置.png => public/images/settings.png} (100%) create mode 100644 docs/security/hardening.md create mode 100644 docs/security/index.md create mode 100644 tools/test-upload-guard.js diff --git a/.gitignore b/.gitignore index 0c619fd..7ba0194 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,9 @@ data/ node_modules/ vendor/ *.log + +# VitePress 文档站构建产物与缓存 +docs/.vitepress/dist/ +docs/.vitepress/cache/ .DS_Store Thumbs.db diff --git a/README.md b/README.md index 5a66f6e..03062e7 100644 --- a/README.md +++ b/README.md @@ -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 秒」)强制单行显示,避免「秒」字换行。 - **成绩明细不再显示每人的「状态」列**:缺考人员统一在「缺考名单」面板查看,成绩明细只保留学号 / 姓名 / 班级 / 成绩 / 提交时间 / 考试时长。 - **新增数据标记**:每次刷新后,相较上一次新出现的提交(按学号判断)整行显示绿色底纹,方便一眼看出谁刚交卷;首次加载不标记。 diff --git a/api.php b/api.php index 49482f0..847fd57 100644 --- a/api.php +++ b/api.php @@ -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']); diff --git a/bootstrap.php b/bootstrap.php index dc91032..534d8c8 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -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', "403 Forbidden

403 Forbidden

\n"); + } } // ---------------- 默认配置 ---------------- diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs new file mode 100644 index 0000000..b550655 --- /dev/null +++ b/docs/.vitepress/config.mjs @@ -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: '切换到深色模式' + } +}) diff --git a/docs/.vitepress/config.mjs.timestamp-1789051213693-e9e6067deebec.mjs b/docs/.vitepress/config.mjs.timestamp-1789051213693-e9e6067deebec.mjs new file mode 100644 index 0000000..8d08e13 --- /dev/null +++ b/docs/.vitepress/config.mjs.timestamp-1789051213693-e9e6067deebec.mjs @@ -0,0 +1,121 @@ +// .vitepress/config.mjs +import { defineConfig } from "file:///C:/Users/Zhang/Documents/jc/ny-exam-platform/docs/node_modules/vitepress/dist/node/index.js"; +var base = process.env.DOCS_BASE || "/"; +var config_default = defineConfig({ + base, + lang: "zh-CN", + title: "NY \u95EE\u5377\u8003\u8BD5\u7CFB\u7EDF\u6570\u636E\u5E73\u53F0", + description: "\u57FA\u4E8E PHP 8 + \u5377\u738B\uFF08SurveyKing\uFF09\u793E\u533A\u7248\u5F00\u653E API \u7684\u96F6\u4F9D\u8D56\u8003\u8BD5\u6570\u636E\u7EDF\u8BA1\u5E73\u53F0", + cleanUrls: true, + ignoreDeadLinks: true, + head: [ + ["meta", { name: "theme-color", content: "#2563eb" }], + ["meta", { name: "referrer", content: "no-referrer" }] + ], + themeConfig: { + // outlineTitle: '本页目录', + outline: [2, 3], + lastUpdated: { text: "\u6700\u540E\u66F4\u65B0\u4E8E" }, + search: { + provider: "local", + options: { + locales: { + zh: { + translations: { + button: { buttonText: "\u641C\u7D22\u6587\u6863", buttonAriaLabel: "\u641C\u7D22\u6587\u6863" }, + modal: { + noResultsText: "\u672A\u627E\u5230\u76F8\u5173\u7ED3\u679C", + resetButtonTitle: "\u6E05\u9664\u67E5\u8BE2\u6761\u4EF6", + footer: { + selectText: "\u9009\u62E9", + navigateText: "\u5207\u6362", + closeText: "\u5173\u95ED" + } + } + } + } + } + } + }, + nav: [ + { text: "\u9996\u9875", link: "/" }, + { text: "\u4F7F\u7528\u6307\u5357", link: "/guide/introduction", activeMatch: "/guide/" }, + { text: "\u5B89\u5168", link: "/security/", activeMatch: "/security/" }, + { text: "\u5F00\u53D1", link: "/dev/architecture", activeMatch: "/dev/" } + ], + sidebar: { + "/guide/": [ + { + text: "\u5F00\u59CB", + items: [ + { text: "\u5E73\u53F0\u7B80\u4ECB", link: "/guide/introduction" }, + { text: "\u73AF\u5883\u8981\u6C42\u4E0E\u90E8\u7F72", link: "/guide/install" }, + { text: "\u5FEB\u901F\u4E0A\u624B", link: "/guide/quickstart" } + ] + }, + { + text: "\u6838\u5FC3\u529F\u80FD", + items: [ + { text: "\u7CFB\u7EDF\u8BBE\u7F6E", link: "/guide/settings" }, + { text: "\u540D\u5355\u7BA1\u7406", link: "/guide/roster" }, + { text: "\u6210\u7EE9\u660E\u7EC6\u4E0E\u7EDF\u8BA1\u53E3\u5F84", link: "/guide/scores" }, + { text: "\u8BD5\u5377\u8BBE\u7F6E\uFF08\u6EE1\u5206/\u5408\u683C\u7387/\u514D\u8003\uFF09", link: "/guide/project-config" }, + { text: "\u5FEB\u7167\u3001\u8D8B\u52BF\u4E0E\u5F02\u5E38\u68C0\u6D4B", link: "/guide/snapshot" }, + { text: "\u5BFC\u51FA\u4E0E\u6253\u5370", link: "/guide/export-print" } + ] + }, + { + text: "\u5176\u4ED6\u9875\u9762", + items: [ + { text: "\u6570\u636E\u770B\u677F", link: "/guide/dashboard" }, + { text: "\u968F\u673A\u62BD\u53D6", link: "/guide/draw" } + ] + }, + { + text: "\u5E2E\u52A9", + items: [ + { text: "\u5E38\u89C1\u95EE\u9898", link: "/guide/faq" } + ] + } + ], + "/security/": [ + { + text: "\u5B89\u5168", + items: [ + { text: "\u5B89\u5168\u603B\u89C8", link: "/security/" }, + { text: "\u90E8\u7F72\u52A0\u56FA\u6E05\u5355", link: "/security/hardening" } + ] + } + ], + "/dev/": [ + { + text: "\u5F00\u53D1", + items: [ + { text: "\u67B6\u6784\u4E0E\u76EE\u5F55", link: "/dev/architecture" }, + { text: "\u63A5\u53E3\u4E00\u89C8", link: "/dev/api" }, + { text: "\u6570\u636E\u5B58\u50A8\uFF08SQLite\uFF09", link: "/dev/database" }, + { text: "\u6D4B\u8BD5", link: "/dev/testing" } + ] + } + ] + }, + socialLinks: [], + footer: { + message: "\u57FA\u4E8E PHP 8 + \u5377\u738B\uFF08SurveyKing\uFF09\u793E\u533A\u7248\u5F00\u653E API \u6784\u5EFA\uFF0C\u96F6\u7B2C\u4E09\u65B9\u8FD0\u884C\u4F9D\u8D56\u3002", + copyright: "Copyright \xA9 NY \u95EE\u5377\u8003\u8BD5\u7CFB\u7EDF\u6570\u636E\u5E73\u53F0" + }, + docFooter: { + prev: "\u4E0A\u4E00\u9875", + next: "\u4E0B\u4E00\u9875" + }, + returnToTopLabel: "\u56DE\u5230\u9876\u90E8", + sidebarMenuLabel: "\u83DC\u5355", + darkModeSwitchLabel: "\u4E3B\u9898", + lightModeSwitchTitle: "\u5207\u6362\u5230\u6D45\u8272\u6A21\u5F0F", + darkModeSwitchTitle: "\u5207\u6362\u5230\u6DF1\u8272\u6A21\u5F0F" + } +}); +export { + config_default as default +}; +//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiLnZpdGVwcmVzcy9jb25maWcubWpzIl0sCiAgInNvdXJjZXNDb250ZW50IjogWyJjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZGlybmFtZSA9IFwiQzpcXFxcVXNlcnNcXFxcWmhhbmdcXFxcRG9jdW1lbnRzXFxcXGpjXFxcXG55LWV4YW0tcGxhdGZvcm1cXFxcZG9jc1xcXFwudml0ZXByZXNzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCJDOlxcXFxVc2Vyc1xcXFxaaGFuZ1xcXFxEb2N1bWVudHNcXFxcamNcXFxcbnktZXhhbS1wbGF0Zm9ybVxcXFxkb2NzXFxcXC52aXRlcHJlc3NcXFxcY29uZmlnLm1qc1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vQzovVXNlcnMvWmhhbmcvRG9jdW1lbnRzL2pjL255LWV4YW0tcGxhdGZvcm0vZG9jcy8udml0ZXByZXNzL2NvbmZpZy5tanNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tICd2aXRlcHJlc3MnXG5cbi8vIFx1OTBFOFx1N0Y3Mlx1NTIzMFx1NUI1MFx1OERFRlx1NUY4NFx1NjVGNlx1RkYwQ1x1Njc4NFx1NUVGQVx1NTI0RFx1OEJCRVx1N0Y2RVx1NzNBRlx1NTg4M1x1NTNEOFx1OTFDRlx1NTM3M1x1NTNFRlx1RkYxQVxuLy8gICBET0NTX0JBU0U9L255LWV4YW0tZG9jcy8gbnBtIHJ1biBkb2NzOmJ1aWxkXG5jb25zdCBiYXNlID0gcHJvY2Vzcy5lbnYuRE9DU19CQVNFIHx8ICcvJ1xuXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICBiYXNlLFxuICBsYW5nOiAnemgtQ04nLFxuICB0aXRsZTogJ05ZIFx1OTVFRVx1NTM3N1x1ODAwM1x1OEJENVx1N0NGQlx1N0VERlx1NjU3MFx1NjM2RVx1NUU3M1x1NTNGMCcsXG4gIGRlc2NyaXB0aW9uOiAnXHU1N0ZBXHU0RThFIFBIUCA4ICsgXHU1Mzc3XHU3MzhCXHVGRjA4U3VydmV5S2luZ1x1RkYwOVx1NzkzRVx1NTMzQVx1NzI0OFx1NUYwMFx1NjUzRSBBUEkgXHU3Njg0XHU5NkY2XHU0RjlEXHU4RDU2XHU4MDAzXHU4QkQ1XHU2NTcwXHU2MzZFXHU3RURGXHU4QkExXHU1RTczXHU1M0YwJyxcbiAgY2xlYW5VcmxzOiB0cnVlLFxuICBpZ25vcmVEZWFkTGlua3M6IHRydWUsXG5cbiAgaGVhZDogW1xuICAgIFsnbWV0YScsIHsgbmFtZTogJ3RoZW1lLWNvbG9yJywgY29udGVudDogJyMyNTYzZWInIH1dLFxuICAgIFsnbWV0YScsIHsgbmFtZTogJ3JlZmVycmVyJywgY29udGVudDogJ25vLXJlZmVycmVyJyB9XVxuICBdLFxuXG4gIHRoZW1lQ29uZmlnOiB7XG4gICAgLy8gb3V0bGluZVRpdGxlOiAnXHU2NzJDXHU5ODc1XHU3NkVFXHU1RjU1JyxcbiAgICBvdXRsaW5lOiBbMiwgM10sXG4gICAgbGFzdFVwZGF0ZWQ6IHsgdGV4dDogJ1x1NjcwMFx1NTQwRVx1NjZGNFx1NjVCMFx1NEU4RScgfSxcblxuICAgIHNlYXJjaDoge1xuICAgICAgcHJvdmlkZXI6ICdsb2NhbCcsXG4gICAgICBvcHRpb25zOiB7XG4gICAgICAgIGxvY2FsZXM6IHtcbiAgICAgICAgICB6aDoge1xuICAgICAgICAgICAgdHJhbnNsYXRpb25zOiB7XG4gICAgICAgICAgICAgIGJ1dHRvbjogeyBidXR0b25UZXh0OiAnXHU2NDFDXHU3RDIyXHU2NTg3XHU2ODYzJywgYnV0dG9uQXJpYUxhYmVsOiAnXHU2NDFDXHU3RDIyXHU2NTg3XHU2ODYzJyB9LFxuICAgICAgICAgICAgICBtb2RhbDoge1xuICAgICAgICAgICAgICAgIG5vUmVzdWx0c1RleHQ6ICdcdTY3MkFcdTYyN0VcdTUyMzBcdTc2RjhcdTUxNzNcdTdFRDNcdTY3OUMnLFxuICAgICAgICAgICAgICAgIHJlc2V0QnV0dG9uVGl0bGU6ICdcdTZFMDVcdTk2NjRcdTY3RTVcdThCRTJcdTY3NjFcdTRFRjYnLFxuICAgICAgICAgICAgICAgIGZvb3Rlcjoge1xuICAgICAgICAgICAgICAgICAgc2VsZWN0VGV4dDogJ1x1OTAwOVx1NjJFOScsXG4gICAgICAgICAgICAgICAgICBuYXZpZ2F0ZVRleHQ6ICdcdTUyMDdcdTYzNjInLFxuICAgICAgICAgICAgICAgICAgY2xvc2VUZXh0OiAnXHU1MTczXHU5NUVEJ1xuICAgICAgICAgICAgICAgIH1cbiAgICAgICAgICAgICAgfVxuICAgICAgICAgICAgfVxuICAgICAgICAgIH1cbiAgICAgICAgfVxuICAgICAgfVxuICAgIH0sXG5cbiAgICBuYXY6IFtcbiAgICAgIHsgdGV4dDogJ1x1OTk5Nlx1OTg3NScsIGxpbms6ICcvJyB9LFxuICAgICAgeyB0ZXh0OiAnXHU0RjdGXHU3NTI4XHU2MzA3XHU1MzU3JywgbGluazogJy9ndWlkZS9pbnRyb2R1Y3Rpb24nLCBhY3RpdmVNYXRjaDogJy9ndWlkZS8nIH0sXG4gICAgICB7IHRleHQ6ICdcdTVCODlcdTUxNjgnLCBsaW5rOiAnL3NlY3VyaXR5LycsIGFjdGl2ZU1hdGNoOiAnL3NlY3VyaXR5LycgfSxcbiAgICAgIHsgdGV4dDogJ1x1NUYwMFx1NTNEMScsIGxpbms6ICcvZGV2L2FyY2hpdGVjdHVyZScsIGFjdGl2ZU1hdGNoOiAnL2Rldi8nIH1cbiAgICBdLFxuXG4gICAgc2lkZWJhcjoge1xuICAgICAgJy9ndWlkZS8nOiBbXG4gICAgICAgIHtcbiAgICAgICAgICB0ZXh0OiAnXHU1RjAwXHU1OUNCJyxcbiAgICAgICAgICBpdGVtczogW1xuICAgICAgICAgICAgeyB0ZXh0OiAnXHU1RTczXHU1M0YwXHU3QjgwXHU0RUNCJywgbGluazogJy9ndWlkZS9pbnRyb2R1Y3Rpb24nIH0sXG4gICAgICAgICAgICB7IHRleHQ6ICdcdTczQUZcdTU4ODNcdTg5ODFcdTZDNDJcdTRFMEVcdTkwRThcdTdGNzInLCBsaW5rOiAnL2d1aWRlL2luc3RhbGwnIH0sXG4gICAgICAgICAgICB7IHRleHQ6ICdcdTVGRUJcdTkwMUZcdTRFMEFcdTYyNEInLCBsaW5rOiAnL2d1aWRlL3F1aWNrc3RhcnQnIH1cbiAgICAgICAgICBdXG4gICAgICAgIH0sXG4gICAgICAgIHtcbiAgICAgICAgICB0ZXh0OiAnXHU2ODM4XHU1RkMzXHU1MjlGXHU4MEZEJyxcbiAgICAgICAgICBpdGVtczogW1xuICAgICAgICAgICAgeyB0ZXh0OiAnXHU3Q0ZCXHU3RURGXHU4QkJFXHU3RjZFJywgbGluazogJy9ndWlkZS9zZXR0aW5ncycgfSxcbiAgICAgICAgICAgIHsgdGV4dDogJ1x1NTQwRFx1NTM1NVx1N0JBMVx1NzQwNicsIGxpbms6ICcvZ3VpZGUvcm9zdGVyJyB9LFxuICAgICAgICAgICAgeyB0ZXh0OiAnXHU2MjEwXHU3RUU5XHU2NjBFXHU3RUM2XHU0RTBFXHU3RURGXHU4QkExXHU1M0UzXHU1Rjg0JywgbGluazogJy9ndWlkZS9zY29yZXMnIH0sXG4gICAgICAgICAgICB7IHRleHQ6ICdcdThCRDVcdTUzNzdcdThCQkVcdTdGNkVcdUZGMDhcdTZFRTFcdTUyMDYvXHU1NDA4XHU2ODNDXHU3Mzg3L1x1NTE0RFx1ODAwM1x1RkYwOScsIGxpbms6ICcvZ3VpZGUvcHJvamVjdC1jb25maWcnIH0sXG4gICAgICAgICAgICB7IHRleHQ6ICdcdTVGRUJcdTcxNjdcdTMwMDFcdThEOEJcdTUyQkZcdTRFMEVcdTVGMDJcdTVFMzhcdTY4QzBcdTZENEInLCBsaW5rOiAnL2d1aWRlL3NuYXBzaG90JyB9LFxuICAgICAgICAgICAgeyB0ZXh0OiAnXHU1QkZDXHU1MUZBXHU0RTBFXHU2MjUzXHU1MzcwJywgbGluazogJy9ndWlkZS9leHBvcnQtcHJpbnQnIH1cbiAgICAgICAgICBdXG4gICAgICAgIH0sXG4gICAgICAgIHtcbiAgICAgICAgICB0ZXh0OiAnXHU1MTc2XHU0RUQ2XHU5ODc1XHU5NzYyJyxcbiAgICAgICAgICBpdGVtczogW1xuICAgICAgICAgICAgeyB0ZXh0OiAnXHU2NTcwXHU2MzZFXHU3NzBCXHU2NzdGJywgbGluazogJy9ndWlkZS9kYXNoYm9hcmQnIH0sXG4gICAgICAgICAgICB7IHRleHQ6ICdcdTk2OEZcdTY3M0FcdTYyQkRcdTUzRDYnLCBsaW5rOiAnL2d1aWRlL2RyYXcnIH1cbiAgICAgICAgICBdXG4gICAgICAgIH0sXG4gICAgICAgIHtcbiAgICAgICAgICB0ZXh0OiAnXHU1RTJFXHU1MkE5JyxcbiAgICAgICAgICBpdGVtczogW1xuICAgICAgICAgICAgeyB0ZXh0OiAnXHU1RTM4XHU4OUMxXHU5NUVFXHU5ODk4JywgbGluazogJy9ndWlkZS9mYXEnIH1cbiAgICAgICAgICBdXG4gICAgICAgIH1cbiAgICAgIF0sXG4gICAgICAnL3NlY3VyaXR5Lyc6IFtcbiAgICAgICAge1xuICAgICAgICAgIHRleHQ6ICdcdTVCODlcdTUxNjgnLFxuICAgICAgICAgIGl0ZW1zOiBbXG4gICAgICAgICAgICB7IHRleHQ6ICdcdTVCODlcdTUxNjhcdTYwM0JcdTg5QzgnLCBsaW5rOiAnL3NlY3VyaXR5LycgfSxcbiAgICAgICAgICAgIHsgdGV4dDogJ1x1OTBFOFx1N0Y3Mlx1NTJBMFx1NTZGQVx1NkUwNVx1NTM1NScsIGxpbms6ICcvc2VjdXJpdHkvaGFyZGVuaW5nJyB9XG4gICAgICAgICAgXVxuICAgICAgICB9XG4gICAgICBdLFxuICAgICAgJy9kZXYvJzogW1xuICAgICAgICB7XG4gICAgICAgICAgdGV4dDogJ1x1NUYwMFx1NTNEMScsXG4gICAgICAgICAgaXRlbXM6IFtcbiAgICAgICAgICAgIHsgdGV4dDogJ1x1NjdCNlx1Njc4NFx1NEUwRVx1NzZFRVx1NUY1NScsIGxpbms6ICcvZGV2L2FyY2hpdGVjdHVyZScgfSxcbiAgICAgICAgICAgIHsgdGV4dDogJ1x1NjNBNVx1NTNFM1x1NEUwMFx1ODlDOCcsIGxpbms6ICcvZGV2L2FwaScgfSxcbiAgICAgICAgICAgIHsgdGV4dDogJ1x1NjU3MFx1NjM2RVx1NUI1OFx1NTBBOFx1RkYwOFNRTGl0ZVx1RkYwOScsIGxpbms6ICcvZGV2L2RhdGFiYXNlJyB9LFxuICAgICAgICAgICAgeyB0ZXh0OiAnXHU2RDRCXHU4QkQ1JywgbGluazogJy9kZXYvdGVzdGluZycgfVxuICAgICAgICAgIF1cbiAgICAgICAgfVxuICAgICAgXVxuICAgIH0sXG5cbiAgICBzb2NpYWxMaW5rczogW10sXG5cbiAgICBmb290ZXI6IHtcbiAgICAgIG1lc3NhZ2U6ICdcdTU3RkFcdTRFOEUgUEhQIDggKyBcdTUzNzdcdTczOEJcdUZGMDhTdXJ2ZXlLaW5nXHVGRjA5XHU3OTNFXHU1MzNBXHU3MjQ4XHU1RjAwXHU2NTNFIEFQSSBcdTY3ODRcdTVFRkFcdUZGMENcdTk2RjZcdTdCMkNcdTRFMDlcdTY1QjlcdThGRDBcdTg4NENcdTRGOURcdThENTZcdTMwMDInLFxuICAgICAgY29weXJpZ2h0OiAnQ29weXJpZ2h0IFx1MDBBOSBOWSBcdTk1RUVcdTUzNzdcdTgwMDNcdThCRDVcdTdDRkJcdTdFREZcdTY1NzBcdTYzNkVcdTVFNzNcdTUzRjAnXG4gICAgfSxcblxuICAgIGRvY0Zvb3Rlcjoge1xuICAgICAgcHJldjogJ1x1NEUwQVx1NEUwMFx1OTg3NScsXG4gICAgICBuZXh0OiAnXHU0RTBCXHU0RTAwXHU5ODc1J1xuICAgIH0sXG5cbiAgICByZXR1cm5Ub1RvcExhYmVsOiAnXHU1NkRFXHU1MjMwXHU5ODc2XHU5MEU4JyxcbiAgICBzaWRlYmFyTWVudUxhYmVsOiAnXHU4M0RDXHU1MzU1JyxcbiAgICBkYXJrTW9kZVN3aXRjaExhYmVsOiAnXHU0RTNCXHU5ODk4JyxcbiAgICBsaWdodE1vZGVTd2l0Y2hUaXRsZTogJ1x1NTIwN1x1NjM2Mlx1NTIzMFx1NkQ0NVx1ODI3Mlx1NkEyMVx1NUYwRicsXG4gICAgZGFya01vZGVTd2l0Y2hUaXRsZTogJ1x1NTIwN1x1NjM2Mlx1NTIzMFx1NkRGMVx1ODI3Mlx1NkEyMVx1NUYwRidcbiAgfVxufSlcbiJdLAogICJtYXBwaW5ncyI6ICI7QUFBOFcsU0FBUyxvQkFBb0I7QUFJM1ksSUFBTSxPQUFPLFFBQVEsSUFBSSxhQUFhO0FBRXRDLElBQU8saUJBQVEs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 diff --git a/docs/dev/api.md b/docs/dev/api.md new file mode 100644 index 0000000..18c92c1 --- /dev/null +++ b/docs/dev/api.md @@ -0,0 +1,106 @@ +# 接口一览 + +所有接口统一走 `api.php?action=`,响应为 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) diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md new file mode 100644 index 0000000..175f495 --- /dev/null +++ b/docs/dev/architecture.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) diff --git a/docs/dev/database.md b/docs/dev/database.md new file mode 100644 index 0000000..00118c6 --- /dev/null +++ b/docs/dev/database.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) diff --git a/docs/dev/testing.md b/docs/dev/testing.md new file mode 100644 index 0000000..08634bd --- /dev/null +++ b/docs/dev/testing.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) diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md new file mode 100644 index 0000000..3a43d01 --- /dev/null +++ b/docs/guide/dashboard.md @@ -0,0 +1,48 @@ +# 数据看板 + +数据看板(`dashboard.php`)是**面向投屏 / 大屏**的汇总视图,适合考试进行中实时监控提交进度。 + +从主页面顶部导航 **📊 数据看板** 打开(新标签页)。 + +## 顶部操作 + +| 控件 | 说明 | +| --- | --- | +| 问卷 / 考试 | 选择要展示的试卷 | +| 班级 | 选择后只展示该班级(需先选试卷) | +| 自动刷新开关 + 间隔 | 与主页面一致的 5 / 10 / 15 / 30 / 60 / 120 秒 | +| 刷新 | 立即拉取一次(跳过缓存) | +| ⛶ 全屏 | 进入 / 退出全屏,布局自动铺满 | + +## 汇总卡片 + +`总人数 / 已提交 / 缺考 / 提交率 / 平均分 / 合格率(≥X%满分)` + +合格率卡片的阈值标签会随 [每卷配置](./project-config.md) 实时变化。 + +## 班级 / 组别下钻 + +看板主体按**班级**分块,每块内再按**组别**对比,指标与统计口径完全相同: +总人数、已提交、缺考、提交率、平均分、合格率、最高分、最低分、平均用时。 + +点击班级内任意成员可查看其**每一次提交**(时间、成绩、时长),最高分有标记。 + +> 未分班的人归入「(未分班)」,未分组的人归入「(未分组)」,不会丢失。 + +## 常见问题 + +### 全屏按钮点了没反应 + +- 浏览器要求全屏必须由**用户手势**触发,脚本自动调用 `requestFullscreen()` 会被拒绝; +- 退出全屏调用的是 `document.exitFullscreen()`(注意不是 `document.documentElement`), + 平台已修正过这一点; +- 若页面被嵌在 `