离线 OJ 系统 v2.0
面向 Windows 10 / 11 的本地代码评测客户端。在本机编译并运行 C / C++ / Python / Java
代码,按测试点判定结果,题库与提交记录完全保存在本地,不需要联网。
判题支持两种数据通道与两种判定方式:标准输入输出 / 题目指定的文件,
精确比对 / 自定义校验器(特殊判题)。四个选项都按题配置,都不配就是最传统的行为
(详见「判题方式」)。
本版本是对原单文件 Tkinter 程序(legacy/OJ.py,2455 行)的一次完整重构:
改为 PySide6 界面 + 分层架构 + Windows 平台规范 + 可打包分发。
快速开始
pip 安装(推荐):
pip install offline-oj
# 命令行评测(无 GUI):
python -m offline_oj.cli --help
# 图形界面:
offline-oj
内核(评测/导出/雷同检测)与协议栈(加密/局域网会话)可作为库导入:
offline_oj.core.*、offline_oj.net.crypto(零依赖 ChaCha20/X25519/SM4 套件)。
源码运行:
# 1. 安装依赖
python -m venv .venv
.venv\Scripts\python.exe -m pip install -r requirements.txt
# 2. 运行
.venv\Scripts\python.exe -m offline_oj
首次启动后:
- 编译器配置 → 点「自动检测」→ 点「验证可用性」→ 保存;
- 存题模块 → 新建题目,填描述与测试点;
- 写题模块 → 写代码,
Ctrl+Enter提交评测。
运行环境:为什么是 Windows 10 1809+ / 11,不是 7 或 8.1
7 和 8.1 都跑不了,而且不是本项目的选择 —— 是 GUI 栈的硬下限。
| 组件 | 对 Win7 | 对 Win8.1 | 依据 |
|---|---|---|---|
| Python 3.13 | ✗ 需降到 3.8 | ✓ | PEP 11:只在微软仍提供扩展支持的系统上支持。1 |
| Qt 6 / PySide6 | ✗ | ✗ | Qt 6 明确不再支持 Windows 7 与 8.x。2 |
也就是说 8.1 是被 Qt 卡住的(Python 那边没问题),7 是被两边同时卡住的。 Qt 最后一次支持 Win7 是 5.15 LTS、支持 8.1 是 5.12 LTS —— 也就是说要让 8.1 上跑, 得把整个界面层从 PySide6 换回 PySide2 / Qt 5.15;要 7 还得连 Python 一起降到 3.8。
本次没有这么做,因为代价和收益不成比例:界面层要过一次 PySide2 的 API 差异
(枚举、信号、QAction 的归属都变了),Python 停在 3.8 意味着三年前的类型语法与库版本,
而 7 / 8.1 早已不在微软的扩展支持范围内(没有安全更新)。如果确实有硬件只能跑 8.1,
那属于要单独立项的事,不是改个 MinVersion 就能过的。
安装包与清单都以 10 为准:packaging/installer.iss 是 MinVersion=10.0(装都装不上),
packaging/app.manifest 的 supportedOS 只声明 Windows 10 / 11。
Python 函数 API
不想碰界面、想在脚本里用内核的话:
from offline_oj import api
# 评测:喂代码 + 测试点,拿普通 dict(语言自动探测、编译器自动寻找)
r = api.judge('a,b=map(int,input().split());print(a+b)',
[{'input': '1 2
', 'output': '3
'}])
print(r['verdict'], r['passed'], '/', r['total'])
# 雷同检测:喂提交记录 dict 列表
report = api.detect_similarity(rows)
# 成绩导出:docx / xlsx / txt / csv
api.export_report('成绩单.docx', data, fmt='docx')
加密协议栈(零依赖 X25519 / SM4-GCM / ChaCha20-Poly1305)从
offline_oj.net.crypto 导入,SUPPORTED_SUITES 可查支持的套件。
构建发布产物
# 只打绿色版(dist\OfflineOJ\,可直接运行)
pwsh packaging\build.ps1 -Clean
# 绿色版 + 便携版 ZIP(dist\portable\OfflineOJ-<版本>-win64-portable.zip,解压即用)
# 这条不需要 Inno Setup,适合"装不了/不想装 Inno Setup"的场景
pwsh packaging\build.ps1 -Clean -Portable
# 绿色版 + 安装程序(dist\installer\*.exe,需先装 Inno Setup 6)
pwsh packaging\build.ps1 -Clean -Installer
若提示"禁止运行脚本",在当前进程内放开即可(不改系统设置):
Set-ExecutionPolicy -Scope Process Bypass -Force
目录结构
offline_oj/
├─ app.py 启动装配:DPI → 日志 → 单实例 → 主窗口 → 异常兜底
├─ paths.py 目录规约(%LOCALAPPDATA%)
├─ settings.py 设置持久化(原子写 + 损坏隔离)
├─ logging_setup.py 滚动日志 + 未捕获异常钩子
├─ context.py 依赖注入容器(AppContext)
├─ cli.py 无界面命令行入口
├─ win32/ ── Windows 平台集成层
│ ├─ dpi.py Per-Monitor V2 高 DPI
│ ├─ appid.py AppUserModelID(任务栏归组)
│ ├─ single_instance.py 命名互斥体
│ ├─ volumes.py 固定驱动器枚举(跨盘符扫描的基础)
│ ├─ msvc.py Visual Studio 定位与 cl.exe 编译环境组装
│ └─ process.py 无窗口建进程 / 杀进程树 / 驱动器类型
├─ core/ ── 评测内核(不依赖界面,可单测)
│ ├─ models.py Language / Verdict / TestCase / Problem
│ ├─ validation.py 路径校验与防目录穿越
│ ├─ compilers.py 编译器探测(PATH / 注册表 / 常见目录 / VS 布局)
│ ├─ sandbox.py 运行结果、编译器锁、时间内存监控
│ ├─ runners.py 四语言"编译 + 运行"+ 编译器家族差异(CompileProfile)
│ ├─ checker.py 自定义校验器(编译一次,逐测试点按协议裁决)
│ ├─ judge.py 评测编排(事件流)
│ ├─ security.py 危险调用静态检查
│ ├─ repository.py 题库持久化 + 提交历史
│ └─ archive.py 单题/批量导入导出
├─ net/ ── 局域网测验(不依赖界面,可单测)
│ ├─ crypto.py ChaCha20-Poly1305(RFC 8439)+ scrypt 密钥派生,零依赖
│ ├─ protocol.py 加密帧编解码(长度前缀 + 方向位 + 单调序号,防重放与反射)
│ ├─ session.py 房间号、设备八位 ID、测验会话、本场策略、提交与排名的数据模型
│ ├─ identity.py 设备标识的生成与持久化(device.json)
│ ├─ server.py 主机端:房号握手、下发题面、判题队列、放榜策略
│ └─ client.py 客户端:连接、拉题面、提交、收结果
└─ ui/ ── PySide6 界面层
├─ theme.py 浅色/深色主题、字体回退链、QSS
├─ widgets.py 代码编辑器(高亮/补全)、路径选择器、Markdown 预览
├─ workers.py QThread 工作线程
├─ single_instance.py 互斥体 + 管道唤出已有窗口
├─ main_window.py 菜单 / 选项卡 / 状态栏
└─ panels/ 六个功能面板(含局域网测验的主机端与学生端)
packaging/ 图标生成、spec、manifest、版本资源、Inno Setup 脚本
tools/ 开发辅助脚本(真实判题验收、判题方式验收、MSVC 验收、截图、稳定性复跑)
tests/ core 层单元测试 + 补全单测 + 打包一致性单测 + conftest + 离屏界面冒烟测试
legacy/OJ.py 重构前的单文件实现(仅作对照保留)
与原版本的主要差异
| 方面 | 原实现 | 现在 |
|---|---|---|
| 代码组织 | 单文件 2455 行 | 分层包,core 与 UI 完全解耦 |
| 界面 | Tkinter,默认主题 | PySide6,浅色/深色主题,代码高亮、行号、代码提示 |
| 用户数据 | 写在 exe 同级目录 | %LOCALAPPDATA%\OfflineOJ(Program Files 下也能正常工作) |
| 保存 | 直接 open(...,'w') |
临时文件 + 原子替换 + .bak 备份 |
| 多开 | 可无限多开,互相覆盖数据 | 单实例互斥体 + 唤出已有窗口 |
| 高 DPI | 无感知,125% 缩放发虚 | Per-Monitor V2 + 清单声明 |
| 任务栏 | 与所有 Python 程序挤在一起 | 显式 AppUserModelID |
| 线程 | threading + root.after 混用 |
QThread + 信号槽,UI 不会被阻塞 |
| 判题输出 | 直接往控件里 insert 字符串 | 事件流,可单测、可做进度条 |
| 数据通道 | 只有标准输入输出 | 可按题改成文件输入输出(题目指定文件名) |
| 判定方式 | 只有"规范化后逐行相等" | 可按题挂自定义校验器,用于答案不唯一 / 浮点容差 / 顺序无关的题目 |
| 题库共享 | 无 | 局域网加密共享:开启房间答题、双模式放榜、设备八位 ID 身份、榜单带耗时内存与第几次提交 |
| 测验留档 | 关房即清,考完什么都没留下 | 关房自动留档 + 回看:整场写进独立目录(题目快照 / 逐份提交 / 榜单 / 校验和),可回看、打开目录、删除;可设"只存成绩不存代码" |
| 日志 | print(GUI 模式下丢失) |
滚动日志文件 + 崩溃兜底对话框 |
| 安全 | 无路径校验,ZIP 可目录穿越 | safe_join 拒绝逃逸,导入包不可信 |
| 编译器探测 | 写死 C:\Program Files\Dev-Cpp\... 等几个目录 |
PATH + 注册表 + 遍历全部固定盘符 + Visual Studio 布局(vswhere + winreg),GCC 优先于 MSVC |
| 编译标准 | 写死一个 -std= |
按编译器能力选择并缓存(旧 GCC 不支持 c++17、MSVC 不认 /std: 时静默降级,各有各的处理) |
| 分发 | 一个裸 .py |
onedir exe(图标/版本资源/清单)+ 安装程序 |
代码编辑器
写题模块的编辑器是自建的(offline_oj/ui/widgets.py),不依赖任何第三方编辑控件:
| 能力 | 说明 |
|---|---|
| 语法着色 | 注释、字符串、数字、关键字、预处理指令;/* */ 跨行状态;浅色/深色各一套配色 |
| 前缀补全 | 输入 2 个及以上字符自动弹出;Ctrl+Space / Alt+/ 手动唤出;↑↓ 选择、Enter/Tab 采纳、Esc 关闭 |
| 候选来源 | ① 按语言预置的关键字 / 类型 / 标准库 API;② 代码片段;③ 当前文件里出现过的标识符 |
| 代码片段 | main、fori、forj、foreach、readarray、fastread、scanner … 展开成多行模板,光标落在待填写处 |
| 编辑体验 | 行号、当前行高亮、Tab 转 4 空格、Shift+Tab 反缩进、回车自动缩进 |
关于补全的定位:这是前缀式词补全,不是 IDE 的语义补全。没有编译器前端就拿不到类型信息,
所以 obj. 之后列成员这类功能做不到 —— 换来的是零依赖、完全离线、开销可忽略。
补全的候选列表用自绘代理(CompletionDelegate)渲染,因为 QCompleter 的弹出列表是单列
QListView(一旦设置 modelColumn,Qt 就会隐藏其余列),类型标签放不进第二列,只能自己画。
判题方式(文件输入输出与特殊判题)
这两项都在「存题模块 → 判题方式」里按题配置,默认值就是最传统的行为。
文件输入输出
有些题目要求程序从指定文件读入、把答案写进指定文件,而不是走标准输入输出。
把「输入输出方式」改成文件输入输出,再填两个文件名即可。
- 文件放在该测试点自己的工作目录里,程序用相对名打开就能读到;
- 每个测试点开跑前会先删掉上一轮留下的输出文件 —— 否则程序这一轮什么都没写,
却会读到上一轮的答案,被误判成通过; - 程序没生成输出文件一律判 WA。这条必须写死:如果只是让标准输出保持为空,
而该测试点的期望输出恰好也是空的,两边空字符串相等就会判成 AC ——
把一个明显的错误判成通过,是最不能接受的一类误判。所以这种情况还会额外给出提示:
"程序把内容打印到了标准输出,但本题的答案要写进文件"; - 文件模式下不喂标准输入。真正读文件的程序不受影响,而误用
scanf/input()的程序
会立刻拿到 EOF,错得更早,也更容易看出原因。
自定义校验器(特殊判题)
答案不唯一、允许浮点误差、与顺序或空白无关的题目,逐字符比对必然误判。
勾上「使用自定义校验器」并写一段校验器源码,判定就交给它。
协议沿用在线评测界的通行做法,三个文件路径按顺序走命令行参数:
校验器 <输入文件> <选手输出文件> <标准答案文件>
退出码 0 → 答案正确 → AC
退出码 1 → 答案错误 → WA
退出码 2 → 格式错误 → PE
其它 / 超时 / 编译失败 → IE(评测机内部错误)
三条实现约定:
- 校验器自己出问题,绝不算到选手头上。 编译不过、跑超时、异常退出都记成 IE,
compile_ok仍为真;结果面板会写明"自定义校验器不可用"并提示去存题模块检查。 - 每题只编译一次,所有测试点复用同一份产物。
- 校验器源码存在题目 JSON 里,随导出 / 导入包一起走,不需要额外分发文件;
没配校验器的题目存出来的 JSON 与老版本逐字节一致。
用"浮点容差"这个最典型的场景感受一下(python tools\e2e_judge_config.py 会真跑一遍,
题目是"输入 n 输出 sqrt(n),允许 1e-5 误差"):
| 选手输出 | 精确比对 | 挂了容差校验器 |
|---|---|---|
1.414214 |
AC | AC |
1.4142135624(数值对、位数不同) |
WA | AC |
1.4242135624(差了 1e-2) |
WA | WA |
sqrt = 1.414214(数值对、混了说明文字) |
WA | PE |
校验器语言只提供 C++ 与 Python:校验器主要在处理字符串与空白,这两种写起来最省事,
在线评测里的校验器也几乎只有这两种写法。C 与 Java 的配置即使写进 JSON 也会被忽略。
写校验器最容易踩的一点:三个参数都是相对文件名,校验器以"当前测试点目录"为工作目录
运行。所以ifstream(argv[1])/open(sys.argv[1])直接就能打开;用相对名也顺手避开了
"中文用户名 → C 运行库把命令行转成 ANSI 代码页 → 找不到文件"这条老路。
编译优化(-O2)
C / C++ 提交默认按 -O2(MSVC 为 /O2)编译,可以关掉按 -O0 编 ——
同一份代码两边耗时能差好几倍,拿它对照一次就能看清"是算法不够快,还是只差个优化"。
开关放在三个地方,各自解决一件事:
| 位置 | 作用范围 |
|---|---|
| 「高级设置 → 判题选项」 | 全局默认值 |
| 「写题模块」编辑器上方的 O2 优化 | 本次提交;新开面板时取全局默认 |
| 「局域网测验 → 房间设置」的判题时开启 O2 优化 | 整场测验,开启房间后锁定 |
最后一条是主机端的决定:判题在主机的机器上做,同一场测验里所有人的编译参数必须一致, 否则榜单上的耗时不具可比性 —— 所以它跟着房间走,学生端既看不到也改不了。
「测试运行」用的是同一个开关值。这不是顺手,是必须的:自测按 -O0、判题按 -O2, 那么"自测挺稳"到了判题就可能变成 TLE(或者反过来白等一场)。
局域网测验(共享题库)
老师在一台机器上开启房间,学生用「地址 + 端口 + 房间号」进场做题。判题在主机的机器上做, 学生机不需要装任何编译器 —— 这条同时决定了测试数据永远不离开主机。
两种模式,外加一个正交的放榜开关
模式只决定榜单什么时候公开,判题、计分、排名规则完全共用同一套代码,
不是两份实现,只有一个 leaderboard_visible() 分支。
| 模式 | 榜单 | 适用 |
|---|---|---|
| 练习模式 | 全程实时更新 | 课堂练习、随堂测 |
| 考试模式 | 结束后统一放榜(含 30 秒收卷宽限) | 正式测验 |
再往上一层还有一个**「公开榜单」开关,它与模式正交**(一个是"什么时候放"、 一个是"放不放")。关掉之后任何模式下都不放榜,包括练习模式的实时榜 —— 这样"只测验、不打榜"就是「考试模式 + 关掉公开榜单」这一个组合,不必为它再造第三种模式。
封榜期间学生看不到别人的成绩,但能看到自己那一行 —— 关榜不该连自己的排名都没有。
本场限制(语言与功能开关)
开房时可以收窄这一场允许的东西,都在「房间设置」下方的本场限制里:
| 开关 | 关掉之后 |
|---|---|
| 允许的语言(C++ / C / Python / Java 四个勾) | 该语言不再出现在学生端的语言下拉框里 |
| 允许学生把代码存成文件 | 学生端的「另存为…」被禁用,代码带不出考场 |
| 提交判定后锁定编辑器 | 每份代码判定回来即锁定编辑器(一题只交一次的场合有用) |
服务端会再校验一次语言。 界面上的置灰只是"别让人白点一次"——学生端是可以被改的
(改 exe、改内存、直接发包都行),所以 server.py 收到提交时会拿本场策略再判一遍,
不在允许列表里就回 ERROR。这是安全边界,不是体验优化;test_net_lan.py 里有一条
绕开界面直接发包的用例盯着它。
策略里没有"自测开关":学生端面板本来就没有「测试运行」入口 —— 判题一律在主机做、 学生机不装编译器。给一个没有可关之物的开关,老师关了发现什么都没变,比不给更糟。
改测验名称
房间开着的时候也能改名字(「房间设置」的测验名称 + 改名按钮),改完立刻广播给
在线学生。不会把学生踢下线:房间号与派生密钥都跟标题无关,改名只是换一个显示标签。
这一点在 ExamSession.rename() 的注释里写死了原因 —— 哪一天有人"顺手"把标题拼进
room_secret,改一次名字就会让全教室同时掉线,而且现象是"改名之后连不上了",
跟标题看着毫不相干,极难查。
房间号即凭据
老师报一个 6 位数字房间号,学生输入房间号加自定义用户名就能进,不需要逐人发牌。
房间号本身绝不明文上线:握手时发出去的只有它的单向索引
(sha256(房间号:口令)[:16]),所以抓包拿不到钥匙。
但它不是强凭据,这一点必须说清楚:
| 搜索空间 | 10⁶ |
| 单次口令尝试 | 约 350 ms(scrypt) |
| 在线爆破 | 被"每 IP 每分钟 12 次握手"挡住 |
| 离线爆破 | 抓到一个握手包后,多核并行下是"小时"量级 |
它的定位是"分房间 + 挡住隔壁教室的人"。要抗离线爆破,请由老师另设房间口令 (可选,默认留空,区分大小写)—— 口令才是加在房间号上面的真实熵。
考场身份:账号进场与选手名单(与房间号进场二选一)
建场时选进场方式,开房后不再改:
| 房间号进场 | 账号进场 | |
|---|---|---|
| 学生报什么 | 房间号(+可选全场口令)+ 自起用户名 | 名单上的账号 + 个人口令 |
| 名字哪来 | 学生自己起(允许重复) | 老师名单说了算,学生自报不采信 |
| 适合 | 随堂练习、临时凑场 | 正式考试、要把"谁在用哪台机器"绑进档案 |
- 名单直接在 APP 里建:主机端「进场方式」选「账号进场」→「名单…」打开编辑器,
逐行敲账号 / 姓名 / 口令 / 座位;「生成口令」只给没口令的人补(6 位数字、首位非 0,
照着念不会把前导零念丢)。名单存
%LOCALAPPDATA%\OfflineOJ\rosters\<名称>.json, 上次用过的自动带回。 - 也认 Excel 导出的 CSV:表头认「学号 / 账号 / 姓名 / 密码」等常见写法,自动认 BOM 与 GBK; 没收进来的行逐条说明原因,绝不静默丢弃。可导出一份带口令的「打印条」考前提早发。
- 账号进场没有用户名可填 —— 界面上连输入框都不出现。开房时名单上还没口令的人会被 自动补发并提示,否则"只要账号就能进场"等于没有密码。
- 明文口令是刻意取舍:老师要打印、要念给学生。这份名单请当试卷保管。
- 一人一机、一机一人:同一账号换设备、同一设备换账号都进不来;断线重连不受影响。
- 抓包同样拿不到凭据:握手上线的仍是单向索引,账号与口令都不明文过网。
离场锁屏(防窥屏)
学生要离开座位(上厕所、交草稿纸),点「离开一下」把整个答题区盖住 —— 题面、代码、榜单全都看不见,邻座凑过来也读不到内容。回来在盖板上输 个人口令或考场口令,才能继续写继续看。
- 盖板挡得住窥屏,挡不住全局快捷键:锁定期间提交等入口在代码里再拦一道。
- 主机「名单」页多一列「锁」,谁离开中一目了然;「让 TA 离开一下 / 让 TA 继续 / 全体盖上 / 全体继续」都在老师手里,老师解锁不需要口令。
- 解锁校验在主机做(常数时间比较),错满 5 次锁死,只能由老师放行 —— 防有人拿别人的座位试密码。
- 学生自己只能"盖上"不能"解开":解锁要主机验过口令才生效;断线重连只会把锁 捡回来,绝不会因为旧快照替你把锁揭开。
身份:设备八位 ID
身份是设备的八位 ID,用户名只是标签。
- 字符集是 32 个字符:
0123456789ABCDEFGHJKMNPQRSTVWXYZ—— 沿用 Crockford Base32 的思路, 排除容易被误读成0/1的I、L、O,再排除U。8 位 = 40 bit,同一场里撞号可以忽略; - 保留
0和1是有意的:只有 0/1 在字符集里,"用户把O打成0" 才能被无歧义地 纠正回来(O→0、I/L→1、U→V)。反过来做就会把一个合法 ID 改坏; - 首次运行生成,存在
%LOCALAPPDATA%\OfflineOJ\device.json,跨场次固定不变: 学生认得出自己那一行,老师照 ID 点名也稳定。这个文件与settings.json分开, 「恢复默认设置」不会换掉学生的身份; - 用户名允许重复。两个"张三"靠设备 ID 区分 —— 强行要求改名,成本落在学生身上,
而"班里两个张三"是常态。同一个设备 ID 再次连接会顶掉旧连接(记为一次
replaced, 老师界面上看得到),断线重连因此不需要额外操作。
榜单字段
总分榜:名次 · 用户名 · 设备 ID · 总分 · 已解决 · 提交次数 · 总耗时 · 总内存 · 最后提交 单题榜:名次 · 用户名 · 设备 ID · 得分 · 通过 · 结论 · 第几次 · 耗时 · 内存 · 提交时间
得分一律写成 37/50 这个形式(NOI 成绩单的写法)—— 满分现在跟着测试点分值走,
光写一个 37 读不出它离满分还有多远。
给分规则对标 NOI:
- 每个测试点自带分值(默认 10 分),得分是通过的测试点分值之和, 每题满分是各点分值之和。三个点 50/30/20 的题,只过第一个是 50 分, 不是"过了 1/3 → 33 分"。想让 5 个点的题满分 100,就把每个点设成 20。
- 同一人同一题取最高分的一次计入总分。
- 名次同分并列,下一名跳过(1, 1, 3)。同分之间再按耗时 → 内存 → 提交时间排队, 但那只决定显示顺序,不改变名次 —— 最后一级用设备 ID 保证结果稳定 (用户名可重复,拿它收尾会让排序结果不稳定)。
外接自己的判题器时,只要填了 verdict 与通过数就能用:没有分值信息的那条路会按 通过比例折算,分母取该题的分值之和,所以"全对 = 该题满分"在两条路径下都成立。
主机端可以看到每个人交的代码
主机端多一页「提交与代码」:逐份列出谁、哪题、第几次、什么结论,点一行就在下面看到 那一份的源码(按提交时的语言着色,带行号);判定说明单独一页签 —— 编译错误的原文 就在那里,是漏了分号还是类型不对,一眼看得出来。代码可以一键复制,讲评时直接粘进稿子。
学生端只有榜单,没有这一页。 这不是靠界面藏起来的:源码在 Submission.to_dict()
里就被排除了,出站载荷里从来没有 code 这个字段 —— 学生机上没有别人的源码可解,
抓包也抓不到。主机端能看,是因为 ExamServer 在自己内存里持有完整的提交记录;
房间一关,这些代码随之丢弃,下一场是另一批学生。
这一页唯一的难点是刷新不能打断阅读:提交列表每几秒就会因为别人的提交而重排, 而重填表格会把选中清掉、把滚动位置打回开头。所以选中跟着提交编号走而不是行号, 内容没变时连重填都不做 —— 老师正看着第 80 行,视图不会自己跳回第 1 行。
时限、收卷与重复提交
- 测验时长可设为 0~600 分钟,设为 0 表示不限时(练习模式常用);
- 到点强制收卷(开关):开启后到点主机广播收卷指令,学生端把编辑器里当前的代码 自动交一次并锁定编辑器,这条提交在榜上标为"自动",老师事后分得清哪几份是系统替交的。 关闭时只提醒、不替学生交 —— 收不收是老师的决定,程序不越权;
- 提前收卷:不等时间到就让全体立刻交卷(现场临时有事);
- 提前结束测验:把截止时刻改到现在,收卷 → 停止接受提交 → 宽限走完后统一放榜;
- 允许重复提交(开关):关闭后同一题只收第一份;开启时榜上标出第几次提交。 截止后仍有 30 秒收卷宽限,网络晚到 0.2 秒的卷子不会被判成迟到。
怎么用
- 老师端:切到「局域网测验 → 主机端」,选模式、勾题目、设时长与收卷策略,点「开启房间」;
- 把大字的「房间号 + 地址 + 端口」报给学生(点「复制房间信息」可以直接粘到班群里);
- 学生端:切到「学生端 · 加入房间」,填地址、端口、房间号、自己的名字,点「加入房间」;
- 老师端可以随时切回「主机端」看总分榜 / 单题榜 / 提交与代码 / 名单 / 现场记录 / 历史场次。
同一台机器可以随时切换角色:老师在开考前用学生端自己试一次,不占第二台机器。
主机端左列会跟着房间状态换一副面孔
开房前后,老师要做的事完全不同,所以左列整块换页,而不是把用不上的设置灰在屏幕上:
| 左边这一列显示什么 | |
|---|---|
| 没开房(准备视图) | 房间设置 / 本场限制 / 本场题目三组,外加「开启房间」 |
| 开了房(监考视图) | 大字房间号 + 只读摘要(模式、进场方式、时长、题数、本场限制)+ 改名 + 开始考试 / 提前收卷 / 提前结束 / 复制房间信息 / 关闭房间 |
只读摘要用的是当前生效的值(服务端实际定下来的那份),不是输入框里可能被改过的草稿。
开房后唯一还能改的是名称(它只是个显示标签,改它不踢人,房间号与连接都不受影响); 其他设置一件都动不了 —— 动了会踢人、会让榜单上的数字不可比。想改就关房重开。
防火墙:首次开启房间时 Windows 会弹出"是否允许此应用通过防火墙",选允许专用网络。 学生连不上时,先确认两台机器在同一局域网、老师那台没被防火墙挡住、端口填的是同一个。
测验结束后的存档(「历史场次」页)
关房即自动留档。 一场测验结束后,整场会被写进一个独立目录:
%LOCALAPPDATA%\OfflineOJ\exams\20260919-173045-期末模拟\
session.json 标题 / 模式 / 策略 / 时间 / 参与者 / 题目快照
submissions.jsonl 全部提交(一行一份)
leaderboard.json 收卷时的榜单快照
archive.json 版本号 + 各文件校验和
主机端「历史场次」页按时间倒序列出所有档案,可以打开回看(复用「提交与代码」页, 页顶会写明这是哪一场)、打开所在目录、删除。
几条刻意的设计:
- 题目存的是快照,不是 ID。 题目日后被改动或删掉,档案还得说得出当时学生看到的是什么;
- 目录名第二段是标题,不是房间号。 房间号是凭据,没有理由撒进文件名;
- 写盘先写
.partial再改名。 中途崩了不会留下半个档案被当成正常的;列表页跳过.partial; - 留档绝不拦下关房。 磁盘满、文件被占用都只记一条日志 —— 下课关房这个动作不能被存档拖住;
- 只存成绩不存代码:设置里可关。开启时抠源码做两道闸(生成时抠一遍、落盘前再抠一遍), 验的是"哪一天有人忘了";关掉之后档案里一个源码字节都不留,同时导出与雷同检测也随之不可用;
- 读档要耐坏:未知字段忽略、半行坏行跳过并计数、校验和对不上照样能看但要标出来。
session.json缺失才算真读不出来; - 删除前先确认,并且校验
archive.json存在才动手,免得误删别人。
隐私提示:默认留档包含全部学生源码。共用一台教师机的场景下, 建议在「高级设置」里关掉"连源码一起留档"。
雷同检测(考中、考后都能跑)
入口在主机端 「提交与代码」页顶部的「雷同检测…」。这一页的数据源同时覆盖 "进行中的这一场"和"打开的档案",所以刚考完想查一下、和翻出去年那场复查, 用的是同一个按钮。
结果分两层报,可信度差着量级,界面上也分开写:
| 层 | 判据 | 能当证据吗 |
|---|---|---|
| 完全重复 | 去掉注释、行首尾空白、空行之后逐字节相同 | 可以:这是确定性的 |
| 高度相似 | 词法归一化 + k-gram 指纹 + Jaccard(改了名字也躲不掉) | 不可以:只是线索,必须人工复核 |
选中结果里的任意一行,下方立刻给出并排 diff(注释行不参与比对, 缩进保留 —— 去掉缩进两段代码摆在一起就读不懂了)。
几条口径,都写在报告里:
- 只在同一道题内比较:两道题都写
for循环不算相似; - 每人每题只取一次(最高分,同分取更晚的那次),报告写明合并了多少份;
- 自动扣除本题的公共模板(
#include <bits/stdc++.h>、快读那段……)。 不扣的话第一版报告会全是 90%+; - 样本太少时不扣(不足 3 份):两份提交里"两份都有"的公开度是 100%, 照比例扣会把两人真正共享的那段一起扣掉 —— 抄的人被判 0 是最坏的一种错。 这时报告会明说"这几道题没能扣除公共模板,请只当作线索";
- 界面里**不出现"抄袭"**字样,顶部常驻一条提示:相似度是线索不是结论, 同一道题的正确解法本来就容易写得像。
隐私前提:如果留档时关掉了"连源码一起留档",档案里没有代码,这一项就无从比。 进行中的这一场不受影响(源码在主机内存里)。
性能:400 份提交 / 1.3 万行约 2 秒;200 份"全班互抄"0.4 秒。 纯 Python(倒排索引只比至少共享一个指纹的那些对),没有引入任何原生模块。
命令行用法
python -m offline_oj.cli list
python -m offline_oj.cli detect --save
python -m offline_oj.cli judge P0001 solution.cpp --language cpp
python -m offline_oj.cli export .\backup.zip
python -m offline_oj.cli import .\backup.zip --strategy rename
python -m offline_oj.cli doctor
退出码:0 通过 · 2 未通过 · 1 出错。
判题方式跟着题目走:judge 一道配有文件输入输出或校验器的题目时,命令行的行为与界面一致,
缺失的工具链也会被自动检测(包括校验器要用的那一种)。
数据位置
| 内容 | 路径 |
|---|---|
| 题库 | %LOCALAPPDATA%\OfflineOJ\problems.json |
| 设置 | %LOCALAPPDATA%\OfflineOJ\settings.json |
| 设备标识 | %LOCALAPPDATA%\OfflineOJ\device.json |
| 题目图片 | %LOCALAPPDATA%\OfflineOJ\problem_resources\ |
| 日志 | %LOCALAPPDATA%\OfflineOJ\logs\app.log |
| 提交历史 | %LOCALAPPDATA%\OfflineOJ\submissions\history.jsonl(本机单人练习,上限 500 条) |
| 测验档案 | %LOCALAPPDATA%\OfflineOJ\exams\<日期>-<标题>\(关房时自动留档,见下) |
| 编译工作区 | %LOCALAPPDATA%\OfflineOJ\workspace\(启动时自动清理) |
device.json 是局域网测验用的设备标识(八位 ID),与 settings.json 刻意分开:
「恢复默认设置」不该把学生的身份换掉。
设置环境变量 OFFLINE_OJ_HOME 可覆盖数据根目录,用于测试隔离或便携部署。
测试
# 全部单元测试(离屏运行,无需桌面环境;项数以 --collect-only 为准,不写死在文档里)
python -m pytest tests -q
# 或
python -m unittest discover -s tests -v
# 上面这条会连"界面重复显示"的守卫一起跑:同一条命令只有一处入口(没有第二
# 条全局工具栏)、同一个字符串不在一屏上出现两次
python -m pytest tests\test_ui_dedup.py -q
# 只跑代码编辑器 / 补全相关(tests\test_completion.py)
python -m pytest tests\test_completion.py -q
# 代码编辑器的信号契约:换主题不该被当成"改过内容",换主题也不该漏高亮器
python -m pytest tests\test_code_editor.py -q
# 界面主题与打磨:徽标对比度、语义角色、内联主题色、间距令牌、行尾
python -m pytest tests\test_ui_theme.py -q
# 快捷方式写入器(手写 MS-SHLLINK,绕开本机对 COM 的限制)
python -m pytest tests\test_shortcut.py -q
# O2 优化开关:勾选值是否真的走到编译参数、提交与自测是否用同一套参数
python -m pytest tests\test_o2_option.py -q
# 命令面一致性:同一个键序列不许绑两次
python -m pytest tests\test_ui_shortcuts.py -q
# 局域网测验:房间号握手、双模式放榜、收卷、设备 ID 身份、身份持久化
python -m pytest tests\test_net_session.py tests\test_net_lan.py `
tests\test_net_protocol.py tests\test_net_crypto.py `
tests\test_net_identity.py -q
# 测试点分值:写题面板的数字框 → 题目满分 → 落盘读回(含"默认 10 分不写盘")
python -m pytest tests\test_point_values.py -q
# 主机端房间设置:改名 / 不放榜 / 本场限制(语言与功能开关),界面接线 + 学生端落地
python -m pytest tests\test_exam_settings.py -q
# 考场身份与管控:名单数据层与 CSV(test_roster)+ 进场方式 / 名单 / 离场锁屏的界面全链
python -m pytest tests\test_roster.py tests\test_exam_access.py -q
# 主机端「提交与代码」页:列表与源码联动、刷新不打断阅读、关房间即丢弃
python -m pytest tests\test_exam_code_view.py -q
# 测验档案(写/读/列/删):目录名净化、校验和、坏行跳过、隐私两闸、删除防误删
python -m pytest tests\test_records.py -q
# 雷同检测:词法(注释/字符串/缩进归一)、完全重复判定、骨架扣除与小样本取舍、
# 指纹与相似度、报告口径;界面侧(结果对话框 + 「提交与代码」页的入口)
python -m pytest tests\test_similarity.py tests\test_similarity_ui.py -q
# 界面冒烟测试:离屏构建主窗口、遍历所有面板,含局域网测验与考场管控的端到端两趟
# (开房 → 真客户端进场 → 提交 → 判定 → 榜单 → 收卷;APP 内建名单 → 账号进场 →
# 离场锁屏 → 老师解锁 → 错口令被拒 → 留档),可选截图
python tests\smoke_gui.py --shot build\screens
python tests\smoke_gui.py --shot build\screens-dark --theme dark
# 局域网测验真实 TCP 验收:按老师上课的顺序把整条链路跑一遍,每步都有证据
# 判题默认走替身(无编译器也能跑),加 --real 则让真实工具链编译执行一遍
python tools\lan_e2e.py
python tools\lan_e2e.py --real
# 编辑器观感截图(需要真实桌面,会短暂弹出窗口)
python tools\shot_editor.py
# 端到端真实判题(自动检测工具链后跑 Python / Java / C / C++)
python tools\e2e_judge.py
# 判题方式验收:文件输入输出 + C++ 编写的校验器(现场编译),含导出导入往返
# 会验证"同一份解,精确比对判 WA、挂上校验器判 AC"这类对照关系
python tools\e2e_judge_config.py
# MSVC 端到端验收:内置「两数求和」语料走 cl.exe,与 GCC 基线逐条比对判定
python tools\msvc_e2e.py -v
# 反复跑同一组用例,暴露偶发崩溃 / 挂死
#(排查"单独跑全过、凑到一起必崩"这类问题;会区分崩溃与超时,并打印崩溃块开头)
python tools\pytest_stability.py tests --repeat 5
python tools\pytest_stability.py tests\test_completion.py::TestPopupLifetime tests\test_core.py --repeat 3
# 带看门狗地跑任何脚本:N 秒后把**所有线程的调用栈**倒出来。
# 挂死最难查的地方是"看起来像还在跑",全线程栈能直接指出谁在等谁。
python tools\watchdog_run.py --after 45 tests\smoke_gui.py
python tools\watchdog_run.py --after 45 --module pytest tests -q # 也能包 pytest(等价 -m)
python tools\watchdog_run.py --after 30 --every 20 --hard-exit 120 tools\lan_e2e.py
# 冻结版产物验收:PE 头/子系统、九帧图标、版本资源、清单、面板是否全在里面
python tools\verify_frozen.py
python tools\verify_frozen.py --launch # 追加:全新数据目录起两次,验单实例唤出
# 在桌面 / 指定目录建 .lnk 快捷方式(手写 MS-SHLLINK,不需要 COM / pywin32)
python tools\make_shortcut.py # 桌面建 OfflineOJ.lnk
python tools\make_shortcut.py --into build --name 测试
python tools\make_shortcut.py --read "%USERPROFILE%\Desktop\OfflineOJ.lnk"
# 未使用导入检查
python tools\lint_unused_imports.py
# 内联主题色检查:控件自己的 setStyleSheet 不会随主题重刷,切深色后会留在浅色
python tools\lint_inline_palette.py
测试覆盖输出比对、题库原子落盘与备份、导入导出往返、ZIP 目录穿越防护、
设置损坏隔离、编辑器补全(前缀提取、候选来源、片段展开、语言切换、弹窗列表列数)、
编辑器的信号契约(语法高亮会让 textChanged 响,所以"用户改了内容"必须看
contentsChange)、换主题不泄漏高亮器、
界面主题(徽标前景色的 WCAG 对比度、语义角色齐备、无内联主题色、间距令牌、源码行尾)、
工具链跨盘符扫描与编译标准降级、MSVC 环境组装与标准参数选择、
可选依赖的导入时机,判题配置的向后兼容与校验器协议(AC / WA / PE / IE / 超时)、
文件模式(含"上一轮残留的输出文件不能当本轮答案"),
编译优化开关的传递(勾选 → 编译参数;提交与「测试运行」用同一套参数;考试主机端一致),
界面命令面的一致性(同一个键序列不许绑两次、没有第二条全局工具栏),
CCF CSP 规约的题目英文名与文件命名(含非法字符清洗、按题目 ID 兜底、{name} 模板展开),
局域网测验的房间号归一化与单向索引、设备八位 ID 的生成/归一化/跨场次持久化、
双模式的放榜时机、重复提交计数、收卷宽限与强制收卷、
NOI 式的给分与名次(逐测试点分值之和、同分并列且下一名跳过、同分只比出显示顺序)、
源码只上行不下发(主机内存里有全套,出站载荷里一个字节都没有)、
主机端读源码页的刷新不打断(选中跟提交编号走、内容没变不重灌、关房间即丢弃),
以及真实执行评测的端到端流程。
写界面测试时的一条经验:
QPlainTextEdit.textChanged背后是QTextDocument.contentsChanged,而语法高亮重排也会触发它 —— 于是"换主题" 与"改正文"在监听者眼里完全等价。这类信号只能靠contentsChange(pos, removed, added)区分(重排格式时它根本不发)。代价很实在:存题面板被标成"有未保存的修改", 关窗口时弹出保存确认,而离屏冒烟测试里没人点得到那个模态框,整轮挂死 4 小时 33 分。
写测试时的一条经验:查询"某个导入有没有副作用"这类问题必须在
独立子进程里做。主测试进程可能已经被别的用例导入过目标模块,
直接查sys.modules会得到假阴性 ——test_sandbox_does_not_import_psutil_at_module_level
就是为此写成子进程的。
界面上这些约定是刻意的
界面改动容易越改越乱,所以下面几条当成硬规矩,tests\test_ui_shortcuts.py 与
tests\test_ui_theme.py 会替我们记着:
- 命令入口只有两层:菜单(唯一真源)+ 面板就地按钮,没有全局工具栏。 加按钮之前先看菜单里有没有同键入口 —— 有的话就是纯重复。这里踩过一次弯路: 最早是一条平铺 7 个按钮的工具栏,后来按选项卡把按钮收起(帮助页整条不显示), 看着"每页只剩两三个"就收工了 —— 但那两三个仍然和面板自己的按钮重复, 写题页一屏上能同时看到两个"提交代码"、编译器页一个叫"验证工具链"一个叫 "验证可用性"。最后整条工具栏删掉了:它不携带任何自己的信息。
- 同一屏上同一个字符串只许出现一次。 题库统计原来在状态栏和题目列表下各显示
一份(一字不差),"当前 N 个测试点"和"测试点数"是同一个数字,
PathPicker的空输入框占位符和右侧状态徽标都写着"未配置"。判断标准很简单: 两个地方同时显示同一件事时,用户要花时间确认"它们是不是一样的", 而这份确认永远没有收益。留信息量更大或位置更顺的那个。 - 同一个键序列只许绑一次。 菜单里一条窗口级
QAction、面板里再一条QShortcut, 就是同一个键绑两次,Qt 会报Ambiguous shortcut overload,按下去哪个生效看运气 —— 用户只会觉得"这个键有时候管用"。测试直接扫QAction+QShortcut查重复。 - 矮的那一栏不要硬撑高。 两栏并排时 QGroupBox 会被拉到和高的那栏齐平,
于是内容少的那栏框里空出一大片灰底,看着像"该有东西没加载出来"。
内容少的那个外面包一层纵向布局 +
addStretch,让留白落在框外。 - 换主题只许改显示,不许改数据状态,间距/边距一律用
theme.py里的令牌, 不许内联主题色(见上面的测试覆盖)。 - 界面走查要看真机截图:离屏环境没有中文字体,截图里标题正文全是方框,
只能看布局矩形 —— 拿那种图"看界面"等于没看。要读界面就
QT_QPA_PLATFORM=windows python tests\smoke_gui.py --shot build\screens-real。 截图默认是 2880×1760(DPI 缩放),想看某个控件的细节别用缩略图下结论, 先按比例裁下来 1:1 看,否则会把正常的三角箭头误判成"不可见的糊块"。
GUI 对象的销毁时机(两个真机上抓到的崩溃)
PySide6 里"Python 对象被回收"和"Qt 对象被析构"是两件事,踩过两次:
- 无父对象的弹出列表会活得比宿主久。
QCompleter自己建的候选列表默认是
没有父对象的顶层窗口,编辑器连同它持有的 completer 销毁之后,那个窗口还在桌面上,
延迟到达的绘制事件会打到已失效的委托上 —— 表现为
Windows fatal exception: access violation,崩溃点在CompletionDelegate.paint,
跟代码毫无逻辑关联。修法是显式让编辑器当父对象
(CodeCompleter.__init__里的popup.setParent(editor, Qt.Popup)),
Qt::Popup 的定位仍由 QCompleter 按全局坐标完成,与QComboBox的下拉是同一套机制。 - GC 在哪个线程跑,控件就在哪个线程被析构。 判题时每条测试数据都会新起一条
oj-monitor监控线程采样子进程内存。它第一次碰到惰性import psutil时,
导入过程的分配会触发一轮全量回收,而那一轮回收发生在监控线程上 ——
于是顺手把别处留下的、已不可达的 PySide 控件也在监控线程里析构了。
Qt 要求QWidget只能在 GUI 线程析构,结果是访问违例或者状态被打坏后直接挂死;
faulthandler 抓到的最内层帧只有一句Garbage-collecting,往上全是 importlib。
两道防线各管一头:ProcessMonitor在构造时(而不是监控线程里)就把
psutil 能力定下来;tests/conftest.py则在每个用例结束时、仍在 GUI 线程上
把本轮垃圾收掉。这个坑的现场很有欺骗性:单独跑用例全过,凑到一起必炸 ——
因为崩不崩只取决于"那一轮 GC 手里有没有 GUI 垃圾"。
定位办法是把pytest -v -u -X faulthandler的输出留下来看崩溃块的最前面,
尾部只剩 pytest 自己的几帧(runpy→_console_main),一点用没有。 - 有父对象的弹窗,
geometry()是父控件坐标系。 哪怕它带着Qt::Window标志、
isWindow()也返回True,geometry()依旧是相对父控件的值,跟屏幕坐标差一个
父窗口左上角。所以问"弹窗在屏幕哪儿"必须用mapToGlobal(QPoint(0, 0))。
这个差异在离屏平台上被掩盖了(窗口位置接近原点,两种算法结果一样),
一换到真实桌面就差了 400 像素 —— 而且弹窗位置其实是对的,红的是断言。还有一条前提:别在没
show()过的窗口上量位置。未显示的顶层窗口,
Qt 给它算出来的全局坐标是虚构的,比较两个虚构的数字没有意义。
test_popup_sits_under_the_caret因此会真的把编辑器显示出来 ——
两个点取自同一窗口,窗口被系统摆在哪里都不影响结论。
工具链探测与编译标准
三个曾经真实存在的坑,都已修好并留了回归用例:
- 候选目录不写死盘符。目录模板统一用
{drive}占位,运行时用
GetLogicalDrives+GetDriveTypeW == DRIVE_FIXED枚举所有固定盘再展开。
早期写死C:\...,导致装在 D 盘的 Dev-Cpp / MinGW 完全探测不到。 - C/C++ 标准参数按编译器能力降级。候选阶梯是
-std=c++17 → c++14 → c++11 → 不加参数(C 是c11 → c99 → c90 → 不加),
只在失败信息确实和该参数有关时才降级(语法错误立刻返回,不白编译三次),
并在同一进程内缓存实测可用的那个。原因是 Dev-Cpp 自带的 TDM-GCC 4.9
不认-std=c++17,写死会让那类机器上每一份 C++ 提交都编译失败,
而且报错长得像用户代码的问题。 - 编译器选择 GCC 优先于 MSVC。不是按版本号排大小 —— 这台机器的
cl.exe是
19.44、Dev-Cpp 的g++是 4.9,按版本排会让 MSVC 胜出。但对刷题工具这是错的:
题解与在线评测都以 GCC 为准(见下面 MSVC 一节的两条实测差异)。
所以家族优先级先比,版本号后比。
MSVC(cl.exe)支持
cl.exe 不是普通的独立编译器,它靠 INCLUDE / LIB / PATH 找标准库 ——
没有这套环境时连 #include <iostream> 都过不了(报 C1034),
光把路径填进配置是没用的。
官方初始化方式是跑 vcvars64.bat,但那条路对宿主程序非常不友好:
- 它是批处理,必须经
cmd.exe执行;而subprocess的list2cmdline会把内层 引号转义成\",cmd.exe不认反斜杠转义,于是带空格的"C:\Program Files\..."直接失败(不是内部或外部命令)。shell=True也救不了。 - 唯一可行的写法是临时写一个无空格路径的包装
.bat—— 能跑,但单次要 100 秒左右, 因为vcvars内部大量调用reg.exe探测 SDK / .NET / 旧工具集版本。
所以改为自己拼:vswhere.exe 定位 VS 安装根 → winreg 读 KitsRoot10 →
按 VS 2015 以来非常稳定的目录布局算出三个变量。实测 1.9 ms,不经过 cmd.exe,
也不依赖 reg.exe。正确性由"真的编译一个程序并运行它"兜底。
编译参数上有几处必须和 GCC 对齐,否则会出现"能过的代码在这台机器上编不过":
| 参数 | 作用 | 不加的后果 |
|---|---|---|
/utf-8 |
显式声明源码与执行字符集都是 UTF-8 | 按系统 ANSI 代码页(中文 Windows 是 936)读源码。L"中文" 直接编错,遇到无法映射的字节还会刷 C4819 |
/EHsc |
标准 C++ 异常语义(只对 C++ 加) | GCC 默认就开,不写会让依赖异常的代码行为不一致 |
/MT |
静态链接 C 运行库 | /MD 引入 vcruntime140.dll 依赖,换台机器就报找不到 DLL |
/W3 |
对应 -Wall 的告警级别 |
/Wall 会把系统头文件的每条提示都倒出来,噪音过大 |
| 相对文件名 | 源文件与产物都用相对名(cwd 已设为工作目录) |
工作目录在 %LOCALAPPDATA% 下,用户名带空格时 /Fe:C:\Users\John Doe\... 会被 cl 的参数解析绊住 |
标准参数不能照搬 GCC 那套"失败就降级":MSVC 对不认识的 /std: 不报错,
只发一条 warning D9002,退出码仍是 0,然后静默按默认标准编译
(实测 /std:c++23 编出来的 _MSVC_LANG 是 201402,即 C++14)。
也就是说"编过了"完全不能证明这个标准被接受了,靠失败来探测行不通。
所以改成按工具集版本号直接选:
| MSVC 版本 | C++ | C |
|---|---|---|
| ≥ 19.14(VS2017 15.7) | /std:c++17 |
— |
| ≥ 19.30(VS2022) | /std:c++17 |
/std:c17 |
| ≥ 19.27(VS2019 16.7) | /std:c++17 |
/std:c11 |
| ≥ 19.00(VS2015 Update 3) | /std:c++14 |
不加 |
| 更早 | 不加 | 不加 |
阶梯末尾永远留一个"不加参数"的兜底;万一某个版本确实忽略了它(D9002 会被
解析出来),下一轮就退到无参数并把结果写进缓存,不会每次都带一条注定被忽略的参数。
顺带修掉一个本地化相关的 bug:cl.exe 的版本横幅随系统语言变化 ——
中文 Windows 上打出来是「用于 x64 的 Microsoft (R) C/C++ 优化编译器 19.44.35228 版」,
而且走的是 stderr(stdout 里放的是用法说明,非空)。
早期写成 stdout or stderr 再匹配英文 Version\s+...,结果永远解析出版本号为空。
现在两个流都看、中英文形态都匹配;目标架构干脆不从横幅读,直接从
bin\Hostx64\x64\cl.exe 的父目录名取,天然与语言无关。
打包产物的验证方式
源码能跑不代表打包产物能跑,因此发布前建议按这套流程验一遍:
# 一条命令跑完全部静态项 + 双实例运行验收
python tools\verify_frozen.py --launch
它会检查:
- PE 头:x64 / PE32+ / 子系统为 2(GUI,双击不弹控制台)/ DllCharacteristics 位;
- 九帧图标:把
assets\oj_icon.ico里每一帧的图像数据拿去 exe 里逐个找 —— 这条是真会坏的,PIL.Image.save(sizes=[...])那种写法只写进一帧,任务栏图标发糊, 而且不报错; - 版本资源(UTF-16LE)与清单(PerMonitorV2 / longPathAware / asInvoker / Common-Controls);
- 关键模块是否都在里面:面板清单是扫目录得来的,因为一旦有人图省事写成
importlib.import_module(f".{name}"),源码照跑,冻结版会整个少掉几个选项卡; --launch:用全新数据目录起两次进程(离屏,不弹窗)—— 第一次应停在事件循环, 第二次应在 1 秒内唤出已有窗口后自行退出,最后查日志里 ERROR 为 0。
手动等价做法(不想用工具时):
$env:QT_QPA_PLATFORM = "offscreen"
$env:OFFLINE_OJ_HOME = "$PWD\build\frozen-check"
.\dist\OfflineOJ\OfflineOJ.exe # 第一次:应进入事件循环
.\dist\OfflineOJ\OfflineOJ.exe # 第二次:应 1 秒内唤出已有窗口并退出
Get-Content .\build\frozen-check\logs\app.log
桌面快捷方式用 python tools\make_shortcut.py 建(默认建到桌面,指向
dist\OfflineOJ\OfflineOJ.exe,工作目录与图标都指对)。
为什么是手写二进制而不是调 COM。 常规做法是
New-Object -ComObject WScript.Shell,但本机安全策略禁止 COM 实例化 (理由是"COM 可以执行任意代码"),环境里也没有pywin32。.lnk用的 MS-SHLLINK 是公开的固定格式,直接写反而更可控:零依赖、换台机器也能用。写的时候踩了一个不报错的坑:第一版只写了
LinkInfo(里面已有完整路径), 省掉了LinkTargetIDList,结果startfile直接报WinError 1155(没有关联)—— 系统压根不认这个文件是快捷方式。别猜规范,去看真货:把资源管理器自己写的Steam.lnk/OneDrive.lnk/爱奇艺.lnk拆开,三个都带着 200~430 字节的这段。 PIDL 的格式(逐级 item ID)手写不现实,所以用SHParseDisplayName(普通 Win32 函数,不是 COM)让 shell 自己拼。顺带量到一条约定:CountCharacters不含结尾的 NUL('Steam'是 5 不是 6)—— 含进去也不会立刻报错,只会让后面几段字符串整体错位。验完别忘了让系统自己解析一次:
os.startfile(lnk)起得来、 目标进程把日志建出来,才算真的能用。拿自己写的解析器回读是循环论证。
已知限制
-
MSVC (
cl.exe) 需要 Visual Studio 或 Build Tools,且判题结论与在线评测有两条实测差异。 定位与环境组装已实测通过(VS 2022 Community / MSVC 19.44,组装 1.9 ms),tools\msvc_e2e.py用同一套 P0001 语料比对过判定。两条差异都源于 MSVC 与 GCC 的 语义差别,不是本程序的缺陷,也无法通过编译参数消掉:语料 GCC(在线评测一致) MSVC 说明 #include <bits/stdc++.h>编过 CE MSVC 不提供这个 GCC 专有头文件 void main()CE AC MSVC 对 void main连警告都不发,/W4、/WX、/we4326一律放过,没有可提级的警告码因此工具链选择器把 GCC 排在 MSVC 前面:只有在机器上没装 GCC 系编译器时才会退回 MSVC。 想复现"
void main判 CE"这类在线评测行为,必须用 GCC。 -
安全软件可能拦下编译产物。装了 360 / 火绒 / 联想电脑管家这类软件时, 刚编译出来的可执行文件会被拦截执行、随后连文件一起删掉,表现为
[WinError 5] 拒绝访问,下一次再试又变成"找不到文件"。 实测特征是:产物编译出来放着不动一直在,一尝试启动就消失。 本程序会在这种情况下给出带具体目录的提示,引导把工作目录加入信任区 (360:安全防护中心 → 信任区;火绒:设置 → 信任区 → 添加目录)。 注意这是逐文件的行为:同一个目录里可能有的产物能跑、有的被拦。 -
C / C++ 判题已在 TDM-GCC 4.9.2 上实测通过 —— 8/8 测试点 AC, 并验证了
void main→ CE、多输出一行 → WA。MSVC 已按上表实测,clang 仍待补测。 -
输出上限是"读完再截断",不是"读到上限就停"。
MAX_OUTPUT_BYTES(4 MB) 约束的是判题结论和交给学生看的信息;子进程的输出实际由subprocess.communicate()一次性读进判题进程的内存,截断发生在那之后。 于是"死循环打印"的选手程序在判成 OLE 之前,会先把那段输出搬进主机内存 (实测 300 MB 量级)。装了 psutil 时,题目的内存上限监控会杀掉子进程兜底, 但读取线程本身不受那个上限约束。要连峰值一起压住,得把读取改成 "分块读 + 累计到上限即停并杀进程"(见core/sandbox.py的wait())。 目前这是已知且记录在案的行为,不是待修的紧急项:判题结论是对的, 受影响的是主机的内存峰值。 -
校验器只支持 C++ 与 Python,且跟着题目源码走。把带校验器的题目导出给别的机器时, 对方必须装有对应语言的工具链,否则整题会判 IE(评测机内部错误)而不是误算到选手头上。 C 与 Java 不提供 —— 校验器主要在处理字符串与空白,这两种写起来远比 C++ / Python 费事。
-
校验器跑在判题机上,与选手程序同等权限。它没有额外的沙箱,只是有 30 秒 / 1 GB 的 上限;而且它是由出题人写的,导入别人给的题目包等于运行对方的代码。这条与 「提交的代码同样在本机运行」是同一类边界,见下方「安全边界」。
-
文件模式只在工作目录内读写。题目只能指定文件名(
JudgeConfig.problems()会拒绝 含路径分隔符或:*?"<>|的名字),程序无法用它把文件写到试题目录之外。 -
安装程序需另行编译 ——
packaging/installer.iss已写好,但本机未安装 Inno Setup 6,执行pwsh packaging\build.ps1 -Installer前需先安装它。绿色版dist\OfflineOJ\可直接使用, 便携版pwsh packaging\build.ps1 -Portable会产出 ZIP,都不依赖 Inno Setup。
安全边界
评测在本机以当前用户权限运行提交的代码,没有虚拟机或容器级隔离。 「设置 → 判题选项 → 提交前检查危险操作」只是一道提示性护栏,不能阻止恶意代码。 请只运行自己信任的代码。
自定义校验器属于同一类边界:它就是一段普通程序,由出题人提供、随题目包一起分发, 导入来源不明的题目包再判题,等同于运行包内附带的代码。这条不会因为 "校验器由本程序编译"而变安全 —— 编译的正是对方给的源码。
局域网测验的安全性建立在一个架构选择上:判题在主机的机器上做,测试数据从不下发。 下发给学生端的只有样例测试点(没有一道题被标记为样例时,样例就是空的 —— 宁可学生看不到 样例,也不能因为"找不到样例就退而求其次发全部"而把正式测试点漏出去)。 这一条比任何传输加密都更根本:数据没出去,就没有"被破解"这回事。
在此之上,握手与提交走 ChaCha20-Poly1305 加密帧,房间号从不明文上线。 但要清楚房间号不是强凭据:6 位数字的搜索空间是 10⁶,离线爆破在抓到一个握手包之后 是"小时"量级。它的定位是"分房间 + 挡住隔壁教室的人";要抗离线爆破,请另设房间口令。 完整的威胁边界见上方「局域网测验 → 房间号即凭据」。
-
Python 3.13 的 Windows 安装说明:"Python 3.13 supports Windows 8.1 and newer. If you require Windows 7 support, please install Python 3.8." ↩
-
Qt 官方《Host operating systems in Qt 6.0》:"Both Windows 7 or 8.x version support will not be available for Qt 6." 现行的 Supported Platforms 列的是 "Windows 10 (1809 or later) / Windows 11"。 ↩
Metadata
Release files for offline-oj 2.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| offline_oj-2.1.1.tar.gz | 607.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| offline_oj-2.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.0 MB
Release files / offline_oj-2.1.1.tar.gz
| Download URL | offline_oj-2.1.1.tar.gz |
|---|---|
| Size | 607.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4e6cb93f443f0cb68716e06f56660457920b264421ce8554c784dcf6fadc1b8c
|
|
BLAKE2b-256 checksum How to use checksums |
0ebc19882d3dc0448d9d4debf94cb2b5525f20ee5b36c93ab55b88d053348e04
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.9
|
Release files / offline_oj-2.1.1-py3-none-any.whl
| Download URL | offline_oj-2.1.1-py3-none-any.whl |
|---|---|
| Size | 425.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7be7e0b9fa78089dc8a195699e0ab06d06b28ccefaabb27265bd7a4f510921e5
|
|
BLAKE2b-256 checksum How to use checksums |
c7e87946e4030c10eb88d1f3ccc474e8b07c30ba9262b6156898847ede1d77cc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.9
|