一个OJ系统的诞生(六)Service层中——ProblemService题目管理

发布时间:2026/6/27 3:14:39
一个OJ系统的诞生(六)Service层中——ProblemService题目管理 1本篇的板块project-cpp-oj-vibecoding/ ├── src/ │ ├── main.cc │ ├── server/ │ ├── handler/ │ ├── service/ ← 今天的主角 │ │ ├── auth_service.cc ← 已讲 │ │ ├── problem_service.hpp ← 头文件38 行 │ │ ├── problem_service.cc ← 实现291 行 │ │ └── executor_service.cc ← 下期讲 │ ├── model/ │ ├── db/ │ └── utils/1ProblemService负责什么它管 9 个方法分为题目操作和测试用例操作两组题目操作Problem CRUDget_problem_list() → 获取所有题目列表含通过率get_problem_detail(id) → 获取单道题详情含示例用例create_problem(...) → 创建新题目update_problem(...) → 更新题目delete_problem(id) → 删除题目problem_exists(id) → 检查题目是否存在测试用例操作Testcase CRUDget_testcases(id, hidden) → 获取题目的测试用例add_testcase(...) → 添加一个测试用例delete_testcase(id) → 删除一个测试用例2数据流用户访问首页 → GET /api/problems │ ▼ Handler 层调用 ProblemService::get_problem_list() │ ▼ ProblemService 从连接池拿连接 │ ▼ 执行 SQLproblems 表 LEFT JOIN submissions 表 同时查出题目信息和提交统计数据 │ ▼ 把每一行数据装进 Problem 结构体 │ ▼ 返回 vectorProblem装好的一堆题目 │ ▼ Handler 层把 vectorProblem 转成 JSON 数组 │ ▼ 返回给浏览器 → 用户看到题目列表2problem_service.hpp// // 文件名: problem_service.hpp // 作用: 定义 ProblemService 题目管理服务 // 这个类很简单——全都是查数据库 → 装模型 → 返回 // #pragma once #include string #include vector // 前向声明告诉编译器这俩结构体在其他文件定义 // 因为头文件只需要声明返回值类型不需要完整的结构体定义 struct Problem; struct TestCase; class ProblemService { public: // 单例模式 static ProblemService instance(); // init() 其实没做啥为了和其他 Service 保持一致的接口 bool init(); // ──── 题目操作6 个方法 ──── // 获取所有题目的列表含通过率统计 std::vectorProblem get_problem_list(); // 获取单道题的详情含示例用例 Problem get_problem_detail(int problem_id); // 检查题目是否存在 bool problem_exists(int problem_id); // 创建题目 int create_problem(const std::string title, const std::string description, const std::string input_desc, const std::string output_desc, const std::string difficulty, int time_limit, int memory_limit); // 更新题目 bool update_problem(int problem_id, const std::string title, const std::string description, const std::string input_desc, const std::string output_desc, const std::string difficulty, int time_limit, int memory_limit); // 删除题目 bool delete_problem(int problem_id); // ──── 测试用例操作3 个方法 ──── // 获取题目的测试用例 // include_hidden false → 只返回示例用例展示给用户 // include_hidden true → 返回全部管理员用 std::vectorTestCase get_testcases(int problem_id, bool include_hidden false); // 添加一个测试用例 int add_testcase(int problem_id, const std::string input, const std::string expected, bool is_sample, int sort_order); // 删除一个测试用例 bool delete_testcase(int tc_id); private: ProblemService() default; ~ProblemService() default; ProblemService(const ProblemService) delete; ProblemService operator(const ProblemService) delete; };3辅助函数SQL注入防护// // 辅助函数转义 SQL 字符串防止 SQL 注入 // // 为什么要有这个函数 // 在 AuthService 中我们每次都要写 // std::vectorchar buf(str.size() * 2 1); // mysql_real_escape_string(conn, buf.data(), str.data(), str.size()); // std::string escaped(buf.data()); // // 太啰嗦了封装成一个函数一行搞定 // static std::string escape(const std::string str, MYSQL* conn) { if (str.empty()) return str; // 空字符串不用转义 std::vectorchar buf(str.length() * 2 1); // 转义后最多变成 2 倍长 mysql_real_escape_string(conn, buf.data(), str.data(), str.length()); // mysql_real_escape_string() 会处理 \ 等特殊字符 // 比如 Its OK → It\s OK return std::string(buf.data()); }对比AuthService的写法// 在 AuthService 中每次都要写 3 行 std::vectorchar esc_buf(username.size() * 2 1); unsigned long esc_len mysql_real_escape_string(conn, esc_buf.data(), username.c_str(), username.size()); std::string esc_username(esc_buf.data(), esc_len); // 在 ProblemService 中封装后只需要写 1 行 std::string esc_title escape(title, conn);4get_problem_list()——获取题目列表这是最复杂的一个查询因为它涉及多表关联JOIN和聚合统计COUNT、SUM。// // 获取所有题目的列表 // // 核心 SQLLEFT JOIN GROUP BY // 把 problems 表 LEFT JOIN submissions 表 // 统计每道题的总提交数和通过数 // // LEFT JOIN 的意思是 // 即使某道题没人提交过submissions 表里没有记录 // 也要显示出来总提交数 0通过率 0% // std::vectorProblem ProblemService::get_problem_list() { std::vectorProblem problems; // 准备一个空数组用来装结果 auto pool ConnectionPool::instance(); auto* conn pool.get(); // 拿连接 if (!conn) return problems; // 没拿到连接 → 返回空数组 // ★ 核心 SQL这是一个 LEFT JOIN 查询 // 把 problems 表p和 submissions 表s关联起来 // LEFT JOIN 保证即使某道题没有提交记录也会出现在结果中 std::string q R( SELECT p.id, p.title, p.difficulty, COUNT(s.id) AS total_sub, SUM(CASE WHEN s.status AC THEN 1 ELSE 0 END) AS ac_count FROM problems p LEFT JOIN submissions s ON p.id s.problem_id GROUP BY p.id ORDER BY p.id ); // 执行查询 if (mysql_query(conn, q.c_str()) ! 0) { Logger::instance().error(get_problem_list query failed: std::string(mysql_error(conn))); pool.release(conn); return problems; } auto* result mysql_store_result(conn); if (!result) { pool.release(conn); return problems; } // 逐行处理查询结果 → 装进 Problem 结构体 while (auto* row mysql_fetch_row(result)) { Problem p; // 创建 Problem 结构体 p.id std::stoi(row[0]); // 第 0 列p.id p.title row[1] ? row[1] : ; // 第 1 列p.title p.difficulty row[2] ? row[2] : easy; // 第 2 列p.difficulty p.total_submissions row[3] ? std::stoi(row[3]) : 0; // 第 3 列总提交数 p.accepted row[4] ? std::stoi(row[4]) : 0; // 第 4 列通过数 // ★ 计算通过率 // 注意这里是百分比如 65.5 表示 65.5% // Problem 结构体中 pass_rate 是 double 类型 p.pass_rate p.total_submissions 0 ? (100.0 * p.accepted / p.total_submissions) : 0.0; problems.push_back(std::move(p)); // 把装好的结构体放进数组 } mysql_free_result(result); // 释放查询结果 pool.release(conn); // 归还连接 return problems; // 返回题目列表 }这个SQL做的事情假设数据库里有这些数据 problems 表 ┌────┬──────────┬────────────┬────────────────────────┐ │ id │ title │ difficulty │ ...其他字段 │ ├────┼──────────┼────────────┼────────────────────────┤ │ 1 │ 两数相加 │ easy │ │ │ 2 │ 反转链表 │ medium │ │ │ 3 │ 红黑树 │ hard │ │ └────┴──────────┴────────────┴────────────────────────┘ submissions 表 ┌────┬────────────┬──────────┬────────┐ │ id │ problem_id │ user_id │ status │ ├────┼────────────┼──────────┼────────┤ │ 1 │ 1 │ 1 │ AC │ │ 2 │ 1 │ 2 │ WA │ │ 3 │ 1 │ 1 │ AC │ │ 4 │ 2 │ 1 │ AC │ └────┴────────────┴──────────┴────────┘ LEFT JOIN GROUP BY 后的结果 ┌────┬──────────┬────────────┬───────────┬──────────┐ │ id │ title │ difficulty │ total_sub │ ac_count │ ├────┼──────────┼────────────┼───────────┼──────────┤ │ 1 │ 两数相加 │ easy │ 3 │ 2 │ ← 3 次提交2 次通过通过率 66.7% │ 2 │ 反转链表 │ medium │ 1 │ 1 │ ← 1 次提交1 次通过通过率 100% │ 3 │ 红黑树 │ hard │ 0 │ 0 │ ← 没人提交通过率 0% └────┴──────────┴────────────┴───────────┴──────────┘ 这就是 LEFT JOIN 的威力即使第三道题没人提交过它仍然出现在列表里total_sub0, ac_count0。如果不用 LEFT JOIN 而用普通的 JOIN没人提交过的题就消失了。5get_problem_detail()——获取题目详细1详细源码// // 获取单道题的详细信息含示例测试用例 // // 相比 get_problem_list()这个更详细 // 返回所有字段description、input_desc、output_desc 等 // 还返回这道题的示例测试用例is_sampletrue // Problem ProblemService::get_problem_detail(int problem_id) { Problem p; // 创建一个空的 Problem auto pool ConnectionPool::instance(); auto* conn pool.get(); if (!conn) return p; // 拿不到连接返回空 // 第 1 步查 problems 表获取题目基本信息 std::string q SELECT id, title, description, input_desc, output_desc, difficulty, time_limit, memory_limit, created_at, updated_at FROM problems WHERE id std::to_string(problem_id); if (mysql_query(conn, q.c_str()) ! 0) { pool.release(conn); return p; } auto* result mysql_store_result(conn); if (!result || mysql_num_rows(result) 0) { // 没有找到这道题 → 返回空的 Problemid0 mysql_free_result(result); pool.release(conn); return p; } // 把查询结果的每一列填进 Problem 结构体 auto* row mysql_fetch_row(result); p.id std::stoi(row[0]); p.title row[1] ? row[1] : ; p.description row[2] ? row[2] : ; p.input_desc row[3] ? row[3] : ; p.output_desc row[4] ? row[4] : ; p.difficulty row[5] ? row[5] : easy; p.time_limit row[6] ? std::stoi(row[6]) : 2; p.memory_limit row[7] ? std::stoi(row[7]) : 256; p.created_at row[8] ? row[8] : ; p.updated_at row[9] ? row[9] : ; mysql_free_result(result); // 第 2 步查这道题的示例测试用例 // ★ 关键调用本类的 get_testcases() 方法 // 第二个参数 include_hidden false → 只返回示例用例 p.sample_cases get_testcases(problem_id, false); pool.release(conn); return p; }2Handler层收到problem后// 在 problem_handler.cc 中简化版 void handle_get_problem(const Request req, Response res) { int problem_id std::stoi(req.path_params.at(id)); // 调用 Service 层 Problem p ProblemService::instance().get_problem_detail(problem_id); if (p.id 0) { res.status 404; res.set_content({\error\:\Problem not found\}, application/json); return; } // 把 Problem 结构体转成 JSON json j; j[id] p.id; j[title] p.title; j[description] p.description; j[difficulty] p.difficulty; j[time_limit] p.time_limit; j[memory_limit] p.memory_limit; // 把示例用例也转成 JSON 数组 j[sample_cases] json::array(); for (const auto tc : p.sample_cases) { json tc_json; tc_json[input] tc.input; tc_json[expected] tc.expected; j[sample_cases].push_back(tc_json); } res.set_content(j.dump(), application/json); }3用户看到的JSON{ id: 1, title: 两数相加, description: 给定两个整数 a 和 b请计算它们的和。, difficulty: easy, time_limit: 2, memory_limit: 256, sample_cases: [ {input: 1 2, expected: 3}, {input: 10 20, expected: 30} ] }6create_problem()——创建题目// // 创建新题目 // 参数标题、描述、输入格式、输出格式、难度、时间限制、内存限制 // 返回值0 新题目 ID-1 失败 // int ProblemService::create_problem( const std::string title, const std::string description, const std::string input_desc, const std::string output_desc, const std::string difficulty, int time_limit, int memory_limit) { auto pool ConnectionPool::instance(); auto* conn pool.get(); if (!conn) return -1; // 构造 INSERT 语句 // 注意所有字符串字段都用 escape() 转义防止 SQL 注入 // 数字字段time_limit, memory_limit用 std::to_string() 转成字符串 std::string q INSERT INTO problems (title, description, input_desc, output_desc, difficulty, time_limit, memory_limit) VALUES ( escape(title, conn) , escape(description, conn) , escape(input_desc, conn) , escape(output_desc, conn) , escape(difficulty, conn) , std::to_string(time_limit) , std::to_string(memory_limit) ); if (mysql_query(conn, q.c_str()) ! 0) { Logger::instance().error(create_problem failed: std::string(mysql_error(conn))); pool.release(conn); return -1; } int id mysql_insert_id(conn); // 获取自动生成的 ID pool.release(conn); Logger::instance().info(Problem created: id std::to_string(id)); return id; }更新题目和删除题目// 更新题目UPDATE bool ProblemService::update_problem( int problem_id, const std::string title, ...) { // ... std::string q UPDATE problems SET title escape(title, conn) , description escape(description, conn) , ... WHERE id std::to_string(problem_id); // ... } // 删除题目DELETE bool ProblemService::delete_problem(int problem_id) { // ... std::string q DELETE FROM problems WHERE id std::to_string(problem_id); // ... // ★ ON DELETE CASCADE 会自动删除这道题的所有测试用例 }注意delete_problem的级联删除在数据库设计时testcases 表的外键设置了 ON DELETE CASCADE。所以当你删除一道题时MySQL **自动**删除这道题的所有测试用例。一行 DELETE FROM problems 就够了不用手动删测试用例。7测试用例操作1get_testcases()——获取测试用例// // 获取指定题目的测试用例 // // 参数 include_hidden // false → 只返回示例用例is_sample1给普通用户看 // true → 返回全部给管理员看 // // 场景 // 用户看题目详情 → include_hiddenfalse → 只看到示例 // 管理员管理后台 → include_hiddentrue → 看到所有 // std::vectorTestCase ProblemService::get_testcases( int problem_id, bool include_hidden) { std::vectorTestCase cases; auto pool ConnectionPool::instance(); auto* conn pool.get(); if (!conn) return cases; // 基础查询查这道题的所有测试用例 std::string q SELECT id, problem_id, input, expected, is_sample, sort_order FROM testcases WHERE problem_id std::to_string(problem_id); // ★ 如果不是管理员只返回示例用例 if (!include_hidden) { q AND is_sample1; } q ORDER BY sort_order, id; // 按 sort_order 排序 // 执行查询... while (auto* row mysql_fetch_row(result)) { TestCase tc; tc.id std::stoi(row[0]); tc.problem_id std::stoi(row[1]); tc.input row[2] ? row[2] : ; tc.expected row[3] ? row[3] : ; tc.is_sample row[4] std::string(row[4]) 1; tc.sort_order row[5] ? std::stoi(row[5]) : 0; cases.push_back(std::move(tc)); } // ... return cases; }2add_testcase()/delete_testcase()// 添加测试用例INSERT INTO testcases int ProblemService::add_testcase( int problem_id, const std::string input, const std::string expected, bool is_sample, int sort_order) { // ... std::string q INSERT INTO testcases (problem_id, input, expected, is_sample, sort_order) VALUES ( std::to_string(problem_id) , escape(input, conn) , escape(expected, conn) , (is_sample ? 1 : 0) , std::to_string(sort_order) ); // ... int id mysql_insert_id(conn); return id; } // 删除测试用例DELETE FROM testcases WHERE id... bool ProblemService::delete_testcase(int tc_id) { // ... std::string q DELETE FROM testcases WHERE id std::to_string(tc_id); // ... }8接口文档ProblemService 的 9 个方法每个方法对应的 SQL方法执行的 SQLget_problem_list()SELECT p.id, p.title, p.difficulty, COUNT(s.id), SUM(CASE ...)FROM problems pLEFT JOIN submissions s ...GROUP BY p.id ORDER BY p.idget_problem_detail(id)SELECT * FROM problems WHERE id ?get_testcases(id, false)problem_exists(id)SELECT 1 FROM problems WHERE id ?create_problem(...)INSERT INTO problems (...) VALUES (...)update_problem(id, ...)UPDATE problems SET ... WHERE id ?delete_problem(id)DELETE FROM problems WHERE id ?ON DELETE CASCADE 自动删测试用例get_testcases(id, hidden)SELECT * FROM testcasesWHERE problem_id ?[AND is_sample1] ORDER BY sort_orderadd_testcase(...)INSERT INTO testcases (...) VALUES (...)delete_testcase(id)DELETE FROM testcases WHERE id ?9ProblemService和AuthService的对比对比维度AuthServiceProblemService核心任务用户认证注册 / 登录 / 鉴权题目管理增删改查密码处理有bcrypt 哈希无Session 管理有文件读写 后台清理无限流有10 秒窗口无线程有后台清理线程无多表查询单表查询LEFT JOIN 多表关联SQL 注入防护每次手动写 3 行封装成 escape () 函数复杂度⭐⭐⭐⭐⭐⭐10总结技术点在 ProblemService 中的体现LEFT JOIN题目列表查询关联 problems 和 submissions 表GROUP BY 聚合函数COUNT (s.id) 统计提交数SUM (CASE...) 统计通过数SQL 注入防护封装escape()函数一行代码完成转义条件查询get_testcases()根据 include_hidden 决定是否加 WHERE 条件级联删除删题目时数据库自动删测试用例ON DELETE CASCADE模型 - 数据库映射SQL 查询结果 → Problem/TestCase 结构体