本文是一个OJ系统的诞生的最后一篇。前面9篇我们逐步剖析了整个后端,现在讲解的是前端和部署。前端是5个原生HTML页面+6个JS文件,不依赖任何前端框架;部署是start.sh+systemd一键启动。
1:本篇模块
cpp
project-cpp-oj-vibecoding/
├── public/ ← ★ 前端:所有用户看到的页面 ★
│ ├── index.html ← 首页:题目列表
│ ├── login.html ← 登录页
│ ├── register.html ← 注册页
│ ├── problem.html ← 题目详情 + 代码编辑器 + 提交
│ ├── admin.html ← 管理后台
│ ├── css/
│ │ └── style.css ← 全局样式(暗色主题)
│ └── js/
│ ├── api.js ← fetch 封装,统一调用后端 API
│ ├── auth.js ← 登录状态检查
│ ├── problem.js ← 题目列表渲染
│ ├── problem_detail.js ← 题目详情 + CodeMirror 编辑器
│ ├── submit.js ← 提交 + 轮询结果
│ └── admin.js ← 管理后台 CRUD 逻辑
├── scripts/
│ ├── start.sh ← 一键编译 + 启动
│ └── oj-backend.service ← systemd 服务配置
前端技术选型
| 技术 | 为什么选它? |
|---|---|
| 原生 HTML + CSS + JS | 零依赖、零构建、打开即用。不需要 npm install、不需要 webpack、不需要 React/Vue。对于 5 个页面的小项目,原生够了 |
| CodeMirror 5(CDN) | 最流行的网页代码编辑器。CDN 引入,一行 HTML 就搞定。支持 C++ 语法高亮、行号、括号匹配、Tab 缩进 |
| fetch API | 浏览器原生支持的 HTTP 请求 API,不需要 axios/jQuery |
2:api.js------前后端通信的电话线
所有前端页面都通过这个文件和后端通信。
cpp
// ============================================================
// 文件名: api.js
// 作用: 封装 fetch API,统一处理所有后端请求
//
// 每个页面调用后端 API 时,都用这个函数:
// api('/api/problems') → GET 请求
// api('/api/login', { body: JSON.stringify({...}) }) → POST 请求
//
// 它会自动:
// 1. 携带 Cookie(credentials: 'same-origin')
// 2. 设置 Content-Type 为 JSON
// 3. 解析返回的 JSON
// 4. 如果出错,抛出异常
// ============================================================
async function api(path, options = {}) {
// 提取 headers(如果有的话),其他参数用 ...rest 接收
const { headers, ...restOptions } = options;
// 发送 HTTP 请求
const resp = await fetch(path, {
credentials: 'same-origin', // ★ 关键:自动携带 Cookie
// 这样后端才能获取 session_id
// 没有这行 → 每次请求都是"未登录"状态
...restOptions, // 其他参数(method, body 等)
headers: {
'Content-Type': 'application/json', // 默认 JSON 格式
...headers, // 可覆盖
},
});
// 解析响应文本
const text = await resp.text();
let data;
// 尝试解析 JSON,失败就用文本作为错误信息
try { data = JSON.parse(text); }
catch (e) { data = { error: text || 'Unknown error' }; }
// 如果 HTTP 状态码不是 2xx,抛出异常
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
// 这样调用者可以用 try-catch 统一处理错误
return data;
}
使用事例
javascript
// 在 login.html 中
async function handleLogin() {
try {
const data = await api('/api/login', {
method: 'POST',
body: JSON.stringify({
username: 'alice',
password: '123456'
}),
});
alert('登录成功!欢迎 ' + data.username);
window.location.href = '/'; // 跳转到首页
} catch (e) {
alert('登录失败:' + e.message); // 比如 "Invalid username or password"
}
}
3:auth.js------登陆状态管理
javascript
// ==========================================================
// 文件名: auth.js
// 作用: 检查用户是否登录
//
// 每个需要登录才能访问的页面,都在页面加载时调用 requireAuth()
// 如果未登录,自动跳转到登录页
// ============================================================
// 检查登录状态:调用 /api/me 接口
async function checkAuth() {
try {
const data = await api('/api/me'); // 获取当前用户信息
return { ok: true, user: data }; // 已登录
} catch (e) {
return { ok: false, error: e.message }; // 未登录或过期
}
}
// 强制要求登录:如果未登录,跳转到登录页
async function requireAuth(redirect = '/login.html') {
const result = await checkAuth();
if (!result.ok) {
window.location.href = redirect; // 跳转到登录页
return null;
}
return result.user; // 返回用户信息
}
在页面中的使用
html
<!-- problem.html 页面加载时 -->
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
// 页面加载时立即检查登录
// 如果未登录,跳转到 login.html
requireAuth();
// 或者可以拿到用户信息
requireAuth().then(user => {
console.log('当前用户:', user.username);
console.log('角色:', user.role);
});
</script>
Cookie鉴权的完整流程
html
浏览器 服务器
│ │
│ POST /api/login │
│ {"username":"alice"} │
│──────────────────────→│
│ │ 验证密码
│ │ 生成 session_id
│ │ 写 Session 文件
│ 200 OK │
│ Set-Cookie: │
│ session_id=a1b2... │
│←──────────────────────│
│ │
│ ★ 浏览器自动保存 Cookie
│ │
│ GET /api/me │
│ Cookie: session_id= │
│ a1b2... │
│──────────────────────→│
│ │ 读 Session 文件
│ │ 返回用户信息
│ 200 OK │
│ {"id":1,"username": │
│ "alice"} │
│←──────────────────────│
4:5个页面职责
1:index.html------题目列表
html
┌─────────────────────────────────────────────────┐
│ 标题: OJ System │
│ 导航: [首页] [管理后台] [登出] │
├─────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────┐│
│ │ 题目列表 ││
│ ├──────┬──────────┬──────────┬──────────┬────┤│
│ │ ID │ 标题 │ 难度 │ 通过率 │ 提交││
│ ├──────┼──────────┼──────────┼──────────┼────┤│
│ │ 1 │ 两数相加 │ easy │ 65.5% │ 200││
│ │ 2 │ 反转链表 │ medium │ 80.0% │ 50 ││
│ │ 3 │ 红黑树 │ hard │ 10.0% │ 100││
│ └──────┴──────────┴──────────┴──────────┴────┘│
└─────────────────────────────────────────────────┘
核心JS逻辑
javascript
// problem.js --- 加载并渲染题目列表
async function loadProblems() {
const data = await api('/api/problems'); // GET /api/problems
const table = document.getElementById('problem-table');
for (const p of data) {
const row = table.insertRow();
row.innerHTML = `
<td>${p.id}</td>
<td><a href="/problem.html?id=${p.id}">${p.title}</a></td>
<td><span class="badge-${p.difficulty}">${p.difficulty}</span></td>
<td>${p.pass_rate.toFixed(1)}%</td>
<td>${p.total_submissions}</td>
`;
}
}
// 页面加载时执行
loadProblems();
2:login.html------登陆页面
·
html
<!DOCTYPE html>
<html>
<head>
<title>登录 - OJ System</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="form-container">
<h2>登录</h2>
<form id="loginForm">
<input type="text" id="username" placeholder="用户名" required>
<input type="password" id="password" placeholder="密码" required>
<button type="submit">登录</button>
</form>
<p>还没有账号?<a href="/register.html">注册</a></p>
</div>
<script src="/js/api.js"></script>
<script>
document.getElementById('loginForm').onsubmit = async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const data = await api('/api/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
// ★ 登录成功!服务器设置了 Set-Cookie
// 浏览器自动保存了 Cookie
// 下次请求自动携带
alert('登录成功!');
window.location.href = '/'; // 跳转到首页
} catch (e) {
alert('登录失败:' + e.message);
}
};
</script>
</body>
</html>
3:register.html------注册页面
html
<!-- 和登录页面非常相似,只是调用 POST /api/register -->
<script>
document.getElementById('registerForm').onsubmit = async (e) => {
e.preventDefault();
try {
const data = await api('/api/register', {
method: 'POST',
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
}),
});
alert('注册成功!请登录');
window.location.href = '/login.html';
} catch (e) {
alert('注册失败:' + e.message);
}
};
</script>
4:problem.html------题目详细+编辑器+提交
这是最核心的前端页面,包含了:题目展示、代码编辑器、提交按钮、结果展示。
html
<!DOCTYPE html>
<html>
<head>
<title>题目详情 - OJ System</title>
<!-- CodeMirror 5(CDN 引入,不需要安装) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.18/codemirror.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.18/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.18/mode/clike/clike.min.js"></script>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div id="problem-container">
<!-- 左侧:题目描述 -->
<div id="problem-desc">
<h2 id="title"></h2>
<div id="description"></div>
<h3>输入格式</h3>
<div id="input-desc"></div>
<h3>输出格式</h3>
<div id="output-desc"></div>
<h3>示例</h3>
<div id="samples"></div>
</div>
<!-- 右侧:代码编辑器 + 提交 -->
<div id="editor-panel">
<textarea id="code-editor"></textarea>
<button id="submit-btn">提交</button>
<div id="result"></div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script src="/js/problem_detail.js"></script>
<script src="/js/submit.js"></script>
</body>
</html>
problem_detail.js ------ 加载题目详情 + 初始化编辑器
javascript
// ============================================================
// 文件名: submit.js
// 作用: 提交代码 + 轮询判题结果
//
// 这是"异步判题"的前端实现:
// 1. 提交代码 → 后端返回 202 + submission_id
// 2. 开始轮询:每隔 1 秒查一次结果
// 3. 等到状态变成 AC/WA/CE 等最终状态
// 4. 展示结果
// ============================================================
document.getElementById('submit-btn').onclick = async () => {
const code = editor.getValue(); // 从 CodeMirror 获取代码
if (!code.trim()) {
alert('请先写代码!');
return;
}
const resultDiv = document.getElementById('result');
resultDiv.innerHTML = '提交中...';
document.getElementById('submit-btn').disabled = true;
try {
// 第 1 步:提交代码
const submitResult = await api('/api/submit', {
method: 'POST',
body: JSON.stringify({
problem_id: parseInt(problemId),
code: code,
}),
});
const submissionId = submitResult.submission_id;
resultDiv.innerHTML = '判题中... (PENDING)';
// 第 2 步:轮询结果
// 每隔 1 秒查询一次,直到状态不再是 PENDING/JUDGING
const poll = setInterval(async () => {
try {
const result = await api(`/api/submissions/${submissionId}`);
// 更新状态显示
resultDiv.innerHTML = `判题中... (${result.status})`;
// 如果到了最终状态,停止轮询并显示结果
if (!['PENDING', 'JUDGING'].includes(result.status)) {
clearInterval(poll);
document.getElementById('submit-btn').disabled = false;
// 根据状态显示不同的结果
if (result.status === 'AC') {
resultDiv.innerHTML = `
<div class="result-ac">✅ 通过 (Accepted)</div>
<div>用时: ${result.time_used}ms</div>
<div>内存: ${result.memory_used}KB</div>
`;
} else if (result.status === 'WA') {
resultDiv.innerHTML = `
<div class="result-wa">❌ 答案错误 (Wrong Answer)</div>
<div>失败用例: #${result.failed_case}</div>
<div>你的输出: ${result.failed_actual || '?'}</div>
<div>期望输出: ${result.failed_expected || '?'}</div>
<div>输入: ${result.failed_input || '?'}</div>
`;
} else if (result.status === 'CE') {
resultDiv.innerHTML = `
<div class="result-ce">⚠️ 编译错误 (Compilation Error)</div>
<pre>${result.error_msg}</pre>
`;
} else if (result.status === 'TLE') {
resultDiv.innerHTML = `
<div class="result-tle">⏱️ 超时 (Time Limit Exceeded)</div>
<div>用时: ${result.time_used}ms</div>
`;
} else {
resultDiv.innerHTML = `
<div>状态: ${result.status}</div>
<div>${result.error_msg || ''}</div>
`;
}
}
} catch (e) {
clearInterval(poll);
document.getElementById('submit-btn').disabled = false;
resultDiv.innerHTML = `查询失败: ${e.message}`;
}
}, 1000); // 1 秒轮询一次
} catch (e) {
document.getElementById('submit-btn').disabled = false;
resultDiv.innerHTML = `提交失败: ${e.message}`;
}
};
轮询流程图
javascript
用户点击"提交"
│
▼
POST /api/submit → 202 Accepted + submission_id
│
▼
setInterval 开始轮询(每 1 秒)
│
├── GET /api/submissions/42 → { status: "PENDING" }
│ └── 显示 "判题中... (PENDING)"
│
├── GET /api/submissions/42 → { status: "JUDGING" }
│ └── 显示 "判题中... (JUDGING)"
│
├── GET /api/submissions/42 → { status: "AC", time_used: 15, ... }
│ └── 显示 "通过!用时 15ms,内存 2048KB"
│ └── clearInterval → 停止轮询
│
└──(如果一直是 PENDING/JUDGING,继续轮询)
5:admin.html------管理后台
管理员可以增删改题目和管理测试用例。主要功能:
javascript
┌─────────────────────────────────────────────────┐
│ 管理后台 │
├─────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────┐│
│ │ 题目列表 + [新增题目] 按钮 ││
│ ├──────┬──────────┬──────────┬────────────────┤│
│ │ ID │ 标题 │ 难度 │ 操作 ││
│ ├──────┼──────────┼──────────┼────────────────┤│
│ │ 1 │ 两数相加 │ easy │ [编辑] [删除] ││
│ │ 2 │ 反转链表 │ medium │ [编辑] [删除] ││
│ └──────┴──────────┴──────────┴────────────────┘│
│ │
│ ┌─────────────────────────────────────────────┐│
│ │ 测试用例管理(点击某道题的"编辑"后弹出) ││
│ │ 可以查看/添加/删除测试用例 ││
│ │ 每个用例可以设置:is_sample 是否展示给用户 ││
│ └─────────────────────────────────────────────┘│
└─────────────────────────────────────────────────┘
5:style.css------暗色主题
css
/* 全局设置 */
body {
font-family: 'Segoe UI', sans-serif;
background-color: #1a1a2e; /* 深色背景 */
color: #e0e0e0; /* 浅色文字 */
margin: 0;
padding: 20px;
}
/* 表单容器(登录/注册页) */
.form-container {
max-width: 400px;
margin: 100px auto;
padding: 30px;
background: #16213e; /* 稍亮的深色卡片 */
border-radius: 8px;
}
/* 输入框 */
input[type="text"],
input[type="password"],
textarea {
width: 100%;
padding: 10px;
margin: 8px 0;
background: #0f3460;
border: 1px solid #333;
color: #fff;
border-radius: 4px;
}
/* 按钮 */
button {
background: #e94560; /* 红色按钮 */
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #c73e54;
}
/* 难度标签 */
.badge-easy { color: #4caf50; } /* 绿色 */
.badge-medium { color: #ff9800; } /* 橙色 */
.badge-hard { color: #f44336; } /* 红色 */
/* 判题结果 */
.result-ac { color: #4caf50; font-size: 1.2em; } /* 绿色通过 */
.result-wa { color: #f44336; font-size: 1.2em; } /* 红色错误 */
.result-ce { color: #ff9800; font-size: 1.2em; } /* 橙色编译错 */
.result-tle { color: #9c27b0; font-size: 1.2em; } /* 紫色超时 */
6:start.sh------布置脚本
bash
#!/bin/bash
set -e # 任何命令失败就退出(不继续执行)
# 获取项目根目录路径
# "$(dirname "$0")" 是脚本所在目录(scripts/)
# "/.." 回到项目根目录
PROJ="$(cd "$(dirname "$0")/.." && pwd)"
BUILD_DIR="$PROJ/build"
echo "==> Creating runtime directories..."
# 创建 Session 目录和沙箱目录
sudo mkdir -p /var/oj/sessions /tmp/oj_sandbox
# 把目录所有权给当前用户(这样不需要 root 也能写文件)
sudo chown -R "$(whoami)" /var/oj/sessions /tmp/oj_sandbox 2>/dev/null || true
echo "==> Building..."
# 在 build/ 目录中生成 CMake 构建文件
cmake -S "$PROJ" -B "$BUILD_DIR"
# 编译 oj_backend 可执行文件
cmake --build "$BUILD_DIR" --target oj_backend -j$(nproc)
echo "==> Starting oj_backend..."
cd "$PROJ"
# exec 替换当前 shell 进程为 oj_backend
# 这样 Ctrl+C 直接发给 oj_backend,而不是 shell
exec "$BUILD_DIR/oj_backend" "$PROJ/config/config.json"
使用方法
一键编译 + 启动
bash scripts/start.sh
如果不想编译,直接运行已有的可执行文件
./build/oj_backend config/config.json
7:systemd服务------开机自启
bash
# scripts/oj-backend.service
# 这是一个 systemd 服务配置文件
# systemd 是 Linux 系统的"进程管理器"------负责启动、停止、监控服务
#
# 安装方式:
# sudo cp scripts/oj-backend.service /etc/systemd/system/
# sudo systemctl daemon-reload
# sudo systemctl enable --now oj-backend
# (enable 是开机自启,--now 是立即启动)
[Unit]
Description=OJ Backend Service # 服务描述
After=network.target mysql.service # 在网络和 MySQL 之后启动
# 确保 MySQL 已经运行了
[Service]
Type=simple # 简单类型:主进程就是服务
User=oj # 以 oj 用户运行(安全和权限考虑)
WorkingDirectory=/opt/oj # 工作目录
ExecStart=/opt/oj/build/oj_backend /opt/oj/config/config.json
# 启动命令
Restart=on-failure # 失败时自动重启
RestartSec=5 # 重启前等待 5 秒
[Install]
WantedBy=multi-user.target # 多用户模式下启动(正常启动)
8:从开发到部署的完整流程
1:开发环境
bash
# 1. 安装依赖
sudo apt install cmake g++ libmysqlclient-dev libseccomp-dev libssl-dev mysql-server
# 2. 初始化数据库
sudo mysql < database/init.sql
# 3. 配置 config.json(改数据库密码)
vim config/config.json
# 4. 编译 + 启动
bash scripts/start.sh
# 5. 打开浏览器访问
# http://localhost:8080
2:环境部署
bash
# 1. 把项目传到服务器
git clone git@github.com:yourname/project-cpp-oj-vibecoding.git
cd project-cpp-oj-vibecoding
# 2. 安装依赖
sudo apt install cmake g++ libmysqlclient-dev libseccomp-dev libssl-dev mysql-server
# 3. 初始化数据库
sudo mysql < database/init.sql
# 4. 配置 config.json
vim config/config.json # 修改数据库密码等
# 5. 安装 systemd 服务
sudo cp scripts/oj-backend.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now oj-backend
# 6. 查看运行状态
sudo systemctl status oj-backend
9:项目完整回顾
bash
┌─────────────────────────────────────────────────────────────────────┐
│ 一个 OJ 系统的诞生 │
│ 12 篇博客 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ [01] 项目动机 + 技术选型 + AI 辅助设计 │
│ 为什么做?为什么用 C++?4 层架构长什么样? │
│ │
│ [02] 工具层 --- Config + Logger │
│ 单例模式、JSON 配置、日志级别、RAII 锁 │
│ 文件: src/utils/config.hpp/cc + logger.hpp/cc │
│ │
│ [03] 数据库层 --- MySQL 连接池 │
│ vector 存连接、mutex 保护、condition_variable 等待/通知 │
│ 文件: src/db/connection_pool.hpp/cc │
│ │
│ [04] 模型层 --- 数据快递盒 │
│ 3 个 struct:User / Problem / TestCase(47 行代码) │
│ 文件: src/model/user.hpp / problem.hpp / test_case.hpp │
│ │
│ [05] Service 层上 --- AuthService │
│ 注册/登录/bcrypt 密码哈希/Session 文件/限流/后台清理 │
│ 文件: src/service/auth_service.cc(414 行) │
│ │
│ [06] Service 层中 --- ProblemService │
│ LEFT JOIN 多表查询、封装 escape() 防 SQL 注入、级联删除 │
│ 文件: src/service/problem_service.cc(291 行) │
│ │
│ [07] Service 层下 --- ExecutorService │
│ 任务队列 + Worker 线程、fork 子进程、seccomp 沙箱、 │
│ setrlimit 资源限制、编译运行比对 │
│ 文件: src/service/executor_service.cc(506 行) │
│ │
│ [08] Handler 层 --- HTTP 接线员 │
│ 15 个 API、12 种 HTTP 状态码、Cookie 设置/清除、6 道安检门 │
│ 文件: src/handler/ 下的 4 个文件 │
│ │
│ [09] main.cc --- 总指挥 │
│ 初始化顺序、信号处理优雅关闭、CMake 构建 │
│ 文件: src/main.cc + src/server/ │
│ │
│ [10] 前端 + 部署 --- 最后一公里 │
│ 原生 HTML/JS/CSS、CodeMirror 编辑器、fetch API 轮询、 │
│ start.sh + systemd 一键部署 │
│ 文件: public/ + scripts/ │
│ │
└─────────────────────────────────────────────────────────────────────┘
数据量统计
| 维度 | 数字 |
|---|---|
| C++ 后端 | 约 1800 行(不含第三方库) |
| 前端 | 5 个 HTML 页面、6 个 JS 文件、1 个 CSS |
| 数据库 | 4 张表 |
| API | 15 个 REST 接口 |
| 判题状态 | 7 种(PENDING → JUDGING → AC/WA/CE/RE/TLE/MLE) |
| 安全防线 | 5 层(seccomp /setrlimit/prctl /pipe/ 超时监控) |
| 代码总行数 | 约 3000 行(含前端) |
10:结束语
从一个模糊的想法------"我想做一个 OJ 系统",经过 10 篇博客的逐层解剖,我们看到了一个完整的在线判题系统是如何从零到一构建起来的。
这个项目不大,但五脏俱全:
它有**完整的用户系统**(注册、登录、Session、限流)
它有**完整的题目系统**(增删改查、测试用例管理)
它有**完整的判题引擎**(编译、沙箱、逐用例运行、结果比对)
它有**完整的前端界面**(5 个页面、代码编辑器、轮询)
它有**完整的部署方案**(编译脚本、systemd 服务)
除了代码本身,更重要的是代码背后的设计思想
**单例模式**------全局唯一的配置、日志、连接池
**分层架构**------Router → Handler → Service → Model → DB,各司其职
**防御性编程**------处处有默认值、处处有错误处理、处处有安全检查
**最小可行产品**------不追求完美,追求够用。文件 Session 而不是 Redis,原生 JS 而不是 React,够用就好