能把题库捐赠给我们吗?
AzusaShirasu · · 科技·工程
前言
有时候就算真给了也无福消受啊,需要一个很好的 Online Judge 平台,即在线评测平台,简称 OJ。
虽然现在已经有很多现成 OJ 框架了,不过对普通人来说可能缺的还是硬件设施:没有公网 ip。用 GitHub Pages 只能显示静态网页。别说在线评测平台了,做一个单纯的留言板或者在线联机小游戏都困难。
内网穿透是一个办法,不过有一种更加行为艺术的方案:用 SupaBase 这种在线数据库。只要规模不是很大,SupaBase 提供的免费额度完全足够一些玩具项目的使用了。
本文将介绍如何由自己写出一个能评测程序的在线评测网站,而且在外界也可以访问。
前端
SupaBase 准备工作
这个平台可以用 GitHub 账号登录,所以只要有个 GitHub 账号,关联上去就可以快速地登进去使用了。
界面元素都很清晰,这里就不放图了,按照下面的指示做:
- 登录后,在主界面点击绿色的 New Project 按钮开始创建一个新的项目;
- 取项目名称,设置项目密码。然后是下面的服务器地区选择,选择访问性较好的服务器就行,推荐新加坡的服务器。
- 点击创建,进入项目主界面。左边菜单栏选择 SQL Editor,这里可以执行命令行语句,懂 SQL 语句的话更方便(有个 Table Editor 可以可视化地编辑数据表格,不过需要自己摸索怎么用)。
数据表准备工作
创建一张数据表,用来处理提交进来的代码。数据表顾名思义就是一张表格,有表头和数据行,要在表头写出每一列的名字和值的类型。之后,每一行就是一条数据记录。
一次提交需要携带的信息有:评测 ID(用户需要根据评测 ID 才能得到结果)、题号、待评测的代码。此外为了方便统计,可以带上用户 UID 和提交评测的时间信息。这张表就叫 submissions。
携带这么多信息,有两种方案:
- 比较正规的方案(直接问 AI 大概率是这种方案),一个信息维护一列,可以得到的创建指令大概如下:
-- 先不要运行这份代码
CREATE TABLE submissions (
uid TEXT NOT NULL,
id BIGINT PRIMARY KEY,
problem_id TEXT NOT NULL,
code_text TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
status TEXT NOT NULL, -- 评测状态
);
- 懒惰的家伙们的方案:评测 ID 单独一列,其他全部包装成 json。
CREATE TABLE submissions (
id BIGINT PRIMARY KEY,
status TEXT NOT NULL, -- 评测状态
info TEXT NOT NULL -- 也可以用 JSONB 但是无所谓了
);
我属于第二个集团。
在 SQL Editor 的右上角那片区域写入指令并点击运行即可。下面显示 Success 就是成功创建了(刚创建的空表一行记录都没有,显示 No rows returned 正常)。
类似地,创建一个用于保存评测结果的数据表 results。包含的数据就可以简单一点了:一个评测 ID,一个 json 字符串评测结果。用 json 是因为每道题的评测点数量不一样,结构也比较复杂,建列很麻烦,不如直接变成 json。同样,运行这条指令创建表格。
CREATE TABLE results (
id BIGINT PRIMARY KEY,
result TEXT NOT NULL
);
然后创建一个题目列表,也创建一个数据表 problems,包含的列有题库 ID、题名(用来更方便地按名查题目)、以及万能 json。这么做的原因下面很快就会讲到。
CREATE TABLE problems (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
info TEXT NOT NULL
);
前端解析
如果想要托管 html 页面的话,一个选择是自己开一个有公网 IP 的服务器,不过已经说了没有;另一个选择是 GitHub 托管。代价是一些动态内容实现需要多花一些精力。
首先先要理解为什么要创建数据库。以题库这个功能来举例:problem/P1001.html 导航到 P1001,problem/P1002.html 导航到 P1002,这没什么问题,在 GitHub 上创建一个 problem 文件夹,里面放上 P1001.html 和 P1002.html 就可以正常访问。现在要多加一道 P1003,就单独创建一个 P1003.html 放进去,也可以,但是这就限制了必须是有 GitHub 仓库管理权限的人才能增删改题目。而且,每次增删改题目都需要直接接触底层文件。这不太方便。
再举一个更直接的例子:讨论区。难道每个人的每一次发言,都要修改一次讨论区的 html 文件吗?这显然是不可承受的。最好的办法还是数据和显示分离,前端只包含一个模板,而每道题目的题面 / 每条发言都从数据库获取,获取到之后动态渲染到页面上(而非直接写在 html 文件里)。具体而言,用类似 problem.html?id=P1001 的方式携带一个参数表示题目 ID,然后动态从 ID 获取题面内容渲染就行了。
说多花一些精力是因为要在前端实现请求的解析。比如 problem?id=P1001,大部分时候用户的浏览器(前端)只负责把请求发给服务器(后端),而后端再根据请求获取数据库的内容,生成 html 之后返回给用户的浏览器。GitHub 托管的页面没有提供自定义后端处理逻辑的功能,所以获取数据库内容这一步只能放在前端了。
题目页面及 utils.js
首先,在 GitHub 上面创建一个项目。用来托管所有页面。
然后创建一个基本的用来显示题目的 problem.html 文件,并放到仓库去。这个文件就是模板,将会把题目内容动态渲染到里面去。注意到 <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js"></script> 这一行了吗?这一行类似 C++ 的 #include 指示,浏览器会自动从这个地方下载 SupaBase 官方提供的 JavaScript 源代码,而这份源代码里包装了很多操作 SupaBase 需要的库函数。
同样的,也可以创建自己的 JavaScript 库文件供引用,只要能够找得到就可以。上面说过,后端处理的逻辑现在搬到了前端,会有非常可以重复利用的代码,所以可以在自己项目根目录下创建一个 utils.js 文件,包含进来就可以了。
据此,先创建的那个 problem.html 文件内容如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Problem List</title>
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/markdown-it.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/texmath.min.js"></script>
</head>
<body>
<h1 id="problem_name">Loading...</h1>
<p>
<div id="options"></div>
</p>
<div id="problem_container"></div>
<script src="utils.js 地址"></script>
</body>
</html>
开头那些引用的 JavaScript 包以及一个样式表是为了支持 MarkDown 和
关于地址,如果把 utils.js 创建在根目录下,那么就是 utils.js;如果创建了一个文件夹(比如叫做 js_code)再在里面创建 utils.js,那么就是 js_code/utils.js。更复杂的请问 AI。
而 utils.js 里要放很多常用的代码:
- 获取请求参数,比如从
problem?id=P1001中获取id=1001。 - 与 SupaBase 建立连接。这也是为什么上面要引用官方提供的 js;
- JavaScript 加载。没错,用 JavaScript 动态加载其他的 JavaScript。每个页面的渲染配置是不一样的,手动给每个页面配置单独的 JavaScript 会比较麻烦。这没关系,在
utils.js里添加代码,让每个页面自己寻找自己的 JavaScript 就行了。比如problem.html自己去寻找problem.js、record.html寻找record.js。之后,把每个页面所需要的 JavaScript 写在对应的 js 文件里就行了。源代码如下:
let supabaseClient = null;
function getClient() {
if (!supabaseClient) {
const supabaseUrl = 'SupaBase 项目地址';
const supabaseKey = 'SupaBase 匿名密钥';
supabaseClient = supabase.createClient(supabaseUrl, supabaseKey);
}
return supabaseClient;
}
function getArgs(key) {
const args = {};
for (const [k, v] of new URLSearchParams(window.location.search).entries()) {
args[k] = v;
}
return key ? args[key] : args;
}
document.addEventListener('DOMContentLoaded', function() {
const currentPath = window.location.pathname;
let scriptPath = currentPath.replace(/\.html$/, '.js');
if (!scriptPath.endsWith(".js")) scriptPath += ".js";
const script = document.createElement('script');
script.src = scriptPath;
document.head.appendChild(script);
});
SupaBase 项目地址,去 SupaBase 的工作界面,左边菜单栏最下面的 Project Settings 里,选择 Data API,可以看到 Project URL。它就是这个项目的地址,也是数据库地址。在 Data API 的下面有个 API Keys,目前只需要 Legacy 的就够了,有个 anon 和 public 的密钥,复制下来,它就是匿名密钥。把这两项信息填入上面的源代码里。
匿名密钥顾名思义,任何人都可以通过这个密钥访问数据库——当然会有安全性的问题,不过可以放心,之后在 SupaBase 上进行一些配置之后就能很方便地控制匿名密钥的权限。
注意:在 anon 密钥下面还有一个 service_role 的密钥,它属于拥有最高权限的密钥。不要泄露它。
题目界面 JavaScript
先确定一下题目界面,即 problem.html 的大概逻辑:
- 如果有
id参数(即problem.html?id=P1001),则从problems获取显示题号对应题目内容,并且显示提交和返回题目列表的超链接; - 如果没有
id参数,从problems数据表获取全部题目,显示成表格。
提交代码的页面就命名为 submit.html,这个文件之后补上。
按照如上的逻辑,在根目录下创建 problem.js,写出代码(这一部分比较简单,可以直接找 AI 帮忙写):
const md = window.markdownit();
md.use(window.texmath.use(window.katex), {
engine: window.katex,
delimiters: 'dollars',
katexOptions: { macros: { "\\RR": "\\mathbb{R}" } }
});
async function render() {
const supabase = getClient();
const problemNameEl = document.getElementById('problem_name');
const optionsEl = document.getElementById('options');
const containerEl = document.getElementById('problem_container');
const id = getArgs('id');
if (id) {
const { data: problem, error } = await supabase
.from('problems')
.select('*')
.eq('id', id)
.single();
if (error) {
problemNameEl.innerHTML = ``;
containerEl.innerHTML = `<p>No such problem</p>`;
return;
}
problemNameEl.textContent = problem.name;
optionsEl.innerHTML = `
<a href="submit?id=${id}">Submit</a>
<a href="problem">Problem List</a>
`;
try {
const info = JSON.parse(problem.info);
containerEl.innerHTML = md.render(info.description) || '<p>No description</p>';
} catch (e) {
containerEl.innerHTML = '<p>No description</p>';
}
} else {
const { data: problems, error } = await supabase
.from('problems')
.select('id, name')
.order('id');
if (error) {
containerEl.innerHTML = `<p>Load problem list failed</p>`;
return;
}
problemNameEl.textContent = "Problem List";
optionsEl.innerHTML = '';
let tableHTML = `
<table border="1" style="width: 100%; border-collapse: collapse;">
<thead>
<tr>
<th>Problem ID</th>
<th>Problem Name</th>
</tr>
</thead>
<tbody>
`;
problems.forEach(problem => {
tableHTML += `
<tr>
<td><a href="problem?id=${problem.id}">${problem.id}</a></td>
<td><a href="problem?id=${problem.id}">${problem.name}</a></td>
</tr>
`;
});
tableHTML += `
</tbody>
</table>
`;
containerEl.innerHTML = tableHTML;
}
}
render();
事实上这里有非常多可以优化的地方,从单纯的观感方面到性能方面——这些是前端工程师们需要考虑的问题。不过既然这个项目只是作为示例,那么懒惰一些也无妨。
配置 RLS
之前说过,持有匿名密钥可以对数据库进行任何操作,如果不加防护的话。启用行防护,即 Row Level Security,就可以很大程度上解决问题。它可以限制不同身份(简单理解成不同密钥)可以执行的操作。
既然已经下发了匿名密钥,那么匿名密钥的权限就是普通用户的权限,所以:
- 对于 submissions 数据表,用户只能读取、创建新行(不能修改已经存在的提交);
- 对于 results 数据表,用户只能读取(不能修改别人的评测结果,也不能自己创建);
- 对于 problems 数据表,用户只能读取(不能创建或修改题面)。
在 SupaBase 的 SQL Editor 里执行下面的命令,即可为上面三个表开启 RLS 防护并且设置权限限制:
ALTER TABLE problems ENABLE ROW LEVEL SECURITY;
ALTER TABLE submissions ENABLE ROW LEVEL SECURITY;
ALTER TABLE results ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Allow anon read problems" ON problems FOR SELECT USING (true);
CREATE POLICY "Allow anon insert submissions" ON submissions FOR INSERT WITH CHECK (true);
CREATE POLICY "Allow anon read submissions" ON submissions FOR SELECT USING (true);
CREATE POLICY "Allow anon read results" ON results FOR SELECT USING (true);
简单测试
先去 GitHub 上把目前的页面给部署了。部署完之后,访问 /problem.html,应该会在加载之后显示出一个空的表格。
表格当然是空的,目前题库里一道题也没有。那么就创建几道测试用的题目。在 SQL Editor 里运行下面的命令创建两道题目:
INSERT INTO problems (id, name, info) VALUES (
'P1001',
'A+B Problem',
'{"description":"Read two integers $a,b$ from **standard input**. Output $a+b$.\n\nThe absolute values of $a,b$ do not exceed $10^7$."}'
);
INSERT INTO problems (id, name, info) VALUES (
'P1002',
'Wolves Catch Bunnies',
'{"description":"自行寻找题面"}'
);
然后就可以在题目列表里看到题目,点进去也可以看到题干了。
提交页面
提交页面可以设置一个文本框,让用户可以设置要提交的题目 ID。这里采用一个比较人性化的设计:如果页面带有 id 参数(比如 submit.html?id=P1001),就自动帮助用户填入题目 ID。
同样地,先创建 submit.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Submit Code</title>
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js"></script>
</head>
<body>
<h1>Submit Code</h1>
<div>
<label for="problem_id">Problem ID: </label>
<input type="text" id="problem_id">
<a id="back_link" href="/problem">Back</a>
</div>
<br>
<button id="submit_btn">Submit</button>
<br><br>
<textarea id="code_text" rows="20" cols="80" placeholder="Your code"></textarea>
<script src="utils.js 的地址"></script>
</body>
</html>
然后创建 submit.js,向 SupaBase 的 submissions 数据表里插入一条包含提交信息的行,并把状态设置为 pending 表示等待评测。
简便起见,这里的评测 ID 使用的是当前是时间戳,精确到毫秒;提交信息只包含题号和用户的代码。如果提交成功,就自动导航到对应的评测结果页等待评测结果。评测结果页就命名为 result.html。
function render() {
const problemIdInput = document.getElementById('problem_id');
const backLink = document.getElementById('back_link');
const submitBtn = document.getElementById('submit_btn');
const codeText = document.getElementById('code_text');
const id = getArgs('id');
if (id) {
problemIdInput.value = id;
backLink.href = `problem?id=${id}`;
} else {
backLink.href = 'problem';
}
submitBtn.addEventListener('click', async function() {
const problemId = problemIdInput.value.trim();
const code = codeText.value.trim();
if (!problemId) {
alert('Please enter Problem ID');
return;
}
if (!code) {
alert('Code is empty');
return;
}
try {
const supabase = getClient();
const submissionId = Date.now();
const submissionData = {
problem: problemId,
code: code,
};
const { data, error } = await supabase
.from('submissions')
.insert([
{
id: submissionId,
status: 'pending',
info: JSON.stringify(submissionData)
}
]);
if (error) {
alert('Submission failed: ' + error.message);
} else {
location.href = "result?id=" + submissionId;
}
} catch (error) {
alert('Error: ' + error.message);
}
});
}
render();
结果页面
结果页面要显示的基本内容至少包括:提交时间、提交的代码、提交的题目和代码,然后是评测结果。为了方便使用,还要有一个返回题目的超链接。创建 result.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Submission Result</title>
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js"></script>
</head>
<body>
<h1 id="title">Submission Result</h1>
<div id="header">
<span id="submit_time"></span>
<a id="back_link" href="/problem">Back</a>
</div>
<div id="result_container">Fetching results...</div>
<div>
<h3>Submitted Code:</h3>
<pre id="code_display"></pre>
</div>
<script src="utils.js 地址"></script>
</body>
</html>
然后是获取评测结果的逻辑。直接获取是比较难的,因为评测有队列,需要花时间。而让用户在结果页面一直刷新等待刷出评测结果的体验并不太好。所以设计一个“自动获取是否有评测结果”的逻辑如下:
- 如果 results 数据表里已经有了这个评测 ID 对应的行,那么抓取并显示;
- 如果没有,那么尝试从 submissions 里抓取,如果没抓取到,说明这个评测 ID 不存在,显示错误信息并返回上一页;
- 如果有,说明这次评测还在队列里等待,显示等待的消息,并时刻关注 results 数据库(轮询),发现对应的行后就显示出来。
这里的“时刻关注”可以用多次发起查询来完成,这里简单实现为每三秒查询一次数据库(当然有个更简便也更高效的办法是 SupaBase 已经实现的 subscribe 功能)。具体请看如下代码,文件名为 result.js:
由于评测结果也是用 json 保存的,需要先设计好评测结果的 json 结构。按照如下格式(verdict 是一个数组,可以包含多个测试点数据):
{
"code": "用户代码",
"id": "题号",
"brief": "总体情况(比如 100 Accepted)",
"verdict": [
{
"case": "测试点编号",
"status": "测试结果",
"details": "测试细节(比如时间空间消耗)",
"point": "测试点得分",
"info": "附加信息"
}
]
}
然后就可以写出对应的 JavaScript 逻辑。
function formatTime(timestamp) {
const date = new Date(parseInt(timestamp));
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const milliseconds = String(date.getMilliseconds()).padStart(3, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`;
}
function renderResult(resultObj) {
const container = document.getElementById('result_container');
const backLink = document.getElementById('back_link');
if (resultObj.id) {
backLink.href = `problem?id=${resultObj.id}`;
}
if (!resultObj.verdict || !Array.isArray(resultObj.verdict)) {
container.innerHTML = '<p>No test results available</p>';
return;
}
let tableHTML = `
<table border="1" style="width: 100%; border-collapse: collapse;">
<thead>
<tr>
<th>Case</th>
<th>Status</th>
<th>Details</th>
<th>Point</th>
<th>Info</th>
</tr>
</thead>
<tbody>
`;
resultObj.verdict.forEach(test => {
tableHTML += `
<tr>
<td>${test.case || ''}</td>
<td>${test.status || ''}</td>
<td>${test.details || ''}</td>
<td>${test.point || ''}</td>
<td>${test.info || ''}</td>
</tr>
`;
});
tableHTML += `
</tbody>
</table>
`;
container.innerHTML = "<h3>" + resultObj.brief + "</h3>";
container.innerHTML += tableHTML;
}
function setCodeDisplay(code) {
const codeDisplay = document.getElementById('code_display');
codeDisplay.textContent = code || 'No code available';
}
async function process() {
const supabase = getClient();
const submissionId = parseInt(getArgs('id'));
const title = document.getElementById('title');
const submitTime = document.getElementById('submit_time');
const backLink = document.getElementById('back_link');
const container = document.getElementById('result_container');
if (!submissionId) {
title.textContent = 'No submission ID provided';
return;
}
title.textContent = 'Submission Result #' + submissionId;
submitTime.textContent = `Submit Time: ${formatTime(submissionId)}`;
const { data: resultData, error: resultError } = await supabase
.from('results')
.select('*')
.eq('id', submissionId)
.single();
if (resultData) {
try {
const resultObj = JSON.parse(resultData.result);
setCodeDisplay(resultObj.code);
renderResult(resultObj);
} catch (e) {
container.innerHTML = '<p>Error parsing result data</p>';
}
return;
}
const { data: submissionData, error: submissionError } = await supabase
.from('submissions')
.select('*')
.eq('id', submissionId)
.single();
if (!submissionData) {
title.textContent = 'No such submission';
container.innerHTML = '<p>Redirecting back in 3 seconds...</p>';
setTimeout(() => {
window.history.back();
}, 30000);
return;
}
container.innerHTML = 'Pending for tests';
try {
const submissionInfo = JSON.parse(submissionData.info);
setCodeDisplay(submissionInfo.code);
if (submissionInfo.problem) {
backLink.href = `/problem?id=${submissionInfo.problem}`;
}
const pollInterval = setInterval(async () => {
const { data: resultData, error } = await supabase
.from('results')
.select('*')
.eq('id', submissionId)
.single();
if (resultData) {
try {
const resultObj = JSON.parse(resultData.result);
setCodeDisplay(resultObj.code);
renderResult(resultObj);
clearInterval(pollInterval);
} catch (e) {
container.innerHTML = '<p>Error parsing result data</p>';
clearInterval(pollInterval);
}
}
}, 3000);
} catch (e) {
container.innerHTML = '<p>Error parsing submission data</p>';
}
}
process();
总体测试
题目显示、提交代码、查看结果三个界面都已经完成了。总体测试一下前端:
- 先打开
/problem.html,可以看到题目列表,点击题号或者题名就可以查看题目; - 查看题目之后,点击 Submit 即可进入提交界面;
- 提交界面的题号框自动填写了题号,此时只要复制粘贴代码后点击 Submit 即可提交评测,并且会自动跳转评测页面;
- 评测页面会显示
Pending for tests.,目前没有实现代码评测,所以会一直卡在这里。 - 复制评测 ID,打开 SQL Editor,运行如下指令来插入一条评测结果:
INSERT INTO results values(
评测 ID,
'{"code":"// nothing","brief":"Score: 100, Wrong Answer","id":"P1001","verdict":[{"case":"1","status":"Accepted","details":"100ms 256MB","point":"100","info":""},{"case":"Extra test","status":"Wrong Answer","details":"200ms 512MB","point":"0","info":""}]}'
);
- 此时在评测页面应当会出现一个展示评测结果的表格了。
后端
后端比前端要处理的细节更少,任务主要是评测提交过来的程序,并生成报告。只要有一台能够访问到 SupaBase 数据的电脑(不过需要 24 小时不关机,因为一旦关机了就没法评测任务了),就能够当做评测后端。不必有公网 ip。如果有足够多的精力和设备,可以设计一套分布式评测,也就是把各个测试点分到多台机器上评测,这样可以更快返回结果。这里就只设计一套单点评测,并且只支持 C++。
评测系统的逻辑很简单:持续关注 submissions 数据表,如果有状态是 pending 的行,取出来进行评测,并把状态设置为 testing;评测完成之后,往 results 数据表里插入评测结果(上面手动插入,这里用代码自动插入),再把状态改为 finished。
关于测试数据,每道题根据题号创建一个文件夹,内部包含若干数据文件,简单起见强制要求输入文件命名为为 in1.txt、in2.txt……输入文件为 out1.txt、out2.txt……。以及一个测试点配置文件 config.json。配置文件的格式也是可以自己设计的,这里设计成这样:
{
"subtasks": [
{
"type": Subtask 评分方式,
"cases": [{
"case": 测试点编号,
"time": 时间限制(毫秒),
"memory": 内存限制(KB),
"points": 得分,
}]
}
]
}
subtasks 和 cases 都是数组,可以往里面放入多个 Subtask / 评测点。Subtask 评分方式也是自己设计。这里设计两个:"sum" 表示所有测试点加和计算、"min" 表示取测试点中得分最小的。至于总体评测信息(即 brief),格式设计为 Score: 分数, 状态。状态取所有测试点中第一个非 Accepted 的点的状态(全都通过了就直接设置为 Accepted)。
评测系统
用 Python 实现后端,需要先安装 SupaBase 的 Python 支持库,运行命令 pip install supabase 安装;然后电脑上需要安装 g++,安装方式自行上网查询。
评测系统的逻辑很清晰,只是有点长(但是仍然可以用 AI 写出来),这里只放出使用时需要比较注意的部分的代码(如果用 AI 生成或者自己写,那自行类比逻辑即可):
if __name__ == "__main__":
SUPABASE_URL = "SupaBase 地址"
SUPABASE_KEY = "SupaBase 的 service key"
judge = CppJudge(SUPABASE_URL, SUPABASE_KEY, "./testdata") # 所有题目的文件夹放在 testdata 文件夹下
try:
judge.start_judging()
except KeyboardInterrupt:
judge.stop()
print("Judge stopped.")
还记得上面说过的 anon key 吗?与之对应的就是 SupaBase 的 service key,拥有全部权限的密钥。毕竟这是自己的主机,赋予最高权限也没什么问题,只要不乱来。
运行程序,程序会自动从 SupaBase 尝试获取评测请求,只要一切配置正确就能成功评测并返回结果。常见的错误:
- 没装某个依赖(比如没装
g++); - 数据表里有重复的项;
- 网断了。
上传题面
如果扩展了用户系统和控制权限的功能,可以把这个功能也做成一个页面。不过如果没有,那么还是从主机上同步题面比较好。
思路是,检测每个题目文件夹下有没有 problem.md 文件,有的话就把里面的内容当做题面上传到 problems 数据表里。这个程序可以设计成定时自动运行,就能从本地同步题面到云端了。手动运行也可以。
import os
import json
from supabase import create_client
def sync_problems_from_markdown(supabase_url: str, supabase_key: str, testdata_base_path: str = "./testdata"):
supabase = create_client(supabase_url, supabase_key)
for problem_id in os.listdir(testdata_base_path):
problem_path = os.path.join(testdata_base_path, problem_id)
if not os.path.isdir(problem_path):
continue
md_file = os.path.join(problem_path, "problem.md")
if not os.path.exists(md_file):
continue
try:
with open(md_file, 'r', encoding='utf-8') as f:
content = f.read().strip()
lines = content.split('\n')
problem_name = problem_id
if lines and lines[0].startswith('# '):
problem_name = lines[0][2:].strip()
description = '\n'.join(lines[1:]).strip()
else:
description = content
info = {
"description": description
}
response = supabase.table('problems').select('id').eq('id', problem_id).execute()
if response.data:
supabase.table('problems').update({
'name': problem_name,
'info': json.dumps(info, ensure_ascii=False)
}).eq('id', problem_id).execute()
print(f"Updated problem: {problem_id} - {problem_name}")
else:
supabase.table('problems').insert({
'id': problem_id,
'name': problem_name,
'info': json.dumps(info, ensure_ascii=False)
}).execute()
print(f"Inserted problem: {problem_id} - {problem_name}")
except Exception as e:
print(f"Error processing problem {problem_id}: {e}")
print("Sync completed!")
if __name__ == "__main__":
SUPABASE_URL = "SupaBase 地址"
SUPABASE_KEY = "SupaBase 的 service key"
TESTDATA_PATH = "./testdata"
sync_problems_from_markdown(SUPABASE_URL, SUPABASE_KEY, TESTDATA_PATH)
总结
还能做什么
SupaBase 这样的平台有点像内网穿透——没有公网 ip 的电脑也可以作为主机对外界提供服务。GitHub Pages 没有存储功能,但是 SupaBase 有;SupaBase 没有太强的数据处理(比如评测程序)功能,但是自己的电脑有;自己的电脑没有公网 ip 无法直接从外界访问,但是 Github Pages 有。三个在一起就可以实现一个丐版的服务器。
没错,是一个服务器。有数据交换能力就能实现几乎所有东西。如果不嫌延迟大,用 SupaBase 开 Minecraft 服务器都是可以的(把报文包装好放进任务队列一个一个处理,服务器处理完之后放进结果队列分发给客户端)。
当然如果这样玩,还有一些好玩的事情可以做(而且很简单):
- 用来下五子棋。虽然也是实时操作游戏,延迟大,不过好在可以接受;
- 开一个 CTF / Problem Hunt 平台,主机用来检测 flag 是不是正确的(靶机另想办法);
- 制作留言板。
SupaBase 还提供了一些类似云函数的功能,这里不展开讲了。云函数就是在云端运行的一个简单函数,用来实现一些普通配置做不到的功能,运用得好甚至可以不使用主机,直接全套云端。
叠甲
- 这里面的代码都只停留在“实现概念”级别,不要在生产环境(真实使用场景)用它们。很多性能和安全缺陷没有考虑。
- 特别是评测机 Python 程序,没有任何防护,用户的程序直接取得整台主机的访问权。绝不要对外开放。不然电脑被打了都不知道。
- 上传题面和数据的时候,小心版权问题。不是所有题目都能随便公开的。
- 如果用来做其他事情,留言板也好,Minecraft 服务器也好,自己注意不要搞出违规的东西。
- SupaBase 有周活限制,如果 7 天内活动太少,项目会被暂停,需要重启才能继续运行;暂停后 90 天没重启项目就会被回收掉。
- 想自己搭建一个体验的话,需要有一定的自己解决问题的能力。不要一点问题就找人问问问!也不要开无趣的玩笑!
试用
本文的 OJ 部署于 https://warfarinbloodanger.github.io/supaoj/problem。源代码在同一个仓库里(backend.zip 是后端代码和文件的压缩包,要下载下来在本地跑的,不要在 GitHub 上改)。
如果愿意的话可以考虑用 GitHub Actions 部署后端。不过需要更多精力。具体可以问 AI。
由于主机运行在本人电脑上,性能有限(加上也没设计沙箱),传过来的评测程序统一返回 Dangerous Syscall。
而且说不定过几天要做点什么事了就把后端关了 / 迁移到 Github Actions。
结语
去吧孩子你是最幫的啸痒