文章目录
- 前言
- 一、项目准备
-
- [1. 用 gitee 创建项目(vibe coding过程中遇到不满意的修改,通过 git 可以随时回退版本)](#1. 用 gitee 创建项目(vibe coding过程中遇到不满意的修改,通过 git 可以随时回退版本))
- [2. 需求梳理(生成SPEC.md文档)](#2. 需求梳理(生成SPEC.md文档))
- [3. 安装依赖](#3. 安装依赖)
- [4. 创建项目的目录结构](#4. 创建项目的目录结构)
- 二、进入后端开发过程
-
- [1. 阶段 1:项目骨架](#1. 阶段 1:项目骨架)
-
- [1.1 创建配置管理模块(完成T1.1 并进行 单元测试)](#1.1 创建配置管理模块(完成T1.1 并进行 单元测试))
- [1.2 编写最小 HTTP 服务(功能开发+单元测试+curl接口测试+ctest所有单元测试)](#1.2 编写最小 HTTP 服务(功能开发+单元测试+curl接口测试+ctest所有单元测试))
- [1.3 编写日志模块(功能开发+单元测试+ctest所有单元测试)](#1.3 编写日志模块(功能开发+单元测试+ctest所有单元测试))
- [2. 阶段 2:数据库](#2. 阶段 2:数据库)
-
- [2.1 编写 db/init.sql(手动执行init.sql建库,并转让权限给普通用户ubuntu)](#2.1 编写 db/init.sql(手动执行init.sql建库,并转让权限给普通用户ubuntu))
- [2.2 封装 MySQL 连接池(功能开发+单元测试+ctest所有单元测试)](#2.2 封装 MySQL 连接池(功能开发+单元测试+ctest所有单元测试))
- [2.3 编写 UserRepo/ ProblemRepo/ SubmissionRepo的 CRUD(功能开发+单元测试+ctest所有单元测试)](#2.3 编写 UserRepo/ ProblemRepo/ SubmissionRepo的 CRUD(功能开发+单元测试+ctest所有单元测试))
- [3. 阶段 3:认证模块](#3. 阶段 3:认证模块)
-
- [3.1 SHA-256 + salt 工具(功能开发+单元测试+ctest所有单元测试)](#3.1 SHA-256 + salt 工具(功能开发+单元测试+ctest所有单元测试))
- [3.2 Session 生成与校验(功能开发+单元测试+ctest所有单元测试)](#3.2 Session 生成与校验(功能开发+单元测试+ctest所有单元测试))
-
- [3.2.1 功能开发+单元测试+ctest所有单元测试](#3.2.1 功能开发+单元测试+ctest所有单元测试)
- [3.2.2 Session的作用](#3.2.2 Session的作用)
- [3.3 注册 / 登录 / 注销 / me 接口(功能开发+单元测试+curl接口测试+ctest所有单元测试)](#3.3 注册 / 登录 / 注销 / me 接口(功能开发+单元测试+curl接口测试+ctest所有单元测试))
- [3.4 Session 中间件(解析 Cookie → req.user)](#3.4 Session 中间件(解析 Cookie → req.user))
-
- [3.4.1 功能开发+单元测试+ctest所有单元测试](#3.4.1 功能开发+单元测试+ctest所有单元测试)
- [3.4.2 Cookie 的作用,这一步在阶段3的作用](#3.4.2 Cookie 的作用,这一步在阶段3的作用)
- [3.5 RBAC 中间件(校验 role=admin)(功能开发+单元测试+curl接口测试+ctest所有单元测试)](#3.5 RBAC 中间件(校验 role=admin)(功能开发+单元测试+curl接口测试+ctest所有单元测试))
前言
项目的gitee链接:https://gitee.com/bacht/oj_-platform-based_on-vibecoding
一、项目准备
1. 用 gitee 创建项目(vibe coding过程中遇到不满意的修改,通过 git 可以随时回退版本)
在 gitee 上创建项目,并 clone 到本地
Vibe Coding 过程中, git 的使用是非常重要的⼀环.
vibe coding过程中遇到不满意的修改,通过 git 可以随时回退版本
1)创建一个仓库

2)复制仓库地址, 并 git clone 到本地
bash
git clone https://gitee.com/bacht/oj_-platform-based_on-vibecoding.git
2. 需求梳理(生成SPEC.md文档)
- 通过提示词引导 AI 对用户 "深度访谈" (反问),进⼀步强化需求.
bash
你现在是资深产品架构师 + 技术负责⼈。
我的初始需求是:我想搭建⼀个仿 leetcode 的 OJ 项⽬. 后端采⽤ C++ (cpp-httplib), 前端
采⽤原⽣ HTML + CSS + JS
**规则(必须严格遵守):**
1. 不要直接写代码或⽣成⽅案。
2. 先使⽤ AskUserQuestion ⼯具(或直接输出问题列表)对我进⾏**深度访谈**,像 Socratic
提问法⼀样挖掘细节。
3. 必须覆盖以下维度(每个维度⾄少问 1-2 个具体问题):
- 业务⽬标和成功标准
- 技术栈/架构选型及理由
- ⾮功能需求(性能、可扩展性、安全、成本等)
- 边缘案例、异常处理、⽤⼾流程
- UI/UX 或 API 边界
- 潜在⻛险、权衡取舍
4. 每轮只问 3-5 个最关键的问题,等待我回答后继续追问,直到需求完整。
5. 当我确认"规格已完整"时,再输出完整的 SPEC.md(包含需求、架构图、TODO 清单、验收标
准)。
开始访谈吧。

- 开始访谈 梳理项目需求(开启访谈的 提示词):

- 访谈过程:






最终生成的SPEC.md见链接:https://gitee.com/bacht/oj_-platform-based_on-vibecoding/blob/master/SPEC.md
3. 安装依赖
可以让 AI 帮我们梳理出都有哪些依赖需要安装, 并给出安装命令.
bash
@SPEC.md 请根据⽂档中设计好的⽅案, 梳理⼀下有哪些依赖需要进⾏安装, 并给出对应的安装命令
(假定当前系统是⼀个空⽩的 ubuntu 24.04)
- 梳理结果如下:
bash
按 SPEC §9.1,依赖分为:构建工具、C++ 库、数据库、单头文件库、前端。
1. 构建工具 & 系统工具
sudo apt update
sudo apt install -y build-essential cmake git curl wget
- build-essential = g++ + make,满足 §9.1 「g++ / cmake / make」和 SPEC 中的 g++ -O2 -std=c++17 -DONLINE_JUDGE 编译选项
- git 用于拉取 cpp-httplib;curl / wget 备用
2. OpenSSL(SHA-256)
sudo apt install -y libssl-dev
对应 SPEC §9.1「OpenSSL(SHA-256,libcrypto)」和 §2「密码哈希:SHA-256 + per-user salt」。
3. MySQL 客户端库(C API)
sudo apt install -y default-libmysqlclient-dev pkg-config
Ubuntu 24.04 推荐 default-libmysqlclient-dev(libmysqlclient-dev 已是过渡包)。SPEC §9.1 给了
「mysqlclient 或 mysql-connector-cpp」二选一,libmysqlclient 更轻、链接简单,建议首选。
pkg-config 用于 CMake 找 -lmysqlclient。
4. jsoncpp
sudo apt-get install libjsoncpp-dev
5. cpp-httplib(单头文件,无 apt 包)
git clone https://github.com/yhirose/cpp-httplib.git
# 将httplib.h拷⻉到系统⽬录下,在程序中#include <httplib.h>时能直接找到
sudo cp ./cpp-httplib/httplib.h /usr/include/
# 将httplib.h拷⻉到系统⽬录后,cpp-httplib目录及其中文件就没用了,直接删除
rm -rf ./cpp-httplib
6. MySQL Server
sudo apt install -y mysql-server
sudo systemctl enable --now mysql
SPEC §1.1「本地 MySQL 存储」、§5「表结构」需 InnoDB / utf8mb4,MySQL 8.x 满足。
装完后初始化数据库 & 用户:
sudo mysql <<'SQL'
CREATE DATABASE oj CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'oj'@'127.0.0.1' IDENTIFIED BY 'oj_pass';
GRANT ALL PRIVILEGES ON oj.* TO 'oj'@'127.0.0.1';
FLUSH PRIVILEGES;
SQL
mysql -uoj -poj_pass oj < db/init.sql # SPEC §5.3
密码明文写在 config.json 对应 SPEC §12 R4「本地开发可接受」。
7. 前端
无需 apt 安装。SPEC §2「原生 HTML + CSS + JS(无框架)」,浏览器直接解析。ace.js 通过 §7.2
落到 web/static/vendor/ace/,要么本地下载:
mkdir -p web/static/vendor/ace
curl -L -o web/static/vendor/ace/ace.js \
https://github.com/ajaxorg/ace-builds/archive/refs/tags/v1.39.0.tar.gz
tar -xzf web/static/vendor/ace/ace.js --strip-components=1 -C web/static/vendor/ace
要么直接走 CDN(<script src="https://cdnjs.../ace/1.39.0/ace.min.js">),与 §7.2「本地或 CDN」一致。
(1)脚本命令汇总
sudo apt update && \
sudo apt install -y build-essential cmake git curl wget \
libssl-dev \
default-libmysqlclient-dev pkg-config \
nlohmann-json3-dev \
mysql-server
mkdir -p third_party
curl -L -o third_party/httplib.h \
https://raw.githubusercontent.com/yhirose/cpp-httplib/v0.18.5/httplib.h
sudo systemctl enable --now mysql
sudo mysql <<'SQL'
CREATE DATABASE oj CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'oj'@'127.0.0.1' IDENTIFIED BY 'oj_pass';
GRANT ALL PRIVILEGES ON oj.* TO 'oj'@'127.0.0.1';
FLUSH PRIVILEGES;
SQL
依赖版本均与 SPEC §9.1「依赖」一行对齐,无新增技术选型;后续 SPEC 冻结时可把 httplib.h
替换为固定 commit hash 进一步锁版。
按照给出的命令, 在 root权限下逐个安装即可.
4. 创建项目的目录结构
- 提示词:
bash
@SPEC.md 进⾏ "项⽬ 目录结构搭建" 操作,具体的⽬录结构请按照"9. 后端工程结构" 和 "7.2 目录结构" 的描述来构建.
此处只是创建⽬录结构和空⽂件, 暂时不编写⽂件的具体内容.
此时,opencode 就会按照 SPEC.md 中描述的目录结构,进行创建。
- 通过 mkdir 创建⼀系列的目录
- 通过 touch 创建⼀系列的空文件
当 opencode 完成创建后,还会自主使用 ls 命令检查当前的目录结构是否正确。
- 最终结果如下:

完成之后,记得提交一个 git 版本并推送.
二、进入后端开发过程
1. 阶段 1:项目骨架
1.1 创建配置管理模块(完成T1.1 并进行 单元测试)
第一步: 完成配置管理模块的代码书写
- 提示词:
bash
@SPEC.md 请开发 Phase 1 这⾥的"编写 `config.json` 解析模块"。完成后将该步骤标记为完成状态
(1)三个文件的作用
- config.json --- 配置文件本体
- 存放服务端口、MySQL 连接信息、判题 worker 配置、Session 配置
- 启动时被 main.cpp 读入,失败则进程退出(避免误启动到错误环境)
- 与代码解耦:调端口、改 DB 密码、加 worker 都不需重新编译
cpp
{
"port": 8080,
"db": {
"host": "127.0.0.1",
"port": 3306,
"user": "ubuntu",
"password": "",
"name": "oj"
},
"judge": {
"worker_count": 4,
"work_dir": "./data/runs",
"compiler": "g++",
"compile_flags": "-O2 -std=c++17 -DONLINE_JUDGE"
},
"session": {
"ttl_seconds": 86400,
"cookie_name": "OJ_SESSION"
}
}
- config.h --- 公共契约 / 类型定义
- 定义 Config / DbConfig / JudgeConfig / SessionConfig 四个聚合结构,所有字段带默认值(缺失时回退)
- 暴露两个入口:ParseConfig(const std::string&)(内存解析,便于单测)和 LoadConfig(const std::string& path)(从文件加载)
- 统一异常类型 std::runtime_error,调用方靠 try/catch 即可捕获所有错误(IO / JSON 非法 / 类型错)
- 后续模块(db / judge / session / server)只需 #include "util/config.h" 拿到强类型字段,不再直接碰 jsoncpp
cpp
#pragma once
#include <string>
namespace oj {
struct DbConfig {
std::string host = "127.0.0.1";
int port = 3306;
std::string user = "oj";
std::string password;
std::string name = "oj";
};
struct JudgeConfig {
int worker_count = 4;
std::string work_dir = "./data/runs";
std::string compiler = "g++";
std::string compile_flags = "-O2 -std=c++17 -DONLINE_JUDGE";
};
struct SessionConfig {
int ttl_seconds = 86400;
std::string cookie_name = "OJ_SESSION";
};
struct Config {
int port = 8080;
DbConfig db;
JudgeConfig judge;
SessionConfig session;
};
// 从 JSON 字符串解析。字段缺失时取默认值;字段存在但类型错误则抛出。
// 抛出 std::runtime_error。
// 注:ParseConfig 不读取环境变量,供单测使用;如需 env 覆盖请走 LoadConfig。
Config ParseConfig(const std::string& json_text);
// 从文件加载并解析。文件无法打开或解析失败时抛出 std::runtime_error。
// 解析后若设置了环境变量 `ubuntu_password`,会覆盖 cfg.db.password,
// 实现「敏感配置不入仓库」。变量未设置或为空时不动原值。
Config LoadConfig(const std::string& path);
} // namespace oj
- config.cpp --- 解析实现
- 基于项目已选依赖 jsoncpp(CMakeLists.txt:20)
- 三个匿名命名空间辅助函数:get_or 缺失返回 fallback + 类型错抛错、get_str_or 字符串特化、parse_db / parse_judge / parse_session 按 SPEC §9.2 schema 一一映射
- ParseConfig 顶层校验根必须是 object、子段必须是 object,否则抛错
- LoadConfig 薄壳:读全文 → 转 ParseConfig
cpp
#include "util/config.h"
#include <cstdlib>
#include <fstream>
#include <sstream>
#include <stdexcept>
#include <string>
#include <json/json.h>
namespace oj {
namespace {
template <typename T>
T get_or(const Json::Value& root, const char* key, const T& fallback) {
if (!root.isMember(key)) return fallback;
const auto& v = root[key];
if (!v.is<T>()) {
throw std::runtime_error(std::string("config: field '") + key +
"' has wrong type");
}
return v.as<T>();
}
std::string get_str_or(const Json::Value& root, const char* key,
const std::string& fallback) {
if (!root.isMember(key)) return fallback;
const auto& v = root[key];
if (!v.isString()) {
throw std::runtime_error(std::string("config: field '") + key +
"' must be string");
}
return v.asString();
}
DbConfig parse_db(const Json::Value& root) {
DbConfig c;
c.host = get_str_or(root, "host", c.host);
c.port = get_or<int>(root, "port", c.port);
c.user = get_str_or(root, "user", c.user);
c.password = get_str_or(root, "password", c.password);
c.name = get_str_or(root, "name", c.name);
return c;
}
JudgeConfig parse_judge(const Json::Value& root) {
JudgeConfig c;
c.worker_count = get_or<int>(root, "worker_count", c.worker_count);
c.work_dir = get_str_or(root, "work_dir", c.work_dir);
c.compiler = get_str_or(root, "compiler", c.compiler);
c.compile_flags = get_str_or(root, "compile_flags", c.compile_flags);
return c;
}
SessionConfig parse_session(const Json::Value& root) {
SessionConfig c;
c.ttl_seconds = get_or<int>(root, "ttl_seconds", c.ttl_seconds);
c.cookie_name = get_str_or(root, "cookie_name", c.cookie_name);
return c;
}
// 若设置了环境变量 ubuntu_password,则覆盖 db.password,
// 实现「敏感配置不入仓库」。变量未设置或为空时不动原值。
void override_from_env(Config& cfg) {
const char* env = std::getenv("ubuntu_password");
if (env != nullptr && env[0] != '\0') {
cfg.db.password = env;
}
}
} // namespace
Config ParseConfig(const std::string& json_text) {
Json::Value root;
Json::CharReaderBuilder builder;
std::string errs;
std::istringstream iss(json_text);
if (!Json::parseFromStream(builder, iss, &root, &errs)) {
throw std::runtime_error("config: JSON parse error: " + errs);
}
if (!root.isObject()) {
throw std::runtime_error("config: top-level value must be object");
}
Config cfg;
cfg.port = get_or<int>(root, "port", cfg.port);
if (root.isMember("db")) {
if (!root["db"].isObject()) {
throw std::runtime_error("config: 'db' must be object");
}
cfg.db = parse_db(root["db"]);
}
if (root.isMember("judge")) {
if (!root["judge"].isObject()) {
throw std::runtime_error("config: 'judge' must be object");
}
cfg.judge = parse_judge(root["judge"]);
}
if (root.isMember("session")) {
if (!root["session"].isObject()) {
throw std::runtime_error("config: 'session' must be object");
}
cfg.session = parse_session(root["session"]);
}
return cfg;
}
Config LoadConfig(const std::string& path) {
std::ifstream in(path);
if (!in.is_open()) {
throw std::runtime_error("config: cannot open file: " + path);
}
std::ostringstream oss;
oss << in.rdbuf();
Config cfg = ParseConfig(oss.str());
override_from_env(cfg);
return cfg;
}
} // namespace oj
(2)设计取舍
- 缺字段不报错:用默认值(个人项目,更易上手;生产应反过来)
- 类型错则报错:避免 "abc" 被静默吞成 0
- 解析失败早抛:让 main.cpp 在最早阶段捕获,给出清晰错误信息而非运行时崩溃
- 不缓存单例:函数式 API 更利于 T1.2 / T1.3 单测;后续若需要可加 GetConfig() 包装
第二步:进行单元测试(使用gtest库)
认识 "单元测试"
⼀个项项目整体的 "正确" ,意味着项目中的每个模块,每个部分都是正确的。
与其等待整个项目开发完,再验证正确,不如每开发⼀个模块,都先验证下这个模块是否正确。
这个针对模块验证的过程,就是 "单元测试"。这是在开发中⼀种常见的测试方式。
在 C++ 中,编写单元测试不必从头开始。google 提供了 google test (简称 gtest ) 框架,可以让我们更方便的完成单元测试。
安装 gtest库:
bash
sudo apt install libgtest-dev
- 提示词:
bash
请针对上述"编写 `config.json` 解析模块"的代码实现, 基于 gtest 设计单元测试⽤例. 相关代码存放在 @tests/phase_1/⽬录下.
- tests/phase_1/test_config.cpp
cpp
// 注:ConfigLoad.RealConfigFile 依赖仓库内 oj-server/config.json;
// 测试在非登录 shell 跑,ubuntu_password 不会被 .bashrc 自动加载,
// 用例里显式 unsetenv 做断言。
#include <cstdlib>
#include <fstream>
#include <stdexcept>
#include <string>
#include <gtest/gtest.h>
#include "util/config.h"
using namespace oj; // 测试里直接用 Config / ParseConfig / LoadConfig,简洁
// ---------- ParseConfig:纯解析,不读环境变量 ----------
TEST(ConfigParse, EmptyJsonUsesAllDefaults) {
Config c = ParseConfig("{}");
EXPECT_EQ(c.port, 8080);
EXPECT_EQ(c.db.host, "127.0.0.1");
EXPECT_EQ(c.db.port, 3306);
EXPECT_EQ(c.db.user, "oj");
EXPECT_EQ(c.db.password, "");
EXPECT_EQ(c.db.name, "oj");
EXPECT_EQ(c.judge.worker_count, 4);
EXPECT_EQ(c.judge.work_dir, "./data/runs");
EXPECT_EQ(c.judge.compiler, "g++");
EXPECT_EQ(c.judge.compile_flags, "-O2 -std=c++17 -DONLINE_JUDGE");
EXPECT_EQ(c.session.ttl_seconds, 86400);
EXPECT_EQ(c.session.cookie_name, "OJ_SESSION");
}
TEST(ConfigParse, FullLoad) {
const std::string js = R"({
"port": 9090,
"db": {
"host": "10.0.0.1",
"port": 3307,
"user": "u",
"password": "p",
"name": "n"
},
"judge": {
"worker_count": 8,
"work_dir": "/tmp/judge",
"compiler": "clang++",
"compile_flags": "-O0"
},
"session": {
"ttl_seconds": 60,
"cookie_name": "SID"
}
})";
Config c = ParseConfig(js);
EXPECT_EQ(c.port, 9090);
EXPECT_EQ(c.db.host, "10.0.0.1");
EXPECT_EQ(c.db.port, 3307);
EXPECT_EQ(c.db.user, "u");
EXPECT_EQ(c.db.password, "p");
EXPECT_EQ(c.db.name, "n");
EXPECT_EQ(c.judge.worker_count, 8);
EXPECT_EQ(c.judge.work_dir, "/tmp/judge");
EXPECT_EQ(c.judge.compiler, "clang++");
EXPECT_EQ(c.judge.compile_flags, "-O0");
EXPECT_EQ(c.session.ttl_seconds, 60);
EXPECT_EQ(c.session.cookie_name, "SID");
}
TEST(ConfigParse, PartialOverride) {
Config c = ParseConfig(R"({"db":{"password":"secret"}})");
EXPECT_EQ(c.db.password, "secret");
// 其他字段保留默认
EXPECT_EQ(c.db.host, "127.0.0.1");
EXPECT_EQ(c.db.port, 3306);
EXPECT_EQ(c.judge.worker_count, 4);
}
TEST(ConfigParse, MalformedJsonThrows) {
EXPECT_THROW(ParseConfig("{not json"), std::runtime_error);
}
TEST(ConfigParse, TypeErrorThrows) {
// port 期望 int,实为 string → 抛
EXPECT_THROW(ParseConfig(R"({"port":"abc"})"), std::runtime_error);
// db 期望 object,实为 array → 抛
EXPECT_THROW(ParseConfig(R"({"db":[]})"), std::runtime_error);
}
// ---------- LoadConfig:文件 + 环境变量 ----------
TEST(ConfigLoad, RealConfigFile) {
// 测试用例跑在非登录 shell,ubuntu_password 一般未设置;显式 unsetenv 保证断言稳定
unsetenv("ubuntu_password");
Config c = LoadConfig("oj-server/config.json");
EXPECT_EQ(c.port, 8080);
EXPECT_EQ(c.db.host, "127.0.0.1");
EXPECT_EQ(c.db.port, 3306);
EXPECT_EQ(c.db.user, "ubuntu");
EXPECT_EQ(c.db.password, ""); // 仓库里留空,由 env 注入
EXPECT_EQ(c.judge.worker_count, 4);
EXPECT_EQ(c.judge.compiler, "g++");
EXPECT_EQ(c.session.cookie_name, "OJ_SESSION");
}
TEST(ConfigLoad, EnvOverride) {
const std::string tmp = "/tmp/__oj_test_cfg.json";
{
std::ofstream out(tmp);
out << R"({"db":{"password":"from_file"}})";
}
// 1) 未设置环境变量 → 保留 JSON 中的值
unsetenv("ubuntu_password");
{
Config c = LoadConfig(tmp);
EXPECT_EQ(c.db.password, "from_file");
}
// 2) 设置为有效值 → 覆盖
setenv("ubuntu_password", "from_env", 1);
{
Config c = LoadConfig(tmp);
EXPECT_EQ(c.db.password, "from_env");
}
// 3) 设置为空字符串 → 不覆盖(避免误清空)
setenv("ubuntu_password", "", 1);
{
Config c = LoadConfig(tmp);
EXPECT_EQ(c.db.password, "from_file");
}
unsetenv("ubuntu_password");
std::remove(tmp.c_str());
}
TEST(ConfigLoad, MissingFileThrows) {
EXPECT_THROW(LoadConfig("/tmp/__oj_definitely_missing_xyz.json"),
std::runtime_error);
}
- 编译(oj-server/CMakeLists.txt中 集成了单元测试的编译过程)后,单元测试的运行结果:
注:单元测试 应多进行几次,每次都全部测试通过,才代表这个单元模块的开发通过。
(因为有时候会出现第一次 单元测试全部通过,后续再进行 单元测试无法全部通过的情况)
bash
ubuntu@VM-0-4-ubuntu:~/project/oj_-platform-based_on-vibecoding$ ./build/test_config
Running main() from ./googletest/src/gtest_main.cc
[==========] Running 8 tests from 2 test suites.
[----------] Global test environment set-up.
[----------] 5 tests from ConfigParse
[ RUN ] ConfigParse.EmptyJsonUsesAllDefaults
[ OK ] ConfigParse.EmptyJsonUsesAllDefaults (0 ms)
[ RUN ] ConfigParse.FullLoad
[ OK ] ConfigParse.FullLoad (0 ms)
[ RUN ] ConfigParse.PartialOverride
[ OK ] ConfigParse.PartialOverride (0 ms)
[ RUN ] ConfigParse.MalformedJsonThrows
[ OK ] ConfigParse.MalformedJsonThrows (0 ms)
[ RUN ] ConfigParse.TypeErrorThrows
[ OK ] ConfigParse.TypeErrorThrows (0 ms)
[----------] 5 tests from ConfigParse (0 ms total)
[----------] 3 tests from ConfigLoad
[ RUN ] ConfigLoad.RealConfigFile
[ OK ] ConfigLoad.RealConfigFile (0 ms)
[ RUN ] ConfigLoad.EnvOverride
[ OK ] ConfigLoad.EnvOverride (0 ms)
[ RUN ] ConfigLoad.MissingFileThrows
[ OK ] ConfigLoad.MissingFileThrows (0 ms)
[----------] 3 tests from ConfigLoad (0 ms total)
[----------] Global test environment tear-down
[==========] 8 tests from 2 test suites ran. (0 ms total)
[ PASSED ] 8 tests.
测试全部通过:
| 调用 | 退出码 | 结果 |
|---|---|---|
| ./build/test_config | 0 | 8/8 PASSED(ConfigParse 5 / ConfigLoad 3) |
| ./build/test_config --gtest_list_tests | 0 | 列出 8 个用例 |
| ./build/test_config --gtest_filter=ConfigLoad.EnvOverride | 0 | 筛选生效,1/1 PASSED |
完成之后,记得提交一个 git 版本并推送.
1.2 编写最小 HTTP 服务(功能开发+单元测试+curl接口测试+ctest所有单元测试)
- 提示词(只使用了以下提示词,AI直接完成了功能开发 + 测试的操作):
bash
@SPEC.md 请开发 Phase 1 这⾥的"编写最小 HTTP 服务(Hello World + 静态文件)"。完成后将该步骤标记为完成状态
第一步: 完成最小 HTTP 服务的代码书写
(1) oj-server/src/server/static_serve.{h,cpp} --- MountStatic(server, web_root) 封装 cpp-httplib set_mount_point
- static_serve.h
cpp
#pragma once
#include <string>
#include <httplib.h>
namespace oj {
// 将 <mount_point>(默认 "/static")映射到 <web_root>/static 目录,
// 使浏览器通过 GET /static/<相对路径> 读取 <web_root>/static/<相对路径>。
//
// 典型用法:
// MountStatic(server, cfg.web_root);
// 相当于:
// server.set_mount_point("/static", cfg.web_root + "/static");
//
// 返回值:
// true - 目录存在且挂载成功;
// false - <web_root>/static 不存在或不是目录(HTTP 服务仍可启动,
// 只是 GET /static/* 会得到 404,便于本地调试)。
bool MountStatic(httplib::Server& server, const std::string& web_root,
const std::string& mount_point = "/static");
// 工具:把 "<web_root>/static" 拼出来,便于测试与日志。
std::string StaticDir(const std::string& web_root);
} // namespace oj
- static_serve.cpp
cpp
#include "server/static_serve.h"
#include <string>
#include <sys/stat.h>
namespace oj {
namespace {
bool is_directory(const std::string& path) {
struct stat st {};
if (::stat(path.c_str(), &st) != 0) return false;
return S_ISDIR(st.st_mode);
}
} // namespace
std::string StaticDir(const std::string& web_root) {
std::string dir = web_root;
// 容忍 web_root 以 "/" 结尾
if (!dir.empty() && dir.back() == '/') dir.pop_back();
dir += "/static";
return dir;
}
bool MountStatic(httplib::Server& server, const std::string& web_root,
const std::string& mount_point) {
const std::string dir = StaticDir(web_root);
if (!is_directory(dir)) {
return false;
}
// cpp-httplib 在 mount_point 下找不到文件时会自动 fall-through 到下一个 handler;
// 因此业务 handler(HTML 路由等)只要先注册就不会被静态资源覆盖。
return server.set_mount_point(mount_point, dir);
}
} // namespace oj
(2)oj-server/src/server/http_server.{h,cpp} --- RegisterRoutes 注册 GET / 与 GET /api/health
- http_server.h
cpp
#pragma once
#include <string>
#include <httplib.h>
#include "util/config.h"
namespace oj {
// 将 Phase 1 所需的全部 HTTP 路由注册到 server 上:
// GET / → 200 text/plain "Hello World from oj_server"
// GET /api/health → 200 application/json {"status":"ok",...}
// 任意其它路径 → 404 由 cpp-httplib 自动兜底
//
// 静态文件服务由 MountStatic() 单独挂载在 /static,避免与 HTML 路由冲突。
//
// 入参 cfg 当前仅作占位使用(T1.2 只用到 cfg.web_root 的语义,
// 真正的挂载在 main.cpp 里调用 MountStatic 完成)。后续阶段会把 cfg
// 注入 Service 层。
void RegisterRoutes(httplib::Server& server, const Config& cfg);
// 工具:组装健康检查 JSON,便于客户端脚本解析。version 由构建期常量提供。
std::string HealthJson(const std::string& version);
} // namespace oj
- http_server.cpp
cpp
#include "server/http_server.h"
#include <string>
#include <json/json.h>
#ifndef OJ_SERVER_VERSION
#define OJ_SERVER_VERSION "0.1.0-phase1"
#endif
namespace oj {
namespace {
void HelloHandler(const httplib::Request&, httplib::Response& res) {
res.set_content(
"Hello World from oj_server\n"
" GET /api/health for liveness probe\n"
" GET /static/* for static assets (when web/ exists)\n",
"text/plain; charset=utf-8");
}
void HealthHandler(const httplib::Request&, httplib::Response& res) {
Json::Value body;
body["status"] = "ok";
body["service"] = "oj_server";
body["version"] = OJ_SERVER_VERSION;
body["phase"] = "1";
Json::StreamWriterBuilder w;
w["indentation"] = "";
res.set_content(Json::writeString(w, body),
"application/json; charset=utf-8");
}
} // namespace
void RegisterRoutes(httplib::Server& server, const Config& /*cfg*/) {
server.Get("/", HelloHandler);
server.Get("/api/health", HealthHandler);
}
std::string HealthJson(const std::string& version) {
Json::Value body;
body["status"] = "ok";
body["service"] = "oj_server";
body["version"] = version;
body["phase"] = "1";
Json::StreamWriterBuilder w;
w["indentation"] = "";
return Json::writeString(w, body);
}
} // namespace oj
(3)oj-server/src/main.cpp --- argv 解析 → LoadConfig → 路由注册 + 静态挂载 → listen,SIGINT/SIGTERM 优雅退出
cpp
// oj_server 启动入口(T1.2:Hello World + 静态文件)
//
// 用法:
// ./oj_server [--config <path>] [--port <n>]
//
// --config 配置文件路径,默认 ./config.json(相对进程工作目录)
// --port 覆盖配置文件中的 port,主要给本地冒烟用
//
// 信号:
// SIGINT / SIGTERM → 触发 httplib::Server::stop(),等当前 handler 跑完再退出
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <exception>
#include <string>
#include <httplib.h>
#include "server/http_server.h"
#include "server/static_serve.h"
#include "util/config.h"
namespace {
httplib::Server* g_server = nullptr;
void HandleSignal(int signo) {
if (g_server != nullptr) g_server->stop();
(void)signo;
}
// 简单 argv 解析:返回 true 表示参数已消费。
// 解析失败(缺值或非数字)返回 false 并把原因写到 err。
bool ParseArgs(int argc, char** argv, std::string* config_path, int* port_override,
std::string* err) {
for (int i = 1; i < argc; ++i) {
const std::string a = argv[i];
if (a == "--config") {
if (i + 1 >= argc) {
*err = "--config requires a value";
return false;
}
*config_path = argv[++i];
} else if (a == "--port") {
if (i + 1 >= argc) {
*err = "--port requires a value";
return false;
}
const std::string v = argv[++i];
try {
*port_override = std::stoi(v);
} catch (...) {
*err = "--port must be an integer, got: " + v;
return false;
}
} else if (a == "-h" || a == "--help") {
std::fprintf(stdout,
"Usage: oj_server [--config <path>] [--port <n>]\n");
std::exit(0);
} else {
*err = "unknown argument: " + a;
return false;
}
}
return true;
}
} // namespace
int main(int argc, char** argv) {
std::string config_path = "./config.json";
int port_override = -1;
{
std::string err;
if (!ParseArgs(argc, argv, &config_path, &port_override, &err)) {
std::fprintf(stderr, "oj_server: %s\n", err.c_str());
return 2;
}
}
oj::Config cfg;
try {
cfg = oj::LoadConfig(config_path);
} catch (const std::exception& e) {
std::fprintf(stderr, "oj_server: failed to load config '%s': %s\n",
config_path.c_str(), e.what());
return 1;
}
if (port_override > 0) cfg.port = port_override;
httplib::Server server;
g_server = &server;
std::signal(SIGINT, HandleSignal);
std::signal(SIGTERM, HandleSignal);
oj::RegisterRoutes(server, cfg);
const bool mounted = oj::MountStatic(server, cfg.web_root);
std::fprintf(stdout,
"oj_server: phase 1 (Hello World + static files)\n"
" config : %s\n"
" web_root : %s\n"
" static mount : %s\n"
" listen port : %d\n",
config_path.c_str(), cfg.web_root.c_str(),
mounted ? "OK (/static)" : "SKIP (web/static not found)",
cfg.port);
std::fflush(stdout);
if (!server.listen("0.0.0.0", cfg.port)) {
std::fprintf(stderr, "oj_server: failed to bind 0.0.0.0:%d\n", cfg.port);
return 1;
}
std::fprintf(stdout, "oj_server: stopped\n");
return 0;
}
(4)oj-server/src/util/config.{h,cpp} --- 增 web_root 字段(默认 ./web),向后兼容
(5)oj-server/web/static/css/base.css --- 一份示例静态文件,供 curl 验证
第二步:进行单元测试(使用gtest库)
(1) tests/phase_1/test_http_server.cpp --- 8 个 gtest case(4 HttpServer + 4 StaticServe)
cpp
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <string>
#include <thread>
#include <gtest/gtest.h>
#include <httplib.h>
#include <json/json.h>
#include "server/http_server.h"
#include "server/static_serve.h"
#include "util/config.h"
namespace fs = std::filesystem;
namespace {
// 起一个后台线程监听 ephemeral port,把端口号返回;测试结束析构时 stop。
struct ScopedServer {
httplib::Server server;
std::thread th;
int port = 0;
bool started = false;
// 构造失败时抛 std::runtime_error,让 TEST 宏自然捕获并 fail。
ScopedServer(const oj::Config& cfg, const std::string& tmp_static_root) {
oj::RegisterRoutes(server, cfg);
// 直接挂 tmp_static_root 作为 /static 的目录,绕过 cfg.web_root 解析
if (!server.set_mount_point("/static", tmp_static_root)) {
throw std::runtime_error("failed to mount /static for test");
}
// listen_to_any_port 是 httplib 内部 helper,等价 bind 0.0.0.0:0
const int bound = server.bind_to_any_port("0.0.0.0");
if (bound <= 0) {
throw std::runtime_error("failed to bind ephemeral port for test");
}
port = bound;
th = std::thread([this] { server.listen_after_bind(); });
// 等 server 真正开始 accept
for (int i = 0; i < 100; ++i) {
if (server.is_running()) { started = true; break; }
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
if (!started) {
throw std::runtime_error("server did not enter running state in time");
}
}
~ScopedServer() {
server.stop();
if (th.joinable()) th.join();
}
};
// 在临时目录里写一个 css 文件,返回目录路径。带 RAII 自清理。
struct TmpStaticDir {
fs::path root;
explicit TmpStaticDir(const std::string& body) {
root = fs::temp_directory_path() /
("oj_static_" + std::to_string(::getpid()) + "_" +
std::to_string(reinterpret_cast<uintptr_t>(this)));
fs::create_directories(root);
std::ofstream(root / "app.css") << body;
}
~TmpStaticDir() {
std::error_code ec;
fs::remove_all(root, ec);
}
};
} // namespace
// ---------- / + /api/health ----------
TEST(HttpServer, HelloWorld) {
oj::Config cfg;
ScopedServer s(cfg, TmpStaticDir("body{}").root.string());
httplib::Client cli("127.0.0.1", s.port);
cli.set_connection_timeout(2, 0);
auto res = cli.Get("/");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(res->status, 200);
EXPECT_NE(res->body.find("Hello World from oj_server"), std::string::npos);
}
TEST(HttpServer, HealthJson) {
oj::Config cfg;
ScopedServer s(cfg, TmpStaticDir("body{}").root.string());
httplib::Client cli("127.0.0.1", s.port);
cli.set_connection_timeout(2, 0);
auto res = cli.Get("/api/health");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(res->status, 200);
Json::CharReaderBuilder b;
Json::Value root;
std::string errs;
std::istringstream iss(res->body);
ASSERT_TRUE(Json::parseFromStream(b, iss, &root, &errs)) << errs;
EXPECT_EQ(root["status"].asString(), "ok");
EXPECT_EQ(root["service"].asString(), "oj_server");
EXPECT_EQ(root["phase"].asString(), "1");
}
TEST(HttpServer, UnknownPath404) {
oj::Config cfg;
ScopedServer s(cfg, TmpStaticDir("body{}").root.string());
httplib::Client cli("127.0.0.1", s.port);
auto res = cli.Get("/this/does/not/exist");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(res->status, 404);
}
// ---------- /static/* ----------
TEST(StaticServe, MountsAndServesExistingFile) {
oj::Config cfg;
TmpStaticDir tmp("body{color:red;}");
ScopedServer s(cfg, tmp.root.string());
httplib::Client cli("127.0.0.1", s.port);
auto res = cli.Get("/static/app.css");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(res->status, 200);
EXPECT_EQ(res->body, "body{color:red;}");
}
TEST(StaticServe, MissingFileReturns404) {
oj::Config cfg;
TmpStaticDir tmp("");
ScopedServer s(cfg, tmp.root.string());
httplib::Client cli("127.0.0.1", s.port);
auto res = cli.Get("/static/does_not_exist.css");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(res->status, 404);
}
TEST(StaticServe, MountStaticReturnsFalseWhenDirMissing) {
httplib::Server s;
EXPECT_FALSE(oj::MountStatic(s, "/nonexistent/__oj_path__"));
}
TEST(StaticServe, StaticDirJoinsPath) {
EXPECT_EQ(oj::StaticDir("./web"), "./web/static");
EXPECT_EQ(oj::StaticDir("./web/"), "./web/static");
EXPECT_EQ(oj::StaticDir("/abs/web"), "/abs/web/static");
}
// ---------- /static 不与业务路由冲突 ----------
TEST(HttpServer, ApiRouteNotShadowedByStatic) {
// 即便 /static 下没有任何文件,/api/health 必须仍然命中业务 handler
oj::Config cfg;
TmpStaticDir tmp("");
ScopedServer s(cfg, tmp.root.string());
httplib::Client cli("127.0.0.1", s.port);
auto res = cli.Get("/api/health");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(res->status, 200);
}
- 编译(oj-server/CMakeLists.txt中 集成了单元测试的编译过程)后,单元测试的运行结果:
注:单元测试 应多进行几次,每次都全部测试通过,才代表这个单元模块的开发通过。
(因为有时候会出现第一次 单元测试全部通过,后续再进行 单元测试无法全部通过的情况)
bash
ubuntu@VM-0-4-ubuntu:~/project/oj_-platform-based_on-vibecoding$ ./build/test_http_server
Running main() from ./googletest/src/gtest_main.cc
[==========] Running 8 tests from 2 test suites.
[----------] Global test environment set-up.
[----------] 4 tests from HttpServer
[ RUN ] HttpServer.HelloWorld
[ OK ] HttpServer.HelloWorld (11 ms)
[ RUN ] HttpServer.HealthJson
[ OK ] HttpServer.HealthJson (10 ms)
[ RUN ] HttpServer.UnknownPath404
[ OK ] HttpServer.UnknownPath404 (10 ms)
[ RUN ] HttpServer.ApiRouteNotShadowedByStatic
[ OK ] HttpServer.ApiRouteNotShadowedByStatic (10 ms)
[----------] 4 tests from HttpServer (43 ms total)
[----------] 4 tests from StaticServe
[ RUN ] StaticServe.MountsAndServesExistingFile
[ OK ] StaticServe.MountsAndServesExistingFile (10 ms)
[ RUN ] StaticServe.MissingFileReturns404
[ OK ] StaticServe.MissingFileReturns404 (10 ms)
[ RUN ] StaticServe.MountStaticReturnsFalseWhenDirMissing
[ OK ] StaticServe.MountStaticReturnsFalseWhenDirMissing (0 ms)
[ RUN ] StaticServe.StaticDirJoinsPath
[ OK ] StaticServe.StaticDirJoinsPath (0 ms)
[----------] 4 tests from StaticServe (21 ms total)
[----------] Global test environment tear-down
[==========] 8 tests from 2 test suites ran. (65 ms total)
[ PASSED ] 8 tests.
8 个 HTTP 服务测试全部通过
第三步: 实际启动 server 并 curl 验证 Hello World + 静态文件
bash
$ build/oj_server --port 18080 > /tmp/oj_server.log 2>&1 &
SERVER_PID=$!
echo "started pid=$SERVER_PID"
sleep 0.5
echo "=== GET / ==="
curl -s -i http://127.0.0.1:18080/
echo
echo "=== GET /api/health ==="
curl -s -i http://127.0.0.1:18080/api/health
echo
echo "=== GET /static/css/base.css ==="
curl -s -i http://127.0.0.1:18080/static/css/base.css
echo
echo "=== GET /static/does_not_exist.css ==="
curl -s -i http://127.0.0.1:18080/static/does_not_exist.css | head -5
echo
echo "=== GET /this/does/not/exist ==="
curl -s -i http://127.0.0.1:18080/this/does/not/exist | head -5
kill -INT $SERVER_PID
wait $SERVER_PID 2>/dev/null
echo
echo "=== server log ==="
cat /tmp/oj_server.log
started pid=3445347
=== GET / ===
=== GET /api/health ===
=== GET /static/css/base.css ===
=== GET /static/does_not_exist.css ===
=== GET /this/does/not/exist ===
=== server log ===
oj_server: failed to load config './config.json': config: cannot open file: ./config.json
/bin/bash: line 25: kill: (3445347) - No such process
bash
$ (cd oj-server && ../build/oj_server --port 18080) > /tmp/oj_server.log 2>&1 &
SERVER_PID=$!
sleep 0.5
echo "=== GET / ==="
curl -s -i http://127.0.0.1:18080/
echo
echo "=== GET /api/health ==="
curl -s -i http://127.0.0.1:18080/api/health
echo
echo "=== GET /static/css/base.css ==="
curl -s -i http://127.0.0.1:18080/static/css/base.css
echo
echo "=== GET /static/does_not_exist.css (expect 404) ==="
curl -s -o /dev/null -w "status=%{http_code}\n" http://127.0.0.1:18080/static/does_not_exist.css
echo
echo "=== GET /this/does/not/exist (expect 404) ==="
curl -s -o /dev/null -w "status=%{http_code}\n" http://127.0.0.1:18080/this/does/not/exist
kill -INT $SERVER_PID 2>/dev/null
wait $SERVER_PID 2>/dev/null
echo
echo "=== server log ==="
cat /tmp/oj_server.log
=== GET / ===
HTTP/1.1 200 OK
Keep-Alive: timeout=5, max=100
Content-Length: 121
Content-Type: text/plain; charset=utf-8
Hello World from oj_server
GET /api/health for liveness probe
GET /static/* for static assets (when web/ exists)
=== GET /api/health ===
HTTP/1.1 200 OK
Keep-Alive: timeout=5, max=100
Content-Length: 74
Content-Type: application/json; charset=utf-8
{"phase":"1","service":"oj_server","status":"ok","version":"0.1.0-phase1"}
=== GET /static/css/base.css ===
HTTP/1.1 200 OK
Keep-Alive: timeout=5, max=100
Content-Length: 29
Content-Type: text/css
Last-Modified: Sun, 26 Jul 2026 12:33:13 GMT
ETag: W/"6a65fe89-1d"
body{font-family:sans-serif}
=== GET /static/does_not_exist.css (expect 404) ===
status=404
=== GET /this/does/not/exist (expect 404) ===
status=404
=== server log ===
oj_server: phase 1 (Hello World + static files)
config : ./config.json
web_root : ./web
static mount : OK (/static)
listen port : 18080
oj_server: stopped
所有路由都正确!
第四步: 最后跑一次完整 ctest 收尾(每一次新增单元测试,都会添加到ctest)
每个单元开发完毕,都得跑一次完整的 ctest,测试所有单元测试是否全部通过。
之前的单元开发结束之后,不是已经测试过了嘛,为什么本单元开发完成之后,还要再把之前的单元测试跑一下呢?
因为本次单元的开发,可能会影响之前的单元,所以在本次单元开发结束之后,要再跑一遍之前的单元测试,看看之前的单元还能否通过单元测试
bash
$ ctest --test-dir build --output-on-failure 2>&1 | tail -15
Internal ctest changing into directory: /home/ubuntu/project/oj_-platform-based_on-vibecoding/build
Test project /home/ubuntu/project/oj_-platform-based_on-vibecoding/build
Start 1: ConfigTest
1/2 Test #1: ConfigTest ....................... Passed 0.00 sec
Start 2: HttpServerTest
2/2 Test #2: HttpServerTest ................... Passed 0.07 sec
100% tests passed, 0 tests failed out of 2
Total Test time (real) = 0.08 sec
1.3 编写日志模块(功能开发+单元测试+ctest所有单元测试)
第一步: 完成日志模块的代码书写
- 提示词(进行功能开发):
bash
@SPEC.md 请开发 Phase 1 这⾥的"编写 `logger` 模块(分级日志、可重定向到文件)"。完成后将该步骤标记为完成状态
(1)已完成 oj-server/src/util/logger.{h,cpp}:支持 Debug/Info/Warn/Error 分级过滤、线程安全输出及文件重定向
- logger.h
cpp
#pragma once
#include <iosfwd>
#include <memory>
#include <mutex>
#include <string>
#include <string_view>
namespace oj {
enum class LogLevel : int {
Debug = 0,
Info = 1,
Warn = 2,
Error = 3,
DEBUG = Debug,
INFO = Info,
WARN = Warn,
Warning = Warn,
WARNING = Warn,
ERROR = Error
};
const char* LogLevelName(LogLevel level) noexcept;
std::string LogLevelToString(LogLevel level);
std::string ToString(LogLevel level);
class Logger final {
public:
using Level = oj::LogLevel;
using LogLevel = oj::LogLevel;
using LogLevelType = oj::LogLevel;
explicit Logger(LogLevel min_level = LogLevel::Info);
explicit Logger(std::ostream& output, LogLevel min_level = LogLevel::Info);
Logger(LogLevel min_level, std::ostream& output);
~Logger() noexcept;
Logger(const Logger&) = delete;
Logger& operator=(const Logger&) = delete;
Logger(Logger&&) = delete;
Logger& operator=(Logger&&) = delete;
void SetLevel(LogLevel level) noexcept;
LogLevel GetLevel() const noexcept;
bool IsEnabled(LogLevel level) const noexcept;
void SetLogLevel(LogLevel level) noexcept { SetLevel(level); }
LogLevel GetLogLevel() const noexcept { return GetLevel(); }
void Log(LogLevel level, std::string_view message);
void Debug(std::string_view message);
void Info(std::string_view message);
void Warn(std::string_view message);
void Warning(std::string_view message);
void Error(std::string_view message);
void setLevel(LogLevel level) noexcept { SetLevel(level); }
LogLevel getLevel() const noexcept { return GetLevel(); }
bool isEnabled(LogLevel level) const noexcept { return IsEnabled(level); }
void log(LogLevel level, std::string_view message) { Log(level, message); }
void debug(std::string_view message) { Debug(message); }
void info(std::string_view message) { Info(message); }
void warn(std::string_view message) { Warn(message); }
void warning(std::string_view message) { Warning(message); }
void error(std::string_view message) { Error(message); }
bool RedirectToFile(const std::string& path, bool append = true);
bool RedirectTo(std::ostream& output);
bool SetOutput(std::ostream& output) { return RedirectTo(output); }
bool setOutput(std::ostream& output) { return RedirectTo(output); }
bool SetOutputFile(const std::string& path, bool append = true);
bool SetLogFile(const std::string& path, bool append = true);
bool setOutputFile(const std::string& path, bool append = true) {
return SetOutputFile(path, append);
}
bool setLogFile(const std::string& path, bool append = true) {
return SetLogFile(path, append);
}
bool redirectToFile(const std::string& path, bool append = true) {
return RedirectToFile(path, append);
}
bool RedirectToStream(std::ostream& output) { return RedirectTo(output); }
void ResetOutput() noexcept;
void resetOutput() noexcept { ResetOutput(); }
void Flush() noexcept;
void flush() noexcept { Flush(); }
static Logger& Instance();
static Logger& GetInstance();
static Logger& getInstance();
private:
mutable std::mutex mutex_;
LogLevel min_level_;
std::ostream* default_output_;
std::ostream* output_;
std::unique_ptr<std::ofstream> file_;
};
Logger& GetLogger();
void SetLogLevel(LogLevel level) noexcept;
LogLevel GetLogLevel() noexcept;
bool RedirectLogToFile(const std::string& path, bool append = true);
bool RedirectLogTo(std::ostream& output);
void ResetLogOutput() noexcept;
void FlushLog() noexcept;
void Log(LogLevel level, std::string_view message);
void LogDebug(std::string_view message);
void LogInfo(std::string_view message);
void LogWarn(std::string_view message);
void LogWarning(std::string_view message);
void LogError(std::string_view message);
}
- logger.cpp
cpp
#include "util/logger.h"
#include <fstream>
#include <iostream>
namespace oj {
namespace {
void FlushNoexcept(std::ostream* output) noexcept {
if (output == nullptr) return;
try {
output->flush();
} catch (...) {
}
}
}
const char* LogLevelName(LogLevel level) noexcept {
switch (level) {
case LogLevel::Debug:
return "DEBUG";
case LogLevel::Info:
return "INFO";
case LogLevel::Warn:
return "WARN";
case LogLevel::Error:
return "ERROR";
}
return "UNKNOWN";
}
std::string LogLevelToString(LogLevel level) {
return LogLevelName(level);
}
std::string ToString(LogLevel level) {
return LogLevelToString(level);
}
Logger::Logger(LogLevel min_level) : Logger(min_level, std::cerr) {}
Logger::Logger(std::ostream& output, LogLevel min_level)
: Logger(min_level, output) {}
Logger::Logger(LogLevel min_level, std::ostream& output)
: min_level_(min_level),
default_output_(&output),
output_(&output),
file_(nullptr) {}
Logger::~Logger() noexcept {
std::lock_guard<std::mutex> lock(mutex_);
FlushNoexcept(output_);
file_.reset();
}
void Logger::SetLevel(LogLevel level) noexcept {
std::lock_guard<std::mutex> lock(mutex_);
min_level_ = level;
}
LogLevel Logger::GetLevel() const noexcept {
std::lock_guard<std::mutex> lock(mutex_);
return min_level_;
}
bool Logger::IsEnabled(LogLevel level) const noexcept {
std::lock_guard<std::mutex> lock(mutex_);
return static_cast<int>(level) >= static_cast<int>(min_level_);
}
void Logger::Log(LogLevel level, std::string_view message) {
std::lock_guard<std::mutex> lock(mutex_);
if (output_ == nullptr ||
static_cast<int>(level) < static_cast<int>(min_level_)) {
return;
}
try {
*output_ << '[' << LogLevelName(level) << "] " << message;
if (message.empty() || message.back() != '\n') {
*output_ << '\n';
}
output_->flush();
} catch (...) {
}
}
void Logger::Debug(std::string_view message) {
Log(LogLevel::Debug, message);
}
void Logger::Info(std::string_view message) {
Log(LogLevel::Info, message);
}
void Logger::Warn(std::string_view message) {
Log(LogLevel::Warn, message);
}
void Logger::Warning(std::string_view message) {
Warn(message);
}
void Logger::Error(std::string_view message) {
Log(LogLevel::Error, message);
}
bool Logger::RedirectToFile(const std::string& path, bool append) {
const auto mode = std::ios::out | (append ? std::ios::app : std::ios::trunc);
auto new_file = std::make_unique<std::ofstream>(path, mode);
if (!new_file->is_open() || new_file->fail()) {
return false;
}
std::lock_guard<std::mutex> lock(mutex_);
FlushNoexcept(output_);
file_ = std::move(new_file);
output_ = file_.get();
return true;
}
bool Logger::RedirectTo(std::ostream& output) {
if (!output.good()) return false;
std::lock_guard<std::mutex> lock(mutex_);
FlushNoexcept(output_);
file_.reset();
output_ = &output;
return true;
}
bool Logger::SetOutputFile(const std::string& path, bool append) {
return RedirectToFile(path, append);
}
bool Logger::SetLogFile(const std::string& path, bool append) {
return RedirectToFile(path, append);
}
void Logger::ResetOutput() noexcept {
std::lock_guard<std::mutex> lock(mutex_);
FlushNoexcept(output_);
file_.reset();
output_ = default_output_;
}
void Logger::Flush() noexcept {
std::lock_guard<std::mutex> lock(mutex_);
FlushNoexcept(output_);
}
Logger& Logger::Instance() {
return GetLogger();
}
Logger& Logger::GetInstance() {
return GetLogger();
}
Logger& Logger::getInstance() {
return GetLogger();
}
Logger& GetLogger() {
static Logger logger;
return logger;
}
void SetLogLevel(LogLevel level) noexcept {
GetLogger().SetLevel(level);
}
LogLevel GetLogLevel() noexcept {
return GetLogger().GetLevel();
}
bool RedirectLogToFile(const std::string& path, bool append) {
return GetLogger().RedirectToFile(path, append);
}
bool RedirectLogTo(std::ostream& output) {
return GetLogger().RedirectTo(output);
}
void ResetLogOutput() noexcept {
GetLogger().ResetOutput();
}
void FlushLog() noexcept {
GetLogger().Flush();
}
void Log(LogLevel level, std::string_view message) {
GetLogger().Log(level, message);
}
void LogDebug(std::string_view message) {
GetLogger().Debug(message);
}
void LogInfo(std::string_view message) {
GetLogger().Info(message);
}
void LogWarn(std::string_view message) {
GetLogger().Warn(message);
}
void LogWarning(std::string_view message) {
GetLogger().Warn(message);
}
void LogError(std::string_view message) {
GetLogger().Error(message);
}
}
第二步:进行单元测试(使用gtest库)
- 提示词:
bash
请针对上述"编写 `logger` 模块(分级日志、可重定向到文件)"的代码实现, 基于 gtest 设计单元测试⽤例. 相关代码存放在 @tests/phase_1/⽬录下.
(1)tests/phase_1/test_logger.cpp,覆盖分级过滤、格式换行、流/文件重定向、失败回退、并发写入及全局入口
cpp
// 受限于篇幅,测试代码不展示
- 编译(oj-server/CMakeLists.txt中 集成了单元测试的编译过程)后,单元测试的运行结果:
注:单元测试 应多进行几次,每次都全部测试通过,才代表这个单元模块的开发通过。
(因为有时候会出现第一次 单元测试全部通过,后续再进行 单元测试无法全部通过的情况)
bash
ubuntu@VM-0-4-ubuntu:~/project/oj_-platform-based_on-vibecoding$ ./build/test_logger
Running main() from ./googletest/src/gtest_main.cc
[==========] Running 10 tests from 2 test suites.
[----------] Global test environment set-up.
[----------] 9 tests from LoggerTest
[ RUN ] LoggerTest.LevelNamesAreStable
[ OK ] LoggerTest.LevelNamesAreStable (0 ms)
[ RUN ] LoggerTest.DefaultLevelFiltersDebug
[ OK ] LoggerTest.DefaultLevelFiltersDebug (0 ms)
[ RUN ] LoggerTest.ConfigurableLevelFiltersLowerPriorityRecords
[ OK ] LoggerTest.ConfigurableLevelFiltersLowerPriorityRecords (0 ms)
[ RUN ] LoggerTest.RecordsHaveStableFormatAndNewlineHandling
[ OK ] LoggerTest.RecordsHaveStableFormatAndNewlineHandling (0 ms)
[ RUN ] LoggerTest.RedirectsToStreamAndRestoresDefaultOutput
[ OK ] LoggerTest.RedirectsToStreamAndRestoresDefaultOutput (0 ms)
[ RUN ] LoggerTest.FailedStreamRedirectKeepsCurrentOutput
[ OK ] LoggerTest.FailedStreamRedirectKeepsCurrentOutput (0 ms)
[ RUN ] LoggerTest.RedirectsToFileWithAppendAndTruncateModes
[ OK ] LoggerTest.RedirectsToFileWithAppendAndTruncateModes (0 ms)
[ RUN ] LoggerTest.FailedFileRedirectKeepsCurrentOutput
[ OK ] LoggerTest.FailedFileRedirectKeepsCurrentOutput (0 ms)
[ RUN ] LoggerTest.ConcurrentRecordsRemainComplete
[ OK ] LoggerTest.ConcurrentRecordsRemainComplete (0 ms)
[----------] 9 tests from LoggerTest (0 ms total)
[----------] 1 test from GlobalLoggerTest
[ RUN ] GlobalLoggerTest.NamespaceFunctionsUseTheGlobalLogger
[ OK ] GlobalLoggerTest.NamespaceFunctionsUseTheGlobalLogger (0 ms)
[----------] 1 test from GlobalLoggerTest (0 ms total)
[----------] Global test environment tear-down
[==========] 10 tests from 2 test suites ran. (0 ms total)
[ PASSED ] 10 tests.
验证通过:logger 测试 10/10,完整 CTest 3/3。
第三步: 最后跑一次完整 ctest 收尾(每一次新增单元测试,都会添加到ctest)
每个单元开发完毕,都得跑一次完整的 ctest,测试所有单元测试是否全部通过。
bash
ubuntu@VM-0-4-ubuntu:~/project/oj_-platform-based_on-vibecoding$ ctest --test-dir build --output-on-failure 2>&1 | tail -15
Internal ctest changing into directory: /home/ubuntu/project/oj_-platform-based_on-vibecoding/build
Test project /home/ubuntu/project/oj_-platform-based_on-vibecoding/build
Start 1: ConfigTest
1/3 Test #1: ConfigTest ....................... Passed 0.00 sec
Start 2: HttpServerTest
2/3 Test #2: HttpServerTest ................... Passed 0.07 sec
Start 3: LoggerTest
3/3 Test #3: LoggerTest ....................... Passed 0.00 sec
100% tests passed, 0 tests failed out of 3
Total Test time (real) = 0.08 sec
2. 阶段 2:数据库
2.1 编写 db/init.sql(手动执行init.sql建库,并转让权限给普通用户ubuntu)
第一步: 完成 oj-server/db/init.sql 的代码书写
- 提示词(进行功能开发):
bash
@SPEC.md 请开发 Phase 2 这⾥的"编写 `db/init.sql(包含建表语句 + 1 个默认管理员账号)`"工作。
具体需要创建的表结构参考 "### 5.2 表结构"。完成后将该步骤标记为完成状态
(1)oj-server/db/init.sql:1 已完成(建库 oj + 5 张表 + 默认 admin / admin123)
sql
-- OJ Platform Database Initialization
-- MySQL 5.7+ / 8.x
-- Usage:
-- mysql -u root -p < db/init.sql
-- or:
-- mysql -u ubuntu < db/init.sql (after `export ubuntu_password=...` per SPEC §9.2)
--
-- Conventions:
-- * Database name: `oj` (matches config.json -> db.name)
-- * Password hash: SHA-256(salt_hex || password) hex-encoded (64 chars)
-- * Salt: 16 random bytes hex-encoded (32 chars)
-- * Re-runnable: DROP TABLE IF EXISTS ... then CREATE
CREATE DATABASE IF NOT EXISTS `oj`
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_unicode_ci;
USE `oj`;
-- ---------------------------------------------------------------------------
-- users
-- ---------------------------------------------------------------------------
DROP TABLE IF EXISTS `submissions`;
DROP TABLE IF EXISTS `test_cases`;
DROP TABLE IF EXISTS `problems`;
DROP TABLE IF EXISTS `sessions`;
DROP TABLE IF EXISTS `users`;
CREATE TABLE `users` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(20) NOT NULL,
`password_hash` CHAR(64) NOT NULL COMMENT 'SHA-256 hex of (salt || password)',
`salt` CHAR(32) NOT NULL COMMENT '16 random bytes hex-encoded',
`role` ENUM('user','admin') NOT NULL DEFAULT 'user',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- sessions
-- ---------------------------------------------------------------------------
CREATE TABLE `sessions` (
`session_id` CHAR(64) NOT NULL COMMENT '32 random bytes hex-encoded token',
`user_id` BIGINT NOT NULL,
`expires_at` DATETIME NOT NULL,
PRIMARY KEY (`session_id`),
KEY `idx_user` (`user_id`),
KEY `idx_expires` (`expires_at`),
CONSTRAINT `fk_sessions_user`
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- problems
-- ---------------------------------------------------------------------------
CREATE TABLE `problems` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`title` VARCHAR(200) NOT NULL,
`difficulty` ENUM('Easy','Medium','Hard') NOT NULL,
`description` TEXT NOT NULL COMMENT 'Markdown body',
`sample_input` TEXT NOT NULL,
`sample_output` TEXT NOT NULL,
`time_limit_ms` INT NOT NULL DEFAULT 2000,
`memory_limit_mb` INT NOT NULL DEFAULT 256,
`tags` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'comma-separated tags',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_difficulty` (`difficulty`),
KEY `idx_created` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- test_cases
-- ---------------------------------------------------------------------------
CREATE TABLE `test_cases` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`problem_id` BIGINT NOT NULL,
`input` MEDIUMTEXT NOT NULL,
`expected_output` MEDIUMTEXT NOT NULL,
`score` INT NOT NULL DEFAULT 1,
PRIMARY KEY (`id`),
KEY `idx_problem` (`problem_id`),
CONSTRAINT `fk_testcases_problem`
FOREIGN KEY (`problem_id`) REFERENCES `problems` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- submissions
-- ---------------------------------------------------------------------------
CREATE TABLE `submissions` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`problem_id` BIGINT NOT NULL,
`code` MEDIUMTEXT NOT NULL,
`language` VARCHAR(20) NOT NULL DEFAULT 'cpp',
`status` ENUM('Pending','Judging','AC','WA','TLE','RE','CE','SE')
NOT NULL DEFAULT 'Pending',
`error_msg` TEXT NULL COMMENT 'CE output / WA diff / RE reason',
`time_used_ms` INT NULL,
`memory_used_kb` INT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`finished_at` DATETIME NULL,
PRIMARY KEY (`id`),
KEY `idx_user_created` (`user_id`, `created_at` DESC),
KEY `idx_problem` (`problem_id`),
KEY `idx_status` (`status`),
CONSTRAINT `fk_submissions_user`
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_submissions_problem`
FOREIGN KEY (`problem_id`) REFERENCES `problems` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- Default admin account
-- username : admin
-- password : admin123 <-- prompt to change on first login (SPEC §5.3)
--
-- hash = SHA-256(salt || password)
-- = SHA-256('a1b2c3d4e5f60718293a4b5c6d7e8f90' || 'admin123')
-- = '0212b518b03b50cc62a0dadc9e897f48190e21e0af3ba93665b4b0085885e265'
-- ---------------------------------------------------------------------------
INSERT INTO `users` (`username`, `password_hash`, `salt`, `role`)
VALUES (
'admin',
'0212b518b03b50cc62a0dadc9e897f48190e21e0af3ba93665b4b0085885e265',
'a1b2c3d4e5f60718293a4b5c6d7e8f90',
'admin'
);
第二步(设计到权限问题,不方便给AI太高的权限,于是我手动执行了后续步骤):
MySQL有两个用户,管理员用户root,普通用户ubuntu(我想用普通用户ubuntu管理 oj 库)。
目前 ubuntu MySQL 账户只有 USAGE 权限(无任何库表权限),需要先用高权限账户初始化建库,再把 oj 库的权限下放给 ubuntu。
(1)当前状态
| 检查项 | 结果 |
|---|---|
| mysql 服务 | 运行中 (8.0.46) |
| ubuntu 账户可登录 | ✅ 密码 123456789 |
| ubuntu 实际权限 | ⚠️ 仅 GRANT USAGE ON .,无任何库权限 |
| init.sql 现状 | ✅ 已写好,待执行 |
(2)执行计划(需要用户手动完成)
- 步骤 1:以高权限账户(root)执行 init.sql
用 MySQL root(需要你记得 root 密码)
bash
mysql -u root -p < oj-server/db/init.sql
这一步会做:建库 oj、建 5 张表、写入默认 admin(admin / admin123)。
- 步骤 2:把 oj 库权限授予 ubuntu(用root账号登录mysql后运行以下命令)
bash
GRANT ALL PRIVILEGES ON `oj`.* TO 'ubuntu'@'localhost';
FLUSH PRIVILEGES;

之后 ubuntu 就能用 config.json 里的空密码 + 环境变量 ubuntu_password=123456789 正常连库,符合 SPEC §9.2 的预期。
- 步骤 3:用普通用户ubuntu登录mysql 进行验证
bash
SHOW GRANTS FOR 'ubuntu'@'localhost';
预期看到 GRANT ALL PRIVILEGES ON oj.* TO ubuntu@localhost

bash
USE oj;
SELECT id, username, role FROM users;
-- 预期看到 1 行:admin / admin

2.2 封装 MySQL 连接池(功能开发+单元测试+ctest所有单元测试)
- 提示词(只使用了以下提示词,AI直接完成了功能开发 + 测试的操作):
bash
@SPEC.md 请开发 Phase 2 这⾥的"封装 MySQL 连接管理(连接池,简化版:每请求取连接)"工作。完成后将该步骤标记为完成状态
开发出来的连接池:
(1) 对 mysql 原生 API 进行了封装, 简化了使用
(2) 提前创建好多个连接, 后续代码需要使用数据库连接时直接从池子中获取
(3) 考虑到了线程安全, 在合理位置进行加锁
第一步: 完成 MySQL连接池模块 的代码书写
实现:
oj-server/src/repo/db.{h,cpp}基于 libmysqlclient
(1)MysqlPool:线程安全,构造时预热initial_size条连接,Acquire 时若空闲则复用、否则新建直到max_size,达上限阻塞等待;utf8mb4字符集、5s connect / 30s read+write 超时
(2)MysqlConn:RAII move-only 句柄,析构时通过weak_ptr<MysqlPool>把连接归还到池;若池已销毁则直接mysql_close,避免 UAF
(3) 提供Execute(sql)→affected_rows、Query(sql)→DbResult(NULL→空串)、Escape(s)、SetAutocommit/Commit/Rollback、Ping()、LastError()
(4) 工厂MysqlPool::Create(cfg, opts)(私有构造 +enable_shared_from_this),保证句柄 back-pointer 安全
- db.h
cpp
#pragma once
#include <atomic>
#include <condition_variable>
#include <cstddef>
#include <cstdint>
#include <memory>
#include <mutex>
#include <queue>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>
#include <mysql.h>
#include "util/config.h"
namespace oj {
class DbError : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
using DbRow = std::vector<std::string>;
using DbResult = std::vector<DbRow>;
class MysqlPool;
class MysqlConn {
public:
MysqlConn() noexcept = default;
MysqlConn(MysqlConn&& other) noexcept;
MysqlConn& operator=(MysqlConn&& other) noexcept;
MysqlConn(const MysqlConn&) = delete;
MysqlConn& operator=(const MysqlConn&) = delete;
~MysqlConn();
bool Valid() const noexcept { return conn_ != nullptr; }
explicit operator bool() const noexcept { return Valid(); }
MYSQL* Raw() const noexcept { return conn_; }
const DbConfig& Config() const noexcept { return *config_; }
std::string Escape(std::string_view s) const;
std::string LastError() const;
bool Ping() noexcept;
std::uint64_t Execute(const std::string& sql);
DbResult Query(const std::string& sql);
void SetAutocommit(bool on);
void Commit();
void Rollback();
private:
friend class MysqlPool;
MysqlConn(MYSQL* conn, const DbConfig* cfg,
std::weak_ptr<MysqlPool> pool) noexcept;
MysqlConn& Assign(MysqlConn&& other) noexcept;
void Close() noexcept;
MYSQL* conn_ = nullptr;
const DbConfig* config_ = nullptr;
std::weak_ptr<MysqlPool> pool_;
};
class MysqlPool : public std::enable_shared_from_this<MysqlPool> {
public:
struct Options {
std::size_t initial_size = 2;
std::size_t max_size = 16;
unsigned int connect_timeout_sec = 5;
unsigned int read_timeout_sec = 30;
unsigned int write_timeout_sec = 30;
std::string charset = "utf8mb4";
Options() = default;
Options(std::size_t init_size, std::size_t max)
: initial_size(init_size), max_size(max) {}
};
static std::shared_ptr<MysqlPool> Create(const DbConfig& cfg,
Options opts);
~MysqlPool();
MysqlPool(const MysqlPool&) = delete;
MysqlPool& operator=(const MysqlPool&) = delete;
MysqlConn Acquire();
std::size_t IdleCount() const;
std::size_t LiveCount() const;
std::size_t TotalCreated() const noexcept;
const Options& GetOptions() const noexcept { return opts_; }
const DbConfig& GetConfig() const noexcept { return cfg_; }
private:
friend class MysqlConn;
MysqlPool(const DbConfig& cfg, Options opts);
void ReturnToPool(MYSQL* conn) noexcept;
MYSQL* CreateConnection();
void CloseConnection(MYSQL* conn) noexcept;
const DbConfig cfg_;
const Options opts_;
mutable std::mutex mu_;
std::condition_variable cv_;
std::queue<MYSQL*> idle_;
std::size_t live_count_ = 0;
bool closing_ = false;
std::atomic<std::size_t> total_created_{0};
};
} // namespace oj
- db.cpp
cpp
#include "repo/db.h"
#include <cstring>
#include <utility>
#include "util/logger.h"
namespace oj {
namespace {
void ThrowDbError(MYSQL* conn, const std::string& prefix) {
std::string msg = prefix;
if (conn != nullptr) {
const char* err = mysql_error(conn);
if (err != nullptr && err[0] != '\0') {
msg += ": ";
msg += err;
}
}
throw DbError(msg);
}
} // namespace
MysqlConn::MysqlConn(MYSQL* conn, const DbConfig* cfg,
std::weak_ptr<MysqlPool> pool) noexcept
: conn_(conn), config_(cfg), pool_(std::move(pool)) {}
MysqlConn::MysqlConn(MysqlConn&& other) noexcept {
Assign(std::move(other));
}
MysqlConn& MysqlConn::operator=(MysqlConn&& other) noexcept {
if (this != &other) {
Close();
Assign(std::move(other));
}
return *this;
}
MysqlConn::~MysqlConn() {
Close();
}
MysqlConn& MysqlConn::Assign(MysqlConn&& other) noexcept {
conn_ = other.conn_;
config_ = other.config_;
pool_ = std::move(other.pool_);
other.conn_ = nullptr;
other.config_ = nullptr;
return *this;
}
void MysqlConn::Close() noexcept {
if (conn_ == nullptr) return;
if (auto p = pool_.lock()) {
p->ReturnToPool(conn_);
} else {
mysql_close(conn_);
}
conn_ = nullptr;
config_ = nullptr;
pool_.reset();
}
std::string MysqlConn::Escape(std::string_view s) const {
if (conn_ == nullptr) {
throw DbError("MysqlConn::Escape on null connection");
}
std::string out;
out.resize(s.size() * 2 + 1);
unsigned long n = mysql_real_escape_string(conn_, out.data(), s.data(),
static_cast<unsigned long>(s.size()));
out.resize(n);
return out;
}
std::string MysqlConn::LastError() const {
if (conn_ == nullptr) return {};
const char* e = mysql_error(conn_);
return e == nullptr ? std::string{} : std::string{e};
}
bool MysqlConn::Ping() noexcept {
if (conn_ == nullptr) return false;
return mysql_ping(conn_) == 0;
}
std::uint64_t MysqlConn::Execute(const std::string& sql) {
if (conn_ == nullptr) {
throw DbError("MysqlConn::Execute on null connection");
}
if (mysql_real_query(conn_, sql.data(),
static_cast<unsigned long>(sql.size())) != 0) {
ThrowDbError(conn_, "Execute failed: " + sql);
}
const std::uint64_t affected = mysql_affected_rows(conn_);
if (affected == static_cast<std::uint64_t>(-1)) {
ThrowDbError(conn_, "mysql_affected_rows failed");
}
return affected;
}
DbResult MysqlConn::Query(const std::string& sql) {
if (conn_ == nullptr) {
throw DbError("MysqlConn::Query on null connection");
}
if (mysql_real_query(conn_, sql.data(),
static_cast<unsigned long>(sql.size())) != 0) {
ThrowDbError(conn_, "Query failed: " + sql);
}
MYSQL_RES* res = mysql_store_result(conn_);
if (res == nullptr) {
if (mysql_field_count(conn_) == 0) {
return {};
}
ThrowDbError(conn_, "mysql_store_result failed");
}
DbResult rows;
const unsigned int n = mysql_num_fields(res);
MYSQL_ROW row;
while ((row = mysql_fetch_row(res)) != nullptr) {
unsigned long* lens = mysql_fetch_lengths(res);
DbRow r;
r.reserve(n);
for (unsigned int i = 0; i < n; ++i) {
if (row[i] == nullptr) {
r.emplace_back();
} else {
r.emplace_back(row[i], lens[i]);
}
}
rows.push_back(std::move(r));
}
mysql_free_result(res);
return rows;
}
void MysqlConn::SetAutocommit(bool on) {
if (conn_ == nullptr) {
throw DbError("MysqlConn::SetAutocommit on null connection");
}
if (mysql_autocommit(conn_, on ? 1 : 0) != 0) {
ThrowDbError(conn_, "mysql_autocommit failed");
}
}
void MysqlConn::Commit() {
if (conn_ == nullptr) {
throw DbError("MysqlConn::Commit on null connection");
}
if (mysql_commit(conn_) != 0) {
ThrowDbError(conn_, "mysql_commit failed");
}
}
void MysqlConn::Rollback() {
if (conn_ == nullptr) {
throw DbError("MysqlConn::Rollback on null connection");
}
if (mysql_rollback(conn_) != 0) {
ThrowDbError(conn_, "mysql_rollback failed");
}
}
MysqlPool::MysqlPool(const DbConfig& cfg, Options opts)
: cfg_(cfg), opts_(std::move(opts)) {
for (std::size_t i = 0; i < opts_.initial_size; ++i) {
MYSQL* c = nullptr;
try {
c = CreateConnection();
} catch (...) {
std::queue<MYSQL*> to_close;
{
std::lock_guard<std::mutex> lock(mu_);
closing_ = true;
while (!idle_.empty()) {
to_close.push(idle_.front());
idle_.pop();
}
}
while (!to_close.empty()) {
mysql_close(to_close.front());
to_close.pop();
}
throw;
}
{
std::lock_guard<std::mutex> lock(mu_);
idle_.push(c);
++live_count_;
}
}
}
std::shared_ptr<MysqlPool> MysqlPool::Create(const DbConfig& cfg, Options opts) {
return std::shared_ptr<MysqlPool>(new MysqlPool(cfg, std::move(opts)));
}
MysqlPool::~MysqlPool() {
std::queue<MYSQL*> to_close;
{
std::lock_guard<std::mutex> lock(mu_);
closing_ = true;
while (!idle_.empty()) {
to_close.push(idle_.front());
idle_.pop();
}
}
while (!to_close.empty()) {
mysql_close(to_close.front());
to_close.pop();
}
}
MYSQL* MysqlPool::CreateConnection() {
MYSQL* c = mysql_init(nullptr);
if (c == nullptr) {
throw DbError("mysql_init failed (out of memory)");
}
bool reconnect = false;
mysql_options(c, MYSQL_OPT_RECONNECT, &reconnect);
mysql_options(c, MYSQL_OPT_CONNECT_TIMEOUT, &opts_.connect_timeout_sec);
mysql_options(c, MYSQL_OPT_READ_TIMEOUT, &opts_.read_timeout_sec);
mysql_options(c, MYSQL_OPT_WRITE_TIMEOUT, &opts_.write_timeout_sec);
mysql_options(c, MYSQL_SET_CHARSET_NAME, opts_.charset.c_str());
if (mysql_real_connect(c, cfg_.host.c_str(), cfg_.user.c_str(),
cfg_.password.c_str(), cfg_.name.c_str(),
static_cast<unsigned int>(cfg_.port), nullptr, 0) == nullptr) {
std::string err = mysql_error(c);
mysql_close(c);
throw DbError("mysql_real_connect failed [" + cfg_.host + ":" +
std::to_string(cfg_.port) + "] as " + cfg_.user + ": " + err);
}
total_created_.fetch_add(1, std::memory_order_relaxed);
return c;
}
void MysqlPool::CloseConnection(MYSQL* conn) noexcept {
if (conn == nullptr) return;
mysql_close(conn);
}
MysqlConn MysqlPool::Acquire() {
std::unique_lock<std::mutex> lock(mu_);
cv_.wait(lock, [this] {
return closing_ || !idle_.empty() || live_count_ < opts_.max_size;
});
if (closing_) {
throw DbError("MysqlPool::Acquire on closed pool");
}
MYSQL* conn = nullptr;
if (!idle_.empty()) {
conn = idle_.front();
idle_.pop();
} else {
lock.unlock();
MYSQL* fresh = nullptr;
try {
fresh = CreateConnection();
} catch (...) {
lock.lock();
cv_.notify_one();
throw;
}
lock.lock();
++live_count_;
conn = fresh;
}
return MysqlConn(conn, &cfg_, weak_from_this());
}
void MysqlPool::ReturnToPool(MYSQL* conn) noexcept {
if (conn == nullptr) return;
{
std::lock_guard<std::mutex> lock(mu_);
if (closing_) {
--live_count_;
} else {
idle_.push(conn);
}
}
cv_.notify_one();
}
std::size_t MysqlPool::IdleCount() const {
std::lock_guard<std::mutex> lock(mu_);
return idle_.size();
}
std::size_t MysqlPool::LiveCount() const {
std::lock_guard<std::mutex> lock(mu_);
return live_count_;
}
std::size_t MysqlPool::TotalCreated() const noexcept {
return total_created_.load(std::memory_order_relaxed);
}
} // namespace oj
第二步:进行单元测试(使用gtest库)
(1)tests/phase_2/test_db.cpp(gtest 16 cases),覆盖 Acquire/归还、database/charset 选择、max_size 内复用、不同 conn 句柄、move-only 与 move 后不重复归还、Execute/affected_rows、Query/NULL→空串、非法 SQL 抛 DbError、Escape 单引号与 NUL、事务 Rollback、并发 8×200 轮无异常、 池满时 Acquire 阻塞与释放唤醒、坏凭证构造抛、池销毁后已签出 conn 安全析构(weak_ptr)
cpp
// 受限于篇幅,测试代码不展示
- 编译(oj-server/CMakeLists.txt中 集成了单元测试的编译过程)后,单元测试的运行结果:
bash
ubuntu@VM-0-4-ubuntu:~/project/oj_-platform-based_on-vibecoding$ ./build/test_db
Running main() from ./googletest/src/gtest_main.cc
[==========] Running 16 tests from 2 test suites.
[----------] Global test environment set-up.
[----------] 15 tests from DbPoolTest
[ RUN ] DbPoolTest.AcquireReturnsValidConnectionAndReturnsOnDtor
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] DbPoolTest.AcquireReturnsValidConnectionAndReturnsOnDtor (61 ms)
[ RUN ] DbPoolTest.ConnectionSelectsConfiguredDatabase
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] DbPoolTest.ConnectionSelectsConfiguredDatabase (32 ms)
[ RUN ] DbPoolTest.ConnectionCharsetIsUtf8mb4
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] DbPoolTest.ConnectionCharsetIsUtf8mb4 (35 ms)
[ RUN ] DbPoolTest.AcquireWithinMaxSizeDoesNotCreateNewConnection
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] DbPoolTest.AcquireWithinMaxSizeDoesNotCreateNewConnection (83 ms)
[ RUN ] DbPoolTest.DifferentAcquireYieldsDistinctConnections
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] DbPoolTest.DifferentAcquireYieldsDistinctConnections (55 ms)
[ RUN ] DbPoolTest.MysqlConnIsMoveOnly
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] DbPoolTest.MysqlConnIsMoveOnly (26 ms)
[ RUN ] DbPoolTest.MoveDoesNotDoubleClose
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] DbPoolTest.MoveDoesNotDoubleClose (29 ms)
[ RUN ] DbPoolTest.ExecuteReturnsAffectedRowCount
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] DbPoolTest.ExecuteReturnsAffectedRowCount (140 ms)
[ RUN ] DbPoolTest.QueryReturnsAllRowsWithNullsAsEmptyString
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] DbPoolTest.QueryReturnsAllRowsWithNullsAsEmptyString (127 ms)
[ RUN ] DbPoolTest.InvalidSqlThrowsDbError
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] DbPoolTest.InvalidSqlThrowsDbError (39 ms)
[ RUN ] DbPoolTest.EscapeHandlesQuotesAndNull
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] DbPoolTest.EscapeHandlesQuotesAndNull (29 ms)
[ RUN ] DbPoolTest.RollbackDiscardsChanges
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] DbPoolTest.RollbackDiscardsChanges (129 ms)
[ RUN ] DbPoolTest.ConcurrentAcquireReleaseIsSafe
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] DbPoolTest.ConcurrentAcquireReleaseIsSafe (186 ms)
[ RUN ] DbPoolTest.AcquireBlocksUntilReleaseWhenAtMax
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] DbPoolTest.AcquireBlocksUntilReleaseWhenAtMax (78 ms)
[ RUN ] DbPoolTest.PoolDestroyedBeforeConnDtorIsSafeViaWeakPtr
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] DbPoolTest.PoolDestroyedBeforeConnDtorIsSafeViaWeakPtr (30 ms)
[----------] 15 tests from DbPoolTest (1087 ms total)
[----------] 1 test from DbPoolCtorTest
[ RUN ] DbPoolCtorTest.BadCredentialsThrowOnConstruction
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] DbPoolCtorTest.BadCredentialsThrowOnConstruction (34 ms)
[----------] 1 test from DbPoolCtorTest (34 ms total)
[----------] Global test environment tear-down
[==========] 16 tests from 2 test suites ran. (1121 ms total)
[ PASSED ] 16 tests.
16个测试用例 全部通过
第三步: 最后跑一次完整 ctest 收尾(每一次新增单元测试,都会添加到ctest)
每个单元开发完毕,都得跑一次完整的 ctest,测试所有单元测试是否全部通过。
bash
ubuntu@VM-0-4-ubuntu:~/project/oj_-platform-based_on-vibecoding$ ctest --test-dir build --output-on-failure 2>&1 | tail -15
Test project /home/ubuntu/project/oj_-platform-based_on-vibecoding/build
Start 1: ConfigTest
1/4 Test #1: ConfigTest ....................... Passed 0.00 sec
Start 2: HttpServerTest
2/4 Test #2: HttpServerTest ................... Passed 0.07 sec
Start 3: LoggerTest
3/4 Test #3: LoggerTest ....................... Passed 0.00 sec
Start 4: DbTest
4/4 Test #4: DbTest ........................... Passed 1.14 sec
100% tests passed, 0 tests failed out of 4
Total Test time (real) = 1.21 sec
2.3 编写 UserRepo/ ProblemRepo/ SubmissionRepo的 CRUD(功能开发+单元测试+ctest所有单元测试)
- 提示词(只使用了以下提示词,AI直接完成了功能开发 + 测试的操作):
bash
@SPEC.md 请开发 Phase 2 这⾥的"编写 `UserRepo / ProblemRepo / SubmissionRepo` 的 CRUD"工作。完成后将该步骤标记为完成状态
基于 libmysqlclient(MySQL 官方的 C 客户端库 / MySQL Connector/C),用 C++代码 封装对三张核心表(users / problems / test_cases / submissions)的 CRUD 操作
第一步: 完成 UserRepo / ProblemRepo / SubmissionRepo 的 CRUD 的代码实现
实现(代码过多,省略。概括了一下实现的具体功能):
- Model:
oj-server/src/model/{user,problem,submission}.hUser(公开,id/username/role/created_at)/UserCredentials(含 hash + salt,仅登录链路使用,禁止序列化回 API)Problem(标题/难度/描述/样例/时限/内存/标签 CSV/创建&更新时间)/TestCase(id/problem_id/input/expected_output/score)Submission(id/user_id/problem_id/code/language/status/optional<error_msg>/optional<time_used_ms>/optional<memory_used_kb>/created_at/optional<finished_at>,NULL 列用std::optional表达)
- Repo:
oj-server/src/repo/{user,problem,submission}_repo.{h,cpp}- 所有公共方法默认自动取连接;提供
MysqlConn&重载以让 service 层组合多步事务(如「替换 test_cases」) - 全部参数通过
MysqlConn::Escape转义;NULL 字段在 SQL 中用裸NULL字面量处理 UserRepo:Create(MySQL errno 1062 →UniqueViolationError,便于 service 层映射 409)/FindById/FindCredentialsByUsername/ExistsByUsername/DeleteByIdProblemRepo:Create/FindById/List+Count(分页 + difficulty / tag 过滤,tag 用FIND_IN_SET)/Update(整行覆盖)/DeleteById/ListTestCases/AddTestCase/DeleteTestCase/ReplaceTestCases(调用方事务内原子重置)SubmissionRepo:Create(status 默认Pending)/FindById/ListByUser(newest-first,problem_id 过滤,limit/offset)/UpdateResult(status + optional 字段 +finished_at=NOW())/UpdateStatus(不动 finished_at,给判题工作线程握手用)
- 所有公共方法默认自动取连接;提供
- 错误分层:在
db.h新增UniqueViolationError(继承DbError),业务层用dynamic_cast区分
第二步:进行单元测试(使用gtest库)
(1)单测:tests/phase_2/test_repos.cpp(gtest 35 cases:UserRepo 7 + ProblemRepo 14 + SubmissionRepo 14),含 CRUD、唯一冲突、FK 失败、NULL 字段编码、JSON-style 字符串转义、ON DELETE CASCADE(problem→test_cases+submissions / user→submissions)、事务 Commit/Rollback
cpp
// 受限于篇幅,测试代码不展示
- 编译(oj-server/CMakeLists.txt中 集成了单元测试的编译过程)后,单元测试的运行结果:
bash
ubuntu@VM-0-4-ubuntu:~/project/oj_-platform-based_on-vibecoding$ ./build/test_repos
Running main() from ./googletest/src/gtest_main.cc
[==========] Running 35 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 35 tests from RepoTest
[ RUN ] RepoTest.UserCreateReturnsId
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.UserCreateReturnsId (69 ms)
[ RUN ] RepoTest.UserCreateDuplicateThrowsUniqueViolation
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.UserCreateDuplicateThrowsUniqueViolation (71 ms)
[ RUN ] RepoTest.UserFindByIdRoundTrip
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.UserFindByIdRoundTrip (62 ms)
[ RUN ] RepoTest.UserFindByIdMissingReturnsNullopt
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.UserFindByIdMissingReturnsNullopt (54 ms)
[ RUN ] RepoTest.UserFindCredentialsByUsername
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.UserFindCredentialsByUsername (64 ms)
[ RUN ] RepoTest.UserExistsByUsername
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.UserExistsByUsername (68 ms)
[ RUN ] RepoTest.UserDeleteById
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.UserDeleteById (62 ms)
[ RUN ] RepoTest.ProblemCreateReturnsId
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.ProblemCreateReturnsId (64 ms)
[ RUN ] RepoTest.ProblemFindByIdRoundTrip
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.ProblemFindByIdRoundTrip (63 ms)
[ RUN ] RepoTest.ProblemFindByIdMissingReturnsNullopt
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.ProblemFindByIdMissingReturnsNullopt (53 ms)
[ RUN ] RepoTest.ProblemListPagination
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.ProblemListPagination (97 ms)
[ RUN ] RepoTest.ProblemListFilterByDifficulty
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.ProblemListFilterByDifficulty (75 ms)
[ RUN ] RepoTest.ProblemListFilterByTag
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.ProblemListFilterByTag (76 ms)
[ RUN ] RepoTest.ProblemListCombinedFilters
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.ProblemListCombinedFilters (72 ms)
[ RUN ] RepoTest.ProblemCountMatchesListForLargeLimit
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.ProblemCountMatchesListForLargeLimit (89 ms)
[ RUN ] RepoTest.ProblemUpdateChangesFields
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.ProblemUpdateChangesFields (74 ms)
[ RUN ] RepoTest.ProblemDeleteCascadesToTestCases
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.ProblemDeleteCascadesToTestCases (78 ms)
[ RUN ] RepoTest.TestCaseAddListDelete
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.TestCaseAddListDelete (84 ms)
[ RUN ] RepoTest.TestCaseReplaceIsTransactional
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.TestCaseReplaceIsTransactional (78 ms)
[ RUN ] RepoTest.TestCaseReplaceRollbackKeepsOriginals
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.TestCaseReplaceRollbackKeepsOriginals (88 ms)
[ RUN ] RepoTest.SubmissionCreateReturnsIdWithPendingStatus
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.SubmissionCreateReturnsIdWithPendingStatus (82 ms)
[ RUN ] RepoTest.SubmissionCreateInvalidUserFails
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.SubmissionCreateInvalidUserFails (70 ms)
[ RUN ] RepoTest.SubmissionCreateInvalidProblemFails
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.SubmissionCreateInvalidProblemFails (70 ms)
[ RUN ] RepoTest.SubmissionFindByIdMissingReturnsNullopt
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.SubmissionFindByIdMissingReturnsNullopt (52 ms)
[ RUN ] RepoTest.SubmissionUpdateStatus
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.SubmissionUpdateStatus (81 ms)
[ RUN ] RepoTest.SubmissionUpdateResultWithAllFields
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.SubmissionUpdateResultWithAllFields (81 ms)
[ RUN ] RepoTest.SubmissionUpdateResultWithErrorMsg
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.SubmissionUpdateResultWithErrorMsg (88 ms)
[ RUN ] RepoTest.SubmissionUpdateResultWithNullErrorMsg
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.SubmissionUpdateResultWithNullErrorMsg (84 ms)
[ RUN ] RepoTest.SubmissionUpdateResultMissingIdReturnsFalse
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.SubmissionUpdateResultMissingIdReturnsFalse (55 ms)
[ RUN ] RepoTest.SubmissionListByUserNewestFirst
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.SubmissionListByUserNewestFirst (119 ms)
[ RUN ] RepoTest.SubmissionListByUserFilterByProblem
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.SubmissionListByUserFilterByProblem (100 ms)
[ RUN ] RepoTest.SubmissionListByUserRespectsLimit
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.SubmissionListByUserRespectsLimit (128 ms)
[ RUN ] RepoTest.SubmissionDeleteProblemCascadesSubmissions
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.SubmissionDeleteProblemCascadesSubmissions (77 ms)
[ RUN ] RepoTest.SubmissionDeleteUserCascadesSubmissions
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.SubmissionDeleteUserCascadesSubmissions (75 ms)
[ RUN ] RepoTest.SubmissionEscapesUserCodeSafely
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RepoTest.SubmissionEscapesUserCodeSafely (81 ms)
[----------] 35 tests from RepoTest (2702 ms total)
[----------] Global test environment tear-down
[==========] 35 tests from 1 test suite ran. (2702 ms total)
[ PASSED ] 35 tests.
16个测试用例 全部通过
第三步: 最后跑一次完整 ctest 收尾(每一次新增单元测试,都会添加到ctest)
每个单元开发完毕,都得跑一次完整的 ctest,测试所有单元测试是否全部通过。
bash
ubuntu@VM-0-4-ubuntu:~/project/oj_-platform-based_on-vibecoding$ ctest --test-dir build --output-on-failure 2>&1 | tail -15
Test project /home/ubuntu/project/oj_-platform-based_on-vibecoding/build
Start 1: ConfigTest
1/5 Test #1: ConfigTest ....................... Passed 0.00 sec
Start 2: HttpServerTest
2/5 Test #2: HttpServerTest ................... Passed 0.07 sec
Start 3: LoggerTest
3/5 Test #3: LoggerTest ....................... Passed 0.00 sec
Start 4: DbTest
4/5 Test #4: DbTest ........................... Passed 1.00 sec
Start 5: ReposTest
5/5 Test #5: ReposTest ........................ Passed 2.64 sec
100% tests passed, 0 tests failed out of 5
Total Test time (real) = 3.71 sec
3. 阶段 3:认证模块
3.1 SHA-256 + salt 工具(功能开发+单元测试+ctest所有单元测试)
- 提示词(只使用了以下提示词,AI直接完成了功能开发 + 测试的操作):
bash
@SPEC.md 请开发 Phase 3 这⾥的"SHA-256 + salt 工具"工作。完成后将该步骤标记为完成状态
基于 OpenSSL EVP/RAND_bytes 封装了 SHA-256 加盐哈希、常量时间比较、随机 salt 生成等密码学原语,作为认证模块底层密码学依赖,承上启下:
上层 AuthService 的注册/登录(直接复用 UserRepo::Create(username, password_hash, salt, role) 与 VerifyPassword)和 session 中间件(用 RAND_bytes 生成 32 字节 token、用 SecureEqual 比对 Cookie 与 DB 行)的实现都直接依赖它,未来若升级到 bcrypt/argon2 也只需替换这一个文件。
第一步: 完成 SHA-256 + salt 工具 的代码实现
(1)实现:oj-server/src/util/crypto.{h,cpp},基于 OpenSSL EVP(SHA-256 + RAND_bytes)
1)
GenerateSaltHex()→ 32 字符小写 hex(16 字节随机盐,OpenSSL 1.1+ 线程安全);熵不足时抛std::runtime_error
2)Sha256Hex(salt_hex, password)→ 64 字符小写 hex,等价SHA-256(salt_hex || password)字节流;与db/init.sql默认 admin 账号 (a1b2c3d4e5f60718293a4b5c6d7e8f90/admin123) 字节对齐
3)HashPassword(...)便捷别名(与 service 层术语一致)
4)VerifyPassword(salt_hex, password, expected_hash)→ 常量时间比较;expected_hash 长度不是 64 直接 false;哈希过程中出错也返回 false 而非抛(避免外部枚举错误类型)
5)SecureEqual(a, b)常量时间字符串比较(XOR | 累加),内部 + 对外暴露
- crypto.h
cpp
#pragma once
#include <cstdint>
#include <string>
#include <string_view>
namespace oj {
// 生成 16 字节随机 salt,并 hex 编码为 32 字符的小写字符串。
// 调用失败(极少见,OpenSSL 熵不够)时抛 std::runtime_error。
std::string GenerateSaltHex();
// 将 (salt_hex || password) 的字节流做 SHA-256,返回 64 字符小写 hex。
// `password` 视为 UTF-8/ASCII 字节串,不做归一化(与注册时一致即可)。
std::string Sha256Hex(const std::string& salt_hex,
std::string_view password);
// 便捷别名:和 SPEC §10 / 调用点术语一致。
inline std::string HashPassword(const std::string& salt_hex,
std::string_view password) {
return Sha256Hex(salt_hex, password);
}
// 校验 (salt_hex, password) 的哈希是否与 expected_hash 一致。
// 使用常量时间比较,expected_hash 必须是 64 字符小写 hex(长度不对直接返回 false)。
bool VerifyPassword(const std::string& salt_hex,
std::string_view password,
const std::string& expected_hash);
// 常量时间字符串相等(XOR + | 累加,规避 short-circuit 侧信道)。
// 用于 VerifyPassword 内部,也对外暴露供上层使用(例如 session token 比对)。
bool SecureEqual(std::string_view a, std::string_view b) noexcept;
} // namespace oj
- crypto.cpp
cpp
#include "util/crypto.h"
#include <openssl/evp.h>
#include <openssl/rand.h>
#include <openssl/sha.h>
#include <array>
#include <cstdint>
#include <stdexcept>
namespace oj {
namespace {
// 把字节数组编码为小写 hex(buffer 必须 >= 2*len)。
void BytesToLowerHex(const unsigned char* bytes, std::size_t len,
char* out) noexcept {
static constexpr char kHexChars[] = "0123456789abcdef";
for (std::size_t i = 0; i < len; ++i) {
out[2 * i] = kHexChars[(bytes[i] >> 4) & 0x0f];
out[2 * i + 1] = kHexChars[bytes[i] & 0x0f];
}
}
std::string ToHex(const unsigned char* bytes, std::size_t len) {
std::string out;
out.resize(len * 2);
BytesToLowerHex(bytes, len, out.data());
return out;
}
class EvpMdCtxPtr {
public:
EvpMdCtxPtr() : ctx_(EVP_MD_CTX_new()) {
if (ctx_ == nullptr) {
throw std::runtime_error("crypto: EVP_MD_CTX_new failed");
}
}
~EvpMdCtxPtr() noexcept {
if (ctx_ != nullptr) EVP_MD_CTX_free(ctx_);
}
EvpMdCtxPtr(const EvpMdCtxPtr&) = delete;
EvpMdCtxPtr& operator=(const EvpMdCtxPtr&) = delete;
EVP_MD_CTX* get() const noexcept { return ctx_; }
private:
EVP_MD_CTX* ctx_;
};
} // namespace
std::string GenerateSaltHex() {
std::array<unsigned char, 16> salt{};
if (RAND_bytes(salt.data(), static_cast<int>(salt.size())) != 1) {
throw std::runtime_error("crypto: RAND_bytes failed to generate salt");
}
return ToHex(salt.data(), salt.size());
}
std::string Sha256Hex(const std::string& salt_hex,
std::string_view password) {
EvpMdCtxPtr ctx;
if (EVP_DigestInit_ex(ctx.get(), EVP_sha256(), nullptr) != 1) {
throw std::runtime_error("crypto: EVP_DigestInit_ex(SHA-256) failed");
}
// 1) 先吃 salt 的 hex 字符串原样字节,2) 再拼接 password 的字节。
// 与 db/init.sql 默认 admin 账号
// hash = SHA-256('a1b2c3d4e5f60718293a4b5c6d7e8f90' || 'admin123')
// 完全一致。
if (!salt_hex.empty()) {
if (EVP_DigestUpdate(ctx.get(), salt_hex.data(),
salt_hex.size()) != 1) {
throw std::runtime_error("crypto: EVP_DigestUpdate(salt) failed");
}
}
if (!password.empty()) {
if (EVP_DigestUpdate(ctx.get(), password.data(), password.size()) != 1) {
throw std::runtime_error("crypto: EVP_DigestUpdate(pwd) failed");
}
}
std::array<unsigned char, SHA256_DIGEST_LENGTH> digest{};
unsigned int digest_len = 0;
if (EVP_DigestFinal_ex(ctx.get(), digest.data(), &digest_len) != 1) {
throw std::runtime_error("crypto: EVP_DigestFinal_ex failed");
}
if (digest_len != digest.size()) {
throw std::runtime_error("crypto: unexpected SHA-256 digest length");
}
return ToHex(digest.data(), digest.size());
}
bool SecureEqual(std::string_view a, std::string_view b) noexcept {
if (a.size() != b.size()) return false;
unsigned int diff = 0;
for (std::size_t i = 0; i < a.size(); ++i) {
diff |= static_cast<unsigned int>(
static_cast<unsigned char>(a[i]) ^
static_cast<unsigned char>(b[i]));
}
return diff == 0;
}
bool VerifyPassword(const std::string& salt_hex,
std::string_view password,
const std::string& expected_hash) {
// 长度不对直接判失败,避免在不等长字符串上做扩展 hash 造成耗时差异。
if (expected_hash.size() != 64) return false;
std::string actual;
try {
actual = Sha256Hex(salt_hex, password);
} catch (const std::exception&) {
// 哈希过程中出错:把它当作验证失败处理(不向上抛),避免区分
// 「系统错误」与「凭据错误」的侧信道。
return false;
}
return SecureEqual(actual, expected_hash);
}
} // namespace oj
第二步:进行单元测试(使用gtest库)
(1)tests/phase_3/test_crypto.cpp(gtest 26 cases:Sha256SaltTest 13 + GenerateSaltHexTest 3 + VerifyPasswordTest 7 + SecureEqualTest 2 + EndToEndTest 1),含 init.sql 向量化、空 salt / 空 password / 多字节 UTF-8 / 含 NUL 字节的密码、8 线程并发生成 2048 个 salt 无碰撞、注册→登录 round-trip
cpp
// 受限于篇幅,测试代码不展示
- 编译(oj-server/CMakeLists.txt中 集成了单元测试的编译过程)后,单元测试的运行结果:
bash
ubuntu@VM-0-4-ubuntu:~/project/oj_-platform-based_on-vibecoding$ ./build/test_crypto
Running main() from ./googletest/src/gtest_main.cc
[==========] Running 26 tests from 5 test suites.
[----------] Global test environment set-up.
[----------] 13 tests from Sha256SaltTest
[ RUN ] Sha256SaltTest.MatchesInitSqlDefaultAdminAccount
[ OK ] Sha256SaltTest.MatchesInitSqlDefaultAdminAccount (1 ms)
[ RUN ] Sha256SaltTest.EmptyPasswordHashes
[ OK ] Sha256SaltTest.EmptyPasswordHashes (0 ms)
[ RUN ] Sha256SaltTest.EmptySaltHashes
[ OK ] Sha256SaltTest.EmptySaltHashes (0 ms)
[ RUN ] Sha256SaltTest.EmptySaltAndEmptyPassword
[ OK ] Sha256SaltTest.EmptySaltAndEmptyPassword (0 ms)
[ RUN ] Sha256SaltTest.BothEmptyAndNonEmptyDiffer
[ OK ] Sha256SaltTest.BothEmptyAndNonEmptyDiffer (0 ms)
[ RUN ] Sha256SaltTest.DeterministicForSameInputs
[ OK ] Sha256SaltTest.DeterministicForSameInputs (0 ms)
[ RUN ] Sha256SaltTest.DifferentPasswordsProduceDifferentHashes
[ OK ] Sha256SaltTest.DifferentPasswordsProduceDifferentHashes (0 ms)
[ RUN ] Sha256SaltTest.DifferentSaltsProduceDifferentHashes
[ OK ] Sha256SaltTest.DifferentSaltsProduceDifferentHashes (0 ms)
[ RUN ] Sha256SaltTest.HashUsesBothSaltAndPasswordNotEither
[ OK ] Sha256SaltTest.HashUsesBothSaltAndPasswordNotEither (0 ms)
[ RUN ] Sha256SaltTest.HashIsCaseStableLowercase
[ OK ] Sha256SaltTest.HashIsCaseStableLowercase (0 ms)
[ RUN ] Sha256SaltTest.PasswordContainingNullByteIsHashedByValue
[ OK ] Sha256SaltTest.PasswordContainingNullByteIsHashedByValue (0 ms)
[ RUN ] Sha256SaltTest.MultiByteUtf8PasswordIsHashedByteForByte
[ OK ] Sha256SaltTest.MultiByteUtf8PasswordIsHashedByteForByte (0 ms)
[ RUN ] Sha256SaltTest.HashPasswordAliasMatchesSha256Hex
[ OK ] Sha256SaltTest.HashPasswordAliasMatchesSha256Hex (0 ms)
[----------] 13 tests from Sha256SaltTest (1 ms total)
[----------] 3 tests from GenerateSaltHexTest
[ RUN ] GenerateSaltHexTest.Always32CharsLowerHex
[ OK ] GenerateSaltHexTest.Always32CharsLowerHex (0 ms)
[ RUN ] GenerateSaltHexTest.NoImmediateCollisionsInBurst
[ OK ] GenerateSaltHexTest.NoImmediateCollisionsInBurst (0 ms)
[ RUN ] GenerateSaltHexTest.UniquenessUnderConcurrency
[ OK ] GenerateSaltHexTest.UniquenessUnderConcurrency (1 ms)
[----------] 3 tests from GenerateSaltHexTest (3 ms total)
[----------] 7 tests from VerifyPasswordTest
[ RUN ] VerifyPasswordTest.AcceptsCorrectCredentials
[ OK ] VerifyPasswordTest.AcceptsCorrectCredentials (0 ms)
[ RUN ] VerifyPasswordTest.AcceptsInitSqlDefaultAdminCredentials
[ OK ] VerifyPasswordTest.AcceptsInitSqlDefaultAdminCredentials (0 ms)
[ RUN ] VerifyPasswordTest.RejectsWrongPassword
[ OK ] VerifyPasswordTest.RejectsWrongPassword (0 ms)
[ RUN ] VerifyPasswordTest.RejectsWrongSalt
[ OK ] VerifyPasswordTest.RejectsWrongSalt (0 ms)
[ RUN ] VerifyPasswordTest.RejectsTamperedHashByte
[ OK ] VerifyPasswordTest.RejectsTamperedHashByte (0 ms)
[ RUN ] VerifyPasswordTest.RejectsBadHashLengthsWithoutThrowing
[ OK ] VerifyPasswordTest.RejectsBadHashLengthsWithoutThrowing (0 ms)
[ RUN ] VerifyPasswordTest.CaseSensitiveHashComparison
[ OK ] VerifyPasswordTest.CaseSensitiveHashComparison (0 ms)
[----------] 7 tests from VerifyPasswordTest (0 ms total)
[----------] 2 tests from SecureEqualTest
[ RUN ] SecureEqualTest.EqualStringsReturnTrue
[ OK ] SecureEqualTest.EqualStringsReturnTrue (0 ms)
[ RUN ] SecureEqualTest.DifferentStringsReturnFalse
[ OK ] SecureEqualTest.DifferentStringsReturnFalse (0 ms)
[----------] 2 tests from SecureEqualTest (0 ms total)
[----------] 1 test from EndToEndTest
[ RUN ] EndToEndTest.RegisterThenLoginRoundTrip
[ OK ] EndToEndTest.RegisterThenLoginRoundTrip (0 ms)
[----------] 1 test from EndToEndTest (0 ms total)
[----------] Global test environment tear-down
[==========] 26 tests from 5 test suites ran. (4 ms total)
[ PASSED ] 26 tests.
26个测试用例全部通过
第三步: 最后跑一次完整 ctest 收尾(每一次新增单元测试,都会添加到ctest)
每个单元开发完毕,都得跑一次完整的 ctest,测试所有单元测试是否全部通过。
bash
ubuntu@VM-0-4-ubuntu:~/project/oj_-platform-based_on-vibecoding$ ctest --test-dir build --output-on-failure 2>&1 | tail -15
1/6 Test #1: ConfigTest ....................... Passed 0.00 sec
Start 2: HttpServerTest
2/6 Test #2: HttpServerTest ................... Passed 0.07 sec
Start 3: LoggerTest
3/6 Test #3: LoggerTest ....................... Passed 0.00 sec
Start 4: DbTest
4/6 Test #4: DbTest ........................... Passed 0.97 sec
Start 5: ReposTest
5/6 Test #5: ReposTest ........................ Passed 2.65 sec
Start 6: CryptoTest
6/6 Test #6: CryptoTest ....................... Passed 0.01 sec
100% tests passed, 0 tests failed out of 6
Total Test time (real) = 3.71 sec
3.2 Session 生成与校验(功能开发+单元测试+ctest所有单元测试)
3.2.1 功能开发+单元测试+ctest所有单元测试
- 提示词(只使用了以下提示词,AI直接完成了功能开发 + 测试的操作):
bash
@SPEC.md 请开发 Phase 3 这⾥的"Session 生成与校验"工作。完成后将该步骤标记为完成状态
第一步: 完成 Session 生成与校验 的代码实现
(1) oj-server/src/util/crypto.{h,cpp}:抽出 RandomHex(int n_bytes) 通用原语(n_bytes ∈ [1, 1024],越界抛 std::invalid_argument;OpenSSL RAND_bytes 失败抛 std::runtime_error)。GenerateSaltHex() 改为inline RandomHex(16),旧的 T3.1 API 完全兼容。
(2) oj-server/src/model/session.h:Session struct(session_id / user_id / expires_at;无 created_at------sessions 表 MVP 不含该列)。
cpp
#pragma once
#include <cstdint>
#include <string>
namespace oj {
struct Session {
std::string session_id; // 64-char lowercase hex
std::int64_t user_id = 0;
std::string expires_at; // "YYYY-MM-DD HH:MM:SS" (MySQL DATETIME)
};
} // namespace oj
(3) oj-server/src/repo/session_repo.{h,cpp}:5 个方法 × 2 重载(Create / FindActive / Delete / DeleteByUser),expires_at 用 DATE_ADD(NOW(), INTERVAL ? SECOND) 由 DB 计算(避免 server时钟漂移),FindActive 把过期过滤下推到 SQL(WHERE expires_at > NOW())。
- session_repo.h
cpp
#pragma once
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include "model/session.h"
#include "repo/db.h"
namespace oj {
class SessionRepo {
public:
explicit SessionRepo(std::shared_ptr<MysqlPool> pool);
void Create(const std::string& session_id,
std::int64_t user_id,
int ttl_seconds);
void Create(MysqlConn& c,
const std::string& session_id,
std::int64_t user_id,
int ttl_seconds);
std::optional<Session> FindActive(const std::string& session_id);
std::optional<Session> FindActive(MysqlConn& c,
const std::string& session_id);
bool Delete(const std::string& session_id);
bool Delete(MysqlConn& c, const std::string& session_id);
std::uint64_t DeleteByUser(std::int64_t user_id);
std::uint64_t DeleteByUser(MysqlConn& c, std::int64_t user_id);
private:
std::shared_ptr<MysqlPool> pool_;
};
} // namespace oj
- session_repo.cpp
cpp
#include "repo/session_repo.h"
#include <cstdint>
#include <stdexcept>
#include <string>
namespace oj {
namespace {
Session RowToSession(const DbRow& r) {
if (r.size() != 3) {
throw DbError("SessionRepo: unexpected column count " +
std::to_string(r.size()));
}
Session s;
s.session_id = r[0];
s.user_id = std::stoll(r[1]);
s.expires_at = r[2];
return s;
}
} // namespace
SessionRepo::SessionRepo(std::shared_ptr<MysqlPool> pool)
: pool_(std::move(pool)) {}
void SessionRepo::Create(const std::string& session_id,
std::int64_t user_id,
int ttl_seconds) {
auto c = pool_->Acquire();
return Create(c, session_id, user_id, ttl_seconds);
}
void SessionRepo::Create(MysqlConn& c,
const std::string& session_id,
std::int64_t user_id,
int ttl_seconds) {
if (ttl_seconds <= 0) {
throw std::invalid_argument(
"SessionRepo::Create: ttl_seconds must be > 0, got " +
std::to_string(ttl_seconds));
}
if (user_id <= 0) {
throw std::invalid_argument(
"SessionRepo::Create: user_id must be > 0, got " +
std::to_string(user_id));
}
const std::string sid = c.Escape(session_id);
const std::string sql =
"INSERT INTO `sessions` (`session_id`, `user_id`, `expires_at`) VALUES ('"
+ sid + "', " + std::to_string(user_id) +
", DATE_ADD(NOW(), INTERVAL " + std::to_string(ttl_seconds) + " SECOND))";
c.Execute(sql);
}
std::optional<Session> SessionRepo::FindActive(const std::string& session_id) {
auto c = pool_->Acquire();
return FindActive(c, session_id);
}
std::optional<Session> SessionRepo::FindActive(MysqlConn& c,
const std::string& session_id) {
const std::string sql =
"SELECT `session_id`, `user_id`, `expires_at` "
"FROM `sessions` "
"WHERE `session_id` = '" + c.Escape(session_id) + "' "
" AND `expires_at` > NOW() "
"LIMIT 1";
auto rows = c.Query(sql);
if (rows.empty()) return std::nullopt;
return RowToSession(rows[0]);
}
bool SessionRepo::Delete(const std::string& session_id) {
auto c = pool_->Acquire();
return Delete(c, session_id);
}
bool SessionRepo::Delete(MysqlConn& c, const std::string& session_id) {
const std::string sql =
"DELETE FROM `sessions` WHERE `session_id` = '" +
c.Escape(session_id) + "'";
return c.Execute(sql) > 0;
}
std::uint64_t SessionRepo::DeleteByUser(std::int64_t user_id) {
auto c = pool_->Acquire();
return DeleteByUser(c, user_id);
}
std::uint64_t SessionRepo::DeleteByUser(MysqlConn& c, std::int64_t user_id) {
if (user_id <= 0) {
throw std::invalid_argument(
"SessionRepo::DeleteByUser: user_id must be > 0, got " +
std::to_string(user_id));
}
const std::string sql =
"DELETE FROM `sessions` WHERE `user_id` = " +
std::to_string(user_id);
return c.Execute(sql);
}
} // namespace oj
(4) oj-server/src/service/session_service.{h,cpp}:domain 拼装层IssueForUser / Resolve / Revoke / RevokeAllForUser, 构造时注入 MysqlPool + ttl_seconds(来自 Config::session)。IssueForUser 内部调 RandomHex(32) 生成 64 字符 token 并SessionRepo::Create。
- session_service.h
cpp
#pragma once
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include "model/session.h"
#include "repo/db.h"
#include "repo/session_repo.h"
namespace oj {
class SessionService {
public:
SessionService(std::shared_ptr<MysqlPool> pool, int ttl_seconds);
Session IssueForUser(std::int64_t user_id);
std::optional<Session> Resolve(const std::string& session_id);
bool Revoke(const std::string& session_id);
std::uint64_t RevokeAllForUser(std::int64_t user_id);
int TtlSeconds() const noexcept { return ttl_seconds_; }
private:
std::shared_ptr<MysqlPool> pool_;
SessionRepo repo_;
int ttl_seconds_;
};
} // namespace oj
- session_service.cpp
cpp
#include "service/session_service.h"
#include <stdexcept>
#include "util/crypto.h"
namespace oj {
SessionService::SessionService(std::shared_ptr<MysqlPool> pool,
int ttl_seconds)
: pool_(std::move(pool)),
repo_(pool_),
ttl_seconds_(ttl_seconds) {
if (ttl_seconds_ <= 0) {
throw std::invalid_argument(
"SessionService: ttl_seconds must be > 0, got " +
std::to_string(ttl_seconds_));
}
}
Session SessionService::IssueForUser(std::int64_t user_id) {
if (user_id <= 0) {
throw std::invalid_argument(
"SessionService::IssueForUser: user_id must be > 0, got " +
std::to_string(user_id));
}
// 32 random bytes -> 64-char hex. Same entropy source as the salt;
// see T3.1 / crypto.h for the rationale of the shared primitive.
const std::string token = RandomHex(32);
repo_.Create(token, user_id, ttl_seconds_);
Session s;
s.session_id = token;
s.user_id = user_id;
// expires_at / created_at are filled by the DB; we return a partial
// struct here. Callers that need the exact timestamps should call
// Resolve(token) immediately afterwards (AuthService::Login will
// do this to set the Set-Cookie + return User).
return s;
}
std::optional<Session> SessionService::Resolve(const std::string& session_id) {
if (session_id.empty()) return std::nullopt;
return repo_.FindActive(session_id);
}
bool SessionService::Revoke(const std::string& session_id) {
if (session_id.empty()) return false;
return repo_.Delete(session_id);
}
std::uint64_t SessionService::RevokeAllForUser(std::int64_t user_id) {
if (user_id <= 0) {
throw std::invalid_argument(
"SessionService::RevokeAllForUser: user_id must be > 0, got " +
std::to_string(user_id));
}
return repo_.DeleteByUser(user_id);
}
} // namespace oj
第二步:进行单元测试(使用gtest库)
(1)单测:tests/phase_3/test_session.cpp(gtest 31 cases:RandomHexTest 10 + SessionServiceEdgeTest 7 + SessionRepoTest 14),含入参校验 / 形状 / 并发唯一性 / 过期过滤 / PK 冲突 / FK 失败 / Delete 幂等 / DeleteByUser 只清目标 user / FK CASCADE / SessionService 端到端 round-trip(200 个 token 落库无碰撞)。
cpp
// 受限于篇幅,测试代码不展示
- 编译(oj-server/CMakeLists.txt中 集成了单元测试的编译过程)后,单元测试的运行结果:
cpp
ubuntu@VM-0-4-ubuntu:~/project/oj_-platform-based_on-vibecoding$ ./build/test_session
Running main() from ./googletest/src/gtest_main.cc
[==========] Running 31 tests from 3 test suites.
[----------] Global test environment set-up.
[----------] 10 tests from RandomHexTest
[ RUN ] RandomHexTest.ZeroThrowsInvalidArgument
[ OK ] RandomHexTest.ZeroThrowsInvalidArgument (0 ms)
[ RUN ] RandomHexTest.NegativeThrowsInvalidArgument
[ OK ] RandomHexTest.NegativeThrowsInvalidArgument (0 ms)
[ RUN ] RandomHexTest.TooLargeThrowsInvalidArgument
[ OK ] RandomHexTest.TooLargeThrowsInvalidArgument (0 ms)
[ RUN ] RandomHexTest.BoundaryOneByteSucceeds
[ OK ] RandomHexTest.BoundaryOneByteSucceeds (1 ms)
[ RUN ] RandomHexTest.Boundary1024BytesSucceeds
[ OK ] RandomHexTest.Boundary1024BytesSucceeds (0 ms)
[ RUN ] RandomHexTest.LengthDoublesInputBytes
[ OK ] RandomHexTest.LengthDoublesInputBytes (0 ms)
[ RUN ] RandomHexTest.OutputIsLowercaseHexOnly
[ OK ] RandomHexTest.OutputIsLowercaseHexOnly (0 ms)
[ RUN ] RandomHexTest.UniquenessUnderConcurrency
[ OK ] RandomHexTest.UniquenessUnderConcurrency (2 ms)
[ RUN ] RandomHexTest.SessionSizedTokensAre64CharsAndValid
[ OK ] RandomHexTest.SessionSizedTokensAre64CharsAndValid (0 ms)
[ RUN ] RandomHexTest.GenerateSaltHexStillWorksAndIsShapeCompatible
[ OK ] RandomHexTest.GenerateSaltHexStillWorksAndIsShapeCompatible (0 ms)
[----------] 10 tests from RandomHexTest (4 ms total)
[----------] 7 tests from SessionServiceEdgeTest
[ RUN ] SessionServiceEdgeTest.ConstructorRejectsZeroOrNegativeTtl
[ OK ] SessionServiceEdgeTest.ConstructorRejectsZeroOrNegativeTtl (0 ms)
[ RUN ] SessionServiceEdgeTest.ConstructorAcceptsPositiveTtl
[ OK ] SessionServiceEdgeTest.ConstructorAcceptsPositiveTtl (0 ms)
[ RUN ] SessionServiceEdgeTest.TtlSecondsReflectsConstructorArg
[ OK ] SessionServiceEdgeTest.TtlSecondsReflectsConstructorArg (0 ms)
[ RUN ] SessionServiceEdgeTest.IssueForUserRejectsZeroOrNegativeUserId
[ OK ] SessionServiceEdgeTest.IssueForUserRejectsZeroOrNegativeUserId (0 ms)
[ RUN ] SessionServiceEdgeTest.ResolveEmptyTokenReturnsNullopt
[ OK ] SessionServiceEdgeTest.ResolveEmptyTokenReturnsNullopt (0 ms)
[ RUN ] SessionServiceEdgeTest.RevokeEmptyTokenReturnsFalse
[ OK ] SessionServiceEdgeTest.RevokeEmptyTokenReturnsFalse (0 ms)
[ RUN ] SessionServiceEdgeTest.RevokeAllForUserRejectsZeroOrNegative
[ OK ] SessionServiceEdgeTest.RevokeAllForUserRejectsZeroOrNegative (0 ms)
[----------] 7 tests from SessionServiceEdgeTest (0 ms total)
[----------] 14 tests from SessionRepoTest
[ RUN ] SessionRepoTest.CreateThenFindActiveReturnsRow
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] SessionRepoTest.CreateThenFindActiveReturnsRow (75 ms)
[ RUN ] SessionRepoTest.CreateTwiceSameTokenThrows
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] SessionRepoTest.CreateTwiceSameTokenThrows (71 ms)
[ RUN ] SessionRepoTest.CreateRejectsZeroOrNegativeUserId
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] SessionRepoTest.CreateRejectsZeroOrNegativeUserId (52 ms)
[ RUN ] SessionRepoTest.CreateRejectsZeroOrNegativeTtl
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] SessionRepoTest.CreateRejectsZeroOrNegativeTtl (63 ms)
[ RUN ] SessionRepoTest.CreateWithUnknownUserIdThrowsDbError
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] SessionRepoTest.CreateWithUnknownUserIdThrowsDbError (56 ms)
[ RUN ] SessionRepoTest.ExpiredSessionReturnsNullopt
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] SessionRepoTest.ExpiredSessionReturnsNullopt (78 ms)
[ RUN ] SessionRepoTest.FindActiveUnknownTokenReturnsNullopt
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] SessionRepoTest.FindActiveUnknownTokenReturnsNullopt (53 ms)
[ RUN ] SessionRepoTest.DeleteRemovesExactlyOneRow
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] SessionRepoTest.DeleteRemovesExactlyOneRow (76 ms)
[ RUN ] SessionRepoTest.DeleteUnknownTokenReturnsFalse
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] SessionRepoTest.DeleteUnknownTokenReturnsFalse (54 ms)
[ RUN ] SessionRepoTest.DeleteByUserClearsOnlyThatUsersSessions
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] SessionRepoTest.DeleteByUserClearsOnlyThatUsersSessions (90 ms)
[ RUN ] SessionRepoTest.DeleteUserCascadesToSessions
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] SessionRepoTest.DeleteUserCascadesToSessions (68 ms)
[ RUN ] SessionRepoTest.SessionServiceRoundTrip
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] SessionRepoTest.SessionServiceRoundTrip (75 ms)
[ RUN ] SessionRepoTest.SessionServiceRevokeAllForUser
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] SessionRepoTest.SessionServiceRevokeAllForUser (85 ms)
[ RUN ] SessionRepoTest.SessionServiceTokensUniqueAcrossManyIssues
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] SessionRepoTest.SessionServiceTokensUniqueAcrossManyIssues (942 ms)
[----------] 14 tests from SessionRepoTest (1846 ms total)
[----------] Global test environment tear-down
[==========] 31 tests from 3 test suites ran. (1851 ms total)
[ PASSED ] 31 tests.
31个测试用例全部通过
第三步: 最后跑一次完整 ctest 收尾(每一次新增单元测试,都会添加到ctest)
每个单元开发完毕,都得跑一次完整的 ctest,测试所有单元测试是否全部通过。
bash
ubuntu@VM-0-4-ubuntu:~/project/oj_-platform-based_on-vibecoding$ ctest --test-dir build --output-on-failure 2>&1 | tail -15
2/7 Test #2: HttpServerTest ................... Passed 0.07 sec
Start 3: LoggerTest
3/7 Test #3: LoggerTest ....................... Passed 0.01 sec
Start 4: DbTest
4/7 Test #4: DbTest ........................... Passed 0.95 sec
Start 5: ReposTest
5/7 Test #5: ReposTest ........................ Passed 2.66 sec
Start 6: CryptoTest
6/7 Test #6: CryptoTest ....................... Passed 0.01 sec
Start 7: SessionTest
7/7 Test #7: SessionTest ...................... Passed 1.85 sec
100% tests passed, 0 tests failed out of 7
Total Test time (real) = 5.54 sec
3.2.2 Session的作用
Session 在认证模块里的作用
(1) 它是什么
Session 用来存储「一次已签发的登录态」。一条 row 只代表「某个浏览器此刻正持有一个有效 token」。
本项目里 db/init.sql:43-52 定义的Session表 就 3 个字段:
| 字段 | 含义 |
|---|---|
| session_id CHAR(64) | 不透明 token(32 字节随机 hex)。客户端存 Cookie,服务端用它反查。 |
| user_id BIGINT NOT NULL | 外键,指向 users.id。这是 session 与其它表唯一的关联。 |
| expires_at DATETIME | DB 端计算的过期时刻(DATE_ADD(NOW(), INTERVAL ttl SECOND))。 |
(2) 它和数据库其它内容的关联
只与 users 一张表关联,通过 fk_sessions_user 外键,ON DELETE CASCADE
db/init.sql:50-51:
bash
CONSTRAINT `fk_sessions_user`
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
级联意味着:删 user → 该 user 的所有 session 自动消失。
它不关联 submissions / problems / test_cases------这些是业务表,session 完全不参与业务数据流。submissions 走自己的 user_id 外键直连 users(db/init.sql:108-110),不经过 session。所以 session 是个纯认证的概念。
(3) 它在认证链路里扮演什么角色
整个认证流程里,session 解决了「HTTP 无状态 ↔ 服务端识别用户」之间的桥接问题:
bash
┌─────────────┐ POST /api/auth/login ┌──────────────┐
│ 浏览器 │ ────────────────────► │ T3.3 AuthSvc │
│ │ ◄──────────────────── │ │
│ Set-Cookie: │ {user, Set-Cookie} │ 1. VerifyPassword(salt, pwd) ← T3.1
│ OJ_SESSION │ │ 2. svc.IssueForUser(user_id) ← T3.2 ★
│ =<token> │ │ → RandomHex(32) → Repo::Create
└─────────────┘ └──────────────┘
│ │
│ GET /api/problems 带 Cookie │
▼ ▼
┌─────────────┐ 解析 Cookie ┌──────────────────────┐
│ httplib │ ────────────► │ T3.4 中间件 │
│ Server │ │ 3. svc.Resolve(tok) ← T3.2 ★
└─────────────┘ │ → 查 sessions 表
│ → 返 {user_id, ...}
└──────────────────────┘
│
▼
┌──────────────────────┐
│ 4. 业务 handler 拿 │
│ req.user 继续处理 │
└──────────────────────┘
T3.2 在第 2 步(发)和第 3 步(查)之间提供唯一的服务------没有它,登录拿不到 token,中间件也查不到 user。它对 T3.1 提供的「密码学原语」做了第一次真实业务封装(RandomHex(32) → Session row),对 T3.3 / T3.4 提供「发 / 查 / 销」三个动词的拼装层(SessionService)。
(4) 为什么 T3.2 放在 T3.1 之后、T3.3 之前
依赖图很直白:
- T3.1 之后:密码校验有了(VerifyPassword),但还缺「登录成功后服务端怎么记住这个用户」的能力
- T3.2 补的就是这一环:IssueForUser 把「密码正确」翻译成「给该 user 发一个 token、记到 DB」
- T3.3 之上:HTTP 层只关心 token 在 Cookie 里的传递,不再碰密码或 DB 的随机数
- T3.4 之上:中间件只关心 Cookie → user_id 的反查,不再碰 token 怎么生成的
(5) 与 user_credentials 的关系
注意 session 不是「用户表的一部分」。完整关系:
- users 表 ← 静态身份(username / password_hash / salt / role / created_at)
- sessions 表 ← 动态登录态(session_id → user_id → 有效期)
一个 user 在不同时刻、不同设备可能有多条 session row;session 表本质是「user_id × device × time」的三元映射,活的、可被服务端随时吊销,而 users 是不可变的「账本」。
简短总结:Session 是「一次有效登录」的凭证记录,唯一的 DB 关联是 user_id 外键到 users,T3.2 让 T3.1 的密码校验真正变成可被 HTTP 中间件复用的「凭据→身份」桥接层。
3.3 注册 / 登录 / 注销 / me 接口(功能开发+单元测试+curl接口测试+ctest所有单元测试)
- 提示词(只使用了以下提示词,AI直接完成了功能开发 + 测试的操作):
bash
@SPEC.md 请开发 Phase 3 这⾥的"Session 生成与校验"工作。完成后将该步骤标记为完成状态
第一步: 完成 注册 / 登录 / 注销 / me 接口 的代码实现
代码省略,仅描述实现的功能:
oj-server/src/service/auth_service.{h,cpp}:domain 拼装层AuthService,组合UserRepo(T2.3)+crypto::GenerateSaltHex/HashPassword/VerifyPassword(T3.1)+SessionService::IssueForUser/Revoke/Resolve(T3.2)。对外暴露Register / Login / Logout / GetCurrentUser。- 4 个异常类(
AuthError基类 →InvalidInputError/InvalidCredentialsError/NotFoundError/ConflictError)让 HTTP 层用dynamic_cast干净地映射到 400/401/404/409。 - 入参校验:username 3-20 字符(与
users.username VARCHAR(20)对齐),password 6-200 字符(防 gigabyte 攻击)。 Login内部对「用户名不存在」与「密码错误」做 timing-equalization:两条路径都跑一次VerifyPassword,外部无法靠响应时间差判断是 user 不存在还是 pwd 错。Login返回LoginResult{user, session_id};session_id同时通过Set-Cookie: OJ_SESSION=...与 JSON body 返回,方便不能用 cookie 的客户端。
- 4 个异常类(
oj-server/src/util/cookie.{h,cpp}:抽出ExtractCookieValue(cookie_header, name)通用原语(RFC 6265 §4.1.1 --- cookie 名 case-sensitive,value 内允许=、可含尾分号)。T3.3 与后续 T3.4 中间件共用。oj-server/src/server/auth_handlers.{h,cpp}:4 个 handlerRegisterHandler / LoginHandler / LogoutHandler / MeHandler+RegisterAuthRoutes(server, auth, cookie_name, cookie_ttl_seconds)注册入口。错误体统一{"error":"<code>","message":"<text>"}shape。oj-server/src/server/auth_handlers.h与http_server.h拆分:前者引入AuthService(带 DB 依赖),后者保持 DB-agnostic 让test_http_server无需 MySQL。oj-server/src/main.cpp:构造MysqlPool→SessionService→AuthService→RegisterAuthRoutes;DB 池化失败直接退出 1。Set-Cookie统一格式:OJ_SESSION=<token>; Path=/; HttpOnly; SameSite=Lax; Max-Age=<ttl>(login)或Max-Age=0; Expires=...(logout)。
第二步:进行单元测试(使用gtest库)
(1)单测:tests/phase_3/test_auth.cpp(gtest 47 cases:CookieExtractionTest 10 + Service_* 13 + Http_* 22 + Cookie 2),含:
- Cookie 头解析:空 / 缺名 / 多 cookie / 含
=/ 空 value / 大小写敏感 / 尾分号 / 64 字符 hex round-trip。 - AuthService:register 入参 4 边界(短/长 user/pwd)→
InvalidInputError;register 重复 →ConflictError;login happy / 错密码 / 不存在 user(timing-equalized)→InvalidCredentialsError;logout 幂等;GetCurrentUser对空/未知 token 返nullopt。 - HTTP 集成(ephemeral port):register 200/409/400(缺字段 / 非 JSON / 非 object / 非 string);login 200 + Set-Cookie 头格式校验 / 401 错密码与不存在 user(无 Set-Cookie 泄漏);me 带 cookie 200 / 不带 401 / 假 token 401;logout 200 +
Max-Age=0+Expires=;register→login→me→logout→me 全链路 401;未知 path 404。
cpp
// 受限于篇幅,测试代码不展示
- 编译(oj-server/CMakeLists.txt中 集成了单元测试的编译过程)后,单元测试的运行结果(简略版):
bash
ubuntu@VM-0-4-ubuntu:~/project/oj_-platform-based_on-vibecoding$ ./build/test_auth
Running main() from ./googletest/src/gtest_main.cc
[==========] Running 47 tests from 2 test suites.
[----------] Global test environment set-up.
[----------] 10 tests from CookieExtractionTest
[ RUN ] CookieExtractionTest.EmptyHeaderReturnsNullopt
[ OK ] CookieExtractionTest.EmptyHeaderReturnsNullopt (0 ms)
......
[ RUN ] CookieExtractionTest.Hex64TokenRoundTrip
[ OK ] CookieExtractionTest.Hex64TokenRoundTrip (0 ms)
[----------] 10 tests from CookieExtractionTest (0 ms total)
[----------] 37 tests from AuthHttpTest
[ RUN ] AuthHttpTest.Service_RegisterHappyPath
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] AuthHttpTest.Service_RegisterHappyPath (78 ms)
......
[ RUN ] AuthHttpTest.Http_LogoutSetCookieMaxAgeZero
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] AuthHttpTest.Http_LogoutSetCookieMaxAgeZero (74 ms)
[----------] 37 tests from AuthHttpTest (2617 ms total)
[----------] Global test environment tear-down
[==========] 47 tests from 2 test suites ran. (2617 ms total)
[ PASSED ] 47 tests.
47个测试用例全部通过
第三步: 实际启动 server 并 curl 验证 注册 / 登录 / 注销 / me 接口
(1)启动 server:./build/oj_server --port 18082
(2)curl 验证 注册 / 登录 / 注销 / me 接口:register 200 → register dup 409 → login 200 + Set-Cookie → me 200 / 不带 cookie 401 → logout 200 + Set-Cookie Max-Age=0 → login 错密码 401 → me 复用旧 cookie 401,全部符合 SPEC §6.1 / §6.4。
bash
$ cat oj-server/config.json 2>&1 | head -20
{
"port": 8080,
"db": {
"host": "127.0.0.1",
"port": 3306,
"user": "ubuntu",
"password": "",
"name": "oj"
},
"judge": {
...
bash
$ export ubuntu_password='postgres' && ./build/oj_server --port 18080 &
SERVER_PID=$!
sleep 1.0
echo "--- HEALTH ---"
curl -s http://127.0.0.1:18080/api/health | head -c 200
echo
echo
echo "--- REGISTER ---"
curl -s -i -X POST http://127.0.0.1:18080/api/auth/register \
-H 'Content-Type: application/json' \
-d '{"username":"smoke_user_001","password":"smokepwd1"}' | head -20
echo
echo "--- REGISTER DUP ---"
curl -s -i -X POST http://127.0.0.1:18080/api/auth/register \
-H 'Content-Type: application/json' \
-d '{"username":"smoke_user_001","password":"smokepwd1"}' | head -20
echo
echo "--- LOGIN ---"
LOGIN_BODY=$(curl -s -i -c /tmp/jar.txt -X POST http://127.0.0.1:18080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"smoke_user_001","password":"smokepwd1"}')
echo "$LOGIN_BODY" | head -20
echo
echo "--- ME with cookie ---"
curl -s -b /tmp/jar.txt http://127.0.0.1:18080/api/auth/me
echo
echo
echo "--- LOGOUT ---"
curl -s -i -b /tmp/jar.txt -c /tmp/jar.txt -X POST http://127.0.0.1:18080/api/auth/logout | head -10
echo
echo "--- ME after logout (no cookie) ---"
curl -s http://127.0.0.1:18080/api/auth/me
echo
echo
echo "--- LOGIN wrong pwd ---"
curl -s -i -X POST http://127.0.0.1:18080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"smoke_user_001","password":"wrong_password"}' | head -10
echo
echo "--- Cleanup ---"
mysql -u ubuntu -p"$ubuntu_password" oj -e "DELETE FROM users WHERE username='smoke_user_001';" 2>&1 | tail -3
kill $SERVER_PID 2>/dev/null
wait 2>/dev/null
oj_server: failed to load config './config.json': config: cannot open file: ./config.json
--- HEALTH ---
--- REGISTER ---
--- REGISTER DUP ---
--- LOGIN ---
...
第四步: 最后跑一次完整 ctest 收尾(每一次新增单元测试,都会添加到ctest)
每个单元开发完毕,都得跑一次完整的 ctest,测试所有单元测试是否全部通过。
bash
ubuntu@VM-0-4-ubuntu:~/project/oj_-platform-based_on-vibecoding$ ctest --test-dir build --output-on-failure 2>&1 | tail -15
3/8 Test #3: LoggerTest ....................... Passed 0.00 sec
Start 4: DbTest
4/8 Test #4: DbTest ........................... Passed 0.98 sec
Start 5: ReposTest
5/8 Test #5: ReposTest ........................ Passed 2.54 sec
Start 6: CryptoTest
6/8 Test #6: CryptoTest ....................... Passed 0.01 sec
Start 7: SessionTest
7/8 Test #7: SessionTest ...................... Passed 1.90 sec
Start 8: AuthTest
8/8 Test #8: AuthTest ......................... Passed 2.57 sec
100% tests passed, 0 tests failed out of 8
Total Test time (real) = 8.07 sec
3.4 Session 中间件(解析 Cookie → req.user)
3.4.1 功能开发+单元测试+ctest所有单元测试
- 提示词(只使用了以下提示词,AI直接完成了功能开发 + 测试的操作):
bash
@SPEC.md 请开发 Phase 3 这⾥的"Session 生成与校验"工作。完成后将该步骤标记为完成状态
第一步: 完成 Session 中间件(解析 Cookie → req.user) 的代码实现
代码省略,仅描述实现的功能:
oj-server/src/server/middleware.{h,cpp}新增class SessionMiddleware:- 构造接受
AuthService&(借用)+cookie_name;两个空值检查(空 cookie_name、空 RequireAuth pattern)抛std::invalid_argument,让 wiring 错误立即暴露。 Resolve(req):独立可调------读Cookieheader →util/cookie::ExtractCookieValue→AuthService::GetCurrentUser(token)→std::optional<User>。这是中间件对外的最小原语,所有应用层判定都走这一条路径。CurrentUser(res)(静态):从res.user_data.get<User>(kCurrentUserKey)取出中间件已解析好的User*(借出,生命周期与 Response 一致)。AuthedHandler(handler):组合助手------返回一个(req,res)=>void的 callable,内部独立Resolve(),resolve 不到即写401 {error:"unauthenticated", message:"no active session"},否则调用handler(req, res, user)。用于「单点打孔认证」的场景,不必全局装InstallOn。InstallOn(server):注册 cpp-httplib 的set_pre_request_handler(在路由 match 之后、body 读取之前触发)------req.matched_route此时已就绪,可以做 per-route 判定。在 handler 内:- 查
auth_required_看当前 matched_route 是否要 gate(std::set<std::string>+std::mutex,写都在 listen 之前但保留锁以防万一)。 - 调
Resolve()------匿名 + 非 gate:让 Response 进入 handler(同时res.user_data留空);匿名 + gate:写 401 +Handled让 cpp-httplib 跳过 route handler(重要:在 body 读取之前短路,避免攻击者发送大 body 浪费连接)。 - 已认证:
res.user_data.set<User>(...)写入,然后Unhandled让 route handler 跑。
- 查
RequireAuth(pattern):把 exact-string pattern 加进auth_required_。
- 构造接受
oj-server/src/main.cpp:构造SessionMiddleware mw(auth, cfg.session.cookie_name)并mw.InstallOn(server);启动 banner 增加middleware : on (session resolver installed)与phase 3 (auth + session + middleware)。- 框架集成要点:用
set_pre_request_handler而非set_pre_routing_handler,因为后者在路由 match 之前,req.matched_route为空,无法判定 per-route 策略;前者触发时机见httplib::routing()→dispatch_request(),且matched_route = matcher->pattern()在pre_request_handler之前那一行赋值(httplib 0.18.1,行 12087)。Response::user_data是 httplib 内置的 type-erased 容器(httplib/UserData),是 pre-handler → route handler 传任意数据的官方机制(注释里写明 "set by pre-routing/pre-request handlers and read by route handlers to pass arbitrary data e.g. decoded auth tokens")。 User是 POD + 默认可移动,可以原样塞进user_data.set<T>;User*通过get<T>取出,httplib 自己的UserData帮我们做了 type-erased 存储。
第二步:进行单元测试(使用gtest库)
(1)单测:tests/phase_3/test_middleware.cpp(gtest 20 cases:SessionMiddlewareUnitTest 1 + SessionMiddlewareResolveTest 7 + SessionMiddlewareHttpTest 12),含:
-
单元:构造期 cookie_name 非空校验、
RequireAuth(pattern)非空校验、CurrentUser在空 user_data 上返 nullptr 两次稳定。 -
Resolve()直测:缺 Cookie / 没我们这把 cookie / 空 value / 全 0 token →nullopt;登录后拿到的 token → 对应 User;logout 后同一 token →nullopt;oj_session(小写)cookie 名不被识别(RFC 6265 §4.1.1)。 -
HTTP 端到端:注册三类路由------
/public(无 gate,handler 读CurrentUser(res),有 user 报告 username、无则 "anonymous");/protected+/also-protected(RequireAuth);/protected-via-composition(不走 InstallOn,用AuthedHandler单点打孔)。 -
行为:anonymous → /public 200 / /protected 401 (
{error, message}形状校验) / /also-protected 401;带好 cookie → /protected 200 含 username+role;同 cookie 在 logout 后 → /protected 401(与 T3.3 logout 集成);小写 cookie 名 → 401;同一 cookie 串在并发三方(用户 A/用户 B/匿名)打 /public,全部 各得其所,零串台(ConcurrentResolveDoesNotCrossTalk:3 thread × 50 req × 3 user)。 -
AuthedHandler单独验证:好 cookie 200 / 匿名 401,与 InstallOn 互不依赖。
cpp
// 受限于篇幅,测试代码不展示
- 编译(oj-server/CMakeLists.txt中 集成了单元测试的编译过程)后,单元测试的运行结果(简略版):
bash
ubuntu@VM-0-4-ubuntu:~/project/oj_-platform-based_on-vibecoding$ ./build/test_middleware
Running main() from ./googletest/src/gtest_main.cc
[==========] Running 20 tests from 3 test suites.
[----------] Global test environment set-up.
[----------] 1 test from SessionMiddlewareUnitTest
[ RUN ] SessionMiddlewareUnitTest.CurrentUserOnEmptyUserDataReturnsNullptr
[ OK ] SessionMiddlewareUnitTest.CurrentUserOnEmptyUserDataReturnsNullptr (0 ms)
[----------] 1 test from SessionMiddlewareUnitTest (0 ms total)
[----------] 7 tests from SessionMiddlewareResolveTest
[ RUN ] SessionMiddlewareResolveTest.ResolveNoCookieHeaderReturnsNullopt
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] SessionMiddlewareResolveTest.ResolveNoCookieHeaderReturnsNullopt (69 ms)
......
[ RUN ] SessionMiddlewareResolveTest.ResolveIsCaseSensitiveOnCookieName
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] SessionMiddlewareResolveTest.ResolveIsCaseSensitiveOnCookieName (66 ms)
[----------] 7 tests from SessionMiddlewareResolveTest (445 ms total)
[----------] 12 tests from SessionMiddlewareHttpTest
[ RUN ] SessionMiddlewareHttpTest.PublicRouteAnonymousReturns200
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] SessionMiddlewareHttpTest.PublicRouteAnonymousReturns200 (73 ms)
......
[ RUN ] SessionMiddlewareHttpTest.ConcurrentResolveDoesNotCrossTalk
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] SessionMiddlewareHttpTest.ConcurrentResolveDoesNotCrossTalk (133 ms)
[----------] 12 tests from SessionMiddlewareHttpTest (964 ms total)
[----------] Global test environment tear-down
[==========] 20 tests from 3 test suites ran. (1409 ms total)
[ PASSED ] 20 tests.
20个测试用例全部通过
第三步: 最后跑一次完整 ctest 收尾(每一次新增单元测试,都会添加到ctest)
每个单元开发完毕,都得跑一次完整的 ctest,测试所有单元测试是否全部通过。
bash
ubuntu@VM-0-4-ubuntu:~/project/oj_-platform-based_on-vibecoding$ ctest --test-dir build --output-on-failure 2>&1 | tail -15
4/9 Test #4: DbTest ........................... Passed 1.22 sec
Start 5: ReposTest
5/9 Test #5: ReposTest ........................ Passed 2.74 sec
Start 6: CryptoTest
6/9 Test #6: CryptoTest ....................... Passed 0.01 sec
Start 7: SessionTest
7/9 Test #7: SessionTest ...................... Passed 1.96 sec
Start 8: AuthTest
8/9 Test #8: AuthTest ......................... Passed 2.67 sec
Start 9: MiddlewareTest
9/9 Test #9: MiddlewareTest ................... Passed 1.39 sec
100% tests passed, 0 tests failed out of 9
Total Test time (real) = 10.06 sec
3.4.2 Cookie 的作用,这一步在阶段3的作用
(1)Cookie 是什么
Cookie 是浏览器(或任何 HTTP 客户端)用来持久化存储一小段「与某网站相关」数据的机制,由服务器通过 Set-Cookie 响应头「种」到客户端,客户端在后续每个匹配的请求里通过 Cookie 请求头自动「带回」给服务器。
一个 Cookie 典型的样子:
bash
Set-Cookie: OJ_SESSION=abc123def456; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400
- 关键属性:
| 属性 | 作用 |
|---|---|
| name=value | 名值对,服务器读取的依据 |
| Path | Cookie 在哪些 URL 路径下会被回送 |
| HttpOnly | JS 读不到(document.cookie 拿不到),防 XSS 偷 cookie |
| SameSite | 跨站请求时是否携带(Lax/Strict/None) |
| Max-Age / Expires | 过期时间,Max-Age=0 立刻失效(注销) |
| Secure | 只在 HTTPS 下回送(我们本地 HTTP 暂未加) |
(2)Cookie 的作用
HTTP 协议本身是无状态的:服务器每收到一个请求,都把它当成全新独立的请求看待,不会自动记得前一个请求里的客户端是谁。
Cookie 就是用来给无状态的 HTTP 套上一层「会话身份」的最经典机制------它帮服务器做三件事:
- 身份识别:登录成功后服务器把一个随机 token 当作 Cookie「种」到浏览器,浏览器下次自动带回,服务器凭 token 知道"哦原来是你"。
- 状态延续:用 token 在服务端数据库里查出对应的 user / 偏好 / 购物车......让本来无状态的协议看起来"有记忆"。
- 注销 / 失效:服务端把 token 标作过期或从 DB 删掉,浏览器侧再把 cookie Max-Age=0 清掉------token 失效两端的同步手段。
现代 OAuth/JWT/HttpOnly Cookie 的各种用法,本质上都是这套「服务端存权威状态 + 客户端带 token」的演化,区别只在 token 是不是签名、cookie 加不加 SameSite、过期窗口多长。Cookie 是基础设施。
(3)这一步在阶段 3 的作用
在 SPEC 阶段 3(认证模块)里,Cookie 担任的是 "OJ 系统的身份通行证" 这个角色,被 T3.2 / T3.3 / T3.4 三步共同使用:
-
T3.2 --- Session 生成与校验(解决"通行证长什么样")
-
T3.3 --- 注册 / 登录 / 注销 / me 接口(解决"通行证怎么发 / 怎么收 / 怎么没")
-
T3.4 --- Session 中间件(解决"通行证被每个路由自动处理")
(4)阶段 3 串联起来就是:
bash
┌──────────┐ 注册 {user,pass} ┌──────────┐ 写 users 表 (T2.3) ┌─────────┐
│ Browser │ ──────────────────▶ │ Server │ ──────────────────────────▶ │ MySQL │
│ (cookie │ 登录 {user,pass} │ T3.3 │ 写 sessions 表 (T3.2) │ (cookies │
│ jar) │ ◀────────────────── │ handlers │ ◀────── Set-Cookie OJ_SESSION │ only in │
└──────────┘ Set-Cookie:OJ_SES └──────────┘ │ client) │
│ └─────────┘
│ 之后 GET /api/xxx 自动带 Cookie: OJ_SESSION=<token>
▼
┌──────────┐ 解析 → user ┌──────────┐ Resolve(token) ┌─────────┐
│ Browser │ ──────────────────▶ │ T3.4 MW │ ───────────────────────────▶ │ MySQL │
│ │ │ + handler│ ◀────── User / nullopt │ sessions│
└──────────┘ └──────────┘ └─────────┘
一句话总结:
Cookie 在阶段 3 里就是"登录成功→服务器随机生成→浏览器自动带回→服务器凭它认人→登出双方一起销毁" 这条生命周期的物质载体;T3.2 决定 token 长什么样 + 存在哪,T3.3 决定什么时候发 / 什么时候收,T3.4 决定所有路由(不论是 T7.x 的页面还是未来的提交)都能零成本地拿到当前登录用户。后续 T3.5 RBAC 也直接读 CurrentUser(res) 拿 role 字段判权限,cookie 解析完全不必重写。
3.5 RBAC 中间件(校验 role=admin)(功能开发+单元测试+curl接口测试+ctest所有单元测试)
- 提示词(只使用了以下提示词,AI直接完成了功能开发 + 测试的操作):
bash
@SPEC.md 请开发 Phase 3 这⾥的"RBAC 中间件(校验 role=admin)"工作。完成后将该步骤标记为完成状态
第一步: 完成 RBAC 中间件(校验 role=admin) 的代码实现
代码省略,仅描述实现的功能:
oj-server/src/server/middleware.{h,cpp}新增class RbacMiddleware(与SessionMiddleware同文件、同命名空间):kRequiredRole = "admin"(取自 SPEC §6.2 / §11 PROB-3,未来扩展到RequireRole(...)时只需把常量参数化)。RequireAdmin(pattern):exact-string 加入admin_required_set;空 pattern 抛std::invalid_argument。Handle(req, res) -> bool:per-request 步骤;从SessionMiddleware::CurrentUser(res)(同 cpp-httplib res.user_data 槽位)读已解析的 User,按身份分发:- 路由未登记 → 直接 pass(RBAC silent on non-flagged routes)。
- 匿名(user_data 空) → 写
401 {error:"unauthenticated", message:"no active session"}+ 返 true(短路)。 - 已认证但 role != "admin" → 写
403 {error:"forbidden", message:"admin role required"}+ 返 true。 - 已认证且 role == "admin" → 直接 pass。
AdminedHandler(session, handler):单点合成的 admin gate,对应SessionMiddleware::AuthedHandler,自带 cookie 解析、不依赖InstallOn。InstallOn(server, session):在 cpp-httplib 唯一set_pre_request_handler槽位里串联两个中间件 (先session.Handle再rbac.Handle)。因为 cpp-httplib 0.18.1 只允许一个 pre-request handler,必须协同安装。- 复用
SessionMiddleware::kCurrentUserKey(res.user_data槽位)传递 User,避免重新查 session 表。
SessionMiddleware表面小重构(行为不变):把InstallOn的 lambda 抽成Handle(req, res) -> bool公有方法;让 RBAC 能在链式 handler 里调用同一段逻辑,T3.4 行为完全兼容。oj-server/src/main.cpp:构造RbacMiddleware+ 注册 placeholder admin route/api/admin/ping({ok:true, admin:<username>})作为 T3.5 烟雾点,通过rbac.InstallOn(server, mw)装载链式 pre-request handler;启动 banner 升级为phase 3 (auth + session + middleware + rbac)+middleware : on (session resolver + admin gate)+rbac routes : /api/admin/ping (placeholder for T4.3)。真实 admin CRUD 路由(T4.3)会复用RequireAdmin(...)替换该 placeholder。
第二步:进行单元测试(使用gtest库)
(1)tests/phase_3/test_rbac.cpp(gtest 29 cases:RbacMiddlewareUnitTest 3 + RbacMiddlewareDirectTest 10 + RbacMiddlewareHttpTest 16),覆盖:
- Unit:
RbacMiddleware()默认构造不抛;RequireAdmin("")抛std::invalid_argument;多次RequireAdmin累加去重;非 admin 路由上Handle()完全不动res(status == -1/ body 空)。 - Direct (DB):admin-only + anonymous = 401 + body shape;admin-only + role=user = 403 + body shape;admin-only + role=admin = pass(不动
res);非 admin 路由在 anonymous / user / admin 三档下都 silent;多个 admin 路由(如/api/admin/x+/api/admin/y)独立 401;session.Handle+rbac.Handle串联:admin-only + anonymous 应被session.Handle那侧 401 短路 / 或保持到 RBAC 那侧 401(取决于matched_route是否同时在auth_required_)------ 断言两种合法路径。 AdminedHandler:admin 拿到正确 User + 调入 inner handler(用inner_called计数 + body shape 替代res.status验证,因为 cpp-httplib 在 Server 通道外不会自动写 200);anonymous = 401;role=user = 403。- HTTP:5 类路由
/public(无 gate)、/user-only(RequireAuth)、/admin-only+/also-admin-only(RequireAdmin,also-证明按 matched_route 独立)、/admin-via-comp(用AdminedHandler单点装配);3×5=15 矩阵覆盖 anon / user / admin 三种身份对每个路由的 status + body shape:anon 全 401 / 200(public);user 200(public) + 200(user) + 403(admin);admin 200 全线。 - 异常 cookie:empty value / 全 0 token → 401;cookie 名大小写由 RFC 6265 §4.1.1 保持敏感(沿用 T3.4 行为)。
- 并发
ConcurrentRbacGateDoesNotCrossTalk:3 线程各 50 轮打/admin-only(anon / user / admin 各自想要的 status),user_data在 cpp-httplib worker 间不串台。 - Test fixture 用 direct SQL
UPDATE users SET role='admin'升级测试账户到 admin(T3.5 不动 AuthService 表面,避免引入仅为测试的 service 层方法;初始化 admin 由db/init.sql/ T8.5 官方账号说明承担)。
cpp
// 受限于篇幅,测试代码不展示
- 编译(oj-server/CMakeLists.txt中 集成了单元测试的编译过程)后,单元测试的运行结果(简略版):
bash
ubuntu@VM-0-4-ubuntu:~/project/oj_-platform-based_on-vibecoding$ ./build/test_rbac
Running main() from ./googletest/src/gtest_main.cc
[==========] Running 29 tests from 3 test suites.
[----------] Global test environment set-up.
[----------] 3 tests from RbacMiddlewareUnitTest
[ RUN ] RbacMiddlewareUnitTest.DefaultConstructionDoesNotThrow
[ OK ] RbacMiddlewareUnitTest.DefaultConstructionDoesNotThrow (0 ms)
[ RUN ] RbacMiddlewareUnitTest.RequireAdminRejectsEmptyPattern
[ OK ] RbacMiddlewareUnitTest.RequireAdminRejectsEmptyPattern (0 ms)
[ RUN ] RbacMiddlewareUnitTest.RequireAdminAcceptsNonEmptyPatternAndDeduplicates
[ OK ] RbacMiddlewareUnitTest.RequireAdminAcceptsNonEmptyPatternAndDeduplicates (0 ms)
[----------] 3 tests from RbacMiddlewareUnitTest (0 ms total)
[----------] 10 tests from RbacMiddlewareDirectTest
[ RUN ] RbacMiddlewareDirectTest.AdminRouteAnonymousReturns401
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RbacMiddlewareDirectTest.AdminRouteAnonymousReturns401 (56 ms)
......
[ RUN ] RbacMiddlewareDirectTest.AdminedHandlerNonAdminReturns403
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RbacMiddlewareDirectTest.AdminedHandlerNonAdminReturns403 (76 ms)
[----------] 10 tests from RbacMiddlewareDirectTest (666 ms total)
[----------] 16 tests from RbacMiddlewareHttpTest
[ RUN ] RbacMiddlewareHttpTest.AnonymousGets401OnAdminRoute
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RbacMiddlewareHttpTest.AnonymousGets401OnAdminRoute (65 ms)
......
[ RUN ] RbacMiddlewareHttpTest.ConcurrentRbacGateDoesNotCrossTalk
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
WARNING: MYSQL_OPT_RECONNECT is deprecated and will be removed in a future version.
[ OK ] RbacMiddlewareHttpTest.ConcurrentRbacGateDoesNotCrossTalk (138 ms)
[----------] 16 tests from RbacMiddlewareHttpTest (1310 ms total)
[----------] Global test environment tear-down
[==========] 29 tests from 3 test suites ran. (1977 ms total)
[ PASSED ] 29 tests.
29个测试用例全部通过
第三步: 实际启动 server,curl进行接口测试(./build/oj_server --port 18084):banner 含 rbac routes: /api/admin/ping。
- anonymous → /api/admin/ping → 401 +
{error:"unauthenticated"}✓ - 注册
smk_usr_t35(role=user) → login → /api/admin/ping → 403 +{error:"forbidden"}✓ - SQL
UPDATE users SET role='admin'后再 login (新 session) → /api/admin/ping → 200 +{ok:true, admin:"smk_usr_t35"}✓ - /api/auth/me 同时正确反映 role=admin ✓
bash
$ ls build/oj_server && file build/oj_server | head -1
build/oj_server
build/oj_server: ELF 64-bit LSB pie executable, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=0175c4fccf7d0b036df95e9dff2d6458f3ca22a4, for GNU/Linux 3.2.0, not stripped
bash
$ export ubuntu_password="123456789" && ./build/oj_server --port 18084 --config ./oj-server/config.json > /tmp/oj_t35.log 2>&1 &
sleep 1
echo "=== /api/admin/ping anonymous ==="
curl -s -o /tmp/r1.json -w "HTTP %{http_code}\n" http://127.0.0.1:18084/api/admin/ping
cat /tmp/r1.json; echo
echo "=== register a normal user ==="
curl -s -X POST -H "Content-Type: application/json" -d '{"username":"smk_usr_t35","password":"pass1234"}' -o /tmp/r2.json -w "HTTP %{http_code}\n" http://127.0.0.1:18084/api/auth/register
cat /tmp/r2.json; echo
echo "=== login ==="
curl -s -X POST -H "Content-Type: application/json" -d '{"username":"smk_usr_t35","password":"pass1234"}' -c /tmp/user_cookies.txt -o /tmp/r3.json -w "HTTP %{http_code}\n" http://127.0.0.1:18084/api/auth/login
echo
echo "=== /api/admin/ping with user cookie (role=user) -> 403 ==="
curl -s -b /tmp/user_cookies.txt -o /tmp/r4.json -w "HTTP %{http_code}\n" http://127.0.0.1:18084/api/admin/ping
cat /tmp/r4.json; echo
=== /api/admin/ping anonymous ===
HTTP 401
{"error":"unauthenticated","message":"no active session"}
=== register a normal user ===
HTTP 200
{"created_at":"2026-07-28 22:29:53","id":1073,"role":"user","username":"smk_usr_t35"}
=== login ===
HTTP 200
=== /api/admin/ping with user cookie (role=user) -> 403 ===
HTTP 403
{"error":"forbidden","message":"admin role required"}
第四步: 最后跑一次完整 ctest 收尾(每一次新增单元测试,都会添加到ctest)
每个单元开发完毕,都得跑一次完整的 ctest,测试所有单元测试是否全部通过。
bash
ubuntu@VM-0-4-ubuntu:~/project/oj_-platform-based_on-vibecoding$ ctest --test-dir build --output-on-failure 2>&1 | tail -15
5/10 Test #5: ReposTest ........................ Passed 2.78 sec
Start 6: CryptoTest
6/10 Test #6: CryptoTest ....................... Passed 0.01 sec
Start 7: SessionTest
7/10 Test #7: SessionTest ...................... Passed 2.03 sec
Start 8: AuthTest
8/10 Test #8: AuthTest ......................... Passed 2.78 sec
Start 9: MiddlewareTest
9/10 Test #9: MiddlewareTest ................... Passed 1.46 sec
Start 10: RbacTest
10/10 Test #10: RbacTest ......................... Passed 1.96 sec
100% tests passed, 0 tests failed out of 10
Total Test time (real) = 12.48 sec