重大变更
1. 高层摘要 (TL;DR)
- 影响等级: 🔴 高 (High) — 改动横跨 Python 后端 / 浏览器 JS / 数据库 Schema / 前端模板 / 打包系统,涉及全新子模块引入、计分模型重构、全栈性能优化。
- 关键变更:
- ✨ 全新 Excel/WPS 操作题判分引擎 — 浏览器内执行 EG 引擎(ExcelJS + HyperFormula + JSZip + SheetJS,全本地无 CDN),后端只存文件与分数
- 📊 计分模型重构 — 从「按步骤累计」改为「按合并小题计分」(全对才满分,任一错即 0)
- ⚡ SQLite + 静态资源 + GZip 多重性能优化 — WAL、mmap、线程局部连接、缓存压缩、版本号击穿
- 🔀 题库导入/导出 + 多库合并工具 —
/api/qbank/export_pack、/api/qbank/import_pack、db_merge_gui.py
- 🚀 自动升级系统 —
exepack/updatecore.py 实现检查→下载→MD5 校验→替换重启全链路
- 📝 题项支持 ≥4 选项(最多 15 个 A–O)+ 选项随机打乱(防邻座互抄)
2. 可视化总览(架构与数据流)
graph TD
subgraph 客户端["前端 (浏览器)"]
STUDENT["学生端<br/>static/index.js<br/>static/exam.js"]
TEACHER["教师/场控端<br/>static/admin.js<br/>static/admin_update.js"]
EXCELJS["Excel/WPS 引擎<br/>static/excel-wps.js<br/>excelgrade/web/js/*"]
end
subgraph 后端["后端 (FastAPI)"]
MAIN["main.py<br/>路由/WS/管理面中间件"]
QBANK["qbank.py<br/>题库 CRUD"]
EXCELAPI["excelgrade/api.py<br/>Excel/WPS 接口"]
RECORDS["records.py<br/>整卷/统计/报告"]
DBCORE["dbcore.py<br/>SQLite/MySQL 抽象 + WAL"]
UPDATE["exepack/updatecore.py<br/>自动升级"]
ACCOUNTS["accounts.py<br/>账号/解锁"]
end
subgraph 数据层
DB[("quiz.db<br/>SQLite WAL<br/>+索引")]
EXCEL_DIR["uploads/excel/<br/>题目三件套+作答文件"]
end
subgraph 性能优化层
CACHED["CachedStaticFiles<br/>GZip + 版本号缓存击穿"]
GZIP["GZipMiddleware<br/>全站文本压缩"]
end
STUDENT -->|"答题/提交"| MAIN
TEACHER -->|"出题/控制"| MAIN
EXCELJS -->|"上传 subs分数"| EXCELAPI
MAIN --> QBANK
MAIN --> RECORDS
MAIN --> EXCELAPI
MAIN --> UPDATE
QBANK --> DBCORE
RECORDS --> DBCORE
EXCELAPI --> QBANK
EXCELAPI --> DBCORE
UPDATE --> DBCORE
EXCELAPI --> EXCEL_DIR
MAIN -.->|"static/*"| CACHED
MAIN -.->|"HTML/JSON"| GZIP
style EXCELJS fill:#fff3e0,color:#e65100
style EXCELAPI fill:#fff3e0,color:#e65100
style CACHED fill:#c8e6c9,color:#1a5e20
style GZIP fill:#c8e6c9,color:#1a5e20
style UPDATE fill:#f3e5f5,color:#7b1fa2
style MAIN fill:#bbdefb,color:#0d47a1
style DBCORE fill:#c8e6c9,color:#1a5e20
Excel/WPS 操作题提交流程
sequenceDiagram
participant S as 学生
participant JS as excel-wps.js
participant EG as EG引擎(浏览器)
participant API as /api/excel
participant DB as 数据库
S->>JS: 选择作答文件(.xlsx)
S->>JS: 点"提交"
JS->>JS: 二次确认(含文件名)
JS->>EG: ensureEngine() 单例懒加载
EG-->>JS: libs + core 就绪
JS->>EG: gradeFile(file)
EG->>EG: reader.readWorkbook
EG->>EG: recalc (HyperFormula)
EG->>EG: grouper.viewByQuestion<br/>按 qgroups 合并为 subs
EG-->>JS: {score, score_max, subs}
JS->>API: POST /api/excel/exam/{sid}/answer
API->>DB: INSERT excel_submission<br/>(含 subs JSON)
DB-->>API: ok
API-->>JS: 回执
3. 详细变更分析
🚀 模块 A:Excel/WPS 操作题判分引擎(全新)
核心架构:判分在浏览器(学生端 EG 引擎 + 教师上传的 judge.js),后端只存文件与分数。
| 维度 |
内容 |
| 新增文件 |
excelgrade/api.py(815行)、excelgrade/grader.py、excelgrade/extractor.py、excelgrade/grouper.py、excelgrade/reader.py、excelgrade/store.py、excelgrade/compare.py、excelgrade/model.py、excelgrade/config.py、excelgrade/__init__.py、excelgrade/cli.py、excelgrade/selftest.py、excelgrade/batch.py、excelgrade/report.py、excelgrade/report_html.py、excelgrade/pairing.py、excelgrade/web/*(20+ 文件)、static/excel-wps.js(490行)、db_merge_core.py、db_merge_gui.py |
| 数据库 Schema 变更 |
qb_questions / questions 新增 template_file_url、json_standard_url、judge_js_url、options(JSON 数组) |
| 计分口径变更 |
🔥 旧 detail[](逐步骤) → 新 subs[](逐小题);全对才给满分(任一错即 0) |
| 关键模型 |
EG.grouper.viewByQuestion(result, qgroups) 按 qgroups 编排把上百条规则聚合成 N 个「小题」 |
| 文件存储 |
uploads/excel/ 下分 q/(题目三件套)、tmp/(草稿)、answers/(场次作答)、live/(实时作答) |
新增 API 端点
| 方法 |
路径 |
用途 |
GET |
/api/excel/meta/{qid} |
编辑弹窗回填(文件元数据) |
POST |
/api/excel/q/{qid}/files |
上传 template/standard/judge(已有题目) |
POST |
/api/excel/tmp/{key}/files |
上传草稿文件(新建题目时无 id) |
POST |
/api/excel/q/{qid}/attach |
草稿迁移到正式题目目录并写库字段 |
DELETE |
/api/excel/q/{qid}/files/{kind} |
移除某个判分文件 |
GET |
/api/excel/res/{qid}/{kind} |
资源下载(鉴权) |
GET |
/api/excel/res/tmp/{key}/{kind} |
草稿资源读取(教职工预览) |
POST |
/api/excel/exam/{sid}/answer |
整卷 Excel 作答(前端已判分,回传分数) |
POST |
/api/excel/live/{code}/{qid}/answer |
实时场 Excel 作答 |
数据库表新增
| 表名 |
字段 |
用途 |
excel_submission |
id/session_id/user_id/qn_id/qb_id/file_path/file_name/score/score_max/passed/failed/unknown/total_rules/detail/subs/created_at/updated_at |
整卷/练习:场次作答留痕(UNIQUE(session,user,qn) 重传覆盖) |
excel_live_sub |
id/room_code/player_id/qid/qb_src/.../score_added |
实时答题:按房间+选手+实时题号独立表 |
attempt_items |
excel_sub_id |
新增列,关联场次作答到文件留痕 |
⚡ 模块 B:性能优化(P0/P1 — 2026-09-15/16)
| 子项 |
来源文件 |
优化手段 |
收益 |
| P0-1 SQLite WAL |
dbcore.py |
journal_mode=WAL / synchronous=NORMAL / cache_size=-4000 / mmap_size=64MB / temp_store=MEMORY / busy_timeout=30s |
机房 Win7/机械盘并发解锁,写入延迟降;不再 database is locked |
| P0-4 线程局部连接 |
dbcore.py |
每线程复用 SQLite 连接(带 PRAGMA 缓存),_Conn.close() 自动清缓存 |
实测每次查询 ~0.93ms 建连 → ~0.001ms 复用(6 条 PRAGMA 占 83%) |
| P1-1 静态资源缓存 |
main.py |
CachedStaticFiles 继承 Starlette:max-age=86400 + GZip(≥1KB) |
wangEditor/qbank.js 等大文件传输量降 ~70% |
| P1-2 全站 GZip |
main.py |
GZipMiddleware(minimum_size=1024, compresslevel=6);已带 content-encoding 跳过避免重复 |
HTML/JSON 文本类响应压缩 |
| P1-3 资源版本号 |
main.py |
_static_ver() 取 static/ 目录 mtime;模板 {{ sv('/x.js') }} 拼 ?v=…;JS 也能读 window.STATIC_VER |
改文件重启后 URL 变 → 旧缓存自动失效 |
| 数据库索引补齐 |
sitedb.py |
_perf_indexes() 添加6 个高频索引:users(session_token) / sessions(school_id,id) / qb_questions(parent_id) / qb_questions(school_id,visibility,id) / attempt_items(session_id,qn_id) / session_users(session_id,score) |
千人库登录态解析 0.53ms → 走索引查找 |
| 列裁剪 |
sitedb.py |
get_user_by_token() 由 SELECT * 改为 11 列 |
19 列 → 11 列,每 /api 请求省回传 |
| N+1 查询消除 |
records.py |
user_sessions() 原每行一次 SELECT quiz_plan 改为一次 IN 批量;plan_from_paper() 子题从「逐材料一次查询」改为一次 IN 批量 |
用户场次列表页性能提升显著 |
| WAL Checkpoint |
dbcore.py |
新增 checkpoint():合并 -wal 回主库 |
防止 db_merge 时漏数据;服务退出前自动调用 |
| 心跳键防膨胀 |
main.py |
_hb_cache 超 2000 时清理 5 分钟未心跳的旧键 |
长期运行/换人时无界增长问题修复 |
| 前端轮询降频 |
static/index.js |
WS 离线兜底轮询:800ms → 5000ms(请求量降 ~6 倍);vizInterval() 后台标签页暂停轮询 |
多班级大并发明显 |
关键 PRAGMA 设置
PRAGMA journal_mode=WAL -- 读写不互锁
PRAGMA synchronous=NORMAL -- 单条提交不 fsync
PRAGMA temp_store=MEMORY -- GROUP BY/ORDER BY 不落盘
PRAGMA cache_size=-4000 -- 4MB 缓存
PRAGMA mmap_size=67108864 -- 64MB 内存映射
PRAGMA busy_timeout=30000 -- 30s 锁等待
🚀 模块 C:自动升级系统(全新 — 2026-09-16)
| 维度 |
内容 |
| 新增文件 |
exepack/updatecore.py(280行)、static/admin_update.js(80行) |
| 核心流程 |
启动后台检查 → 通知前端 → 老师点「立即升级」 → 下载(带进度)→ MD5 校验 → 生成 bat → 退出进程 → bat 覆盖 exe →拉起新版 → 自删 |
| 更新源优先级 |
exe 同目录 更新地址.txt > 内置常量 https://img.sakaay.com/p/img/quiz/latest.json |
| 安全设计 |
MD5 校验失败自动删下载文件;进程退出前 dbcore.checkpoint() 合并 WAL;bat 失败留 update_fail.txt 痕迹 |
| 打包产物 |
exepack/build.py 新增 publish_update():复制产物到 dist_update/ 并合并 latest.json(win7/win10 两条目分别打包累积),文件名用纯 ASCII 避免图床改名 |
| 管理员界面 |
顶部悬浮条:「发现新版本 vX.X.X(当前 vY.Y.Y)」→「立即升级」→「下载中 XX%」→「新版本已下载完成」→「重启应用」 |
自动升级 API
| 方法 |
路径 |
权限 |
用途 |
GET |
/api/update/check |
教职工 |
主动触发检查,返回 {has_update, latest, cur, notes, size, packaged} |
POST |
/api/update/start |
教职工 |
开始下载(后台线程 +进度状态) |
GET |
/api/update/status |
教职工 |
轮询下载进度(每 800ms) |
POST |
/api/update/apply |
教职工 |
触发替换 +退出进程 |
📚 模块 D:题库导入/导出 + 多库合并
| 维度 |
内容 |
| Web端 API |
GET /api/qbank/export_pack 下载 JSON;POST /api/qbank/import_pack?strategy=xxx 增量合并(导入前自动备份 .bak_import_时间戳) |
| GUI 工具 |
db_merge_gui.py(tkinter,零三方依赖)选择目标库 + 多源库 + 策略,预览/执行 |
| 核心引擎 |
db_merge_core.py(242 行),GUI 与 Web 共用同一套逻辑 |
| 策略 |
① keep_all 全部保留(重映射 id,推荐);② overwrite 源覆盖目标;③ skip 跳过冲突 |
| 表范围 |
qb_categories / qb_tags / qb_papers / qb_questions / qb_paper_items / qb_question_tags(不碰 users/sessions/players/questions隐私与运行期数据) |
| 自动加列 |
源库有新字段(如升级后的 Excel 题 3 字段)→目标库自动 ALTER TABLE 加列 |
| 外键级联 |
parent_id / qid / tid / paper_id 在重映射后自动级联修正 |
📚 模块 E:题型与题项扩展
| 变更 |
内容 |
| 新增题型 |
excel_wps(Excel/WPS 操作题)加入 TYPES 元组 |
| 字段扩展 |
QIn 模型新增 template_file_url / json_standard_url / judge_js_url(None 表示不覆盖) |
| 选项上限 |
MAX_OPTIONS 4 → 6,配合 DB options JSON 列扩展最多 15 个 A–O(_live_opts() 函数优先 options,回退 optA..optD) |
| 选项随机打乱 |
教师可设 shuffle_options=1,每位学生选项顺序随机但内容一致(防邻座互抄) |
| Python 3.8 兼容 |
int \| None → Optional[int];list[dict] → List[Dict](PEP 604 需 3.10+;Win7 打包要求3.8) |
| 校验调整 |
type=excel_wps 时跳过传统答案校验(return {"keys": [], "ans": None}) |
| 题组成组 |
r["type"] in ("material", "excel_wps") 同样触发子题下发;WPS 题组保留 t=excel_wps;父题不限 material |
| 子题独立出题 |
parent_id 单独出材料子题时自动附上材料正文 question["material"] |
| 题型识别 |
q_type_of() 增加 excel_wps 分支,避免被「答案长度推断」误判为单选 |
🔒 模块 F:报告权限模型重构(2026-09-15)
| 变更 |
内容 |
新增 require_staff() |
统计报告等教职工接口统一闸:登录 + 状态正常 + 教师/管理员角色 |
新增 school_scoped() |
教师/学校管理员可看本校任意场次(不再按房间隔离);超管不限校;满足「本校成绩统一查阅」 |
room_scoped() 抽提 |
写操作仍保持房间级(编辑/删除/收卷),与查看类区分 |
_room_manageable() |
房间管理判定:超管/房间创建者/本校管理员;与列表的 can_manage 同源 |
报告列表 ?school= |
超管可 ?school=<id|code> 切校查看;普通教师恒为本校 |
/api/stats 纳入守卫 |
/api/stats 不在管理面中间件前缀内,接口内必须强制身份 |
操作题满分口径修正
| 场景 |
旧 |
新 |
| 整卷组卷时操作题满分 |
套用组卷默认分(10 分) |
excelgrade.api._excel_standard_max(qid) 解析考点 JSON 取 Σ规则分(manualMax 优先) |
| 实时场结算满分 |
默认 10 分/题 |
同上,按判分文件满分 |
| 排行榜「全对」 |
仅客观题可比对 |
操作题以「达满分」计(_excel_full()) |
🔒 模块 G:账号体系增强
| 变更 |
内容 |
新增 POST /api/users/{uid}/unlock |
解除账号「登录失败临时锁定」(清 lock_until + fail_count=0),幂等;教师仅可解本校学生,校管理员可解本校教师/学生,超管不限;不能解超管 |
list_users 返回 locked |
服务端按当前时间计算,前端列表/解锁按钮统一依据本字段,避免各处自行比较 lock_until 造成口径不一致 |
| 锁字段暴露 |
user_pub 等消费点需要时同步扩充 |
🎨 模块 H:前端 UI 改进
| 改动 |
内容 |
| 抽屉响应式自适应 |
templates/admin.html 把排行榜/现场动态从悬浮面板改为可拖动抽屉;CSS 容器查询(container-type: inline-size)+ clamp(...cqi...) 让内部字号/图标等比缩放;删除 @media 硬切宽度跳变 |
| 悬浮面板瘦身 |
主控按钮改为图标(▶ 开跑/继续、⏸ 暂停、↻ 新一轮),含义放 title悬浮提示 |
| 题目列表分布懒加载 |
static/admin.js 用 IntersectionObserver 仅对视口内题目请求分布;名单悬停时按题单取并缓存;题目数据没变化不重建列表 DOM(解决 600 题卡顿) |
| WebSocket + 轮询降频 |
WS 在线时由服务端每 2s 推送;离线时才用轮询兜底(请求量降 6 倍) |
| 心跳离开判定 |
切走标签页 = 离开本页超 3 秒;已离开 = 关闭/失联超时;学生回本页自动转「在线」 |
| admin.html 模板 |
引入 {{ sv('/static/style.css') }} 版本号;新增答题现场入口链接(新窗口打开) |
| 模板公共函数 |
templates.env.globals["sv"] + static_ver(供 JS 动态加载资源时拼版本号) |
🧹 模块 I:API 接口清理
| 状态 |
接口 |
原因 |
| ❌ 已删除 |
GET /api/portal |
兼容遗留,前端只从 /api/room/me.portal 读 |
| ❌ 已删除 |
GET /api/admin/dist |
被 dist_batch + dist_names 取代 |
| ❌ 已删除 |
POST /api/admin/add_q |
旧「手动新增单选题」,无前端入口 |
| ❌ 已删除 |
POST /api/admin/q_ana |
旧「单独编辑解析」,无调用方 |
📦 模块 J:打包系统升级(Win7 兼容)
| 改动 |
内容 |
| PyInstaller 版本策略 |
--target auto:Python 3.8 → win7(PyInstaller 5.13.2,最后支持 Win7/8 的 bootloader);Python 3.9+ → win10(PyInstaller 6.x) |
| Win7 UCRT 剔除 |
spec 中 strip_ucrt=True 时在 Analysis 之后剔除 ucrtbase.dll 与 api-ms-win-*(Win7 SP1+KB2999226 自带 UCRT) |
| pycryptodome 瘦身 |
显式过滤 Crypto/PublicKey/*、Crypto/Signature/*、Crypto/IO/*、Crypto/Protocol/*(仅 _ec_ws.pyd 单项省0.7MB) |
| uvicorn 父包补全 |
HIDDEN 加 uvicorn.loops/uvicorn.protocols/uvicorn.lifespan(PyInstaller 5.x 报 cannot handle module 'uvicorn.loops') |
| 低配机瘦身 |
EXCLUDES 增加 unittest/pydoc_data/lib2to3 |
| 自动升级模块显式收集 |
HIDDEN 加 exepack.updatecore(main.py 顶层 import) |
| 版本号文件随包 |
version.txt 随打包发出(updatecore.read_version 从 RES_DIR 读取) |
| exepack 退出 checkpoint |
exepack/svc.py 退出前 dbcore.checkpoint() 合并 WAL,老师随后拷贝 quiz.db 不漏数据 |
🆕 模块 K:excel_submission 数据迁移
| 变更 |
内容 |
attempt_items 新列 |
excel_sub_id 指向本次提交的作答明细(普通题为 NULL) |
subs 字段 |
小题结果(计分/展示口径);detail 仅排查用 |
| 重传覆盖 |
UNIQUE(session_id, user_id, qn_id) 约束保证同一场同一考生同一题只保留最后一次提交 |
| 结算转存 |
settle_live 把实时留痕 excel_live_sub 转存到场次留痕 excel_submission,并 UPDATE attempt_items.excel_sub_id |
| 本轮清空 |
实时场结束后 DELETE FROM excel_live_sub WHERE room_code=?(否则同一房间下一轮再答同一题时 score_added 仍是上一轮的值) |
4. 影响 & 风险评估
⚠️ Breaking Changes(破坏性变更)
| 变更 |
影响面 |
迁移建议 |
| 数据库 Schema 新增 4 列 |
所有环境 |
init_db() 幂等 ALTER 自动兼容,旧库无感升级 |
excel_submission/excel_live_sub 新表 |
所有环境 |
自动建表,无需手动 |
| 索引补齐 6 个 |
所有环境 |
自动 CREATE INDEX IF NOT EXISTS |
| Python 3.8 兼容性 |
全部 Python 代码 |
已统一改为 Optional[int] / List[Dict] |
路径 /api/portal 删除 |
仅学生端前端 |
前端早已不调用 |
路径 /api/admin/dist 等删除 |
教师端 |
已被新接口替代 |
题型 excel_wps 进入 TYPES |
题库编辑 |
UI 已支持 |
⚠️ 风险点
| 风险 |
触发条件 |
建议测试 |
| WAL + 线程局部连接 |
打包到只读介质(精简 Windows) |
启动日志观察 PRAGMA 失败是否回退 |
| GZip 与 CachedStaticFiles 双重压缩 |
极端中间件链路 |
浏览器 F12确认 Content-Encoding: gzip 仅出现一次 |
| Excel 引擎 2.6MB 懒加载 |
弱网学生首次答题 |
实测首次判分耗时,预热降级路径 |
| 自动升级 MD5 校验失败 |
CDN/图床改名 |
失败时留 update_fail.txt 不影响旧 exe 运行 |
| bat 替换死锁 |
旧进程未释放 exe |
APPLY_WAIT_TRIES=120 秒重试 + 超时落盘 |
| 题库合并 id 重映射 |
他校题 id 与本校冲突 |
默认 keep_all 策略;预览后再执行 |
报告 school_scoped |
他校管理员误看本校成绩 |
require_staff() + school_id 比对已加 |
🧪 测试建议
| 场景 |
验证点 |
| Excel 操作题提交 |
整卷+实时双路径;上传 .xlsx / .xls;多次重传;非 Excel 类型拒绝 |
| 计分口径 |
subs 全对→满分;任一错→0;detail 仅排查不展示 |
| SQLite 并发 |
8 教师机同时刷报告页,确认无 database is locked |
| 静态资源缓存 |
改文件重启后 URL 变;GZip 命中 ≥1KB 文本 |
| 自动升级全链路 |
故意改 MD5 看失败重试;中途断网看进度;bat 替换成功后旧 exe 自删 |
| 题库合并 |
① 三个源库合并,id 重映射;② 同主键字段差异;③ 源库有 Excel 题3 字段时目标自动加列 |
| 账号解锁 |
连续输错密码被锁 → 教师解锁;幂等;权限边界(教师不能解校管理员) |
| Python 3.8 打包 |
python exepack/build.py --target win7 产出在 win7/;UCRT 剔除 |
| 抽屉响应式 |
三档实测(桌面/平板/移动);拖动调节宽度后内部字号跟随 |
✅ 已通过的回归
python -m py_compile main.py 通过
_e2e_smoke.py 51/51 通过
- 浏览器三档实测抽屉缩放(桌面/平板/移动)通过
5. 总结
本次变更是一次全栈级别的版本升级,核心价值:
| 价值 |
体现 |
| 判分能力扩展 |
新增 Excel/WPS 操作题(浏览器内 EG 引擎),突破传统客观题边界 |
| 计分精细化 |
从「按步骤」到「按合并小题」,贴近实考判分习惯 |
| 运维自动化 |
自动升级、题库合并 GUI、WAL checkpoint,机房老师几乎无感升级 |
| 性能翻倍 |
SQLite 并发解锁、静态资源降 70%传输量、WebSocket 推送替代高频轮询 |
| 权限精细化 |
报告「查看」放开为校域,「写操作」仍房间级;账号解锁不再依赖重置密码 |
建议重点回归: Excel 操作题提交链路 + 自动升级全链路 + SQLite 并发 + 题库合并 + 抽屉响应式。