> 从零构建一个仿 LeetCode 在线判题平台:C++17 后端 + 原生前端 + MySQL
> 不需要 Docker、不需要 Nginx、不需要任何第三方 C++ 库
--
目录
-
项目概述(#1-项目概述)
-
系统架构(#2-系统架构)
-
前端实现详解(#3-前端实现详解)
-
后端实现详解(#4-后端实现详解)
-
安全性设计(#5-安全性设计)
-
部署运维(#6-部署运维)
-
踩坑记录(#7-踩坑记录)
1. 项目概述
1.1 这是什么?
Mini OJ 是一个轻量级的**在线判题系统**(Online Judge),核心功能是:用户提交 C++ 代码 → 服务端编译运行 → 与预期输出比对 → 返回判题结果。
1.2 技术栈一览
| 层 | 技术 | 为什么选它 |
|---|------|-----------|
| 后端 | C++17 + 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 # 建库建表 + 示例 A+B 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 (patternPartsi.startsWith(':')) {
// 动态参数:/problem/:id 匹配 /problem/5 → {id: "5"}
paramspatternParts\[i.slice(1)] = pathPartsi;
} else if (patternPartsi !== pathPartsi) {
return null;
}
}
return params;
}
```
**设计亮点:**
-
模式匹配支持 `:param` 动态参数,如 `/problem/:id` 可匹配 `/problem/1`、`/problem/99`
-
`queryParams()` 独立解析 `?key=value` 查询字符串,不与路由耦合
-
默认路由 `home` --- 未登录展示介绍页,已登录自动跳转题目列表
**注册路由(app.js):**
```javascript
Router.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 -std=c++17 -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 -std=c++17 -Wall -o "
- bin_path + " " + code_path + " 2>&1";
std::array<char, 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 时间限制:超限 → SIGXCPU
struct rlimit rl;
rl.rlim_cur = cpu_sec;
rl.rlim_max = cpu_sec + 1;
setrlimit(RLIMIT_CPU, &rl);
// 2) 内存限制:超限 → malloc 失败 / SIGKILL
rl.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/null
dup2(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_RESTART
sigaction(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()` 不设置 handler,SIGALRM 直接杀死**整个后端进程**,导致任何人提交超时代码都能让服务崩溃。
**修复方式**:安装一个空 handler,且**不设置 SA_RESTART**:
```cpp
static 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()` 被中断后返回 -1(errno=EINTR),父进程就知道超时了。
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 时间和最大常驻内存:
```cpp
result.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_guard<std::mutex> lock(queue_mutex_);
task_queue_.push(task);
}
cv_.notify_one(); // 唤醒一个等待的工作线程
}
void worker_loop() {
while (running_) {
JudgeTask task;
{
std::unique_lock<std::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 w64;
for (int i = 0; i < 16; ++i)
wi = (blocki\*4<<24) | (blocki\*4+1<<16)
| (blocki\*4+2<<8) | blocki\*4+3;
for (int i = 16; i < 64; ++i) {
uint32_t s0 = rotr(wi-15,7) ^ rotr(wi-15,18) ^ (wi-15>>3);
uint32_t s1 = rotr(wi-2,17) ^ rotr(wi-2,19) ^ (wi-2>>10);
wi = wi-16 + s0 + wi-7 + s1;
}
// 2. 64 轮压缩
uint32_t a=h_0, b=h_1, c=h_2, d=h_3,
e=h_4, f=h_5, g=h_6, h=h_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 + Ki + wi;
uint32_t S0 = rotr(a,2) ^ rotr(a,13) ^ rotr(a,22);
uint32_t maj = (a & b) ^ (a & c) ^ (b & c);
h=g; g=f; f=e; e=d+t1; d=c; c=b; b=a; a=t1+t2;
}
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-256):
```cpp
// 存储格式: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_ptr<Connection> acquire() {
// 先尝试复用池中连接
if (!pool_.empty()) {
auto conn = std::move(pool_.back());
pool_.pop_back();
if (conn->ping()) return conn; // 连接存活 → 复用
}
// 池空或连接已断 → 新建
auto conn = std::make_unique<Connection>();
conn->connect();
return conn;
}
void release(std::unique_ptr<Connection> 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); // 正则匹配 ID
svr.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 Builder
json::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 int1e9` | `setrlimit(RLIMIT_AS)` → SIGKILL |
| Fork 炸弹 | `setrlimit(RLIMIT_NPROC, 1)` |
| 文件系统破坏 | I/O 全部通过 pipe,不直接访问磁盘 |
| 读取期望输出 | 子进程看不到数据库,只能在隔离目录执行 |
5.2 SQL 注入防护
```cpp
// ✅ 安全:参数化拼接 + escape
db->execute("INSERT INTO users (username, password_hash) VALUES ('"
- db->escape(username) + "', '" + db->escape(hash) + "')");
// ✅ 安全:problem_id 先做 int 验证
int problem_id = std::stoi(req.matches1); // 非数字直接抛异常
// ❌ 绝对避免的做法
// 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 管理
-
Token:128-bit 随机 hex 字符串
-
存储:内存 `unordered_map`,过期自动清理
-
传输:`Authorization: Bearer <token>` header
6. 部署运维
6.1 一键启动
```bash
必须从项目根目录启动(STATIC_DIR 是相对路径)!
cd ~/project
nohup ./backend/build/oj_backend > /tmp/oj.log 2>&1 &
验证
ss -tlnp | grep 8080
```
6.2 首次部署流程
```bash
1. 安装依赖
sudo apt install -y build-essential cmake g++ mysql-server libmysqlclient-dev
2. 下载 cpp-httplib
cd backend/third_party
curl -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 2>&1 &
7. 设管理员
mysql -u root -p -e "USE oj_platform; UPDATE users SET is_admin=1 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