# Mini OJ — 项目详解文档

📅 2026/8/8 21:53:13
# Mini OJ — 项目详解文档
从零构建一个仿 LeetCode 在线判题平台C17 后端 原生前端 MySQL 不需要 Docker、不需要 Nginx、不需要任何第三方 C 库--## 目录1. [项目概述](#1-项目概述)2. [系统架构](#2-系统架构)3. [前端实现详解](#3-前端实现详解)4. [后端实现详解](#4-后端实现详解)5. [安全性设计](#5-安全性设计)6. [部署运维](#6-部署运维)7. [踩坑记录](#7-踩坑记录)---## 1. 项目概述### 1.1 这是什么Mini OJ 是一个轻量级的**在线判题系统**Online Judge核心功能是用户提交 C 代码 → 服务端编译运行 → 与预期输出比对 → 返回判题结果。### 1.2 技术栈一览| 层 | 技术 | 为什么选它 ||---|------|-----------|| 后端 | C17 cpp-httplib | 高性能header-only 零依赖 || 数据库 | MySQL 8.0 | 关系型适合题目/提交数据 || 前端 | 原生 HTML/CSS/JS | 零构建工具部署即静态文件 || CSS | Water.css | classless 自动暗色模式 || 安全 | fork setrlimit | 进程级隔离无需 Docker |### 1.3 核心功能- **用户系统**注册/登录/登出Session Bearer Token 认证- **题目浏览**题目列表 详情页支持 Markdown 描述、样例展示- **在线提交**编辑器提交 C 代码实时轮询判题结果- **判题引擎**编译 → 沙箱运行 → 逐用例比对 → 6 种状态反馈- **管理后台**CRUD 题目 管理测试用例- **提交记录**查看历史提交、每个用例的详细结果---## 2. 系统架构### 2.1 整体拓扑┌─────────────────────────────────────────────────┐│ 浏览器 ││ ┌──────────────────────────────────────────┐ ││ │ SPA (Hash Router) │ ││ │ 首页 → 登录 → 题目列表 → 详情提交 → 结果 │ ││ └──────────────────────────────────────────┘ │└──────────────────┬──────────────────────────────┘│ HTTP (8080)┌──────────────────▼──────────────────────────────┐│ cpp-httplib HTTP Server ││ ┌─────────────┐ ┌────────────────────────────┐ ││ │ 静态文件服务 │ │ 12 个 API Handler │ ││ │ (frontend/) │ │ /api/auth/* │ ││ │ │ │ /api/problems/* │ ││ │ │ │ /api/submissions/* │ ││ │ │ │ /api/admin/* │ ││ └─────────────┘ └──────────┬─────────────────┘ ││ │ ││ ┌───────────────────────────▼─────────────────┐ ││ │ Judge Manager (2 线程) │ ││ │ ┌──────────┐ ┌──────────────────────┐ │ ││ │ │ 编译 g │→│ fork setrlimit 沙箱 │ │ ││ │ └──────────┘ └──────────────────────┘ │ ││ └────────────────────────────────────────────┘ │└──────────────────┬──────────────────────────────┘│ TCP (3306)┌──────────────────▼──────────────────────────────┐│ MySQL 8.0 ││ users | problems | test_cases | submissions │└─────────────────────────────────────────────────┘### 2.2 目录结构├── database/init.sql # 建库建表 示例 AB Problem├── backend/│ ├── CMakeLists.txt # 构建配置│ ├── src/│ │ ├── main.cpp # 入口init() → start()│ │ ├── config.h # 所有可配置常量│ │ ├── server.h # HTTP 路由 12 个 Handler│ │ ├── auth.h # Session 管理器内存│ │ ├── db.h # MySQL RAII 连接池│ │ ├── utils.h # JSON 构建 / SHA-256 / 密码哈希│ │ └── judge_manager.h # 判题引擎编译沙箱队列│ └── third_party/│ └── cpp-httplib.h # ← 需手动下载├── frontend/│ ├── index.html # SPA 入口│ ├── css/app.css # 自定义样式│ └── js/│ ├── app.js # 路由注册 启动│ ├── router.js # Hash Router│ ├── api.js # API 请求封装│ ├── auth.js # 登录状态管理│ ├── components/ # navbar / problem-table / judge-result│ └── pages/ # 7 个页面含首页├── SPEC.md # 完整规格文档├── DEPLOY.md # 部署指南└── README.md # 快速开始---## 3. 前端实现详解### 3.1 Hash Router — 零依赖 SPA 路由前端采用 **Hash-based SPA** 架构全部路由逻辑不到 120 行。**核心匹配算法**javascript// router.js — 路由匹配核心function match(pattern, path) {const patternParts pattern.split(/);const pathParts path.split(/);if (patternParts.length ! pathParts.length) return null;const params {};for (let i 0; i patternParts.length; i) {if (patternParts[i].startsWith(:)) {// 动态参数/problem/:id 匹配 /problem/5 → {id: 5}params[patternParts[i].slice(1)] pathParts[i];} else if (patternParts[i] ! pathParts[i]) {return null;}}return params;}**设计亮点**- 模式匹配支持 :param 动态参数如 /problem/:id 可匹配 /problem/1、/problem/99- queryParams() 独立解析 ?keyvalue 查询字符串不与路由耦合- 默认路由 home — 未登录展示介绍页已登录自动跳转题目列表**注册路由app.js**javascriptRouter.register(home, (p) Pages.Landing.render(p));Router.register(auth, (p) Pages.Landing.render(p));Router.register(problems, (p) Pages.ProblemList.render(p));Router.register(problem/:id, (p) Pages.ProblemDetail.render(p));Router.register(submissions, (p) Pages.SubmissionHistory.render(p));Router.register(admin, (p) Pages.AdminProblemList.render(p));Router.register(admin/problem/:id, (p) Pages.AdminProblemEdit.render(p)); ⚠️ **踩坑**传递方法引用如 Pages.ProblemDetail.render 会丢失 this 上下文必须用箭头函数 (p) Pages.ProblemDetail.render(p) 包裹。### 3.2 判题结果轮询提交代码后前端每秒轮询一次结果直到判题完成javascript// problem-detail.js — 轮询判题结果_pollForResult(submissionId) {let attempts 0;this._pollTimer setInterval(async () {const result await API.getSubmission(submissionId);attempts;if (result.status ! pending result.status ! compiling result.status ! running) {// 判题完成渲染结果clearInterval(this._pollTimer);document.getElementById(judgeResultPanel).innerHTML JudgeResult.render(result);return;}// 60 秒超时保护if (attempts 60) {clearInterval(this._pollTimer);// 提示用户手动刷新}}, 1000);}**设计亮点**不是 WebSocket 而是轮询 — 足够简单60 行能搞定的事不需要引入额外协议。### 3.3 组件化设计javascript// judge-result.js — 可复用判题结果组件const JudgeResult {render(result) {// 汇总信息AC/WA/TLE 总用时 总内存// 编译错误展示如 CE// 逐用例详情表格用例# / 结果 / 用时 / 内存 / 期望 / 实际}};// problem-table.js — 可复用题目表格const ProblemTable {render(problems, { isAdmin, onDelete } {}) {// 普通用户查看题目// 管理员额外显示 编辑/删除 操作按钮}};**设计理念**组件只负责渲染不处理路由跳转。解耦后任何页面都能复用同一套组件。### 3.4 首页设计未登录用户看到的首页⚡ Mini OJ带脉冲动画轻量级在线判题平台┌──────────┐ ┌──────────┐ ┌──────────┐│ 在线判题│ │ ️ 安全沙箱│ │ 题目管理│└──────────┘ └──────────┘ └──────────┘┌─ 登录 ─┬─ 注册 ─┐│ 登录表单 │└──────────────────┘登录后自动跳转题目列表导航栏展开完整功能。---## 4. 后端实现详解### 4.1 判题引擎 — 进程沙箱隔离这是整个项目最核心、最精巧的部分。#### 4.1.1 三步流水线用户提交代码│▼┌──────────┐│ 1. 编译 │ g -O2 -stdc17 -Wall → 二进制文件└────┬─────┘│ 成功▼┌──────────┐│ 2. 运行 │ fork() → setrlimit() → alarm() → execl()└────┬─────┘│▼┌──────────┐│ 3. 比对 │ trim(实际输出) trim(期望输出) ?└──────────┘#### 4.1.2 编译阶段cpp// judge_manager.h — 编译bool compile(const std::string code_path, const std::string bin_path,std::string error) {std::string cmd g -O2 -stdc17 -Wall -o bin_path code_path 21;std::arraychar, 256 buffer;FILE* pipe popen(cmd.c_str(), r); // 捕获 g 输出while (fgets(buffer.data(), buffer.size(), pipe) ! nullptr) {error buffer.data(); // 收集编译错误信息}return (pclose(pipe) 0); // 返回码 0 编译成功}#### 4.1.3 沙箱运行 — 核心安全机制cpp// judge_manager.h — run_test() 核心流程pid_t pid fork();if (pid 0) {// 子进程用户代码运行环境 // 1) CPU 时间限制超限 → SIGXCPUstruct rlimit rl;rl.rlim_cur cpu_sec;rl.rlim_max cpu_sec 1;setrlimit(RLIMIT_CPU, rl);// 2) 内存限制超限 → malloc 失败 / SIGKILLrl.rlim_cur memory_limit_kb * 1024;rl.rlim_max memory_limit_kb * 1024 1024 * 1024;setrlimit(RLIMIT_AS, rl);// 3) 进程数限制 1防止 fork 炸弹rl.rlim_cur 1;rl.rlim_max 1;setrlimit(RLIMIT_NPROC, rl);// 4) I/O 重定向stdin ← 输入文件, stdout → 输出文件, stderr → /dev/nulldup2(fd_in, STDIN_FILENO);dup2(fd_out, STDOUT_FILENO);dup2(fd_null, STDERR_FILENO);// 5) 执行编译好的二进制execl(bin_path.c_str(), bin_path.c_str(), nullptr);_exit(127); // execl 失败才到这里}// 父进程监控与计时 // 设置 SIGALRM 处理只打断 wait4()不杀死进程struct sigaction sa;sa.sa_handler alarm_noop; // 空函数sa.sa_flags 0; // 不设置 SA_RESTARTsigaction(SIGALRM, sa, old_sa);alarm(wall_sec); // 挂钟超时 用户时限 2 秒缓冲pid_t waited wait4(pid, status, 0, rusage); // 阻塞等待alarm(0); // 取消闹钟sigaction(SIGALRM, old_sa, nullptr); // 恢复旧 handler**三层防护机制**| 限制 | 机制 | 触发条件 | 判题结果 ||------|------|---------|---------|| CPU 时间 | setrlimit(RLIMIT_CPU) | 用户代码循环/死循环 | TLE (SIGXCPU) || 内存 | setrlimit(RLIMIT_AS) | 数组过大/内存泄漏 | MLE (SIGKILL) || 挂钟时间 | alarm(wall_sec) | I/O 阻塞/恶意 sleep | TLE (SIGALRM) | **为什么需要三层** CPU 限制挡不住 sleep(100) 这种不耗 CPU 的恶意代码内存限制挡不住死循环占着 CPU 不放。必须配合使用。#### 4.1.4 信号处理 — 踩坑与修复**最初的 Bug**alarm() 不设置 handlerSIGALRM 直接杀死**整个后端进程**导致任何人提交超时代码都能让服务崩溃。**修复方式**安装一个空 handler且**不设置 SA_RESTART**cppstatic void alarm_noop(int) {// 空函数 — 什么也不做但 prevent 进程被杀}struct sigaction sa;sa.sa_handler alarm_noop;sa.sa_flags 0; // ⚠️ 关键不设 SA_RESTART → wait4() 收到 EINTR 返回**原理**sigaction 默认行为是收到信号后自动重启被中断的系统调用。但这里我们需要 SIGALRM **打断** wait4()所以显式不设置 SA_RESTART。wait4() 被中断后返回 -1errnoEINTR父进程就知道超时了。#### 4.1.5 结果判定cpp// judge_manager.h — 信号 → 状态映射if (WIFEXITED(status)) {int exit_code WEXITSTATUS(status);if (exit_code ! 0) {result.status runtime_error; // 任何非零退出码 → RE}} else if (WIFSIGNALED(status)) {switch (WTERMSIG(status)) {case SIGALRM: // alarm 超时case SIGXCPU: // RLIMIT_CPU 超限result.status time_limit_exceeded;break;case SIGSEGV: // 段错误case SIGABRT: // abort()case SIGFPE: // 除零/浮点异常result.status runtime_error;break;case SIGKILL: // 通常是被 OOM Killer 或 RLIMIT_AS 杀result.status memory_limit_exceeded;break;}}// 然后再比对输出if (result.status accepted) {if (trim(actual_output) ! trim(expected_output)) {result.status wrong_answer;}}**资源统计**wait4() 返回的 rusage 结构体提供精确的用户态/内核态 CPU 时间和最大常驻内存cppresult.time_ms rusage.ru_utime.tv_sec * 1000 rusage.ru_utime.tv_usec / 1000 rusage.ru_stime.tv_sec * 1000 rusage.ru_stime.tv_usec / 1000;result.memory_kb rusage.ru_maxrss;### 4.2 生产者-消费者判题队列cpp// judge_manager.h — 队列模型void enqueue(int submission_id, int problem_id, const std::string code) {JudgeTask task{submission_id, problem_id, code};{std::lock_guardstd::mutex lock(queue_mutex_);task_queue_.push(task);}cv_.notify_one(); // 唤醒一个等待的工作线程}void worker_loop() {while (running_) {JudgeTask task;{std::unique_lockstd::mutex lock(queue_mutex_);cv_.wait(lock, [this] {return !task_queue_.empty() || !running_;});if (!running_) return;task task_queue_.front();task_queue_.pop();}judge_task(task); // 编译 → 运行 → 比对 → 写库}}**设计要点**- 使用 condition_variable 而非忙等 — 空闲时线程休眠不耗 CPU- cv_.wait() 的谓词检查 !running_ — 优雅关闭时能立即退出- 默认 2 个工作线程config::MAX_CONCURRENT_JUDGE支持并发判题### 4.3 纯 C SHA-256 实现**最初的实现**严重安全漏洞cpp// ❌ 危险命令注入std::string cmd echo -n input | sha256sum;FILE* pipe popen(cmd.c_str(), r);// 输入 abc; rm -rf /; echo → 灾难**最终实现** — 完整 FIPS 180-4 算法cpp// utils.h — SHA256 类class SHA256 {void process_block(const uint8_t* block) {// 1. 消息调度16 个 32-bit 字 → 64 个uint32_t w[64];for (int i 0; i 16; i)w[i] (block[i*4]24) | (block[i*41]16)| (block[i*42]8) | block[i*43];for (int i 16; i 64; i) {uint32_t s0 rotr(w[i-15],7) ^ rotr(w[i-15],18) ^ (w[i-15]3);uint32_t s1 rotr(w[i-2],17) ^ rotr(w[i-2],19) ^ (w[i-2]10);w[i] w[i-16] s0 w[i-7] s1;}// 2. 64 轮压缩uint32_t ah_[0], bh_[1], ch_[2], dh_[3],eh_[4], fh_[5], gh_[6], hh_[7];for (int i 0; i 64; i) {uint32_t S1 rotr(e,6) ^ rotr(e,11) ^ rotr(e,25);uint32_t ch (e f) ^ (~e g);uint32_t t1 h S1 ch K[i] w[i];uint32_t S0 rotr(a,2) ^ rotr(a,13) ^ rotr(a,22);uint32_t maj (a b) ^ (a c) ^ (b c);hg; gf; fe; edt1; dc; cb; ba; at1t2;}h_[0]a; h_[1]b; h_[2]c; h_[3]d;h_[4]e; h_[5]f; h_[6]g; h_[7]h;}};**密码哈希**盐值 SHA-256cpp// 存储格式16字节hex盐 : 64字符hex哈希std::string hash_password(const std::string password) {std::string salt random_hex(16); // 128-bit 随机盐return salt : sha256(salt password);}bool verify_password(const std::string password, const std::string stored) {auto pos stored.find(:);std::string salt stored.substr(0, pos);return sha256(salt password) stored.substr(pos 1);}### 4.4 MySQL RAII 连接池cpp// db.h — 连接池结构class Pool {public:static Pool instance() { // 单例static Pool pool;return pool;}std::unique_ptrConnection acquire() {// 先尝试复用池中连接if (!pool_.empty()) {auto conn std::move(pool_.back());pool_.pop_back();if (conn-ping()) return conn; // 连接存活 → 复用}// 池空或连接已断 → 新建auto conn std::make_uniqueConnection();conn-connect();return conn;}void release(std::unique_ptrConnection conn) {if (pool_.size() POOL_SIZE) {pool_.push_back(std::move(conn)); // 归还池中}// 超过池大小 → 自动析构关闭}};**设计要点**- Connection 析构自动 mysql_close()配合 unique_ptr 不会泄漏- acquire() 先 ping() 再复用防止长连接断开- escape() 封装 mysql_real_escape_string()所有 SQL 拼接都用它防注入### 4.5 HTTP API 路由cpp// server.h — 路由注册12 个 API 端点svr.Post(/api/auth/register, handler_register);svr.Post(/api/auth/login, handler_login);svr.Get(/api/problems, handler_problem_list);svr.Get(/api/problems/(\\d), handler_problem_detail); // 正则匹配 IDsvr.Post(/api/submissions, handler_submit);// ...共 12 个端点// 认证守卫宏#define AUTH_GUARD(req, res) \auto* session get_session(req); \if (!session) { \res.set_content(json::error(Authentication required), application/json); \return; \}// 管理员守卫宏#define ADMIN_GUARD(req, res) \AUTH_GUARD(req, res); \if (!session-is_admin) { ... }**设计亮点**用宏来实现声明式权限检查业务代码只需一行 AUTH_GUARD(req, res)避免在每个 handler 里重复写认证逻辑。### 4.6 零依赖 JSON 构建器不引入任何 JSON 库用 Builder 模式手工构建cpp// utils.h — JSON Object Builderjson::Object resp;resp.add(id, problem_id);resp.add(title, A B Problem);resp.add(difficulty, easy);resp.add_raw(testcases, all_arr.str()); // 嵌入子数组return resp.str(); // → {id:1,title:A B Problem,...}**设计要点**add_raw() 允许直接插入已序列化的 JSON 片段支持嵌套对象和数组避免递归构建的复杂性。---## 5. 安全性设计### 5.1 进程隔离| 威胁 | 防护 ||------|------|| 恶意代码 while(1){} | setrlimit(RLIMIT_CPU) → SIGXCPU || 恶意代码 sleep(100) | alarm() 挂钟超时 || 恶意代码 new int[1e9] | setrlimit(RLIMIT_AS) → SIGKILL || Fork 炸弹 | setrlimit(RLIMIT_NPROC, 1) || 文件系统破坏 | I/O 全部通过 pipe不直接访问磁盘 || 读取期望输出 | 子进程看不到数据库只能在隔离目录执行 |### 5.2 SQL 注入防护cpp// ✅ 安全参数化拼接 escapedb-execute(INSERT INTO users (username, password_hash) VALUES ( db-escape(username) , db-escape(hash) ));// ✅ 安全problem_id 先做 int 验证int problem_id std::stoi(req.matches[1]); // 非数字直接抛异常// ❌ 绝对避免的做法// std::string sql SELECT * FROM users WHERE username username ;### 5.3 XSS 防护javascript// 前端所有用户输入渲染前转义function escapeHtml(str) {const div document.createElement(div);div.textContent str;return div.innerHTML;}### 5.4 Session 管理- Token128-bit 随机 hex 字符串- 存储内存 unordered_map过期自动清理- 传输Authorization: Bearer token header---## 6. 部署运维### 6.1 一键启动bash# 必须从项目根目录启动STATIC_DIR 是相对路径cd ~/projectnohup ./backend/build/oj_backend /tmp/oj.log 21 # 验证ss -tlnp | grep 8080### 6.2 首次部署流程bash# 1. 安装依赖sudo apt install -y build-essential cmake g mysql-server libmysqlclient-dev# 2. 下载 cpp-httplibcd backend/third_partycurl -o cpp-httplib.h https://raw.githubusercontent.com/yhirose/cpp-httplib/master/httplib.h# 3. 改数据库密码vim backend/src/config.h # 改 DB_PASS# 4. 初始化数据库sudo mysql -u root -p database/init.sql# 5. 编译cd backend mkdir build cd build cmake .. make -j$(nproc)# 6. 启动在项目根目录cd ~/project nohup ./backend/build/oj_backend /tmp/oj.log 21 # 7. 设管理员mysql -u root -p -e USE oj_platform; UPDATE users SET is_admin1 WHERE username你的用户名; ⚠️ **设为管理员后必须退出重新登录**因为 Session 缓存了旧的 is_admin 值。### 6.3 阿里云安全组别忘了在阿里云控制台 → 安全组 → 入方向规则中放行 **8080** 端口否则外网访问会是 ERR_CONNECTION_REFUSED。---## 7. 踩坑记录这是实际开发中遇到的所有问题按时间顺序记录| # | 问题 | 根因 | 解决方案 ||---|------|------|---------|| 1 | my_bool 未声明 | MySQL 8.0 废弃了 my_bool 类型 | 改为 bool || 2 | undefined reference to SSL_* | cpp-httplib v0.7 需要链接 OpenSSL | CMake 加 ssl crypto || 3 | cpp-httplib.h 第一行 404: Not Found | curl URL 写错single_include 分支 | 用 master/httplib.h || 4 | Failed to connect to MySQL | config.h 中 DB_PASS 为空字符串 | 改为真实密码**重编译** || 5 | 启动后端口没监听 | 没在项目根目录启动STATIC_DIR 是相对路径 | 必须 cd ~/project || 6 | Exit 127 | build 目录被删二进制不存在 | 重新 cmake .. make || 7 | SIGALRM 杀掉整个后端 | alarm() 无 handler → 进程收到 SIGALRM 默认终止 | 安装空 handler 不设 SA_RESTART || 8 | SHA-256 shell 命令注入 | popen(echo -n input \| sha256sum) | 纯 C 实现 SHA-256 || 9 | 设为管理员后仍提示无权限 | Session 内存缓存了旧 is_admin | 退出重新登录 || 10 | Pages is not defined | JS 加载顺序问题 | var Pages {}; 必须在页面脚本之前 || 11 | this._renderFullPage is not a function | 方法引用传参时 this 丢失 | 用箭头函数包裹 |---## 附录判题状态说明| 状态码 | 含义 | 触发条件 ||--------|------|---------|| AC (Accepted) | 通过 | 所有用例输出匹配 || WA (Wrong Answer) | 答案错误 | 输出与预期不一致 || TLE (Time Limit Exceeded) | 超时 | CPU 时限或挂钟时限到达 || MLE (Memory Limit Exceeded) | 内存超限 | 超过内存限制被 SIGKILL || RE (Runtime Error) | 运行错误 | 非零退出码/SIGSEGV/SIGFPE || CE (Compilation Error) | 编译错误 | g 编译失败 |--- 项目地址GitHub Qingfeng-Blip | 部署于阿里云 ECS 8.134.126.29:8080