Files
ny-exam-platform/docs/dev/architecture.md
T
nxxy335 a3be81b261 fix(security): 修复上传名单文件可被 URL 直连访问(nginx 下可 getshell)
严重漏洞:roster_import 使用 $_FILES 的原始扩展名拼 roster_YYYYMMDD_HHMMSS.<ext>
落在 Web 根目录内的 data/uploads/,既无扩展名白名单(可上传 .php),文件名又
可按秒枚举;而 bootstrap 只写了 data/.htaccess,nginx 不读 .htaccess,
生产环境(nginx + PHP-FPM)下可直接远程代码执行。

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

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

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

docs: 新增 VitePress 文档站(18 页)
- 使用指南 12 页:简介/部署/快速上手/系统设置/名单管理/统计口径/试卷设置/
  快照趋势异常/导出打印/数据看板/随机抽取/常见问题
- 安全 2 页:安全总览(含本次漏洞复盘)与部署加固清单(P0-P2)
- 开发 4 页:架构与目录/接口一览/SQLite 存储/测试
- 配图迁移至 docs/public/images/;.gitignore 忽略文档构建产物
2026-09-10 22:42:13 +08:00

111 lines
5.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 架构与目录
## 技术栈
- **后端**: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)