feat(backend): add sqlite migrations + app state + tests
这个提交包含在:
@@ -9,13 +9,17 @@ find_package(Catch2 3 REQUIRED)
|
|||||||
|
|
||||||
add_library(csp_core
|
add_library(csp_core
|
||||||
src/version.cc
|
src/version.cc
|
||||||
|
src/db/sqlite_db.cc
|
||||||
|
src/app_state.cc
|
||||||
)
|
)
|
||||||
|
|
||||||
target_include_directories(csp_core PUBLIC
|
target_include_directories(csp_core PUBLIC
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/include
|
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||||
)
|
)
|
||||||
|
|
||||||
# SQLite will be used via Drogon DB client in later iterations.
|
target_link_libraries(csp_core PUBLIC
|
||||||
|
SQLite3_lib
|
||||||
|
)
|
||||||
|
|
||||||
add_executable(csp_server
|
add_executable(csp_server
|
||||||
src/main.cc
|
src/main.cc
|
||||||
@@ -35,6 +39,7 @@ enable_testing()
|
|||||||
add_executable(csp_tests
|
add_executable(csp_tests
|
||||||
tests/test_main.cc
|
tests/test_main.cc
|
||||||
tests/version_test.cc
|
tests/version_test.cc
|
||||||
|
tests/sqlite_db_test.cc
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(csp_tests PRIVATE
|
target_link_libraries(csp_tests PRIVATE
|
||||||
|
|||||||
24
backend/include/csp/app_state.h
普通文件
24
backend/include/csp/app_state.h
普通文件
@@ -0,0 +1,24 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "csp/db/sqlite_db.h"
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace csp {
|
||||||
|
|
||||||
|
class AppState {
|
||||||
|
public:
|
||||||
|
static AppState& Instance();
|
||||||
|
|
||||||
|
void Init(const std::string& sqlite_path);
|
||||||
|
|
||||||
|
db::SqliteDb& db();
|
||||||
|
|
||||||
|
private:
|
||||||
|
AppState() = default;
|
||||||
|
|
||||||
|
std::unique_ptr<db::SqliteDb> db_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace csp
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <sqlite3.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace csp::db {
|
||||||
|
|
||||||
|
class SqliteDb {
|
||||||
|
public:
|
||||||
|
static SqliteDb OpenFile(const std::string& path);
|
||||||
|
static SqliteDb OpenMemory();
|
||||||
|
|
||||||
|
SqliteDb() = default;
|
||||||
|
~SqliteDb();
|
||||||
|
|
||||||
|
SqliteDb(const SqliteDb&) = delete;
|
||||||
|
SqliteDb& operator=(const SqliteDb&) = delete;
|
||||||
|
|
||||||
|
SqliteDb(SqliteDb&& other) noexcept;
|
||||||
|
SqliteDb& operator=(SqliteDb&& other) noexcept;
|
||||||
|
|
||||||
|
sqlite3* raw() const { return db_; }
|
||||||
|
|
||||||
|
void Exec(const std::string& sql);
|
||||||
|
|
||||||
|
private:
|
||||||
|
explicit SqliteDb(sqlite3* db) : db_(db) {}
|
||||||
|
|
||||||
|
sqlite3* db_ = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply SQL migrations in order. For now we ship a single init migration.
|
||||||
|
void ApplyMigrations(SqliteDb& db);
|
||||||
|
|
||||||
|
} // namespace csp::db
|
||||||
66
backend/migrations/001_init.sql
普通文件
66
backend/migrations/001_init.sql
普通文件
@@ -0,0 +1,66 @@
|
|||||||
|
-- 001_init.sql
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_salt TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
rating INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS problems (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
statement_md TEXT NOT NULL,
|
||||||
|
difficulty INTEGER NOT NULL DEFAULT 1,
|
||||||
|
source TEXT NOT NULL DEFAULT "",
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS problem_tags (
|
||||||
|
problem_id INTEGER NOT NULL,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(problem_id, tag),
|
||||||
|
FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS submissions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
problem_id INTEGER NOT NULL,
|
||||||
|
language TEXT NOT NULL,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
score INTEGER NOT NULL DEFAULT 0,
|
||||||
|
time_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
memory_kb INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wrong_book (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
problem_id INTEGER NOT NULL,
|
||||||
|
last_submission_id INTEGER,
|
||||||
|
note TEXT NOT NULL DEFAULT "",
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(user_id, problem_id),
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(last_submission_id) REFERENCES submissions(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_submissions_user_created_at ON submissions(user_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_submissions_problem_created_at ON submissions(problem_id, created_at DESC);
|
||||||
22
backend/src/app_state.cc
普通文件
22
backend/src/app_state.cc
普通文件
@@ -0,0 +1,22 @@
|
|||||||
|
#include "csp/app_state.h"
|
||||||
|
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace csp {
|
||||||
|
|
||||||
|
AppState& AppState::Instance() {
|
||||||
|
static AppState inst;
|
||||||
|
return inst;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppState::Init(const std::string& sqlite_path) {
|
||||||
|
db_ = std::make_unique<db::SqliteDb>(db::SqliteDb::OpenFile(sqlite_path));
|
||||||
|
csp::db::ApplyMigrations(*db_);
|
||||||
|
}
|
||||||
|
|
||||||
|
csp::db::SqliteDb& AppState::db() {
|
||||||
|
if (!db_) throw std::runtime_error("AppState not initialized");
|
||||||
|
return *db_;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace csp
|
||||||
135
backend/src/db/sqlite_db.cc
普通文件
135
backend/src/db/sqlite_db.cc
普通文件
@@ -0,0 +1,135 @@
|
|||||||
|
#include "csp/db/sqlite_db.h"
|
||||||
|
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace csp::db {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void ThrowSqlite(int rc, sqlite3* db, const char* what) {
|
||||||
|
if (rc == SQLITE_OK) return;
|
||||||
|
const char* msg = db ? sqlite3_errmsg(db) : "";
|
||||||
|
throw std::runtime_error(std::string(what) + ": " + msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
SqliteDb SqliteDb::OpenFile(const std::string& path) {
|
||||||
|
sqlite3* db = nullptr;
|
||||||
|
const int rc = sqlite3_open(path.c_str(), &db);
|
||||||
|
if (rc != SQLITE_OK) {
|
||||||
|
const char* msg = db ? sqlite3_errmsg(db) : "";
|
||||||
|
if (db) sqlite3_close(db);
|
||||||
|
throw std::runtime_error(std::string("sqlite3_open failed: ") + msg);
|
||||||
|
}
|
||||||
|
return SqliteDb(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
SqliteDb SqliteDb::OpenMemory() {
|
||||||
|
sqlite3* db = nullptr;
|
||||||
|
const int rc = sqlite3_open(":memory:", &db);
|
||||||
|
ThrowSqlite(rc, db, "sqlite3_open(:memory:) failed");
|
||||||
|
return SqliteDb(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
SqliteDb::~SqliteDb() {
|
||||||
|
if (db_) sqlite3_close(db_);
|
||||||
|
}
|
||||||
|
|
||||||
|
SqliteDb::SqliteDb(SqliteDb&& other) noexcept : db_(other.db_) {
|
||||||
|
other.db_ = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
SqliteDb& SqliteDb::operator=(SqliteDb&& other) noexcept {
|
||||||
|
if (this == &other) return *this;
|
||||||
|
if (db_) sqlite3_close(db_);
|
||||||
|
db_ = other.db_;
|
||||||
|
other.db_ = nullptr;
|
||||||
|
return *this;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SqliteDb::Exec(const std::string& sql) {
|
||||||
|
char* err = nullptr;
|
||||||
|
const int rc = sqlite3_exec(db_, sql.c_str(), nullptr, nullptr, &err);
|
||||||
|
if (rc != SQLITE_OK) {
|
||||||
|
std::string msg = err ? err : "";
|
||||||
|
sqlite3_free(err);
|
||||||
|
ThrowSqlite(rc, db_, msg.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApplyMigrations(SqliteDb& db) {
|
||||||
|
// Keep it simple for MVP: apply the bundled init SQL.
|
||||||
|
// In later iterations we'll add a migrations table + incremental runner.
|
||||||
|
db.Exec("PRAGMA foreign_keys = ON;");
|
||||||
|
|
||||||
|
// 001_init.sql (embedded)
|
||||||
|
db.Exec(R"SQL(
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_salt TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
rating INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS problems (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
statement_md TEXT NOT NULL,
|
||||||
|
difficulty INTEGER NOT NULL DEFAULT 1,
|
||||||
|
source TEXT NOT NULL DEFAULT "",
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS problem_tags (
|
||||||
|
problem_id INTEGER NOT NULL,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(problem_id, tag),
|
||||||
|
FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS submissions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
problem_id INTEGER NOT NULL,
|
||||||
|
language TEXT NOT NULL,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
score INTEGER NOT NULL DEFAULT 0,
|
||||||
|
time_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
memory_kb INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wrong_book (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
problem_id INTEGER NOT NULL,
|
||||||
|
last_submission_id INTEGER,
|
||||||
|
note TEXT NOT NULL DEFAULT "",
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(user_id, problem_id),
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(last_submission_id) REFERENCES submissions(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_submissions_user_created_at ON submissions(user_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_submissions_problem_created_at ON submissions(problem_id, created_at DESC);
|
||||||
|
)SQL");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace csp::db
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
#include <drogon/drogon.h>
|
#include <drogon/drogon.h>
|
||||||
|
|
||||||
int main(int argc, char** argv) {
|
#include "csp/app_state.h"
|
||||||
(void)argc;
|
|
||||||
(void)argv;
|
#include <filesystem>
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
const std::string db_path = (argc >= 2) ? argv[1] : std::string("data/csp.db");
|
||||||
|
std::filesystem::create_directories(std::filesystem::path(db_path).parent_path());
|
||||||
|
|
||||||
|
csp::AppState::Instance().Init(db_path);
|
||||||
|
|
||||||
// Basic defaults. Will be moved to config-driven later.
|
|
||||||
drogon::app()
|
drogon::app()
|
||||||
.addListener("0.0.0.0", 8080)
|
.addListener("0.0.0.0", 8080)
|
||||||
.setThreadNum(4);
|
.setThreadNum(4);
|
||||||
|
|
||||||
LOG_INFO << "csp_server starting at http://0.0.0.0:8080";
|
LOG_INFO << "csp_server starting at http://0.0.0.0:8080";
|
||||||
|
LOG_INFO << "sqlite db: " << db_path;
|
||||||
|
|
||||||
drogon::app().run();
|
drogon::app().run();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
33
backend/tests/sqlite_db_test.cc
普通文件
33
backend/tests/sqlite_db_test.cc
普通文件
@@ -0,0 +1,33 @@
|
|||||||
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
|
||||||
|
#include "csp/db/sqlite_db.h"
|
||||||
|
|
||||||
|
#include <sqlite3.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int CountTable(sqlite3* db, const char* table_name) {
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
const char* sql =
|
||||||
|
"SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name=?";
|
||||||
|
REQUIRE(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) == SQLITE_OK);
|
||||||
|
REQUIRE(sqlite3_bind_text(stmt, 1, table_name, -1, SQLITE_TRANSIENT) ==
|
||||||
|
SQLITE_OK);
|
||||||
|
REQUIRE(sqlite3_step(stmt) == SQLITE_ROW);
|
||||||
|
const int v = sqlite3_column_int(stmt, 0);
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("migrations create core tables") {
|
||||||
|
auto db = csp::db::SqliteDb::OpenMemory();
|
||||||
|
csp::db::ApplyMigrations(db);
|
||||||
|
|
||||||
|
REQUIRE(CountTable(db.raw(), "users") == 1);
|
||||||
|
REQUIRE(CountTable(db.raw(), "sessions") == 1);
|
||||||
|
REQUIRE(CountTable(db.raw(), "problems") == 1);
|
||||||
|
REQUIRE(CountTable(db.raw(), "submissions") == 1);
|
||||||
|
REQUIRE(CountTable(db.raw(), "wrong_book") == 1);
|
||||||
|
}
|
||||||
在新工单中引用
屏蔽一个用户