Skip to main content

离线 OJ 系统 v2.0

CI License: MIT [Platform]

面向 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

首次启动后:

  1. 编译器配置 → 点「自动检测」→ 点「验证可用性」→ 保存;
  2. 存题模块 → 新建题目,填描述与测试点;
  3. 写题模块 → 写代码,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')

更多能力:

repo = api.open_repository('./my_problems')        # 题库:增删改查 + 搜索
repo.add({'id': 'P1001', 'title': 'A+B', 'testcases': [...]})
api.import_problems(repo, '题目包.zip')            # zip/目录/单文件自动识别
api.export_problems(repo, '导出.zip')              # 打包导出

api.check_toolchain()                              # 探测并自检本机编译器
api.list_exam_archives('./archives')               # 考试档案列表/读取
api.load_roster('名单.csv')                        # 花名册
api.similarity_diff(code1, code2)                  # 行级差异

# 加密便捷层(nonce 自动生成前缀):
blob = api.encrypt_message(key, b'secret')
api.decrypt_message(key, blob)

加密协议栈(零依赖 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(评测机内部错误)

三条实现约定:

  1. 校验器自己出问题,绝不算到选手头上。 编译不过、跑超时、异常退出都记成 IE,
    compile_ok 仍为真;结果面板会写明"自定义校验器不可用"并提示去存题模块检查。
  2. 每题只编译一次,所有测试点复用同一份产物。
  3. 校验器源码存在题目 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 秒的卷子不会被判成迟到。

怎么用

  1. 老师端:切到「局域网测验 → 主机端」,选模式、勾题目、设时长与收卷策略,点「开启房间」;
  2. 把大字的「房间号 + 地址 + 端口」报给学生(点「复制房间信息」可以直接粘到班群里);
  3. 学生端:切到「学生端 · 加入房间」,填地址、端口、房间号、自己的名字,点「加入房间」;
  4. 老师端可以随时切回「主机端」看总分榜 / 单题榜 / 提交与代码 / 名单 / 现场记录 / 历史场次。

同一台机器可以随时切换角色:老师在开考前用学生端自己试一次,不占第二台机器。

主机端左列会跟着房间状态换一副面孔

开房前后,老师要做的事完全不同,所以左列整块换页,而不是把用不上的设置灰在屏幕上:

左边这一列显示什么
没开房(准备视图) 房间设置 / 本场限制 / 本场题目三组,外加「开启房间」
开了房(监考视图) 大字房间号 + 只读摘要(模式、进场方式、时长、题数、本场限制)+ 改名 + 开始考试 / 提前收卷 / 提前结束 / 复制房间信息 / 关闭房间

只读摘要用的是当前生效的值(服务端实际定下来的那份),不是输入框里可能被改过的草稿。

开房后唯一还能改的是名称(它只是个显示标签,改它不踢人,房间号与连接都不受影响); 其他设置一件都动不了 —— 动了会踢人、会让榜单上的数字不可比。想改就关房重开。

防火墙:首次开启房间时 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,但那条路对宿主程序非常不友好:

  1. 它是批处理,必须经 cmd.exe 执行;而 subprocess 的 list2cmdline 会把内层 引号转义成 \",cmd.exe 不认反斜杠转义,于是带空格的 "C:\Program Files\..." 直接失败(不是内部或外部命令)。shell=True 也救不了。
  2. 唯一可行的写法是临时写一个无空格路径的包装 .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⁶,离线爆破在抓到一个握手包之后 是"小时"量级。它的定位是"分房间 + 挡住隔壁教室的人";要抗离线爆破,请另设房间口令。 完整的威胁边界见上方「局域网测验 → 房间号即凭据」。

  1. Python 3.13 的 Windows 安装说明:"Python 3.13 supports Windows 8.1 and newer. If you require Windows 7 support, please install Python 3.8." ↩

  2. 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.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for offline-oj 2.2.0
File Size Uploaded
offline_oj-2.2.0.tar.gz 612.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for offline-oj 2.2.0
File Interpreter ABI Platform
offline_oj-2.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 1.0 MB

Release files / offline_oj-2.2.0.tar.gz

Download URL offline_oj-2.2.0.tar.gz
Size 612.0 kB
Tags Source
SHA-256 checksum
How to use checksums
9931db25985215f3894783796d8aa9255d62e2ffa8afbdd31234f073a1e3a4b5
BLAKE2b-256 checksum
How to use checksums
851ee5add9d0545e21ab05aad5da24c1a02b1e52a3a1776f779ed25d708b3a61
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.2.0-py3-none-any.whl

Download URL offline_oj-2.2.0-py3-none-any.whl
Size 429.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
40f2b49474a7cbbc2a242dc5a1aeaf430d028ea2217505e0cbbc666060c90bf2
BLAKE2b-256 checksum
How to use checksums
00101ed5b7d101ce5b8eab828f067da63e039d914e2aa729785f6be2e4f005d3
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.9

Release history Release notifications | RSS feed

This release

2.2.0 This release

2 release files

2.1.1

2 release files

2.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page