本文是一个OJ系统的但是的第四篇。前面我们讲了工具层Config+Logger和数据库层Connection Pool,现在是模型层Model Layer。这一层只有三个文件,每一层只有一个struct,一共47行代码。这47行代码定义了整个系统的"形状"
1:模块位置
bash
project-cpp-oj-vibecoding/
├── src/
│ ├── main.cc
│ ├── server/
│ ├── handler/
│ ├── service/
│ ├── model/ ← ★ 今天的主角:模型层 ★
│ │ ├── user.hpp ← 用户模型(11 行)
│ │ ├── problem.hpp ← 题目模型(24 行)
│ │ └── test_case.hpp ← 测试用例模型(12 行)
│ ├── db/
│ └── utils/
1:什么是模型层
模型层是整个系统里最简单的一层------它只有 3 个 struct,没有任何函数,没有任何逻辑,纯粹就是"打包数据"。
可以把模型层的struct想象成一个快递盒
| 模型 | 相当于 | 里面装的 |
|---|---|---|
User |
用户档案袋 | 用户 ID、用户名、密码、角色 |
Problem |
题目文件夹 | 题目 ID、标题、描述、难度、测试用例 |
TestCase |
测试用例卡 | 输入数据、期望输出、是否是示例 |
2:模型层的作用
模型层是连接数据库(原始SQL)和业务逻辑(Service)的桥梁
bash
数据库(MySQL) 模型层(C++ struct) 业务逻辑(Service)
users 表里的行 ──→ User 结构体 ──→ 注册时处理用户数据
problems 表里的行 ──→ Problem 结构体 ──→ 展示题目列表
testcases 表里的行 ──→ TestCase 结构体 ──→ 判题时比对输出
2:项目数据全景图
在深入代码之前,先看看这 3 个模型在整个 OJ 系统中是怎么流转的:
bash
User
┌──────┐
注册 ──────────────→ │ id │ ←─ 提交代码
登录 ──────────────→ │ name │ ↓
查询个人信息 ←─────── │ role │ Submission (提交记录)
└──────┘ ┌──────────┐
│ user_id │
│ problem_id│
Problem │ code │
┌──────────────┐ │ status │
题目列表 → │ id │ │ AC/WA/TLE│
题目详情 → │ title │ └──────────┘
│ description │
│ difficulty │ ↑
│ sample_cases├──── TestCase ─────┘
│ pass_rate │ ┌──────────┐
└──────────────┘ │ input │
│ expected│
│ is_sample│
└──────────┘
- 一道题**(Problem)包含多个测试用例(TestCase)------ `Problem` 里有个 `vector<TestCase> sample_cases`
- 一个用户(User)提交**多次代码**------ `submissions` 表里存了 `user_id` 和 `problem_id`(外键关联)
- 通过率(pass_rate)是 `accepted / total_submissions` 算出来的
3:test_case.hpp------测试用例模型
先从最小的模型开始看。TestCase是所有模型里面最简单的,他只有5个字段
cpp
// ============================================================
// 文件名: test_case.hpp
// 作用: 定义测试用例数据结构
// 测试用例 = 给用户程序的一组"输入" + 期望的"输出"
//
// 比如一道题"两数相加":
// 输入(input): "1 2\n"
// 期望输出(expected): "3\n"
//
// 判题器会:
// 1. 把 input 作为 stdin 喂给用户程序
// 2. 捕获用户程序的 stdout 输出
// 3. 比对 stdout 和 expected 是否一致
// 一致 → AC(通过),不一致 → WA(答案错误)
// ============================================================
#pragma once
#include <string>
struct TestCase {
int id = 0; // 测试用例 ID(主键,自动增长)
// 由 MySQL AUTO_INCREMENT 生成
// 唯一标识这个测试用例
int problem_id = 0; // 所属题目的 ID
// 关联到 problems 表的 id
// 表示"这个测试用例属于哪道题"
std::string input; // 测试用例的输入数据
// 就是给用户程序的"标准输入"(stdin)
// 例子:对于"两数相加"题,可能是 "1 2\n"
// 对于"字符串反转"题,可能是 "hello\n"
std::string expected; // 期望输出
// 用户程序应该输出的"标准输出"(stdout)
// 判题时:拿用户程序的实际输出和这个比对
// 完全一致 → 该用例通过
bool is_sample = false; // 是否是"示例用例"
// true → 示例用例(展示给用户看,帮助理解题目)
// false → 隐藏用例(真正判题用的,用户看不到)
//
// 为什么分两种?
// 示例用例:告诉用户"输入什么格式、输出什么格式"
// 隐藏用例:真正的判题依据,防止用户"针对用例写代码"
// 比如示例用例是 1+2=3,隐藏用例是 100+200=300
int sort_order = 0; // 排序顺序
// 测试用例的执行顺序
// 数字越小越先执行
// 判题时按 sort_order 从小到大依次运行
};
为什么TestCase是最基本的积木
因为题目(Problem)是由测试用例组成的,一道题肯包含:
cpp
题目:两数相加
├── 示例用例 1(is_sample = true)
│ ├── input: "1 2\n"
│ └── expected: "3\n"
├── 隐藏用例 1(is_sample = false)
│ ├── input: "100 200\n"
│ └── expected: "300\n"
├── 隐藏用例 2(is_sample = false)
│ ├── input: "-5 10\n"
│ └── expected: "5\n"
└── 隐藏用例 3(is_sample = false)
├── input: "0 0\n"
└── expected: "0\n"
示例用例给用户看,隐藏用例用来真正判题。这就是 `is_sample` 字段的意义。
4:user.hpp------用户模型
cpp
// ============================================================
// 文件名: user.hpp
// 作用: 定义用户数据结构
// 对应 MySQL 数据库中的 users 表
// ============================================================
#pragma once
#include <string>
struct User {
int id = 0; // 用户 ID(主键,自动增长)
// 每个注册用户获得一个唯一的 ID
// 比如第一个注册的用户 id=1,第二个 id=2
std::string username; // 用户名
// 用户注册时填的名字
// 数据库里设置了 UNIQUE(不能重复)
// 所以不能有两个人都叫 "admin"
std::string password; // 密码(bcrypt 哈希加密后的字符串)
// ★ 注意:存的不是明文密码!
// 比如你设密码为 "123456"
// 存到数据库的是 "$2b$12$rRLy3uV8Hoq1aKm..."
// 这叫"哈希加密"------只能加密不能解密
// 登录验证时:把输入的密码哈希一下,对比两个哈希值
std::string role = "user"; // 用户角色,默认 "user"
// 可选值:
// "user" --- 普通用户:可以看题、提交代码
// "admin" --- 管理员:还可以增删改题目
// 预设的 root 账号就是 "admin"
// 普通用户注册默认就是 "user"
std::string created_at; // 注册时间
// 格式如 "2026-06-24 14:00:00"
// 由 MySQL 的 DEFAULT CURRENT_TIMESTAMP 自动生成
// 存的是用户注册那一刻的时间
};
关于密码哈希
cpp
// 绝对不要这样存密码
User u;
u.password = "123456"; // 如果数据库被黑了,所有密码都暴露了!
// 而且用户往往在多个网站用同一个密码
// 一个泄露 = 全网沦陷
// 正确做法:存哈希值
u.password = "$2b$12$rRLy3uV8Hoq1aKm..."; // bcrypt 哈希
// 哈希是"单向"的:
// "123456" → 哈希 → "$2b$12$...(乱码)" 可以
// "$2b$12$..." → 反解 → "123456" 不行!
//
// 登录时:把你输入的密码哈希一下,对比两个哈希值
5:problem.hpp------题目模型
Problem 是 3 个模型中最复杂的一个,因为它包含了 TestCase,还附带了一些统计数据。
cpp
// ============================================================
// 文件名: problem.hpp
// 作用: 定义题目数据结构
// 对应 MySQL 数据库中的 problems 表
// 是 OJ 系统最核心的数据------没有题目还判什么?
// ============================================================
#pragma once
#include <string> // std::string
#include <vector> // std::vector --- 动态数组
#include <optional> // std::optional --- C++17 新特性
// 表示"可能有值,也可能没有"
// 就像"可选的"箱子------要么装着东西,要么空着
#include "test_case.hpp" // TestCase 结构体,因为 Problem 里要用
struct Problem {
// ──── 基本信息(对应 problems 表的列) ────
int id = 0; // 题目 ID(主键,自动增长)
// 每道题的"身份证号"
// 用户访问 /problem?id=1 就是看 id=1 的题目
std::string title; // 题目标题
// 比如 "两数相加"、"反转字符串"、"判断回文数"
// 显示在题目列表和题目详情页的顶部
std::string description; // 题目描述
// 告诉用户这道题要干什么
// 比如:
// "给定两个整数 a 和 b,请计算它们的和。"
// 支持多行文本,可能包含格式说明
std::string input_desc; // 输入格式说明
// 告诉用户输入长什么样
// 比如:
// "第一行包含一个整数 T,表示测试数据组数。
// 接下来 T 行,每行包含两个整数 a 和 b。"
std::string output_desc; // 输出格式说明
// 告诉用户应该输出什么
// 比如:
// "对于每组测试数据,输出 a + b 的值。""
std::string difficulty = "easy"; // 难度标签
// 可选值:easy(简单)、medium(中等)、hard(困难)
// 显示在题目列表中,帮助用户选题
// 默认 easy,创建题目时可以改
int time_limit = 2; // 时间限制(秒),默认 2 秒
// 用户程序运行超过这个时间 → TLE(超时)
// 可以在 config.json 中设置默认值
// 每道题可以单独设置不同的时间限制
int memory_limit = 256; // 内存限制(MB),默认 256MB
// 用户程序占用内存超过这个值 → MLE(超内存)
// 可以在 config.json 中设置默认值
std::string created_at; // 创建时间
std::string updated_at; // 最后修改时间
// 由 MySQL 的 ON UPDATE CURRENT_TIMESTAMP 自动更新
// ──── 关联数据(从其他表查询得来) ────
std::vector<TestCase> sample_cases;
// ★ 关键:题目包含多个示例测试用例
// std::vector<TestCase> 表示"一个装着 TestCase 的动态数组"
// 为什么用 vector?因为一道题可以有多个示例用例
//
// 比如题目"两数相加"有 2 个示例用例:
// sample_cases[0]: input="1 2", expected="3"
// sample_cases[1]: input="10 20", expected="30"
//
// 这些示例用例会展示给用户看,帮助他们理解题目要求
// ──── 统计数据(从 submissions 表统计得来) ────
double pass_rate = 0.0; // 通过率
// 计算公式:accepted / total_submissions
// 比如:总共 100 次提交,30 次通过
// pass_rate = 30.0 / 100.0 = 0.3 = 30%
// 显示在题目列表中,帮助用户判断题目难度
int total_submissions = 0; // 总提交次数
// 这道题被提交了多少次
// 不管 AC 还是 WA,只要提交了就算
int accepted = 0; // 通过次数
// 这道题有多少次提交是 AC 的
// 注意:同一个用户提交多次都 AC,每次都算
};
Problem的两个隐藏来源
注意Problem结构体里有些字段不是直接从problems表里面查出来的
problems 表直接存储:
id, title, description, input_desc, output_desc,
difficulty, time_limit, memory_limit
从 testcases 表查询得来:
sample_cases → SELECT * FROM testcases
WHERE problem_id = ? AND is_sample = true
ORDER BY sort_order
从 submissions 表统计得来:
pass_rate, total_submissions, accepted
→ SELECT COUNT(*) as total,
SUM(CASE WHEN status='AC' THEN 1 ELSE 0 END) as accepted
FROM submissions WHERE problem_id = ?
这体现了模型层的设计思想:模型是数据整合后的最终形态,而不是数据库表的一对一映射。Problem 结构体把来自 3 张表的数据整合成了一个方便使用的对象。
6:三个模型之间的关系图
这三个struct之间没有继承关系,他们只是包含关系
cpp
┌─────────────────────────────────────────────────────────┐
│ Problem (题目) │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ 基本信息(来自 problems 表) │ │
│ │ id / title / description / difficulty ... │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ 示例用例(来自 testcases 表,is_sample=true) │ │
│ │ vector<TestCase> sample_cases │ │
│ │ ├── TestCase{ input:"1 2", expected:"3" } │ │
│ │ └── TestCase{ input:"10 20", expected:"30" } │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ 统计数据(来自 submissions 表的聚合查询) │ │
│ │ pass_rate / total_submissions / accepted │ │
│ └────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
数据流全景
cpp
用户注册
│
▼
表单数据 {"username":"alice","password":"xxx"}
│
▼
User 结构体 ------ Service 层填充 ------ 存入 MySQL users 表
│
▼
从数据库查出来再装回 User 结构体 ------ 返回给前端
────────────────────────────────────────────────
管理员创建题目
│
▼
表单数据 {"title":"两数相加","description":"..."}
│
▼
Problem 结构体 + TestCase 结构体 ------ Service 层 ------ 存入 MySQL
────────────────────────────────────────────────
用户查看题目详情
│
▼
Service 层查数据库 → 组装 Problem 结构体
├── 从 problems 表查基本信息
├── 从 testcases 表查示例用例(is_sample=true)
└── 把数据装进 Problem 结构体,返回给 Handler
────────────────────────────────────────────────
判题引擎判题
│
▼
从 submissions 表查这道题的统计数据
└── 更新 Problem 结构体的 pass_rate / total_submissions / accepted
7:模型在实际代码中怎么用
1:在Service层中------从数据库差数据,装进模型
cpp
// src/service/problem_service.cc(简化版)
#include "model/problem.hpp"
#include "model/test_case.hpp"
#include "db/connection_pool.hpp"
// ============================================================
// 获取题目列表
// 从数据库查数据 → 装进 vector<Problem> → 返回
// ============================================================
std::vector<Problem> ProblemService::get_problem_list() {
std::vector<Problem> problems; // 准备一个"空箱子",用来装所有题目
auto& pool = ConnectionPool::instance();
MYSQL* conn = pool.get(); // 从连接池拿一个连接
// 执行 SQL:查所有题目
mysql_query(conn, "SELECT id, title, difficulty FROM problems");
MYSQL_RES* result = mysql_store_result(conn);
// 逐行处理查询结果
MYSQL_ROW row;
while ((row = mysql_fetch_row(result)) != nullptr) {
Problem p; // ★ 创建 Problem 结构体
p.id = std::stoi(row[0]); // 第 0 列 → id
p.title = row[1]; // 第 1 列 → title
p.difficulty = row[2]; // 第 2 列 → difficulty
// 这里的 time_limit、memory_limit 等字段保持默认值
// 因为列表页不需要展示这些细节
problems.push_back(p); // ★ 把装好的结构体放进数组
}
mysql_free_result(result);
pool.release(conn); // 归还连接
return problems; // 返回装满题目结构体的数组
}
// ============================================================
// 获取题目详情(包含示例用例)
// ============================================================
Problem ProblemService::get_problem_detail(int problem_id) {
Problem p; // ★ 创建一个空的 Problem 结构体
auto& pool = ConnectionPool::instance();
MYSQL* conn = pool.get();
// 第 1 步:查 problems 表的基本信息
string q = "SELECT id, title, description, input_desc, output_desc, "
"difficulty, time_limit, memory_limit "
"FROM problems WHERE id = " + to_string(problem_id);
mysql_query(conn, q.c_str());
MYSQL_RES* result = mysql_store_result(conn);
MYSQL_ROW row = mysql_fetch_row(result);
if (!row) {
// 题目不存在
mysql_free_result(result);
pool.release(conn);
return p; // 返回一个 id=0 的空 Problem
}
p.id = stoi(row[0]);
p.title = row[1];
p.description = row[2];
p.input_desc = row[3];
p.output_desc = row[4];
p.difficulty = row[5];
p.time_limit = stoi(row[6]);
p.memory_limit = stoi(row[7]);
mysql_free_result(result);
// 第 2 步:查这道题的示例用例
// ★ 这里 TestCase 结构体就派上用场了
q = "SELECT id, input, expected, sort_order "
"FROM testcases "
"WHERE problem_id = " + to_string(problem_id) + " AND is_sample = true "
"ORDER BY sort_order";
mysql_query(conn, q.c_str());
result = mysql_store_result(conn);
while ((row = mysql_fetch_row(result)) != nullptr) {
TestCase tc; // ★ 创建 TestCase 结构体
tc.id = stoi(row[0]);
tc.input = row[1];
tc.expected = row[2];
tc.sort_order = stoi(row[3]);
tc.is_sample = true;
p.sample_cases.push_back(tc); // ★ 把测试用例添加到题目的 sample_cases 数组
}
// 第 3 步:统计通过率
q = "SELECT COUNT(*) as total, "
"SUM(CASE WHEN status='AC' THEN 1 ELSE 0 END) as accepted "
"FROM submissions WHERE problem_id = " + to_string(problem_id);
mysql_query(conn, q.c_str());
result = mysql_store_result(conn);
row = mysql_fetch_row(result);
if (row) {
p.total_submissions = stoi(row[0]);
p.accepted = stoi(row[1]);
if (p.total_submissions > 0) {
p.pass_rate = (double)p.accepted / p.total_submissions;
}
}
mysql_free_result(result);
pool.release(conn);
return p; // ★ 返回装满了数据的 Problem 结构体
}
2:在Handler层中------把模型转成JSON返回给前端
cpp
// src/handler/problem_handler.cc(简化版)
// Handler 拿到 Service 返回的 Problem 结构体
// 把它转换成 JSON 格式,通过 HTTP 返回给浏览器
void handle_get_problem(const Request& req, Response& res) {
int problem_id = stoi(req.path_params.at("id"));
// 调用 Service 层获取 Problem 结构体
Problem p = ProblemService::get_problem_detail(problem_id);
if (p.id == 0) { // 题目不存在
res.status = 404;
res.set_content(R"({"error":"Problem not found"})", "application/json");
return;
}
// ★ 把 C++ 的 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;
j["pass_rate"] = p.pass_rate;
// 把示例用例也转成 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);
}
// 返回 JSON 给浏览器
res.set_content(j.dump(), "application/json");
}
前端收到的JSON长这样
cpp
{
"id": 1,
"title": "两数相加",
"description": "给定两个整数 a 和 b,请计算它们的和。",
"difficulty": "easy",
"time_limit": 2,
"memory_limit": 256,
"pass_rate": 0.65,
"sample_cases": [
{"input": "1 2", "expected": "3"},
{"input": "10 20", "expected": "30"}
]
}
完整的数据流转链条
cpp
① 浏览器请求 /api/problems/1
│
② Handler 收到 HTTP 请求
│
▼
③ Handler 调用 Service 层
ProblemService::get_problem_detail(1)
│
▼
④ Service 从连接池拿连接 → 查数据库
│
├── SELECT * FROM problems WHERE id = 1
│ ↓
│ 把行数据装进 Problem 结构体
│
├── SELECT * FROM testcases WHERE problem_id=1 AND is_sample=true
│ ↓
│ 把每行装进 TestCase 结构体 → push_back 到 Problem.sample_cases
│
└── SELECT COUNT, SUM FROM submissions WHERE problem_id=1
↓
算出 pass_rate → 存入 Problem.pass_rate
│
▼
⑤ Service 返回装好的 Problem 结构体
│
▼
⑥ Handler 把 Problem 转成 JSON
│
▼
⑦ 返回 HTTP 响应给浏览器
浏览器收到 JSON → 渲染成题目详情页
8:设计理由
1:极简主义
这 3 个 struct 一共 47 行代码,没有构造函数、没有成员函数、没有继承、没有虚函数。为什么?
如果把它写成 Java 风格
java
// 过度设计的 User 类
public class User {
private int id;
private String username;
private String password;
private String role;
private String createdAt;
// 一堆 getter/setter
public int getId() { return id; }
public void setId(int id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
// ... 继续 20 行 getter/setter ...
// equals/hashCode/toString 又要写一堆
}
这个项目的选择:struct 是纯数据容器,不需要封装行为。数据怎么组装、怎么验证是 Service 层的事。
2:结构体组合而不是继承
有些框架会这样设计模型:
null
// 继承式设计(本项目没采用)
class BaseModel { // 基类
int id;
String createdAt;
}
class Problem extends BaseModel { // 继承
String title;
// ...
}
class User extends BaseModel { // 继承
String username;
// ...
}
这个项目的选择:三个独立的 struct,不继承任何东西。 因为它们只是"数据的形状",不需要共享行为。
3:默认值
每个字段都有默认值:
null
int id = 0; // 默认 0,表示"尚未分配"
std::string role = "user"; // 默认普通用户
bool is_sample = false; // 默认隐藏用例
int time_limit = 2; // 默认 2 秒
int memory_limit = 256; // 默认 256MB
好处:创建结构体时不需要手动给每个字段赋值,没赋值的自动用默认值,减少遗漏。
9:三个模型和数据库的对应关系
C++ 结构体 MySQL 表
─────────────────────────────────────────────────
User { users 表
int id; id INT AUTO_INCREMENT
string username; username VARCHAR(64) UNIQUE
string password; password VARCHAR(256)
string role; role ENUM('user','admin')
string created_at; created_at DATETIME
}
Problem { problems 表
int id; id INT AUTO_INCREMENT
string title; title VARCHAR(256)
string description; description TEXT
string input_desc; input_desc TEXT
string output_desc; output_desc TEXT
string difficulty; difficulty ENUM('easy','medium','hard')
int time_limit; time_limit INT
int memory_limit; memory_limit INT
string created_at; created_at DATETIME
string updated_at; updated_at DATETIME
// ★ 以下字段不存在于 problems 表
vector<TestCase> sample_cases; ← 来自 testcases 表
double pass_rate; ← 来自 submissions 表的统计
int total_submissions; ← 来自 submissions 表的统计
int accepted; ← 来自 submissions 表的统计
}
TestCase { testcases 表
int id; id INT AUTO_INCREMENT
int problem_id; problem_id INT
string input; input TEXT
string expected; expected TEXT
bool is_sample; is_sample BOOLEAN
int sort_order; sort_order INT
}
10:总结
| 概念 | 在模型层中的体现 |
|---|---|
| struct(结构体) | 3 个纯数据容器,没有任何函数 |
std::vector |
Problem 用 vector<TestCase> 装多个测试用例 |
std::optional |
C++17 新特性,表示 "可能有值"(虽然这个项目里没实际使用) |
| 默认值 | 所有字段都有默认值,避免未初始化 |
| 数据整合 | Problem 结构体整合了来自 3 张表的数据 |
| 模型 - 视图分离 | 模型只管存数据,不管怎么展示 |
| JSON 序列化 | Handler 层把 struct 转成 JSON 返回给前端 |