【用 Vibe Coding 实现的 C++17 在线判题系统】项目准备 + 后端开发

文章目录

  • 前言
  • 一、项目准备
    • [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_rowsQuery(sql)→DbResult(NULL→空串)、Escape(s)SetAutocommit/Commit/RollbackPing()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}.h
    • User(公开,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 字面量处理
    • UserRepoCreate(MySQL errno 1062 → UniqueViolationError,便于 service 层映射 409)/ FindById / FindCredentialsByUsername / ExistsByUsername / DeleteById
    • ProblemRepoCreate / FindById / List + Count(分页 + difficulty / tag 过滤,tag 用 FIND_IN_SET)/ Update(整行覆盖)/ DeleteById / ListTestCases / AddTestCase / DeleteTestCase / ReplaceTestCases(调用方事务内原子重置)
    • SubmissionRepoCreate(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.hSession 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_atDATE_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 的客户端。
  • 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 个 handler RegisterHandler / LoginHandler / LogoutHandler / MeHandler + RegisterAuthRoutes(server, auth, cookie_name, cookie_ttl_seconds) 注册入口。错误体统一 {"error":"<code>","message":"<text>"} shape。
  • oj-server/src/server/auth_handlers.hhttp_server.h 拆分:前者引入 AuthService(带 DB 依赖),后者保持 DB-agnostic 让 test_http_server 无需 MySQL。
  • oj-server/src/main.cpp:构造 MysqlPoolSessionServiceAuthServiceRegisterAuthRoutes;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.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):独立可调------读 Cookie header → util/cookie::ExtractCookieValueAuthService::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 内:
      1. auth_required_ 看当前 matched_route 是否要 gate(std::set<std::string> + std::mutex,写都在 listen 之前但保留锁以防万一)。
      2. Resolve()------匿名 + 非 gate:让 Response 进入 handler(同时 res.user_data 留空);匿名 + gate:写 401 + Handled 让 cpp-httplib 跳过 route handler(重要:在 body 读取之前短路,避免攻击者发送大 body 浪费连接)。
      3. 已认证: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 → nulloptoj_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

(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 套上一层「会话身份」的最经典机制------它帮服务器做三件事:

  1. 身份识别:登录成功后服务器把一个随机 token 当作 Cookie「种」到浏览器,浏览器下次自动带回,服务器凭 token 知道"哦原来是你"。
  2. 状态延续:用 token 在服务端数据库里查出对应的 user / 偏好 / 购物车......让本来无状态的协议看起来"有记忆"。
  3. 注销 / 失效:服务端把 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.Handlerbac.Handle)。因为 cpp-httplib 0.18.1 只允许一个 pre-request handler,必须协同安装。
    • 复用 SessionMiddleware::kCurrentUserKeyres.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() 完全不动 resstatus == -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

相关推荐
Setsuna_F_Seiei21 分钟前
前端的 AI 学习之路 02 之 Provider 与 Structured Output - 规范化模型输入输出
人工智能·agent·ai编程
Setsuna_F_Seiei1 小时前
前端的 AI 学习之路 01 之 Agent API 调用 - 和 Agent 的基础对话
前端·人工智能·ai编程
Jooolin4 小时前
AI项目实战日记ep1:从零做一个 AI 日志分析助手
ai编程
码匠许师傅5 小时前
【C++ 面试真题】30. 聊聊 C++ 的互斥锁与读写锁
java·c++·面试
小虎AI生活5 小时前
用 WorkBuddy 三个 Skill 串起一条流水线:8 张随手拍照片 30 分钟变口播视频
ai编程
ShineWinsu6 小时前
对于 C++:C++14中从变量模板、泛型 Lambda 到并发与字面量的解析
c++·算法
大家的林语冰6 小时前
✌️ 字节太牛了,爽用 Trae Work 取代小龙虾,AI 自动设计封面和数据可视化~
人工智能·ai编程·trae
kyriewen6 小时前
我带着DeepSeek Harness跑了一周真实需求——这份避坑速查表请收好
前端·ai编程·deepseek
逸Y 仙X7 小时前
MCP(模型控制协议)完全指南:从核心概念到实战开发
python·大模型·llm·ai编程·mcp
ServBay8 小时前
2026 年值得关注的 8 款 AI 智能体工具
后端·aigc·ai编程