feat: rebuild CSP practice workflow, UX and automation

这个提交包含在:
Codex CLI
2026-02-13 15:49:05 +08:00
父节点 d33deed4c5
当前提交 e2ab522b78
修改 105 个文件,包含 15669 行新增428 行删除

查看文件

@@ -8,7 +8,7 @@ RUN apt-get update -y && \
libdrogon-dev libjsoncpp-dev libyaml-cpp-dev libhiredis-dev \ libdrogon-dev libjsoncpp-dev libyaml-cpp-dev libhiredis-dev \
libpq-dev libmariadb-dev libmariadb-dev-compat \ libpq-dev libmariadb-dev libmariadb-dev-compat \
libsqlite3-dev sqlite3 \ libsqlite3-dev sqlite3 \
libssl-dev && \ libssl-dev uuid-dev libbrotli-dev catch2 && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
WORKDIR /src WORKDIR /src
@@ -24,11 +24,13 @@ FROM ubuntu:24.04 AS runtime
RUN apt-get update -y && \ RUN apt-get update -y && \
DEBIAN_FRONTEND=noninteractive apt-get install -y \ DEBIAN_FRONTEND=noninteractive apt-get install -y \
libdrogon1t64 libjsoncpp25 libyaml-cpp0.8 libhiredis1.1.0 \ libdrogon1t64 libjsoncpp25 libyaml-cpp0.8 libhiredis1.1.0 \
libpq5 libmariadb3 libsqlite3-0 libssl3t64 ca-certificates && \ libpq5 libmariadb3 libsqlite3-0 libssl3t64 libuuid1 ca-certificates \
g++ python3 python3-requests curl poppler-utils && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY --from=build /src/build/backend/csp_server /app/csp_server COPY --from=build /src/build/backend/csp_server /app/csp_server
COPY scripts/ /app/scripts/
EXPOSE 8080 EXPOSE 8080

153
README.md
查看文件

@@ -1,45 +1,148 @@
# CSP 在线练习 / 模拟竞赛平台(前后端分离) # CSP 在线学习与竞赛平台
- 前端Next.js目录`frontend/` 面向 OI/CSP 学习场景的全栈 Web 系统(前后端分离):
- 后端C++20 + Drogon + SQLite目录`backend/`
## 1. 本地开发Ubuntu 24.04 - 前端Next.js 16App Router, TypeScript
- 后端C++20 + Drogon + SQLite
- 部署Docker Compose
### 1.1 安装依赖 核心能力:
- 用户注册/登录Bearer Token
- 题库检索、题面查看、在线提交评测
- 日常练习提交记录
- 错题本(自动沉淀 + 手动备注)
- 模拟竞赛(报名、题单、排行榜)
- 学习知识库(文章 + 题目关联)
- 在线 C++ 编写/编译/运行调试
- 题目页 Markdown 语义渲染(数学公式 / 图片本地缓存)
- 代码草稿保存、试运行、异步多解题解生成
- MCP JSON-RPC 接口(供 Agent 调用)
- Swagger API 文档页面
## 1. 快速开始
### 1.1 Docker 一键启动(推荐)
```bash
git clone ssh://git@git.hk.hao.work:2222/hao/csp.git
cd csp
docker compose up -d --build
```
访问:
- 前端:`http://<你的IP>:7888/`
- 后端健康检查(经前端反代):`http://<你的IP>:7888/admin139/api/health`
### 1.2 本机开发启动
```bash ```bash
./scripts/bootstrap_ubuntu.sh ./scripts/bootstrap_ubuntu.sh
```
### 1.2 构建与运行后端
```bash
cmake -S . -B build -G Ninja cmake -S . -B build -G Ninja
cmake --build build cmake --build build
ctest --test-dir build -V ctest --test-dir build -V
./build/backend/csp_server ./build/backend/csp_server
# http://localhost:8080/api/health
``` ```
### 1.3 运行前端 前端:
```bash ```bash
cd frontend npm --prefix frontend ci
npm run dev npm --prefix frontend run dev
# http://localhost:3000
``` ```
## 2. 目录结构 ## 2. 目录结构
- `backend/` 后端 C++ 服务与测试 - `backend/`Drogon 后端(控制器/服务/领域模型/SQLite
- `frontend/` 前端 Next.js - `frontend/`Next.js 前端
- `docs/` 开发文档(设计、接口、部署等) - `docs/`架构、API、数据库、测试、部署文档
- `scripts/` 一键脚本 - `scripts/`:开发与初始化脚本
## 3. 当前状态 ## 3. API 入口说明
已完成工程骨架 生产/Compose 场景建议统一通过前端同域反代访问后端
- 后端Drogon 服务启动 + `/api/health`
- 后端Catch2 单测接入(`ctest` 可跑) - 浏览器访问:`/admin139/...`
- 前端Next.js 工程初始化 - 例如:`/admin139/api/v1/problems`
本地后端直连调试时可用 `http://localhost:8080`
详细 API`docs/API参考.md`,或直接访问 Swagger 页面 `/api-docs`(规范地址 `/admin139/api/openapi.json`)。
## 4. 测试与 TDD
- 后端测试框架Catch2
- 测试命令:`ctest --test-dir build -V`
- 当前覆盖迁移、鉴权、题库、提交评测、错题本、竞赛、知识库、HTTP 控制器核心路径
详见:`docs/测试与TDD.md`
## 5. 端口与外部访问
- 对外端口:`7888 -> frontend:3000`
- 默认监听:`0.0.0.0:7888`
- 若需排查网络访问/Clash 影响,见 `docs/Docker部署.md` 的“故障排查”章节。
## 6. 题库初始化(默认:洛谷 CSP J/S
默认导入脚本:`scripts/import_luogu_csp.py`,按洛谷标签抓取 CSP-J / CSP-S / NOIP 题目,结果写入 `problems` / `problem_tags`,并将任务进度写入 `import_jobs` / `import_job_items`
```bash
python3 scripts/import_luogu_csp.py \
--db-path /var/lib/docker/volumes/csp_csp_data/_data/csp.db \
--workers 3 \
--clear-all-problems
```
前端 `/imports` 页面可查看导入状态和明细;后端容器重启后默认自动执行一次(可通过环境变量关闭)。
`.env` / `docker-compose` 常用参数:
```env
OI_IMPORT_AUTO_RUN=true
OI_IMPORT_WORKERS=3
OI_IMPORT_SCRIPT_PATH=/app/scripts/import_luogu_csp.py
OI_IMPORT_CLEAR_ALL_PROBLEMS=true
```
如果你还要使用旧的 PDF + LLM 导入流程,可手动运行 `scripts/import_winterant_oi.py`
## 7. CSP-J 题目自动生成RAG + 去重)
提供脚本:`scripts/generate_cspj_problem_rag.py`,流程为:
1. 爬取洛谷 CSP-J/NOIP 入门题名作为外部语料
2. 融合本地 `problems` 题库做关键词压缩RAG 上下文)
3. 调用 LLM 生成新题 JSON
4. 生成前+入库前各做一次相似题检索,疑似重复则跳过
手动执行一次(默认 1 题):
```bash
python3 scripts/generate_cspj_problem_rag.py \
--db-path /var/lib/docker/volumes/csp_csp_data/_data/csp.db \
--count 1
```
容器默认会在后端启动时自动尝试生成 1 题(可通过环境变量关闭)。当同时开启自动导入时,会等待导入完成后再触发生题,避免被清库流程覆盖:
```env
CSP_GEN_AUTO_RUN=true
CSP_GEN_COUNT=1
CSP_GEN_WAIT_FOR_IMPORT=true
```
## 8. MCP 接口
- 入口:`POST /admin139/api/v1/mcp`
- 支持方法:`initialize``tools/list``tools/call`
- 内置工具:`health``list_problems``get_problem``run_cpp``generate_cspj_problem`
## 9. 文档索引
- `docs/平台总体设计.md`
- `docs/数据库设计.md`
- `docs/API参考.md`
- `docs/测试与TDD.md`
- `docs/Docker部署.md`

查看文件

@@ -14,6 +14,17 @@ add_library(csp_core
src/app_state.cc src/app_state.cc
src/services/crypto.cc src/services/crypto.cc
src/services/auth_service.cc src/services/auth_service.cc
src/services/problem_service.cc
src/services/user_service.cc
src/services/wrong_book_service.cc
src/services/kb_service.cc
src/services/contest_service.cc
src/services/submission_service.cc
src/services/problem_workspace_service.cc
src/services/problem_solution_runner.cc
src/services/problem_gen_runner.cc
src/services/import_service.cc
src/services/import_runner.cc
src/domain/enum_strings.cc src/domain/enum_strings.cc
src/domain/json.cc src/domain/json.cc
) )
@@ -30,6 +41,15 @@ target_link_libraries(csp_core PUBLIC
add_library(csp_web add_library(csp_web
src/controllers/auth_controller.cc src/controllers/auth_controller.cc
src/controllers/problem_controller.cc
src/controllers/submission_controller.cc
src/controllers/me_controller.cc
src/controllers/contest_controller.cc
src/controllers/leaderboard_controller.cc
src/controllers/kb_controller.cc
src/controllers/import_controller.cc
src/controllers/meta_controller.cc
src/controllers/problem_gen_controller.cc
src/health_controller.cc src/health_controller.cc
) )
@@ -53,7 +73,7 @@ target_include_directories(csp_server PRIVATE
target_link_libraries(csp_server PRIVATE target_link_libraries(csp_server PRIVATE
Drogon::Drogon Drogon::Drogon
csp_core csp_core
csp_web "$<LINK_LIBRARY:WHOLE_ARCHIVE,csp_web>"
) )
enable_testing() enable_testing()
@@ -64,6 +84,18 @@ add_executable(csp_tests
tests/auth_service_test.cc tests/auth_service_test.cc
tests/auth_http_test.cc tests/auth_http_test.cc
tests/domain_test.cc tests/domain_test.cc
tests/problem_service_test.cc
tests/kb_service_test.cc
tests/contest_service_test.cc
tests/submission_service_test.cc
tests/me_http_test.cc
tests/problem_http_test.cc
tests/problem_workspace_service_test.cc
tests/problem_workspace_http_test.cc
tests/contest_http_test.cc
tests/submission_http_test.cc
tests/import_service_test.cc
tests/import_http_test.cc
) )
target_include_directories(csp_tests PRIVATE target_include_directories(csp_tests PRIVATE
@@ -74,7 +106,7 @@ target_link_libraries(csp_tests PRIVATE
Catch2::Catch2WithMain Catch2::Catch2WithMain
Drogon::Drogon Drogon::Drogon
csp_core csp_core
csp_web "$<LINK_LIBRARY:WHOLE_ARCHIVE,csp_web>"
) )
include(CTest) include(CTest)

查看文件

@@ -0,0 +1,34 @@
#pragma once
#include <drogon/HttpController.h>
#include <cstdint>
namespace csp::controllers {
class ContestController : public drogon::HttpController<ContestController> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(ContestController::list, "/api/v1/contests", drogon::Get);
ADD_METHOD_TO(ContestController::getById, "/api/v1/contests/{1}", drogon::Get);
ADD_METHOD_TO(ContestController::registerForContest, "/api/v1/contests/{1}/register", drogon::Post);
ADD_METHOD_TO(ContestController::leaderboard, "/api/v1/contests/{1}/leaderboard", drogon::Get);
METHOD_LIST_END
void list(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void getById(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t contest_id);
void registerForContest(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t contest_id);
void leaderboard(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t contest_id);
};
} // namespace csp::controllers

查看文件

@@ -0,0 +1,33 @@
#pragma once
#include <drogon/HttpController.h>
#include <cstdint>
namespace csp::controllers {
class ImportController : public drogon::HttpController<ImportController> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(ImportController::latestJob, "/api/v1/import/jobs/latest", drogon::Get);
ADD_METHOD_TO(ImportController::jobById, "/api/v1/import/jobs/{1}", drogon::Get);
ADD_METHOD_TO(ImportController::jobItems, "/api/v1/import/jobs/{1}/items", drogon::Get);
ADD_METHOD_TO(ImportController::runJob, "/api/v1/import/jobs/run", drogon::Post);
METHOD_LIST_END
void latestJob(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void jobById(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t job_id);
void jobItems(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t job_id);
void runJob(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
};
} // namespace csp::controllers

查看文件

@@ -0,0 +1,24 @@
#pragma once
#include <drogon/HttpController.h>
#include <string>
namespace csp::controllers {
class KbController : public drogon::HttpController<KbController> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(KbController::listArticles, "/api/v1/kb/articles", drogon::Get);
ADD_METHOD_TO(KbController::getArticle, "/api/v1/kb/articles/{1}", drogon::Get);
METHOD_LIST_END
void listArticles(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void getArticle(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
std::string slug);
};
} // namespace csp::controllers

查看文件

@@ -0,0 +1,17 @@
#pragma once
#include <drogon/HttpController.h>
namespace csp::controllers {
class LeaderboardController : public drogon::HttpController<LeaderboardController> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(LeaderboardController::global, "/api/v1/leaderboard/global", drogon::Get);
METHOD_LIST_END
void global(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
};
} // namespace csp::controllers

查看文件

@@ -0,0 +1,33 @@
#pragma once
#include <drogon/HttpController.h>
#include <cstdint>
namespace csp::controllers {
class MeController : public drogon::HttpController<MeController> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(MeController::profile, "/api/v1/me", drogon::Get);
ADD_METHOD_TO(MeController::listWrongBook, "/api/v1/me/wrong-book", drogon::Get);
ADD_METHOD_TO(MeController::upsertWrongBookNote, "/api/v1/me/wrong-book/{1}", drogon::Patch);
ADD_METHOD_TO(MeController::deleteWrongBookItem, "/api/v1/me/wrong-book/{1}", drogon::Delete);
METHOD_LIST_END
void profile(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void listWrongBook(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void upsertWrongBookNote(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t problem_id);
void deleteWrongBookItem(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t problem_id);
};
} // namespace csp::controllers

查看文件

@@ -0,0 +1,21 @@
#pragma once
#include <drogon/HttpController.h>
namespace csp::controllers {
class MetaController : public drogon::HttpController<MetaController> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(MetaController::openapi, "/api/openapi.json", drogon::Get);
ADD_METHOD_TO(MetaController::mcp, "/api/v1/mcp", drogon::Post);
METHOD_LIST_END
void openapi(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void mcp(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
};
} // namespace csp::controllers

查看文件

@@ -0,0 +1,44 @@
#pragma once
#include <drogon/HttpController.h>
#include <cstdint>
namespace csp::controllers {
class ProblemController : public drogon::HttpController<ProblemController> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(ProblemController::list, "/api/v1/problems", drogon::Get);
ADD_METHOD_TO(ProblemController::getById, "/api/v1/problems/{1}", drogon::Get);
ADD_METHOD_TO(ProblemController::getDraft, "/api/v1/problems/{1}/draft", drogon::Get);
ADD_METHOD_TO(ProblemController::saveDraft, "/api/v1/problems/{1}/draft", drogon::Put);
ADD_METHOD_TO(ProblemController::listSolutions, "/api/v1/problems/{1}/solutions", drogon::Get);
ADD_METHOD_TO(ProblemController::generateSolutions, "/api/v1/problems/{1}/solutions/generate", drogon::Post);
METHOD_LIST_END
void list(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void getById(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t problem_id);
void getDraft(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t problem_id);
void saveDraft(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t problem_id);
void listSolutions(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t problem_id);
void generateSolutions(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t problem_id);
};
} // namespace csp::controllers

查看文件

@@ -0,0 +1,21 @@
#pragma once
#include <drogon/HttpController.h>
namespace csp::controllers {
class ProblemGenController : public drogon::HttpController<ProblemGenController> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(ProblemGenController::status, "/api/v1/problem-gen/status", drogon::Get);
ADD_METHOD_TO(ProblemGenController::run, "/api/v1/problem-gen/run", drogon::Post);
METHOD_LIST_END
void status(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void run(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
};
} // namespace csp::controllers

查看文件

@@ -0,0 +1,33 @@
#pragma once
#include <drogon/HttpController.h>
#include <cstdint>
namespace csp::controllers {
class SubmissionController : public drogon::HttpController<SubmissionController> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(SubmissionController::submitProblem, "/api/v1/problems/{1}/submit", drogon::Post);
ADD_METHOD_TO(SubmissionController::listSubmissions, "/api/v1/submissions", drogon::Get);
ADD_METHOD_TO(SubmissionController::getSubmission, "/api/v1/submissions/{1}", drogon::Get);
ADD_METHOD_TO(SubmissionController::runCpp, "/api/v1/run/cpp", drogon::Post);
METHOD_LIST_END
void submitProblem(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t problem_id);
void listSubmissions(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void getSubmission(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t submission_id);
void runCpp(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
};
} // namespace csp::controllers

查看文件

@@ -33,5 +33,6 @@ class SqliteDb {
// Apply SQL migrations in order. For now we ship a single init migration. // Apply SQL migrations in order. For now we ship a single init migration.
void ApplyMigrations(SqliteDb& db); void ApplyMigrations(SqliteDb& db);
void SeedDemoData(SqliteDb& db);
} // namespace csp::db } // namespace csp::db

查看文件

@@ -33,6 +33,10 @@ struct Problem {
std::string statement_md; std::string statement_md;
int32_t difficulty = 1; int32_t difficulty = 1;
std::string source; std::string source;
std::string statement_url;
std::string llm_profile_json;
std::string sample_input;
std::string sample_output;
int64_t created_at = 0; int64_t created_at = 0;
}; };
@@ -64,12 +68,15 @@ struct Submission {
int64_t id = 0; int64_t id = 0;
int64_t user_id = 0; int64_t user_id = 0;
int64_t problem_id = 0; int64_t problem_id = 0;
std::optional<int64_t> contest_id;
Language language = Language::Cpp; Language language = Language::Cpp;
std::string code; std::string code;
SubmissionStatus status = SubmissionStatus::Pending; SubmissionStatus status = SubmissionStatus::Pending;
int32_t score = 0; int32_t score = 0;
int32_t time_ms = 0; int32_t time_ms = 0;
int32_t memory_kb = 0; int32_t memory_kb = 0;
std::string compile_log;
std::string runtime_log;
int64_t created_at = 0; int64_t created_at = 0;
}; };
@@ -81,4 +88,51 @@ struct WrongBookItem {
int64_t updated_at = 0; int64_t updated_at = 0;
}; };
struct Contest {
int64_t id = 0;
std::string title;
int64_t starts_at = 0;
int64_t ends_at = 0;
std::string rule_json;
};
struct ContestProblem {
int64_t contest_id = 0;
int64_t problem_id = 0;
int32_t idx = 0;
};
struct ContestRegistration {
int64_t contest_id = 0;
int64_t user_id = 0;
int64_t registered_at = 0;
};
struct KbArticle {
int64_t id = 0;
std::string slug;
std::string title;
std::string content_md;
int64_t created_at = 0;
};
struct KbArticleLink {
int64_t article_id = 0;
int64_t problem_id = 0;
};
struct GlobalLeaderboardEntry {
int64_t user_id = 0;
std::string username;
int32_t rating = 0;
int64_t created_at = 0;
};
struct ContestLeaderboardEntry {
int64_t user_id = 0;
std::string username;
int32_t solved = 0;
int64_t penalty_sec = 0;
};
} // namespace csp::domain } // namespace csp::domain

查看文件

@@ -14,5 +14,9 @@ Json::Value ToPublicJson(const User& u);
Json::Value ToJson(const Problem& p); Json::Value ToJson(const Problem& p);
Json::Value ToJson(const Submission& s); Json::Value ToJson(const Submission& s);
Json::Value ToJson(const WrongBookItem& w); Json::Value ToJson(const WrongBookItem& w);
Json::Value ToJson(const Contest& c);
Json::Value ToJson(const KbArticle& a);
Json::Value ToJson(const GlobalLeaderboardEntry& e);
Json::Value ToJson(const ContestLeaderboardEntry& e);
} // namespace csp::domain } // namespace csp::domain

查看文件

@@ -0,0 +1,36 @@
#pragma once
#include "csp/db/sqlite_db.h"
#include "csp/domain/entities.h"
#include <cstdint>
#include <optional>
#include <vector>
namespace csp::services {
struct ContestDetail {
domain::Contest contest;
std::vector<domain::Problem> problems;
};
class ContestService {
public:
explicit ContestService(db::SqliteDb& db) : db_(db) {}
std::vector<domain::Contest> ListContests();
std::optional<domain::Contest> GetContest(int64_t contest_id);
std::vector<domain::Problem> ListContestProblems(int64_t contest_id);
void Register(int64_t contest_id, int64_t user_id);
bool IsRegistered(int64_t contest_id, int64_t user_id);
bool ContainsProblem(int64_t contest_id, int64_t problem_id);
bool IsRunning(int64_t contest_id);
std::vector<domain::ContestLeaderboardEntry> Leaderboard(int64_t contest_id);
private:
db::SqliteDb& db_;
};
} // namespace csp::services

查看文件

@@ -0,0 +1,40 @@
#pragma once
#include <cstdint>
#include <mutex>
#include <optional>
#include <string>
namespace csp::services {
struct ImportRunOptions {
bool clear_all_problems = false;
};
class ImportRunner {
public:
static ImportRunner& Instance();
void Configure(std::string db_path);
bool TriggerAsync(const std::string& trigger, const ImportRunOptions& options);
void AutoStartIfEnabled();
bool IsRunning() const;
std::string LastCommand() const;
std::optional<int> LastExitCode() const;
int64_t LastStartedAt() const;
int64_t LastFinishedAt() const;
private:
ImportRunner() = default;
std::string db_path_;
mutable std::mutex mu_;
bool running_ = false;
std::string last_command_;
std::optional<int> last_exit_code_;
int64_t last_started_at_ = 0;
int64_t last_finished_at_ = 0;
};
} // namespace csp::services

查看文件

@@ -0,0 +1,61 @@
#pragma once
#include "csp/db/sqlite_db.h"
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
namespace csp::services {
struct ImportJob {
int64_t id = 0;
std::string status;
std::string trigger;
int32_t total_count = 0;
int32_t processed_count = 0;
int32_t success_count = 0;
int32_t failed_count = 0;
std::string options_json;
std::string last_error;
int64_t started_at = 0;
std::optional<int64_t> finished_at;
int64_t updated_at = 0;
int64_t created_at = 0;
};
struct ImportJobItem {
int64_t id = 0;
int64_t job_id = 0;
std::string source_path;
std::string status;
std::string title;
int32_t difficulty = 0;
std::optional<int64_t> problem_id;
std::string error_text;
std::optional<int64_t> started_at;
std::optional<int64_t> finished_at;
int64_t updated_at = 0;
int64_t created_at = 0;
};
struct ImportJobItemQuery {
std::string status;
int page = 1;
int page_size = 50;
};
class ImportService {
public:
explicit ImportService(db::SqliteDb& db) : db_(db) {}
std::optional<ImportJob> GetLatestJob();
std::optional<ImportJob> GetById(int64_t job_id);
std::vector<ImportJobItem> ListItems(int64_t job_id, const ImportJobItemQuery& query);
private:
db::SqliteDb& db_;
};
} // namespace csp::services

查看文件

@@ -0,0 +1,29 @@
#pragma once
#include "csp/db/sqlite_db.h"
#include "csp/domain/entities.h"
#include <optional>
#include <string>
#include <utility>
#include <vector>
namespace csp::services {
struct KbArticleDetail {
domain::KbArticle article;
std::vector<std::pair<int64_t, std::string>> related_problems;
};
class KbService {
public:
explicit KbService(db::SqliteDb& db) : db_(db) {}
std::vector<domain::KbArticle> ListArticles();
std::optional<KbArticleDetail> GetBySlug(const std::string& slug);
private:
db::SqliteDb& db_;
};
} // namespace csp::services

查看文件

@@ -0,0 +1,36 @@
#pragma once
#include <cstdint>
#include <mutex>
#include <optional>
#include <string>
namespace csp::services {
class ProblemGenRunner {
public:
static ProblemGenRunner& Instance();
void Configure(std::string db_path);
bool TriggerAsync(const std::string& trigger, int count = 1);
void AutoStartIfEnabled();
bool IsRunning() const;
std::string LastCommand() const;
std::optional<int> LastExitCode() const;
int64_t LastStartedAt() const;
int64_t LastFinishedAt() const;
private:
ProblemGenRunner() = default;
mutable std::mutex mu_;
std::string db_path_;
bool running_ = false;
std::string last_command_;
std::optional<int> last_exit_code_;
int64_t last_started_at_ = 0;
int64_t last_finished_at_ = 0;
};
} // namespace csp::services

查看文件

@@ -0,0 +1,41 @@
#pragma once
#include "csp/db/sqlite_db.h"
#include "csp/domain/entities.h"
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
namespace csp::services {
struct ProblemQuery {
std::string q;
std::string tag;
std::vector<std::string> tags;
std::string source_prefix;
int difficulty = 0;
int page = 1;
int page_size = 20;
std::string order_by = "id";
std::string order = "asc";
};
struct ProblemListResult {
std::vector<domain::Problem> items;
int total_count = 0;
};
class ProblemService {
public:
explicit ProblemService(db::SqliteDb& db) : db_(db) {}
ProblemListResult List(const ProblemQuery& query);
std::optional<domain::Problem> GetById(int64_t id);
private:
db::SqliteDb& db_;
};
} // namespace csp::services

查看文件

@@ -0,0 +1,28 @@
#pragma once
#include <cstdint>
#include <mutex>
#include <optional>
#include <set>
#include <string>
namespace csp::services {
class ProblemSolutionRunner {
public:
static ProblemSolutionRunner& Instance();
void Configure(std::string db_path);
bool TriggerAsync(int64_t problem_id, int64_t job_id, int max_solutions);
bool IsRunning(int64_t problem_id) const;
private:
ProblemSolutionRunner() = default;
std::string db_path_;
mutable std::mutex mu_;
std::set<int64_t> running_problem_ids_;
};
} // namespace csp::services

查看文件

@@ -0,0 +1,69 @@
#pragma once
#include "csp/db/sqlite_db.h"
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
namespace csp::services {
struct ProblemDraft {
std::string language;
std::string code;
std::string stdin_text;
int64_t updated_at = 0;
};
struct ProblemSolution {
int64_t id = 0;
int64_t problem_id = 0;
int variant = 1;
std::string title;
std::string idea_md;
std::string explanation_md;
std::string code_cpp;
std::string complexity;
std::string tags_json;
std::string source;
int64_t created_at = 0;
int64_t updated_at = 0;
};
struct ProblemSolutionJob {
int64_t id = 0;
int64_t problem_id = 0;
std::string status;
int progress = 0;
std::string message;
int64_t created_by = 0;
int max_solutions = 3;
int64_t created_at = 0;
std::optional<int64_t> started_at;
std::optional<int64_t> finished_at;
int64_t updated_at = 0;
};
class ProblemWorkspaceService {
public:
explicit ProblemWorkspaceService(db::SqliteDb& db) : db_(db) {}
bool ProblemExists(int64_t problem_id);
void SaveDraft(int64_t user_id,
int64_t problem_id,
const std::string& language,
const std::string& code,
const std::string& stdin_text);
std::optional<ProblemDraft> GetDraft(int64_t user_id, int64_t problem_id);
int64_t CreateSolutionJob(int64_t problem_id, int64_t created_by, int max_solutions);
std::optional<ProblemSolutionJob> GetLatestSolutionJob(int64_t problem_id);
std::vector<ProblemSolution> ListSolutions(int64_t problem_id);
private:
db::SqliteDb& db_;
};
} // namespace csp::services

查看文件

@@ -0,0 +1,48 @@
#pragma once
#include "csp/db/sqlite_db.h"
#include "csp/domain/entities.h"
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
namespace csp::services {
struct SubmissionCreateRequest {
int64_t user_id = 0;
int64_t problem_id = 0;
std::optional<int64_t> contest_id;
std::string language = "cpp";
std::string code;
};
struct RunOnlyResult {
domain::SubmissionStatus status = domain::SubmissionStatus::Unknown;
int time_ms = 0;
std::string stdout_text;
std::string stderr_text;
std::string compile_log;
};
class SubmissionService {
public:
explicit SubmissionService(db::SqliteDb& db) : db_(db) {}
domain::Submission CreateAndJudge(const SubmissionCreateRequest& req);
std::vector<domain::Submission> List(std::optional<int64_t> user_id,
std::optional<int64_t> problem_id,
std::optional<int64_t> contest_id,
int page,
int page_size);
std::optional<domain::Submission> GetById(int64_t id);
RunOnlyResult RunOnlyCpp(const std::string& code, const std::string& input);
private:
db::SqliteDb& db_;
};
} // namespace csp::services

查看文件

@@ -0,0 +1,23 @@
#pragma once
#include "csp/db/sqlite_db.h"
#include "csp/domain/entities.h"
#include <cstdint>
#include <optional>
#include <vector>
namespace csp::services {
class UserService {
public:
explicit UserService(db::SqliteDb& db) : db_(db) {}
std::optional<domain::User> GetById(int64_t id);
std::vector<domain::GlobalLeaderboardEntry> GlobalLeaderboard(int limit = 100);
private:
db::SqliteDb& db_;
};
} // namespace csp::services

查看文件

@@ -0,0 +1,33 @@
#pragma once
#include "csp/db/sqlite_db.h"
#include "csp/domain/entities.h"
#include <cstdint>
#include <string>
#include <vector>
namespace csp::services {
struct WrongBookEntry {
domain::WrongBookItem item;
std::string problem_title;
};
class WrongBookService {
public:
explicit WrongBookService(db::SqliteDb& db) : db_(db) {}
std::vector<WrongBookEntry> ListByUser(int64_t user_id);
void UpsertNote(int64_t user_id, int64_t problem_id, const std::string& note);
void UpsertBySubmission(int64_t user_id,
int64_t problem_id,
int64_t submission_id,
const std::string& note);
void Remove(int64_t user_id, int64_t problem_id);
private:
db::SqliteDb& db_;
};
} // namespace csp::services

查看文件

@@ -24,7 +24,11 @@ CREATE TABLE IF NOT EXISTS problems (
title TEXT NOT NULL, title TEXT NOT NULL,
statement_md TEXT NOT NULL, statement_md TEXT NOT NULL,
difficulty INTEGER NOT NULL DEFAULT 1, difficulty INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT "", source TEXT NOT NULL DEFAULT '',
statement_url TEXT NOT NULL DEFAULT '',
llm_profile_json TEXT NOT NULL DEFAULT '{}',
sample_input TEXT NOT NULL DEFAULT '',
sample_output TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
); );
@@ -39,22 +43,26 @@ CREATE TABLE IF NOT EXISTS submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
problem_id INTEGER NOT NULL, problem_id INTEGER NOT NULL,
contest_id INTEGER,
language TEXT NOT NULL, language TEXT NOT NULL,
code TEXT NOT NULL, code TEXT NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
score INTEGER NOT NULL DEFAULT 0, score INTEGER NOT NULL DEFAULT 0,
time_ms INTEGER NOT NULL DEFAULT 0, time_ms INTEGER NOT NULL DEFAULT 0,
memory_kb INTEGER NOT NULL DEFAULT 0, memory_kb INTEGER NOT NULL DEFAULT 0,
compile_log TEXT NOT NULL DEFAULT '',
runtime_log TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE,
FOREIGN KEY(contest_id) REFERENCES contests(id) ON DELETE SET NULL
); );
CREATE TABLE IF NOT EXISTS wrong_book ( CREATE TABLE IF NOT EXISTS wrong_book (
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
problem_id INTEGER NOT NULL, problem_id INTEGER NOT NULL,
last_submission_id INTEGER, last_submission_id INTEGER,
note TEXT NOT NULL DEFAULT "", note TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
PRIMARY KEY(user_id, problem_id), PRIMARY KEY(user_id, problem_id),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
@@ -62,5 +70,133 @@ CREATE TABLE IF NOT EXISTS wrong_book (
FOREIGN KEY(last_submission_id) REFERENCES submissions(id) ON DELETE SET NULL FOREIGN KEY(last_submission_id) REFERENCES submissions(id) ON DELETE SET NULL
); );
CREATE TABLE IF NOT EXISTS contests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
starts_at INTEGER NOT NULL,
ends_at INTEGER NOT NULL,
rule_json TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS contest_problems (
contest_id INTEGER NOT NULL,
problem_id INTEGER NOT NULL,
idx INTEGER NOT NULL,
PRIMARY KEY(contest_id, problem_id),
FOREIGN KEY(contest_id) REFERENCES contests(id) ON DELETE CASCADE,
FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS contest_registrations (
contest_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
registered_at INTEGER NOT NULL,
PRIMARY KEY(contest_id, user_id),
FOREIGN KEY(contest_id) REFERENCES contests(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS kb_articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
content_md TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS kb_article_links (
article_id INTEGER NOT NULL,
problem_id INTEGER NOT NULL,
PRIMARY KEY(article_id, problem_id),
FOREIGN KEY(article_id) REFERENCES kb_articles(id) ON DELETE CASCADE,
FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS import_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status TEXT NOT NULL,
trigger TEXT NOT NULL DEFAULT 'manual',
total_count INTEGER NOT NULL DEFAULT 0,
processed_count INTEGER NOT NULL DEFAULT 0,
success_count INTEGER NOT NULL DEFAULT 0,
failed_count INTEGER NOT NULL DEFAULT 0,
options_json TEXT NOT NULL DEFAULT '{}',
last_error TEXT NOT NULL DEFAULT '',
started_at INTEGER NOT NULL,
finished_at INTEGER,
updated_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS import_job_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
source_path TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
title TEXT NOT NULL DEFAULT '',
difficulty INTEGER NOT NULL DEFAULT 0,
problem_id INTEGER,
error_text TEXT NOT NULL DEFAULT '',
started_at INTEGER,
finished_at INTEGER,
updated_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(job_id) REFERENCES import_jobs(id) ON DELETE CASCADE,
UNIQUE(job_id, source_path)
);
CREATE TABLE IF NOT EXISTS problem_drafts (
user_id INTEGER NOT NULL,
problem_id INTEGER NOT NULL,
language TEXT NOT NULL DEFAULT 'cpp',
code TEXT NOT NULL DEFAULT '',
stdin TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL,
created_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
);
CREATE TABLE IF NOT EXISTS problem_solution_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
problem_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
progress INTEGER NOT NULL DEFAULT 0,
message TEXT NOT NULL DEFAULT '',
created_by INTEGER NOT NULL DEFAULT 0,
max_solutions INTEGER NOT NULL DEFAULT 3,
created_at INTEGER NOT NULL,
started_at INTEGER,
finished_at INTEGER,
updated_at INTEGER NOT NULL,
FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS problem_solutions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
problem_id INTEGER NOT NULL,
variant INTEGER NOT NULL DEFAULT 1,
title TEXT NOT NULL DEFAULT '',
idea_md TEXT NOT NULL DEFAULT '',
explanation_md TEXT NOT NULL DEFAULT '',
code_cpp TEXT NOT NULL DEFAULT '',
complexity TEXT NOT NULL DEFAULT '',
tags_json TEXT NOT NULL DEFAULT '[]',
source TEXT NOT NULL DEFAULT 'llm',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_submissions_user_created_at ON submissions(user_id, created_at DESC); 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); CREATE INDEX IF NOT EXISTS idx_submissions_problem_created_at ON submissions(problem_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_submissions_contest_user_created_at ON submissions(contest_id, user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_problem_tags_tag ON problem_tags(tag);
CREATE INDEX IF NOT EXISTS idx_kb_article_links_problem_id ON kb_article_links(problem_id);
CREATE INDEX IF NOT EXISTS idx_import_jobs_created_at ON import_jobs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_import_jobs_status ON import_jobs(status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_import_job_items_job_status ON import_job_items(job_id, status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_problem_drafts_updated ON problem_drafts(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_problem_solution_jobs_problem ON problem_solution_jobs(problem_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_problem_solutions_problem ON problem_solutions(problem_id, variant, id);

查看文件

@@ -16,6 +16,7 @@ void AppState::Init(const std::string& sqlite_path) {
db_ = std::make_unique<db::SqliteDb>(db::SqliteDb::OpenFile(sqlite_path)); db_ = std::make_unique<db::SqliteDb>(db::SqliteDb::OpenFile(sqlite_path));
} }
csp::db::ApplyMigrations(*db_); csp::db::ApplyMigrations(*db_);
csp::db::SeedDemoData(*db_);
} }
csp::db::SqliteDb& AppState::db() { csp::db::SqliteDb& AppState::db() {

查看文件

@@ -0,0 +1,133 @@
#include "csp/controllers/contest_controller.h"
#include "csp/app_state.h"
#include "csp/domain/json.h"
#include "csp/services/contest_service.h"
#include "http_auth.h"
#include <exception>
#include <string>
namespace csp::controllers {
namespace {
drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code,
const std::string& msg) {
Json::Value j;
j["ok"] = false;
j["error"] = msg;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(code);
return resp;
}
drogon::HttpResponsePtr JsonOk(const Json::Value& data) {
Json::Value j;
j["ok"] = true;
j["data"] = data;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(drogon::k200OK);
return resp;
}
} // namespace
void ContestController::list(
const drogon::HttpRequestPtr& /*req*/,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
services::ContestService svc(csp::AppState::Instance().db());
const auto contests = svc.ListContests();
Json::Value arr(Json::arrayValue);
for (const auto& c : contests) arr.append(domain::ToJson(c));
cb(JsonOk(arr));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void ContestController::getById(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t contest_id) {
try {
services::ContestService svc(csp::AppState::Instance().db());
const auto contest = svc.GetContest(contest_id);
if (!contest.has_value()) {
cb(JsonError(drogon::k404NotFound, "contest not found"));
return;
}
Json::Value data;
data["contest"] = domain::ToJson(*contest);
Json::Value problems(Json::arrayValue);
for (const auto& p : svc.ListContestProblems(contest_id)) {
problems.append(domain::ToJson(p));
}
data["problems"] = problems;
std::string auth_error;
const auto user_id = GetAuthedUserId(req, auth_error);
if (user_id.has_value()) {
data["registered"] = svc.IsRegistered(contest_id, *user_id);
}
cb(JsonOk(data));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void ContestController::registerForContest(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t contest_id) {
try {
std::string auth_error;
const auto user_id = GetAuthedUserId(req, auth_error);
if (!user_id.has_value()) {
cb(JsonError(drogon::k401Unauthorized, auth_error));
return;
}
services::ContestService svc(csp::AppState::Instance().db());
if (!svc.GetContest(contest_id).has_value()) {
cb(JsonError(drogon::k404NotFound, "contest not found"));
return;
}
svc.Register(contest_id, *user_id);
Json::Value data;
data["contest_id"] = Json::Int64(contest_id);
data["user_id"] = Json::Int64(*user_id);
data["registered"] = true;
cb(JsonOk(data));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void ContestController::leaderboard(
const drogon::HttpRequestPtr& /*req*/,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t contest_id) {
try {
services::ContestService svc(csp::AppState::Instance().db());
if (!svc.GetContest(contest_id).has_value()) {
cb(JsonError(drogon::k404NotFound, "contest not found"));
return;
}
const auto rows = svc.Leaderboard(contest_id);
Json::Value arr(Json::arrayValue);
for (const auto& r : rows) arr.append(domain::ToJson(r));
cb(JsonOk(arr));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
} // namespace csp::controllers

查看文件

@@ -0,0 +1,37 @@
#pragma once
#include "csp/app_state.h"
#include "csp/services/auth_service.h"
#include <drogon/HttpRequest.h>
#include <optional>
#include <string>
namespace csp::controllers {
inline std::optional<int64_t> GetAuthedUserId(const drogon::HttpRequestPtr& req,
std::string& error) {
const std::string authz = req->getHeader("Authorization");
const std::string prefix = "Bearer ";
if (authz.rfind(prefix, 0) != 0) {
error = "missing Authorization: Bearer <token>";
return std::nullopt;
}
const std::string token = authz.substr(prefix.size());
if (token.empty()) {
error = "empty bearer token";
return std::nullopt;
}
services::AuthService auth(csp::AppState::Instance().db());
const auto user_id = auth.VerifyToken(token);
if (!user_id.has_value()) {
error = "invalid or expired token";
return std::nullopt;
}
return static_cast<int64_t>(*user_id);
}
} // namespace csp::controllers

查看文件

@@ -0,0 +1,190 @@
#include "csp/controllers/import_controller.h"
#include "csp/app_state.h"
#include "csp/services/import_runner.h"
#include "csp/services/import_service.h"
#include <algorithm>
#include <exception>
#include <string>
namespace csp::controllers {
namespace {
drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code,
const std::string& msg) {
Json::Value j;
j["ok"] = false;
j["error"] = msg;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(code);
return resp;
}
drogon::HttpResponsePtr JsonOk(const Json::Value& data) {
Json::Value j;
j["ok"] = true;
j["data"] = data;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(drogon::k200OK);
return resp;
}
int ParsePositiveInt(const std::string& s,
int default_value,
int min_value,
int max_value) {
if (s.empty()) return default_value;
const int v = std::stoi(s);
return std::max(min_value, std::min(max_value, v));
}
Json::Value ToJson(const services::ImportJob& job) {
Json::Value j;
j["id"] = Json::Int64(job.id);
j["status"] = job.status;
j["trigger"] = job.trigger;
j["total_count"] = job.total_count;
j["processed_count"] = job.processed_count;
j["success_count"] = job.success_count;
j["failed_count"] = job.failed_count;
j["options_json"] = job.options_json;
j["last_error"] = job.last_error;
j["started_at"] = Json::Int64(job.started_at);
if (job.finished_at.has_value()) {
j["finished_at"] = Json::Int64(*job.finished_at);
} else {
j["finished_at"] = Json::nullValue;
}
j["updated_at"] = Json::Int64(job.updated_at);
j["created_at"] = Json::Int64(job.created_at);
return j;
}
Json::Value ToJson(const services::ImportJobItem& item) {
Json::Value j;
j["id"] = Json::Int64(item.id);
j["job_id"] = Json::Int64(item.job_id);
j["source_path"] = item.source_path;
j["status"] = item.status;
j["title"] = item.title;
j["difficulty"] = item.difficulty;
if (item.problem_id.has_value()) {
j["problem_id"] = Json::Int64(*item.problem_id);
} else {
j["problem_id"] = Json::nullValue;
}
j["error_text"] = item.error_text;
if (item.started_at.has_value()) {
j["started_at"] = Json::Int64(*item.started_at);
} else {
j["started_at"] = Json::nullValue;
}
if (item.finished_at.has_value()) {
j["finished_at"] = Json::Int64(*item.finished_at);
} else {
j["finished_at"] = Json::nullValue;
}
j["updated_at"] = Json::Int64(item.updated_at);
j["created_at"] = Json::Int64(item.created_at);
return j;
}
} // namespace
void ImportController::latestJob(
const drogon::HttpRequestPtr& /*req*/,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
services::ImportService svc(csp::AppState::Instance().db());
Json::Value payload;
const auto job = svc.GetLatestJob();
payload["runner_running"] = services::ImportRunner::Instance().IsRunning();
if (job.has_value()) {
payload["job"] = ToJson(*job);
} else {
payload["job"] = Json::nullValue;
}
cb(JsonOk(payload));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void ImportController::jobById(
const drogon::HttpRequestPtr& /*req*/,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t job_id) {
try {
services::ImportService svc(csp::AppState::Instance().db());
const auto job = svc.GetById(job_id);
if (!job.has_value()) {
cb(JsonError(drogon::k404NotFound, "import job not found"));
return;
}
Json::Value payload = ToJson(*job);
payload["runner_running"] = services::ImportRunner::Instance().IsRunning();
cb(JsonOk(payload));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void ImportController::jobItems(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t job_id) {
try {
services::ImportJobItemQuery query;
query.status = req->getParameter("status");
query.page = ParsePositiveInt(req->getParameter("page"), 1, 1, 100000);
query.page_size = ParsePositiveInt(req->getParameter("page_size"), 100, 1, 500);
services::ImportService svc(csp::AppState::Instance().db());
const auto rows = svc.ListItems(job_id, query);
Json::Value arr(Json::arrayValue);
for (const auto& row : rows) arr.append(ToJson(row));
Json::Value payload;
payload["items"] = arr;
payload["page"] = query.page;
payload["page_size"] = query.page_size;
cb(JsonOk(payload));
} catch (const std::invalid_argument&) {
cb(JsonError(drogon::k400BadRequest, "invalid query parameter"));
} catch (const std::out_of_range&) {
cb(JsonError(drogon::k400BadRequest, "query parameter out of range"));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void ImportController::runJob(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
services::ImportRunOptions opts;
const auto json = req->getJsonObject();
if (json) {
opts.clear_all_problems =
(*json).isMember("clear_all_problems") &&
(*json)["clear_all_problems"].asBool();
}
const bool started =
services::ImportRunner::Instance().TriggerAsync("manual", opts);
if (!started) {
cb(JsonError(drogon::k409Conflict, "import job already running"));
return;
}
Json::Value payload;
payload["started"] = true;
payload["running"] = true;
cb(JsonOk(payload));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
} // namespace csp::controllers

查看文件

@@ -0,0 +1,81 @@
#include "csp/controllers/kb_controller.h"
#include "csp/app_state.h"
#include "csp/domain/json.h"
#include "csp/services/kb_service.h"
#include <exception>
#include <string>
namespace csp::controllers {
namespace {
drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code,
const std::string& msg) {
Json::Value j;
j["ok"] = false;
j["error"] = msg;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(code);
return resp;
}
drogon::HttpResponsePtr JsonOk(const Json::Value& data) {
Json::Value j;
j["ok"] = true;
j["data"] = data;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(drogon::k200OK);
return resp;
}
} // namespace
void KbController::listArticles(
const drogon::HttpRequestPtr& /*req*/,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
services::KbService svc(csp::AppState::Instance().db());
const auto rows = svc.ListArticles();
Json::Value arr(Json::arrayValue);
for (const auto& a : rows) arr.append(domain::ToJson(a));
cb(JsonOk(arr));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void KbController::getArticle(
const drogon::HttpRequestPtr& /*req*/,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
std::string slug) {
try {
services::KbService svc(csp::AppState::Instance().db());
const auto detail = svc.GetBySlug(slug);
if (!detail.has_value()) {
cb(JsonError(drogon::k404NotFound, "article not found"));
return;
}
Json::Value data;
data["article"] = domain::ToJson(detail->article);
Json::Value rel(Json::arrayValue);
for (const auto& p : detail->related_problems) {
Json::Value item;
item["problem_id"] = Json::Int64(p.first);
item["title"] = p.second;
rel.append(item);
}
data["related_problems"] = rel;
cb(JsonOk(data));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
} // namespace csp::controllers

查看文件

@@ -0,0 +1,57 @@
#include "csp/controllers/leaderboard_controller.h"
#include "csp/app_state.h"
#include "csp/domain/json.h"
#include "csp/services/user_service.h"
#include <algorithm>
#include <exception>
#include <string>
namespace csp::controllers {
namespace {
drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code,
const std::string& msg) {
Json::Value j;
j["ok"] = false;
j["error"] = msg;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(code);
return resp;
}
drogon::HttpResponsePtr JsonOkArray(const Json::Value& arr) {
Json::Value j;
j["ok"] = true;
j["data"] = arr;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(drogon::k200OK);
return resp;
}
} // namespace
void LeaderboardController::global(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
int limit = 100;
const auto limit_str = req->getParameter("limit");
if (!limit_str.empty()) {
limit = std::max(1, std::min(500, std::stoi(limit_str)));
}
services::UserService users(csp::AppState::Instance().db());
const auto rows = users.GlobalLeaderboard(limit);
Json::Value arr(Json::arrayValue);
for (const auto& r : rows) arr.append(domain::ToJson(r));
cb(JsonOkArray(arr));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
} // namespace csp::controllers

查看文件

@@ -0,0 +1,146 @@
#include "csp/controllers/me_controller.h"
#include "csp/app_state.h"
#include "csp/domain/json.h"
#include "csp/services/user_service.h"
#include "csp/services/wrong_book_service.h"
#include "http_auth.h"
#include <exception>
#include <optional>
#include <string>
namespace csp::controllers {
namespace {
drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code,
const std::string& msg) {
Json::Value j;
j["ok"] = false;
j["error"] = msg;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(code);
return resp;
}
drogon::HttpResponsePtr JsonOk(const Json::Value& data) {
Json::Value j;
j["ok"] = true;
j["data"] = data;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(drogon::k200OK);
return resp;
}
std::optional<int64_t> RequireAuth(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>& cb) {
std::string auth_error;
const auto user_id = GetAuthedUserId(req, auth_error);
if (!user_id.has_value()) {
cb(JsonError(drogon::k401Unauthorized, auth_error));
return std::nullopt;
}
return user_id;
}
} // namespace
void MeController::profile(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value()) return;
services::UserService users(csp::AppState::Instance().db());
const auto user = users.GetById(*user_id);
if (!user.has_value()) {
cb(JsonError(drogon::k404NotFound, "user not found"));
return;
}
cb(JsonOk(domain::ToPublicJson(*user)));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void MeController::listWrongBook(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value()) return;
services::WrongBookService wrong_book(csp::AppState::Instance().db());
const auto rows = wrong_book.ListByUser(*user_id);
Json::Value arr(Json::arrayValue);
for (const auto& row : rows) {
Json::Value item = domain::ToJson(row.item);
item["problem_title"] = row.problem_title;
arr.append(item);
}
cb(JsonOk(arr));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void MeController::upsertWrongBookNote(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t problem_id) {
try {
const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value()) return;
const auto json = req->getJsonObject();
if (!json) {
cb(JsonError(drogon::k400BadRequest, "body must be json"));
return;
}
const std::string note = (*json).get("note", "").asString();
if (note.size() > 4000) {
cb(JsonError(drogon::k400BadRequest, "note too long"));
return;
}
services::WrongBookService wrong_book(csp::AppState::Instance().db());
wrong_book.UpsertNote(*user_id, problem_id, note);
Json::Value data;
data["user_id"] = Json::Int64(*user_id);
data["problem_id"] = Json::Int64(problem_id);
data["note"] = note;
cb(JsonOk(data));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void MeController::deleteWrongBookItem(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t problem_id) {
try {
const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value()) return;
services::WrongBookService wrong_book(csp::AppState::Instance().db());
wrong_book.Remove(*user_id, problem_id);
Json::Value data;
data["user_id"] = Json::Int64(*user_id);
data["problem_id"] = Json::Int64(problem_id);
data["deleted"] = true;
cb(JsonOk(data));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
} // namespace csp::controllers

查看文件

@@ -0,0 +1,273 @@
#include "csp/controllers/meta_controller.h"
#include "csp/app_state.h"
#include "csp/domain/enum_strings.h"
#include "csp/domain/json.h"
#include "csp/services/problem_gen_runner.h"
#include "csp/services/problem_service.h"
#include "csp/services/submission_service.h"
#include <algorithm>
#include <exception>
#include <string>
namespace csp::controllers {
namespace {
drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code,
const std::string& msg) {
Json::Value j;
j["ok"] = false;
j["error"] = msg;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(code);
return resp;
}
Json::Value BuildOpenApiSpec() {
Json::Value root;
root["openapi"] = "3.1.0";
root["info"]["title"] = "CSP Platform API";
root["info"]["version"] = "1.0.0";
root["info"]["description"] =
"CSP 训练平台接口文档,含认证、题库、评测、导入、MCP。";
root["servers"][0]["url"] = "/";
auto& paths = root["paths"];
paths["/api/health"]["get"]["summary"] = "健康检查";
paths["/api/health"]["get"]["responses"]["200"]["description"] = "OK";
paths["/api/v1/auth/login"]["post"]["summary"] = "登录";
paths["/api/v1/auth/register"]["post"]["summary"] = "注册";
paths["/api/v1/problems"]["get"]["summary"] = "题库列表";
paths["/api/v1/problems/{id}"]["get"]["summary"] = "题目详情";
paths["/api/v1/problems/{id}/submit"]["post"]["summary"] = "提交评测";
paths["/api/v1/problems/{id}/draft"]["get"]["summary"] = "读取代码草稿";
paths["/api/v1/problems/{id}/draft"]["put"]["summary"] = "保存代码草稿";
paths["/api/v1/problems/{id}/solutions"]["get"]["summary"] = "题解列表/任务状态";
paths["/api/v1/problems/{id}/solutions/generate"]["post"]["summary"] =
"异步生成题解";
paths["/api/v1/run/cpp"]["post"]["summary"] = "C++ 试运行";
paths["/api/v1/submissions"]["get"]["summary"] = "提交记录";
paths["/api/v1/submissions/{id}"]["get"]["summary"] = "提交详情";
paths["/api/v1/import/jobs/latest"]["get"]["summary"] = "最新导入任务";
paths["/api/v1/import/jobs/run"]["post"]["summary"] = "触发导入任务";
paths["/api/v1/problem-gen/status"]["get"]["summary"] = "CSP-J 生成任务状态";
paths["/api/v1/problem-gen/run"]["post"]["summary"] = "触发生成新题RAG+去重)";
paths["/api/v1/mcp"]["post"]["summary"] = "MCP JSON-RPC 入口";
auto& components = root["components"];
components["securitySchemes"]["bearerAuth"]["type"] = "http";
components["securitySchemes"]["bearerAuth"]["scheme"] = "bearer";
components["securitySchemes"]["bearerAuth"]["bearerFormat"] = "JWT";
return root;
}
Json::Value BuildMcpError(const Json::Value& id,
int code,
const std::string& message) {
Json::Value out;
out["jsonrpc"] = "2.0";
out["id"] = id;
out["error"]["code"] = code;
out["error"]["message"] = message;
return out;
}
Json::Value BuildMcpOk(const Json::Value& id, const Json::Value& result) {
Json::Value out;
out["jsonrpc"] = "2.0";
out["id"] = id;
out["result"] = result;
return out;
}
Json::Value McpToolsList() {
Json::Value tools(Json::arrayValue);
Json::Value t1;
t1["name"] = "health";
t1["description"] = "Get backend health";
tools.append(t1);
Json::Value t2;
t2["name"] = "list_problems";
t2["description"] = "List problems with filters";
tools.append(t2);
Json::Value t3;
t3["name"] = "get_problem";
t3["description"] = "Get problem by id";
tools.append(t3);
Json::Value t4;
t4["name"] = "run_cpp";
t4["description"] = "Run C++ code with input";
tools.append(t4);
Json::Value t5;
t5["name"] = "generate_cspj_problem";
t5["description"] = "Trigger RAG-based CSP-J problem generation";
tools.append(t5);
return tools;
}
} // namespace
void MetaController::openapi(
const drogon::HttpRequestPtr& /*req*/,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
auto resp = drogon::HttpResponse::newHttpJsonResponse(BuildOpenApiSpec());
resp->setStatusCode(drogon::k200OK);
cb(resp);
}
void MetaController::mcp(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
const auto json = req->getJsonObject();
if (!json) {
auto resp = drogon::HttpResponse::newHttpJsonResponse(
BuildMcpError(Json::nullValue, -32700, "body must be json"));
resp->setStatusCode(drogon::k400BadRequest);
cb(resp);
return;
}
const Json::Value id = (*json).isMember("id") ? (*json)["id"] : Json::nullValue;
const std::string method = (*json).get("method", "").asString();
const Json::Value params = (*json).isMember("params") ? (*json)["params"] : Json::Value();
if (method == "initialize") {
Json::Value result;
result["server_name"] = "csp-platform-mcp";
result["server_version"] = "1.0.0";
result["capabilities"]["tools"] = true;
auto resp = drogon::HttpResponse::newHttpJsonResponse(BuildMcpOk(id, result));
cb(resp);
return;
}
if (method == "tools/list") {
Json::Value result;
result["tools"] = McpToolsList();
auto resp = drogon::HttpResponse::newHttpJsonResponse(BuildMcpOk(id, result));
cb(resp);
return;
}
if (method != "tools/call") {
auto resp = drogon::HttpResponse::newHttpJsonResponse(
BuildMcpError(id, -32601, "method not found"));
cb(resp);
return;
}
const std::string tool_name = params.get("name", "").asString();
const Json::Value args = params.isMember("arguments") ? params["arguments"] : Json::Value();
if (tool_name == "health") {
Json::Value result;
result["ok"] = true;
result["service"] = "csp-backend";
auto resp = drogon::HttpResponse::newHttpJsonResponse(BuildMcpOk(id, result));
cb(resp);
return;
}
if (tool_name == "list_problems") {
services::ProblemQuery q;
q.q = args.get("q", "").asString();
q.page = std::max(1, args.get("page", 1).asInt());
q.page_size = std::max(1, std::min(100, args.get("page_size", 20).asInt()));
q.source_prefix = args.get("source_prefix", "").asString();
q.difficulty = std::max(0, std::min(10, args.get("difficulty", 0).asInt()));
services::ProblemService svc(csp::AppState::Instance().db());
const auto rows = svc.List(q);
Json::Value items(Json::arrayValue);
for (const auto& row : rows.items) {
items.append(domain::ToJson(row));
}
Json::Value result;
result["items"] = items;
result["total_count"] = rows.total_count;
auto resp = drogon::HttpResponse::newHttpJsonResponse(BuildMcpOk(id, result));
cb(resp);
return;
}
if (tool_name == "get_problem") {
const int64_t problem_id = args.get("problem_id", 0).asInt64();
services::ProblemService svc(csp::AppState::Instance().db());
const auto p = svc.GetById(problem_id);
if (!p.has_value()) {
auto resp = drogon::HttpResponse::newHttpJsonResponse(
BuildMcpError(id, -32004, "problem not found"));
cb(resp);
return;
}
Json::Value result = domain::ToJson(*p);
auto resp = drogon::HttpResponse::newHttpJsonResponse(BuildMcpOk(id, result));
cb(resp);
return;
}
if (tool_name == "run_cpp") {
const std::string code = args.get("code", "").asString();
const std::string input = args.get("input", "").asString();
services::SubmissionService svc(csp::AppState::Instance().db());
const auto r = svc.RunOnlyCpp(code, input);
Json::Value result;
result["status"] = domain::ToString(r.status);
result["time_ms"] = r.time_ms;
result["stdout"] = r.stdout_text;
result["stderr"] = r.stderr_text;
result["compile_log"] = r.compile_log;
auto resp = drogon::HttpResponse::newHttpJsonResponse(BuildMcpOk(id, result));
cb(resp);
return;
}
if (tool_name == "generate_cspj_problem") {
const int count = std::max(1, std::min(5, args.get("count", 1).asInt()));
const bool started =
services::ProblemGenRunner::Instance().TriggerAsync("mcp", count);
Json::Value result;
result["started"] = started;
result["count"] = count;
result["running"] = services::ProblemGenRunner::Instance().IsRunning();
auto resp = drogon::HttpResponse::newHttpJsonResponse(BuildMcpOk(id, result));
cb(resp);
return;
}
auto resp = drogon::HttpResponse::newHttpJsonResponse(
BuildMcpError(id, -32602, "unknown tool"));
cb(resp);
} catch (const std::runtime_error& e) {
auto resp = drogon::HttpResponse::newHttpJsonResponse(
BuildMcpError(Json::nullValue, -32000, e.what()));
resp->setStatusCode(drogon::k400BadRequest);
cb(resp);
} catch (const std::exception& e) {
auto resp = drogon::HttpResponse::newHttpJsonResponse(
BuildMcpError(Json::nullValue, -32000, e.what()));
resp->setStatusCode(drogon::k500InternalServerError);
cb(resp);
}
}
} // namespace csp::controllers

查看文件

@@ -0,0 +1,299 @@
#include "csp/controllers/problem_controller.h"
#include "csp/app_state.h"
#include "csp/domain/json.h"
#include "csp/services/problem_service.h"
#include "csp/services/problem_solution_runner.h"
#include "csp/services/problem_workspace_service.h"
#include "http_auth.h"
#include <algorithm>
#include <cctype>
#include <exception>
#include <sstream>
#include <string>
#include <vector>
namespace csp::controllers {
namespace {
drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code,
const std::string& msg) {
Json::Value j;
j["ok"] = false;
j["error"] = msg;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(code);
return resp;
}
drogon::HttpResponsePtr JsonOk(const Json::Value& data) {
Json::Value j;
j["ok"] = true;
j["data"] = data;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(drogon::k200OK);
return resp;
}
int ParsePositiveInt(const std::string& s,
int default_value,
int min_value,
int max_value) {
if (s.empty()) return default_value;
const int v = std::stoi(s);
return std::max(min_value, std::min(max_value, v));
}
std::vector<std::string> ParseCsv(const std::string& raw) {
auto trim = [](std::string s) {
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) {
s.erase(s.begin());
}
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) {
s.pop_back();
}
return s;
};
std::vector<std::string> out;
std::stringstream ss(raw);
std::string item;
while (std::getline(ss, item, ',')) {
item = trim(item);
if (item.empty()) continue;
out.push_back(item);
}
return out;
}
} // namespace
void ProblemController::list(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
services::ProblemQuery q;
q.q = req->getParameter("q");
q.tag = req->getParameter("tag");
q.tags = ParseCsv(req->getParameter("tags"));
q.source_prefix = req->getParameter("source_prefix");
q.difficulty = ParsePositiveInt(req->getParameter("difficulty"), 0, 0, 10);
q.page = ParsePositiveInt(req->getParameter("page"), 1, 1, 100000);
q.page_size = ParsePositiveInt(req->getParameter("page_size"), 20, 1, 200);
q.order_by = req->getParameter("order_by");
q.order = req->getParameter("order");
services::ProblemService svc(csp::AppState::Instance().db());
const auto result = svc.List(q);
Json::Value arr(Json::arrayValue);
for (const auto& p : result.items) arr.append(domain::ToJson(p));
Json::Value payload;
payload["items"] = arr;
payload["total_count"] = result.total_count;
payload["page"] = q.page;
payload["page_size"] = q.page_size;
cb(JsonOk(payload));
} catch (const std::invalid_argument&) {
cb(JsonError(drogon::k400BadRequest, "invalid query parameter"));
} catch (const std::out_of_range&) {
cb(JsonError(drogon::k400BadRequest, "query parameter out of range"));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void ProblemController::getById(
const drogon::HttpRequestPtr& /*req*/,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t problem_id) {
try {
services::ProblemService svc(csp::AppState::Instance().db());
const auto p = svc.GetById(problem_id);
if (!p.has_value()) {
cb(JsonError(drogon::k404NotFound, "problem not found"));
return;
}
cb(JsonOk(domain::ToJson(*p)));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void ProblemController::getDraft(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t problem_id) {
try {
std::string auth_error;
const auto user_id = GetAuthedUserId(req, auth_error);
if (!user_id.has_value()) {
cb(JsonError(drogon::k401Unauthorized, auth_error));
return;
}
services::ProblemWorkspaceService svc(csp::AppState::Instance().db());
const auto draft = svc.GetDraft(*user_id, problem_id);
if (!draft.has_value()) {
cb(JsonError(drogon::k404NotFound, "draft not found"));
return;
}
Json::Value payload;
payload["language"] = draft->language;
payload["code"] = draft->code;
payload["stdin"] = draft->stdin_text;
payload["updated_at"] = Json::Int64(draft->updated_at);
cb(JsonOk(payload));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void ProblemController::saveDraft(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t problem_id) {
try {
std::string auth_error;
const auto user_id = GetAuthedUserId(req, auth_error);
if (!user_id.has_value()) {
cb(JsonError(drogon::k401Unauthorized, auth_error));
return;
}
const auto json = req->getJsonObject();
if (!json) {
cb(JsonError(drogon::k400BadRequest, "body must be json"));
return;
}
const std::string language = (*json).get("language", "cpp").asString();
const std::string code = (*json).get("code", "").asString();
const std::string stdin_text = (*json).get("stdin", "").asString();
services::ProblemWorkspaceService svc(csp::AppState::Instance().db());
if (!svc.ProblemExists(problem_id)) {
cb(JsonError(drogon::k404NotFound, "problem not found"));
return;
}
svc.SaveDraft(*user_id, problem_id, language, code, stdin_text);
Json::Value payload;
payload["saved"] = true;
cb(JsonOk(payload));
} catch (const std::runtime_error& e) {
cb(JsonError(drogon::k400BadRequest, e.what()));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void ProblemController::listSolutions(
const drogon::HttpRequestPtr& /*req*/,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t problem_id) {
try {
services::ProblemWorkspaceService svc(csp::AppState::Instance().db());
if (!svc.ProblemExists(problem_id)) {
cb(JsonError(drogon::k404NotFound, "problem not found"));
return;
}
const auto rows = svc.ListSolutions(problem_id);
const auto latest_job = svc.GetLatestSolutionJob(problem_id);
Json::Value arr(Json::arrayValue);
for (const auto& item : rows) {
Json::Value j;
j["id"] = Json::Int64(item.id);
j["problem_id"] = Json::Int64(item.problem_id);
j["variant"] = item.variant;
j["title"] = item.title;
j["idea_md"] = item.idea_md;
j["explanation_md"] = item.explanation_md;
j["code_cpp"] = item.code_cpp;
j["complexity"] = item.complexity;
j["tags_json"] = item.tags_json;
j["source"] = item.source;
j["created_at"] = Json::Int64(item.created_at);
j["updated_at"] = Json::Int64(item.updated_at);
arr.append(j);
}
Json::Value payload;
payload["items"] = arr;
payload["runner_running"] =
services::ProblemSolutionRunner::Instance().IsRunning(problem_id);
if (latest_job.has_value()) {
Json::Value j;
j["id"] = Json::Int64(latest_job->id);
j["problem_id"] = Json::Int64(latest_job->problem_id);
j["status"] = latest_job->status;
j["progress"] = latest_job->progress;
j["message"] = latest_job->message;
j["created_at"] = Json::Int64(latest_job->created_at);
if (latest_job->started_at.has_value()) {
j["started_at"] = Json::Int64(*latest_job->started_at);
} else {
j["started_at"] = Json::nullValue;
}
if (latest_job->finished_at.has_value()) {
j["finished_at"] = Json::Int64(*latest_job->finished_at);
} else {
j["finished_at"] = Json::nullValue;
}
payload["latest_job"] = j;
} else {
payload["latest_job"] = Json::nullValue;
}
cb(JsonOk(payload));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void ProblemController::generateSolutions(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t problem_id) {
try {
std::string auth_error;
const auto user_id = GetAuthedUserId(req, auth_error);
if (!user_id.has_value()) {
cb(JsonError(drogon::k401Unauthorized, auth_error));
return;
}
int max_solutions = 3;
const auto json = req->getJsonObject();
if (json && (*json).isMember("max_solutions")) {
max_solutions = std::max(1, std::min(5, (*json)["max_solutions"].asInt()));
}
services::ProblemWorkspaceService svc(csp::AppState::Instance().db());
if (!svc.ProblemExists(problem_id)) {
cb(JsonError(drogon::k404NotFound, "problem not found"));
return;
}
const int64_t job_id = svc.CreateSolutionJob(problem_id, *user_id, max_solutions);
const bool started = services::ProblemSolutionRunner::Instance().TriggerAsync(
problem_id, job_id, max_solutions);
if (!started) {
cb(JsonError(drogon::k409Conflict, "solution generation is already running"));
return;
}
Json::Value payload;
payload["started"] = true;
payload["job_id"] = Json::Int64(job_id);
cb(JsonOk(payload));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
} // namespace csp::controllers

查看文件

@@ -0,0 +1,80 @@
#include "csp/controllers/problem_gen_controller.h"
#include "csp/services/problem_gen_runner.h"
#include <exception>
#include <string>
namespace csp::controllers {
namespace {
drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code,
const std::string& msg) {
Json::Value j;
j["ok"] = false;
j["error"] = msg;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(code);
return resp;
}
drogon::HttpResponsePtr JsonOk(const Json::Value& data) {
Json::Value j;
j["ok"] = true;
j["data"] = data;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(drogon::k200OK);
return resp;
}
} // namespace
void ProblemGenController::status(
const drogon::HttpRequestPtr& /*req*/,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
auto& runner = services::ProblemGenRunner::Instance();
Json::Value payload;
payload["running"] = runner.IsRunning();
payload["last_command"] = runner.LastCommand();
if (runner.LastExitCode().has_value()) {
payload["last_exit_code"] = *runner.LastExitCode();
} else {
payload["last_exit_code"] = Json::nullValue;
}
payload["last_started_at"] = Json::Int64(runner.LastStartedAt());
payload["last_finished_at"] = Json::Int64(runner.LastFinishedAt());
cb(JsonOk(payload));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void ProblemGenController::run(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
int count = 1;
const auto json = req->getJsonObject();
if (json && (*json).isMember("count")) {
count = (*json)["count"].asInt();
}
auto& runner = services::ProblemGenRunner::Instance();
const bool started = runner.TriggerAsync("manual", count);
if (!started) {
cb(JsonError(drogon::k409Conflict, "problem generation job already running"));
return;
}
Json::Value payload;
payload["started"] = true;
payload["count"] = count;
cb(JsonOk(payload));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
} // namespace csp::controllers

查看文件

@@ -0,0 +1,192 @@
#include "csp/controllers/submission_controller.h"
#include "csp/app_state.h"
#include "csp/domain/enum_strings.h"
#include "csp/domain/json.h"
#include "csp/services/contest_service.h"
#include "csp/services/submission_service.h"
#include "http_auth.h"
#include <algorithm>
#include <exception>
#include <optional>
#include <string>
namespace csp::controllers {
namespace {
drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code,
const std::string& msg) {
Json::Value j;
j["ok"] = false;
j["error"] = msg;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(code);
return resp;
}
drogon::HttpResponsePtr JsonOk(const Json::Value& data) {
Json::Value j;
j["ok"] = true;
j["data"] = data;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(drogon::k200OK);
return resp;
}
int ParseClampedInt(const std::string& s,
int default_value,
int min_value,
int max_value) {
if (s.empty()) return default_value;
const int v = std::stoi(s);
return std::max(min_value, std::min(max_value, v));
}
std::optional<int64_t> ParseOptionalInt64(const std::string& s) {
if (s.empty()) return std::nullopt;
return std::stoll(s);
}
} // namespace
void SubmissionController::submitProblem(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t problem_id) {
try {
std::string auth_error;
const auto user_id = GetAuthedUserId(req, auth_error);
if (!user_id.has_value()) {
cb(JsonError(drogon::k401Unauthorized, auth_error));
return;
}
const auto json = req->getJsonObject();
if (!json) {
cb(JsonError(drogon::k400BadRequest, "body must be json"));
return;
}
services::SubmissionCreateRequest create;
create.user_id = *user_id;
create.problem_id = problem_id;
create.language = (*json).get("language", "cpp").asString();
create.code = (*json).get("code", "").asString();
if ((*json).isMember("contest_id") && !(*json)["contest_id"].isNull()) {
create.contest_id = (*json)["contest_id"].asInt64();
services::ContestService contest(csp::AppState::Instance().db());
if (!contest.GetContest(*create.contest_id).has_value()) {
cb(JsonError(drogon::k400BadRequest, "contest not found"));
return;
}
if (!contest.ContainsProblem(*create.contest_id, problem_id)) {
cb(JsonError(drogon::k400BadRequest, "problem not in contest"));
return;
}
if (!contest.IsRegistered(*create.contest_id, *user_id)) {
cb(JsonError(drogon::k403Forbidden, "user is not registered for contest"));
return;
}
if (!contest.IsRunning(*create.contest_id)) {
cb(JsonError(drogon::k403Forbidden, "contest is not running"));
return;
}
}
services::SubmissionService svc(csp::AppState::Instance().db());
auto s = svc.CreateAndJudge(create);
cb(JsonOk(domain::ToJson(s)));
} catch (const std::invalid_argument&) {
cb(JsonError(drogon::k400BadRequest, "invalid numeric field"));
} catch (const std::out_of_range&) {
cb(JsonError(drogon::k400BadRequest, "numeric field out of range"));
} catch (const std::runtime_error& e) {
cb(JsonError(drogon::k400BadRequest, e.what()));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void SubmissionController::listSubmissions(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
const auto user_id = ParseOptionalInt64(req->getParameter("user_id"));
const auto problem_id = ParseOptionalInt64(req->getParameter("problem_id"));
const auto contest_id = ParseOptionalInt64(req->getParameter("contest_id"));
const int page = ParseClampedInt(req->getParameter("page"), 1, 1, 100000);
const int page_size =
ParseClampedInt(req->getParameter("page_size"), 20, 1, 200);
services::SubmissionService svc(csp::AppState::Instance().db());
const auto rows = svc.List(user_id, problem_id, contest_id, page, page_size);
Json::Value arr(Json::arrayValue);
for (const auto& s : rows) arr.append(domain::ToJson(s));
Json::Value payload;
payload["items"] = arr;
payload["page"] = page;
payload["page_size"] = page_size;
cb(JsonOk(payload));
} catch (const std::invalid_argument&) {
cb(JsonError(drogon::k400BadRequest, "invalid query parameter"));
} catch (const std::out_of_range&) {
cb(JsonError(drogon::k400BadRequest, "query parameter out of range"));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void SubmissionController::getSubmission(
const drogon::HttpRequestPtr& /*req*/,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t submission_id) {
try {
services::SubmissionService svc(csp::AppState::Instance().db());
const auto s = svc.GetById(submission_id);
if (!s.has_value()) {
cb(JsonError(drogon::k404NotFound, "submission not found"));
return;
}
cb(JsonOk(domain::ToJson(*s)));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void SubmissionController::runCpp(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
const auto json = req->getJsonObject();
if (!json) {
cb(JsonError(drogon::k400BadRequest, "body must be json"));
return;
}
const std::string code = (*json).get("code", "").asString();
const std::string input = (*json).get("input", "").asString();
services::SubmissionService svc(csp::AppState::Instance().db());
const auto r = svc.RunOnlyCpp(code, input);
Json::Value payload;
payload["status"] = domain::ToString(r.status);
payload["time_ms"] = r.time_ms;
payload["stdout"] = r.stdout_text;
payload["stderr"] = r.stderr_text;
payload["compile_log"] = r.compile_log;
cb(JsonOk(payload));
} catch (const std::runtime_error& e) {
cb(JsonError(drogon::k400BadRequest, e.what()));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
} // namespace csp::controllers

查看文件

@@ -1,6 +1,9 @@
#include "csp/db/sqlite_db.h" #include "csp/db/sqlite_db.h"
#include <chrono>
#include <optional>
#include <stdexcept> #include <stdexcept>
#include <string>
#include <utility> #include <utility>
namespace csp::db { namespace csp::db {
@@ -8,11 +11,203 @@ namespace csp::db {
namespace { namespace {
void ThrowSqlite(int rc, sqlite3* db, const char* what) { void ThrowSqlite(int rc, sqlite3* db, const char* what) {
if (rc == SQLITE_OK) return; if (rc == SQLITE_OK || rc == SQLITE_DONE || rc == SQLITE_ROW) return;
const char* msg = db ? sqlite3_errmsg(db) : ""; const char* msg = db ? sqlite3_errmsg(db) : "";
throw std::runtime_error(std::string(what) + ": " + msg); throw std::runtime_error(std::string(what) + ": " + msg);
} }
int64_t NowSec() {
using namespace std::chrono;
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
}
bool ColumnExists(sqlite3* db, const char* table, const char* col) {
sqlite3_stmt* stmt = nullptr;
const std::string sql = std::string("PRAGMA table_info(") + table + ")";
const int rc = sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr);
if (rc != SQLITE_OK) {
if (stmt) sqlite3_finalize(stmt);
return false;
}
bool found = false;
while (sqlite3_step(stmt) == SQLITE_ROW) {
const unsigned char* name = sqlite3_column_text(stmt, 1);
if (name && std::string(reinterpret_cast<const char*>(name)) == col) {
found = true;
break;
}
}
sqlite3_finalize(stmt);
return found;
}
void EnsureColumn(SqliteDb& db,
const char* table,
const char* col_name,
const char* col_def) {
if (ColumnExists(db.raw(), table, col_name)) return;
db.Exec(std::string("ALTER TABLE ") + table + " ADD COLUMN " + col_def + ";");
}
int CountRows(sqlite3* db, const char* table) {
sqlite3_stmt* stmt = nullptr;
const std::string sql = std::string("SELECT COUNT(1) FROM ") + table;
int rc = sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr);
if (rc != SQLITE_OK) {
if (stmt) sqlite3_finalize(stmt);
return 0;
}
rc = sqlite3_step(stmt);
int count = 0;
if (rc == SQLITE_ROW) count = sqlite3_column_int(stmt, 0);
sqlite3_finalize(stmt);
return count;
}
std::optional<int64_t> QueryOneId(sqlite3* db, const std::string& sql) {
sqlite3_stmt* stmt = nullptr;
const int rc = sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr);
if (rc != SQLITE_OK) {
if (stmt) sqlite3_finalize(stmt);
return std::nullopt;
}
if (sqlite3_step(stmt) != SQLITE_ROW) {
sqlite3_finalize(stmt);
return std::nullopt;
}
const auto id = sqlite3_column_int64(stmt, 0);
sqlite3_finalize(stmt);
return id;
}
void InsertProblem(sqlite3* db,
const std::string& slug,
const std::string& title,
const std::string& statement,
int difficulty,
const std::string& source,
const std::string& sample_in,
const std::string& sample_out,
int64_t created_at) {
sqlite3_stmt* stmt = nullptr;
const char* sql =
"INSERT INTO problems(slug,title,statement_md,difficulty,source,sample_input,sample_output,created_at) "
"VALUES(?,?,?,?,?,?,?,?)";
ThrowSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare insert problem");
ThrowSqlite(sqlite3_bind_text(stmt, 1, slug.c_str(), -1, SQLITE_TRANSIENT), db,
"bind problem.slug");
ThrowSqlite(sqlite3_bind_text(stmt, 2, title.c_str(), -1, SQLITE_TRANSIENT), db,
"bind problem.title");
ThrowSqlite(sqlite3_bind_text(stmt, 3, statement.c_str(), -1, SQLITE_TRANSIENT), db,
"bind problem.statement");
ThrowSqlite(sqlite3_bind_int(stmt, 4, difficulty), db,
"bind problem.difficulty");
ThrowSqlite(sqlite3_bind_text(stmt, 5, source.c_str(), -1, SQLITE_TRANSIENT), db,
"bind problem.source");
ThrowSqlite(sqlite3_bind_text(stmt, 6, sample_in.c_str(), -1, SQLITE_TRANSIENT), db,
"bind problem.sample_input");
ThrowSqlite(sqlite3_bind_text(stmt, 7, sample_out.c_str(), -1, SQLITE_TRANSIENT), db,
"bind problem.sample_output");
ThrowSqlite(sqlite3_bind_int64(stmt, 8, created_at), db,
"bind problem.created_at");
ThrowSqlite(sqlite3_step(stmt), db, "insert problem");
sqlite3_finalize(stmt);
}
void InsertProblemTag(sqlite3* db, int64_t problem_id, const std::string& tag) {
sqlite3_stmt* stmt = nullptr;
const char* sql =
"INSERT OR IGNORE INTO problem_tags(problem_id,tag) VALUES(?,?)";
ThrowSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare insert problem_tag");
ThrowSqlite(sqlite3_bind_int64(stmt, 1, problem_id), db,
"bind problem_tag.problem_id");
ThrowSqlite(sqlite3_bind_text(stmt, 2, tag.c_str(), -1, SQLITE_TRANSIENT), db,
"bind problem_tag.tag");
ThrowSqlite(sqlite3_step(stmt), db, "insert problem_tag");
sqlite3_finalize(stmt);
}
void InsertKbArticle(sqlite3* db,
const std::string& slug,
const std::string& title,
const std::string& content_md,
int64_t created_at) {
sqlite3_stmt* stmt = nullptr;
const char* sql =
"INSERT INTO kb_articles(slug,title,content_md,created_at) VALUES(?,?,?,?)";
ThrowSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare insert kb_article");
ThrowSqlite(sqlite3_bind_text(stmt, 1, slug.c_str(), -1, SQLITE_TRANSIENT), db,
"bind kb_article.slug");
ThrowSqlite(sqlite3_bind_text(stmt, 2, title.c_str(), -1, SQLITE_TRANSIENT), db,
"bind kb_article.title");
ThrowSqlite(sqlite3_bind_text(stmt, 3, content_md.c_str(), -1, SQLITE_TRANSIENT), db,
"bind kb_article.content");
ThrowSqlite(sqlite3_bind_int64(stmt, 4, created_at), db,
"bind kb_article.created_at");
ThrowSqlite(sqlite3_step(stmt), db, "insert kb_article");
sqlite3_finalize(stmt);
}
void InsertKbLink(sqlite3* db, int64_t article_id, int64_t problem_id) {
sqlite3_stmt* stmt = nullptr;
const char* sql =
"INSERT OR IGNORE INTO kb_article_links(article_id,problem_id) VALUES(?,?)";
ThrowSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare insert kb_article_link");
ThrowSqlite(sqlite3_bind_int64(stmt, 1, article_id), db,
"bind kb_article_link.article_id");
ThrowSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db,
"bind kb_article_link.problem_id");
ThrowSqlite(sqlite3_step(stmt), db, "insert kb_article_link");
sqlite3_finalize(stmt);
}
void InsertContest(sqlite3* db,
const std::string& title,
int64_t starts_at,
int64_t ends_at,
const std::string& rule_json) {
sqlite3_stmt* stmt = nullptr;
const char* sql =
"INSERT INTO contests(title,starts_at,ends_at,rule_json) VALUES(?,?,?,?)";
ThrowSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare insert contest");
ThrowSqlite(sqlite3_bind_text(stmt, 1, title.c_str(), -1, SQLITE_TRANSIENT), db,
"bind contest.title");
ThrowSqlite(sqlite3_bind_int64(stmt, 2, starts_at), db,
"bind contest.starts_at");
ThrowSqlite(sqlite3_bind_int64(stmt, 3, ends_at), db,
"bind contest.ends_at");
ThrowSqlite(sqlite3_bind_text(stmt, 4, rule_json.c_str(), -1, SQLITE_TRANSIENT), db,
"bind contest.rule_json");
ThrowSqlite(sqlite3_step(stmt), db, "insert contest");
sqlite3_finalize(stmt);
}
void InsertContestProblem(sqlite3* db,
int64_t contest_id,
int64_t problem_id,
int idx) {
sqlite3_stmt* stmt = nullptr;
const char* sql =
"INSERT OR IGNORE INTO contest_problems(contest_id,problem_id,idx) VALUES(?,?,?)";
ThrowSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare insert contest_problem");
ThrowSqlite(sqlite3_bind_int64(stmt, 1, contest_id), db,
"bind contest_problem.contest_id");
ThrowSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db,
"bind contest_problem.problem_id");
ThrowSqlite(sqlite3_bind_int(stmt, 3, idx), db,
"bind contest_problem.idx");
ThrowSqlite(sqlite3_step(stmt), db, "insert contest_problem");
sqlite3_finalize(stmt);
}
} // namespace } // namespace
SqliteDb SqliteDb::OpenFile(const std::string& path) { SqliteDb SqliteDb::OpenFile(const std::string& path) {
@@ -60,11 +255,9 @@ void SqliteDb::Exec(const std::string& sql) {
} }
void ApplyMigrations(SqliteDb& db) { void ApplyMigrations(SqliteDb& db) {
// Keep it simple for MVP: apply the bundled init SQL. // Keep it simple for MVP: create missing tables, then patch missing columns.
// In later iterations we'll add a migrations table + incremental runner.
db.Exec("PRAGMA foreign_keys = ON;"); db.Exec("PRAGMA foreign_keys = ON;");
// 001_init.sql (embedded)
db.Exec(R"SQL( db.Exec(R"SQL(
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -90,6 +283,10 @@ CREATE TABLE IF NOT EXISTS problems (
statement_md TEXT NOT NULL, statement_md TEXT NOT NULL,
difficulty INTEGER NOT NULL DEFAULT 1, difficulty INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT "", source TEXT NOT NULL DEFAULT "",
statement_url TEXT NOT NULL DEFAULT "",
llm_profile_json TEXT NOT NULL DEFAULT "{}",
sample_input TEXT NOT NULL DEFAULT "",
sample_output TEXT NOT NULL DEFAULT "",
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
); );
@@ -104,15 +301,19 @@ CREATE TABLE IF NOT EXISTS submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
problem_id INTEGER NOT NULL, problem_id INTEGER NOT NULL,
contest_id INTEGER,
language TEXT NOT NULL, language TEXT NOT NULL,
code TEXT NOT NULL, code TEXT NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
score INTEGER NOT NULL DEFAULT 0, score INTEGER NOT NULL DEFAULT 0,
time_ms INTEGER NOT NULL DEFAULT 0, time_ms INTEGER NOT NULL DEFAULT 0,
memory_kb INTEGER NOT NULL DEFAULT 0, memory_kb INTEGER NOT NULL DEFAULT 0,
compile_log TEXT NOT NULL DEFAULT "",
runtime_log TEXT NOT NULL DEFAULT "",
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE,
FOREIGN KEY(contest_id) REFERENCES contests(id) ON DELETE SET NULL
); );
CREATE TABLE IF NOT EXISTS wrong_book ( CREATE TABLE IF NOT EXISTS wrong_book (
@@ -127,9 +328,280 @@ CREATE TABLE IF NOT EXISTS wrong_book (
FOREIGN KEY(last_submission_id) REFERENCES submissions(id) ON DELETE SET NULL FOREIGN KEY(last_submission_id) REFERENCES submissions(id) ON DELETE SET NULL
); );
CREATE TABLE IF NOT EXISTS contests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
starts_at INTEGER NOT NULL,
ends_at INTEGER NOT NULL,
rule_json TEXT NOT NULL DEFAULT "{}"
);
CREATE TABLE IF NOT EXISTS contest_problems (
contest_id INTEGER NOT NULL,
problem_id INTEGER NOT NULL,
idx INTEGER NOT NULL,
PRIMARY KEY(contest_id, problem_id),
FOREIGN KEY(contest_id) REFERENCES contests(id) ON DELETE CASCADE,
FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS contest_registrations (
contest_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
registered_at INTEGER NOT NULL,
PRIMARY KEY(contest_id, user_id),
FOREIGN KEY(contest_id) REFERENCES contests(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS kb_articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
content_md TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS kb_article_links (
article_id INTEGER NOT NULL,
problem_id INTEGER NOT NULL,
PRIMARY KEY(article_id, problem_id),
FOREIGN KEY(article_id) REFERENCES kb_articles(id) ON DELETE CASCADE,
FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS import_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status TEXT NOT NULL,
trigger TEXT NOT NULL DEFAULT "manual",
total_count INTEGER NOT NULL DEFAULT 0,
processed_count INTEGER NOT NULL DEFAULT 0,
success_count INTEGER NOT NULL DEFAULT 0,
failed_count INTEGER NOT NULL DEFAULT 0,
options_json TEXT NOT NULL DEFAULT "{}",
last_error TEXT NOT NULL DEFAULT "",
started_at INTEGER NOT NULL,
finished_at INTEGER,
updated_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS import_job_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
source_path TEXT NOT NULL,
status TEXT NOT NULL DEFAULT "queued",
title TEXT NOT NULL DEFAULT "",
difficulty INTEGER NOT NULL DEFAULT 0,
problem_id INTEGER,
error_text TEXT NOT NULL DEFAULT "",
started_at INTEGER,
finished_at INTEGER,
updated_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(job_id) REFERENCES import_jobs(id) ON DELETE CASCADE,
UNIQUE(job_id, source_path)
);
CREATE TABLE IF NOT EXISTS problem_drafts (
user_id INTEGER NOT NULL,
problem_id INTEGER NOT NULL,
language TEXT NOT NULL DEFAULT "cpp",
code TEXT NOT NULL DEFAULT "",
stdin TEXT NOT NULL DEFAULT "",
updated_at INTEGER NOT NULL,
created_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
);
CREATE TABLE IF NOT EXISTS problem_solution_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
problem_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT "queued",
progress INTEGER NOT NULL DEFAULT 0,
message TEXT NOT NULL DEFAULT "",
created_by INTEGER NOT NULL DEFAULT 0,
max_solutions INTEGER NOT NULL DEFAULT 3,
created_at INTEGER NOT NULL,
started_at INTEGER,
finished_at INTEGER,
updated_at INTEGER NOT NULL,
FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS problem_solutions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
problem_id INTEGER NOT NULL,
variant INTEGER NOT NULL DEFAULT 1,
title TEXT NOT NULL DEFAULT "",
idea_md TEXT NOT NULL DEFAULT "",
explanation_md TEXT NOT NULL DEFAULT "",
code_cpp TEXT NOT NULL DEFAULT "",
complexity TEXT NOT NULL DEFAULT "",
tags_json TEXT NOT NULL DEFAULT "[]",
source TEXT NOT NULL DEFAULT "llm",
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE
);
)SQL");
// Backward-compatible schema upgrades for existing deployments.
EnsureColumn(db, "problems", "sample_input",
"sample_input TEXT NOT NULL DEFAULT ''");
EnsureColumn(db, "problems", "sample_output",
"sample_output TEXT NOT NULL DEFAULT ''");
EnsureColumn(db, "problems", "statement_url",
"statement_url TEXT NOT NULL DEFAULT ''");
EnsureColumn(db, "problems", "llm_profile_json",
"llm_profile_json TEXT NOT NULL DEFAULT '{}'");
EnsureColumn(db, "import_jobs", "trigger",
"trigger TEXT NOT NULL DEFAULT 'manual'");
EnsureColumn(db, "import_jobs", "options_json",
"options_json TEXT NOT NULL DEFAULT '{}'");
EnsureColumn(db, "import_jobs", "last_error",
"last_error TEXT NOT NULL DEFAULT ''");
EnsureColumn(db, "submissions", "contest_id", "contest_id INTEGER");
EnsureColumn(db, "submissions", "compile_log",
"compile_log TEXT NOT NULL DEFAULT ''");
EnsureColumn(db, "submissions", "runtime_log",
"runtime_log TEXT NOT NULL DEFAULT ''");
EnsureColumn(db, "problem_drafts", "stdin", "stdin TEXT NOT NULL DEFAULT ''");
EnsureColumn(db, "problem_solution_jobs", "max_solutions",
"max_solutions INTEGER NOT NULL DEFAULT 3");
EnsureColumn(db, "problem_solutions", "variant", "variant INTEGER NOT NULL DEFAULT 1");
EnsureColumn(db, "problem_solutions", "idea_md", "idea_md TEXT NOT NULL DEFAULT ''");
EnsureColumn(db, "problem_solutions", "explanation_md",
"explanation_md TEXT NOT NULL DEFAULT ''");
EnsureColumn(db, "problem_solutions", "code_cpp", "code_cpp TEXT NOT NULL DEFAULT ''");
EnsureColumn(db, "problem_solutions", "complexity",
"complexity TEXT NOT NULL DEFAULT ''");
EnsureColumn(db, "problem_solutions", "tags_json", "tags_json TEXT NOT NULL DEFAULT '[]'");
// Build indexes after compatibility ALTERs so old schemas won't fail on
// missing columns (e.g. legacy submissions table without contest_id).
db.Exec(R"SQL(
CREATE INDEX IF NOT EXISTS idx_submissions_user_created_at ON submissions(user_id, created_at DESC); 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); CREATE INDEX IF NOT EXISTS idx_submissions_problem_created_at ON submissions(problem_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_submissions_contest_user_created_at ON submissions(contest_id, user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_problem_tags_tag ON problem_tags(tag);
CREATE INDEX IF NOT EXISTS idx_kb_article_links_problem_id ON kb_article_links(problem_id);
CREATE INDEX IF NOT EXISTS idx_import_jobs_created_at ON import_jobs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_import_jobs_status ON import_jobs(status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_import_job_items_job_status ON import_job_items(job_id, status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_problem_drafts_updated ON problem_drafts(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_problem_solution_jobs_problem ON problem_solution_jobs(problem_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_problem_solutions_problem ON problem_solutions(problem_id, variant, id);
)SQL"); )SQL");
} }
void SeedDemoData(SqliteDb& db) {
sqlite3* raw = db.raw();
const int64_t now = NowSec();
if (CountRows(raw, "problems") == 0) {
InsertProblem(
raw,
"a-plus-b",
"A + B",
"给定两个整数 A 与 B,输出它们的和。",
1,
"CSP-入门",
"1 2\n",
"3\n",
now);
InsertProblem(
raw,
"fibonacci-n",
"Fibonacci 第 n 项",
"输入 n (0<=n<=40),输出第 n 项 Fibonacci 数。",
2,
"CSP-基础",
"10\n",
"55\n",
now);
InsertProblem(
raw,
"sort-numbers",
"整数排序",
"输入 n 和 n 个整数,按升序输出。",
2,
"CSP-基础",
"5\n5 1 4 2 3\n",
"1 2 3 4 5\n",
now);
}
if (CountRows(raw, "problem_tags") == 0) {
const auto p1 =
QueryOneId(raw, "SELECT id FROM problems WHERE slug='a-plus-b'");
const auto p2 =
QueryOneId(raw, "SELECT id FROM problems WHERE slug='fibonacci-n'");
const auto p3 =
QueryOneId(raw, "SELECT id FROM problems WHERE slug='sort-numbers'");
if (p1) {
InsertProblemTag(raw, *p1, "math");
InsertProblemTag(raw, *p1, "implementation");
}
if (p2) {
InsertProblemTag(raw, *p2, "dp");
InsertProblemTag(raw, *p2, "recursion");
}
if (p3) {
InsertProblemTag(raw, *p3, "sort");
InsertProblemTag(raw, *p3, "array");
}
}
if (CountRows(raw, "kb_articles") == 0) {
InsertKbArticle(
raw,
"cpp-fast-io",
"C++ 快速输入输出",
"# C++ 快速输入输出\n\n在 OI/CSP 中,建议关闭同步并解绑 cin/cout\n\n```cpp\nstd::ios::sync_with_stdio(false);\nstd::cin.tie(nullptr);\n```\n",
now);
InsertKbArticle(
raw,
"intro-dp",
"动态规划入门",
"# 动态规划入门\n\n动态规划的核心是:**状态定义**、**状态转移**、**边界条件**。\n",
now);
}
if (CountRows(raw, "kb_article_links") == 0) {
const auto p1 =
QueryOneId(raw, "SELECT id FROM problems WHERE slug='a-plus-b'");
const auto p2 =
QueryOneId(raw, "SELECT id FROM problems WHERE slug='fibonacci-n'");
const auto a1 =
QueryOneId(raw, "SELECT id FROM kb_articles WHERE slug='cpp-fast-io'");
const auto a2 =
QueryOneId(raw, "SELECT id FROM kb_articles WHERE slug='intro-dp'");
if (a1 && p1) InsertKbLink(raw, *a1, *p1);
if (a2 && p2) InsertKbLink(raw, *a2, *p2);
}
if (CountRows(raw, "contests") == 0) {
InsertContest(
raw,
"CSP 模拟赛(示例)",
now - 3600,
now + 7 * 24 * 3600,
R"({"type":"acm","desc":""})");
}
if (CountRows(raw, "contest_problems") == 0) {
const auto contest_id = QueryOneId(raw, "SELECT id FROM contests ORDER BY id LIMIT 1");
const auto p1 = QueryOneId(raw, "SELECT id FROM problems ORDER BY id LIMIT 1");
const auto p2 = QueryOneId(raw, "SELECT id FROM problems ORDER BY id LIMIT 1 OFFSET 1");
if (contest_id && p1) InsertContestProblem(raw, *contest_id, *p1, 1);
if (contest_id && p2) InsertContestProblem(raw, *contest_id, *p2, 2);
}
}
} // namespace csp::db } // namespace csp::db

查看文件

@@ -19,6 +19,10 @@ Json::Value ToJson(const Problem& p) {
j["statement_md"] = p.statement_md; j["statement_md"] = p.statement_md;
j["difficulty"] = p.difficulty; j["difficulty"] = p.difficulty;
j["source"] = p.source; j["source"] = p.source;
j["statement_url"] = p.statement_url;
j["llm_profile_json"] = p.llm_profile_json;
j["sample_input"] = p.sample_input;
j["sample_output"] = p.sample_output;
j["created_at"] = Json::Int64(p.created_at); j["created_at"] = Json::Int64(p.created_at);
return j; return j;
} }
@@ -28,11 +32,18 @@ Json::Value ToJson(const Submission& s) {
j["id"] = Json::Int64(s.id); j["id"] = Json::Int64(s.id);
j["user_id"] = Json::Int64(s.user_id); j["user_id"] = Json::Int64(s.user_id);
j["problem_id"] = Json::Int64(s.problem_id); j["problem_id"] = Json::Int64(s.problem_id);
if (s.contest_id.has_value()) {
j["contest_id"] = Json::Int64(*s.contest_id);
} else {
j["contest_id"] = Json::nullValue;
}
j["language"] = ToString(s.language); j["language"] = ToString(s.language);
j["status"] = ToString(s.status); j["status"] = ToString(s.status);
j["score"] = s.score; j["score"] = s.score;
j["time_ms"] = s.time_ms; j["time_ms"] = s.time_ms;
j["memory_kb"] = s.memory_kb; j["memory_kb"] = s.memory_kb;
j["compile_log"] = s.compile_log;
j["runtime_log"] = s.runtime_log;
j["created_at"] = Json::Int64(s.created_at); j["created_at"] = Json::Int64(s.created_at);
return j; return j;
} }
@@ -51,4 +62,42 @@ Json::Value ToJson(const WrongBookItem& w) {
return j; return j;
} }
Json::Value ToJson(const Contest& c) {
Json::Value j;
j["id"] = Json::Int64(c.id);
j["title"] = c.title;
j["starts_at"] = Json::Int64(c.starts_at);
j["ends_at"] = Json::Int64(c.ends_at);
j["rule_json"] = c.rule_json;
return j;
}
Json::Value ToJson(const KbArticle& a) {
Json::Value j;
j["id"] = Json::Int64(a.id);
j["slug"] = a.slug;
j["title"] = a.title;
j["content_md"] = a.content_md;
j["created_at"] = Json::Int64(a.created_at);
return j;
}
Json::Value ToJson(const GlobalLeaderboardEntry& e) {
Json::Value j;
j["user_id"] = Json::Int64(e.user_id);
j["username"] = e.username;
j["rating"] = e.rating;
j["created_at"] = Json::Int64(e.created_at);
return j;
}
Json::Value ToJson(const ContestLeaderboardEntry& e) {
Json::Value j;
j["user_id"] = Json::Int64(e.user_id);
j["username"] = e.username;
j["solved"] = e.solved;
j["penalty_sec"] = Json::Int64(e.penalty_sec);
return j;
}
} // namespace csp::domain } // namespace csp::domain

查看文件

@@ -2,6 +2,9 @@
#include "csp/app_state.h" #include "csp/app_state.h"
#include "csp/services/auth_service.h" #include "csp/services/auth_service.h"
#include "csp/services/import_runner.h"
#include "csp/services/problem_gen_runner.h"
#include "csp/services/problem_solution_runner.h"
#include <cstdlib> #include <cstdlib>
#include <filesystem> #include <filesystem>
@@ -12,6 +15,9 @@ int main(int argc, char** argv) {
if (!parent.empty()) std::filesystem::create_directories(parent); if (!parent.empty()) std::filesystem::create_directories(parent);
csp::AppState::Instance().Init(db_path); csp::AppState::Instance().Init(db_path);
csp::services::ImportRunner::Instance().Configure(db_path);
csp::services::ProblemSolutionRunner::Instance().Configure(db_path);
csp::services::ProblemGenRunner::Instance().Configure(db_path);
// Optional seed admin user for dev/test. // Optional seed admin user for dev/test.
{ {
@@ -29,6 +35,11 @@ int main(int argc, char** argv) {
} }
} }
// Auto-run PDF -> LLM import workflow on startup unless explicitly disabled.
csp::services::ImportRunner::Instance().AutoStartIfEnabled();
// Auto-generate one CSP-J new problem (RAG + dedupe) on startup by default.
csp::services::ProblemGenRunner::Instance().AutoStartIfEnabled();
// CORS (dev-friendly). In production, prefer reverse proxy same-origin. // CORS (dev-friendly). In production, prefer reverse proxy same-origin.
drogon::app().registerPreRoutingAdvice([](const drogon::HttpRequestPtr& req, drogon::app().registerPreRoutingAdvice([](const drogon::HttpRequestPtr& req,
drogon::AdviceCallback&& cb, drogon::AdviceCallback&& cb,

查看文件

@@ -0,0 +1,210 @@
#include "csp/services/contest_service.h"
#include <sqlite3.h>
#include <chrono>
#include <stdexcept>
#include <string>
namespace csp::services {
namespace {
void CheckSqlite(int rc, sqlite3* db, const char* what) {
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return;
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
}
int64_t NowSec() {
using namespace std::chrono;
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
}
std::string ColText(sqlite3_stmt* stmt, int col) {
const unsigned char* txt = sqlite3_column_text(stmt, col);
return txt ? reinterpret_cast<const char*>(txt) : std::string();
}
} // namespace
std::vector<domain::Contest> ContestService::ListContests() {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,title,starts_at,ends_at,rule_json FROM contests ORDER BY id ASC";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare list contests");
std::vector<domain::Contest> out;
while (sqlite3_step(stmt) == SQLITE_ROW) {
domain::Contest c;
c.id = sqlite3_column_int64(stmt, 0);
c.title = ColText(stmt, 1);
c.starts_at = sqlite3_column_int64(stmt, 2);
c.ends_at = sqlite3_column_int64(stmt, 3);
c.rule_json = ColText(stmt, 4);
out.push_back(std::move(c));
}
sqlite3_finalize(stmt);
return out;
}
std::optional<domain::Contest> ContestService::GetContest(int64_t contest_id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,title,starts_at,ends_at,rule_json FROM contests WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare get contest");
CheckSqlite(sqlite3_bind_int64(stmt, 1, contest_id), db,
"bind contest_id");
if (sqlite3_step(stmt) != SQLITE_ROW) {
sqlite3_finalize(stmt);
return std::nullopt;
}
domain::Contest c;
c.id = sqlite3_column_int64(stmt, 0);
c.title = ColText(stmt, 1);
c.starts_at = sqlite3_column_int64(stmt, 2);
c.ends_at = sqlite3_column_int64(stmt, 3);
c.rule_json = ColText(stmt, 4);
sqlite3_finalize(stmt);
return c;
}
std::vector<domain::Problem> ContestService::ListContestProblems(int64_t contest_id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT p.id,p.slug,p.title,p.statement_md,p.difficulty,p.source,p.statement_url,p.llm_profile_json,"
"p.sample_input,p.sample_output,p.created_at "
"FROM contest_problems cp "
"JOIN problems p ON p.id=cp.problem_id "
"WHERE cp.contest_id=? ORDER BY cp.idx ASC";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare list contest problems");
CheckSqlite(sqlite3_bind_int64(stmt, 1, contest_id), db,
"bind contest_id");
std::vector<domain::Problem> out;
while (sqlite3_step(stmt) == SQLITE_ROW) {
domain::Problem p;
p.id = sqlite3_column_int64(stmt, 0);
p.slug = ColText(stmt, 1);
p.title = ColText(stmt, 2);
p.statement_md = ColText(stmt, 3);
p.difficulty = sqlite3_column_int(stmt, 4);
p.source = ColText(stmt, 5);
p.statement_url = ColText(stmt, 6);
p.llm_profile_json = ColText(stmt, 7);
p.sample_input = ColText(stmt, 8);
p.sample_output = ColText(stmt, 9);
p.created_at = sqlite3_column_int64(stmt, 10);
out.push_back(std::move(p));
}
sqlite3_finalize(stmt);
return out;
}
void ContestService::Register(int64_t contest_id, int64_t user_id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"INSERT OR IGNORE INTO contest_registrations(contest_id,user_id,registered_at) VALUES(?,?,?)";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare contest register");
CheckSqlite(sqlite3_bind_int64(stmt, 1, contest_id), db,
"bind contest_id");
CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db,
"bind user_id");
CheckSqlite(sqlite3_bind_int64(stmt, 3, NowSec()), db,
"bind registered_at");
CheckSqlite(sqlite3_step(stmt), db, "contest register");
sqlite3_finalize(stmt);
}
bool ContestService::IsRegistered(int64_t contest_id, int64_t user_id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT 1 FROM contest_registrations WHERE contest_id=? AND user_id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare contest is_registered");
CheckSqlite(sqlite3_bind_int64(stmt, 1, contest_id), db,
"bind contest_id");
CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id");
const bool exists = sqlite3_step(stmt) == SQLITE_ROW;
sqlite3_finalize(stmt);
return exists;
}
bool ContestService::ContainsProblem(int64_t contest_id, int64_t problem_id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT 1 FROM contest_problems WHERE contest_id=? AND problem_id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare contest contains_problem");
CheckSqlite(sqlite3_bind_int64(stmt, 1, contest_id), db,
"bind contest_id");
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db,
"bind problem_id");
const bool exists = sqlite3_step(stmt) == SQLITE_ROW;
sqlite3_finalize(stmt);
return exists;
}
bool ContestService::IsRunning(int64_t contest_id) {
const auto c = GetContest(contest_id);
if (!c.has_value()) return false;
const auto now = NowSec();
return now >= c->starts_at && now <= c->ends_at;
}
std::vector<domain::ContestLeaderboardEntry> ContestService::Leaderboard(int64_t contest_id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql = R"SQL(
SELECT
r.user_id,
u.username,
COUNT(a.problem_id) AS solved,
COALESCE(SUM(a.first_ac - c.starts_at), 0) AS penalty_sec
FROM contest_registrations r
JOIN users u ON u.id = r.user_id
JOIN contests c ON c.id = r.contest_id
LEFT JOIN (
SELECT user_id, problem_id, MIN(created_at) AS first_ac
FROM submissions
WHERE contest_id = ? AND status = 'AC'
GROUP BY user_id, problem_id
) a ON a.user_id = r.user_id
WHERE r.contest_id = ?
GROUP BY r.user_id, u.username, c.starts_at
ORDER BY solved DESC, penalty_sec ASC, r.user_id ASC
)SQL";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare contest leaderboard");
CheckSqlite(sqlite3_bind_int64(stmt, 1, contest_id), db,
"bind contest_id ac");
CheckSqlite(sqlite3_bind_int64(stmt, 2, contest_id), db,
"bind contest_id reg");
std::vector<domain::ContestLeaderboardEntry> out;
while (sqlite3_step(stmt) == SQLITE_ROW) {
domain::ContestLeaderboardEntry e;
e.user_id = sqlite3_column_int64(stmt, 0);
e.username = ColText(stmt, 1);
e.solved = sqlite3_column_int(stmt, 2);
e.penalty_sec = sqlite3_column_int64(stmt, 3);
out.push_back(std::move(e));
}
sqlite3_finalize(stmt);
return out;
}
} // namespace csp::services

查看文件

@@ -0,0 +1,183 @@
#include "csp/services/import_runner.h"
#include <drogon/drogon.h>
#include <chrono>
#include <cctype>
#include <cstdlib>
#include <filesystem>
#include <thread>
#include <utility>
#include <vector>
namespace csp::services {
namespace {
int64_t NowSec() {
using namespace std::chrono;
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
}
bool EnvBool(const char* key, bool default_value) {
const char* raw = std::getenv(key);
if (!raw) return default_value;
std::string val(raw);
for (auto& c : val) c = static_cast<char>(::tolower(static_cast<unsigned char>(c)));
if (val == "1" || val == "true" || val == "yes" || val == "on") return true;
if (val == "0" || val == "false" || val == "no" || val == "off") return false;
return default_value;
}
int EnvInt(const char* key, int default_value) {
const char* raw = std::getenv(key);
if (!raw) return default_value;
try {
return std::stoi(raw);
} catch (...) {
return default_value;
}
}
std::string ShellQuote(const std::string& text) {
std::string out = "'";
for (char c : text) {
if (c == '\'') {
out += "'\"'\"'";
} else {
out.push_back(c);
}
}
out.push_back('\'');
return out;
}
std::string ResolveScriptPath() {
const char* env_path = std::getenv("OI_IMPORT_SCRIPT_PATH");
if (env_path && std::filesystem::exists(env_path)) return env_path;
const std::vector<std::string> candidates = {
"/app/scripts/import_luogu_csp.py",
"scripts/import_luogu_csp.py",
"../scripts/import_luogu_csp.py",
"../../scripts/import_luogu_csp.py",
"/app/scripts/import_winterant_oi.py",
"scripts/import_winterant_oi.py",
"../scripts/import_winterant_oi.py",
"../../scripts/import_winterant_oi.py",
};
for (const auto& p : candidates) {
if (std::filesystem::exists(p)) return p;
}
return "/app/scripts/import_luogu_csp.py";
}
std::string BuildCommand(const std::string& db_path,
const std::string& trigger,
const ImportRunOptions& options) {
const std::string script_path = ResolveScriptPath();
const int workers = std::max(1, EnvInt("OI_IMPORT_WORKERS", 3));
const int llm_limit = EnvInt("OI_IMPORT_LLM_LIMIT", 0);
const int max_problems = EnvInt("OI_IMPORT_MAX_PROBLEMS", 0);
const bool skip_llm = EnvBool("OI_IMPORT_SKIP_LLM", false);
const bool clear_existing = EnvBool("OI_IMPORT_CLEAR_EXISTING", true);
const bool clear_all_default = EnvBool("OI_IMPORT_CLEAR_ALL_PROBLEMS", false);
const std::string clear_source_prefix =
std::getenv("OI_IMPORT_CLEAR_SOURCE_PREFIX")
? std::string(std::getenv("OI_IMPORT_CLEAR_SOURCE_PREFIX"))
: std::string("winterant/oi");
std::string cmd = "python3 " + ShellQuote(script_path) + " --db-path " +
ShellQuote(db_path) + " --workers " +
std::to_string(workers) + " --job-trigger " +
ShellQuote(trigger);
if (max_problems > 0) cmd += " --max-problems " + std::to_string(max_problems);
if (skip_llm) cmd += " --skip-llm";
if (llm_limit > 0) cmd += " --llm-limit " + std::to_string(llm_limit);
const bool clear_all = options.clear_all_problems || clear_all_default;
if (clear_all) {
cmd += " --clear-all-problems";
} else if (clear_existing) {
cmd += " --clear-existing --clear-existing-source-prefix " +
ShellQuote(clear_source_prefix);
}
return cmd;
}
} // namespace
ImportRunner& ImportRunner::Instance() {
static ImportRunner inst;
return inst;
}
void ImportRunner::Configure(std::string db_path) {
std::lock_guard<std::mutex> lock(mu_);
db_path_ = std::move(db_path);
}
bool ImportRunner::TriggerAsync(const std::string& trigger,
const ImportRunOptions& options) {
std::string cmd;
{
std::lock_guard<std::mutex> lock(mu_);
if (running_) return false;
if (db_path_.empty()) return false;
cmd = BuildCommand(db_path_, trigger, options);
running_ = true;
last_started_at_ = NowSec();
last_command_ = cmd;
last_exit_code_.reset();
}
std::thread([this, command = std::move(cmd)]() {
const int rc = std::system(command.c_str());
std::lock_guard<std::mutex> lock(mu_);
last_exit_code_ = rc;
last_finished_at_ = NowSec();
running_ = false;
}).detach();
return true;
}
void ImportRunner::AutoStartIfEnabled() {
if (!EnvBool("OI_IMPORT_AUTO_RUN", true)) return;
const bool started = TriggerAsync("auto", ImportRunOptions{});
if (started) {
LOG_INFO << "import runner auto-started";
} else {
LOG_INFO << "import runner auto-start skipped";
}
}
bool ImportRunner::IsRunning() const {
std::lock_guard<std::mutex> lock(mu_);
return running_;
}
std::string ImportRunner::LastCommand() const {
std::lock_guard<std::mutex> lock(mu_);
return last_command_;
}
std::optional<int> ImportRunner::LastExitCode() const {
std::lock_guard<std::mutex> lock(mu_);
return last_exit_code_;
}
int64_t ImportRunner::LastStartedAt() const {
std::lock_guard<std::mutex> lock(mu_);
return last_started_at_;
}
int64_t ImportRunner::LastFinishedAt() const {
std::lock_guard<std::mutex> lock(mu_);
return last_finished_at_;
}
} // namespace csp::services

查看文件

@@ -0,0 +1,138 @@
#include "csp/services/import_service.h"
#include <sqlite3.h>
#include <algorithm>
#include <stdexcept>
#include <string>
namespace csp::services {
namespace {
void CheckSqlite(int rc, sqlite3* db, const char* what) {
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return;
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
}
std::string ColText(sqlite3_stmt* stmt, int col) {
const unsigned char* txt = sqlite3_column_text(stmt, col);
return txt ? reinterpret_cast<const char*>(txt) : std::string();
}
std::optional<int64_t> ColNullableInt64(sqlite3_stmt* stmt, int col) {
if (sqlite3_column_type(stmt, col) == SQLITE_NULL) return std::nullopt;
return sqlite3_column_int64(stmt, col);
}
ImportJob ReadJob(sqlite3_stmt* stmt) {
ImportJob j;
j.id = sqlite3_column_int64(stmt, 0);
j.status = ColText(stmt, 1);
j.trigger = ColText(stmt, 2);
j.total_count = sqlite3_column_int(stmt, 3);
j.processed_count = sqlite3_column_int(stmt, 4);
j.success_count = sqlite3_column_int(stmt, 5);
j.failed_count = sqlite3_column_int(stmt, 6);
j.options_json = ColText(stmt, 7);
j.last_error = ColText(stmt, 8);
j.started_at = sqlite3_column_int64(stmt, 9);
j.finished_at = ColNullableInt64(stmt, 10);
j.updated_at = sqlite3_column_int64(stmt, 11);
j.created_at = sqlite3_column_int64(stmt, 12);
return j;
}
ImportJobItem ReadItem(sqlite3_stmt* stmt) {
ImportJobItem item;
item.id = sqlite3_column_int64(stmt, 0);
item.job_id = sqlite3_column_int64(stmt, 1);
item.source_path = ColText(stmt, 2);
item.status = ColText(stmt, 3);
item.title = ColText(stmt, 4);
item.difficulty = sqlite3_column_int(stmt, 5);
item.problem_id = ColNullableInt64(stmt, 6);
item.error_text = ColText(stmt, 7);
item.started_at = ColNullableInt64(stmt, 8);
item.finished_at = ColNullableInt64(stmt, 9);
item.updated_at = sqlite3_column_int64(stmt, 10);
item.created_at = sqlite3_column_int64(stmt, 11);
return item;
}
} // namespace
std::optional<ImportJob> ImportService::GetLatestJob() {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,status,trigger,total_count,processed_count,success_count,failed_count,"
"options_json,last_error,started_at,finished_at,updated_at,created_at "
"FROM import_jobs ORDER BY id DESC LIMIT 1";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare get latest import job");
if (sqlite3_step(stmt) != SQLITE_ROW) {
sqlite3_finalize(stmt);
return std::nullopt;
}
auto row = ReadJob(stmt);
sqlite3_finalize(stmt);
return row;
}
std::optional<ImportJob> ImportService::GetById(int64_t job_id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,status,trigger,total_count,processed_count,success_count,failed_count,"
"options_json,last_error,started_at,finished_at,updated_at,created_at "
"FROM import_jobs WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare get import job by id");
CheckSqlite(sqlite3_bind_int64(stmt, 1, job_id), db, "bind job_id");
if (sqlite3_step(stmt) != SQLITE_ROW) {
sqlite3_finalize(stmt);
return std::nullopt;
}
auto row = ReadJob(stmt);
sqlite3_finalize(stmt);
return row;
}
std::vector<ImportJobItem> ImportService::ListItems(int64_t job_id,
const ImportJobItemQuery& query) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
std::string sql =
"SELECT id,job_id,source_path,status,title,difficulty,problem_id,error_text,"
"started_at,finished_at,updated_at,created_at "
"FROM import_job_items WHERE job_id=?";
if (!query.status.empty()) {
sql += " AND status=?";
}
sql += " ORDER BY id ASC LIMIT ? OFFSET ?";
CheckSqlite(sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr), db,
"prepare list import job items");
int bind_index = 1;
CheckSqlite(sqlite3_bind_int64(stmt, bind_index++, job_id), db, "bind job_id");
if (!query.status.empty()) {
CheckSqlite(
sqlite3_bind_text(stmt, bind_index++, query.status.c_str(), -1, SQLITE_TRANSIENT),
db, "bind status");
}
const int page = query.page <= 0 ? 1 : query.page;
const int page_size = std::max(1, std::min(500, query.page_size <= 0 ? 50 : query.page_size));
const int offset = (page - 1) * page_size;
CheckSqlite(sqlite3_bind_int(stmt, bind_index++, page_size), db, "bind page_size");
CheckSqlite(sqlite3_bind_int(stmt, bind_index++, offset), db, "bind offset");
std::vector<ImportJobItem> rows;
while (sqlite3_step(stmt) == SQLITE_ROW) {
rows.push_back(ReadItem(stmt));
}
sqlite3_finalize(stmt);
return rows;
}
} // namespace csp::services

查看文件

@@ -0,0 +1,87 @@
#include "csp/services/kb_service.h"
#include <sqlite3.h>
#include <stdexcept>
#include <string>
namespace csp::services {
namespace {
void CheckSqlite(int rc, sqlite3* db, const char* what) {
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return;
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
}
std::string ColText(sqlite3_stmt* stmt, int col) {
const unsigned char* txt = sqlite3_column_text(stmt, col);
return txt ? reinterpret_cast<const char*>(txt) : std::string();
}
} // namespace
std::vector<domain::KbArticle> KbService::ListArticles() {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,slug,title,content_md,created_at FROM kb_articles ORDER BY id ASC";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare kb list");
std::vector<domain::KbArticle> out;
while (sqlite3_step(stmt) == SQLITE_ROW) {
domain::KbArticle a;
a.id = sqlite3_column_int64(stmt, 0);
a.slug = ColText(stmt, 1);
a.title = ColText(stmt, 2);
a.content_md = ColText(stmt, 3);
a.created_at = sqlite3_column_int64(stmt, 4);
out.push_back(std::move(a));
}
sqlite3_finalize(stmt);
return out;
}
std::optional<KbArticleDetail> KbService::GetBySlug(const std::string& slug) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,slug,title,content_md,created_at FROM kb_articles WHERE slug=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare kb get");
CheckSqlite(sqlite3_bind_text(stmt, 1, slug.c_str(), -1, SQLITE_TRANSIENT), db,
"bind kb slug");
if (sqlite3_step(stmt) != SQLITE_ROW) {
sqlite3_finalize(stmt);
return std::nullopt;
}
KbArticleDetail detail;
detail.article.id = sqlite3_column_int64(stmt, 0);
detail.article.slug = ColText(stmt, 1);
detail.article.title = ColText(stmt, 2);
detail.article.content_md = ColText(stmt, 3);
detail.article.created_at = sqlite3_column_int64(stmt, 4);
sqlite3_finalize(stmt);
sqlite3_stmt* link_stmt = nullptr;
const char* link_sql =
"SELECT p.id,p.title FROM kb_article_links l "
"JOIN problems p ON p.id=l.problem_id "
"WHERE l.article_id=? ORDER BY p.id ASC";
CheckSqlite(sqlite3_prepare_v2(db, link_sql, -1, &link_stmt, nullptr), db,
"prepare kb links");
CheckSqlite(sqlite3_bind_int64(link_stmt, 1, detail.article.id), db,
"bind article_id");
while (sqlite3_step(link_stmt) == SQLITE_ROW) {
detail.related_problems.emplace_back(sqlite3_column_int64(link_stmt, 0),
ColText(link_stmt, 1));
}
sqlite3_finalize(link_stmt);
return detail;
}
} // namespace csp::services

查看文件

@@ -0,0 +1,174 @@
#include "csp/services/problem_gen_runner.h"
#include <drogon/drogon.h>
#include "csp/services/import_runner.h"
#include <algorithm>
#include <chrono>
#include <cctype>
#include <cstdlib>
#include <filesystem>
#include <thread>
#include <vector>
namespace csp::services {
namespace {
int64_t NowSec() {
using namespace std::chrono;
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
}
bool EnvBool(const char* key, bool default_value) {
const char* raw = std::getenv(key);
if (!raw) return default_value;
std::string val(raw);
for (auto& c : val) c = static_cast<char>(::tolower(static_cast<unsigned char>(c)));
if (val == "1" || val == "true" || val == "yes" || val == "on") return true;
if (val == "0" || val == "false" || val == "no" || val == "off") return false;
return default_value;
}
int EnvInt(const char* key, int default_value) {
const char* raw = std::getenv(key);
if (!raw) return default_value;
try {
return std::stoi(raw);
} catch (...) {
return default_value;
}
}
std::string ShellQuote(const std::string& text) {
std::string out = "'";
for (char c : text) {
if (c == '\'') {
out += "'\"'\"'";
} else {
out.push_back(c);
}
}
out.push_back('\'');
return out;
}
std::string ResolveScriptPath() {
const char* env_path = std::getenv("CSP_GEN_SCRIPT_PATH");
if (env_path && std::filesystem::exists(env_path)) return env_path;
const std::vector<std::string> candidates = {
"/app/scripts/generate_cspj_problem_rag.py",
"scripts/generate_cspj_problem_rag.py",
"../scripts/generate_cspj_problem_rag.py",
"../../scripts/generate_cspj_problem_rag.py",
};
for (const auto& p : candidates) {
if (std::filesystem::exists(p)) return p;
}
return "/app/scripts/generate_cspj_problem_rag.py";
}
std::string BuildCommand(const std::string& db_path,
const std::string& trigger,
int count) {
const std::string script_path = ResolveScriptPath();
const int final_count = std::max(1, std::min(5, count));
return "python3 " + ShellQuote(script_path) + " --db-path " +
ShellQuote(db_path) + " --count " + std::to_string(final_count);
}
} // namespace
ProblemGenRunner& ProblemGenRunner::Instance() {
static ProblemGenRunner inst;
return inst;
}
void ProblemGenRunner::Configure(std::string db_path) {
std::lock_guard<std::mutex> lock(mu_);
db_path_ = std::move(db_path);
}
bool ProblemGenRunner::TriggerAsync(const std::string& /*trigger*/, int count) {
std::string cmd;
{
std::lock_guard<std::mutex> lock(mu_);
if (running_) return false;
if (db_path_.empty()) return false;
cmd = BuildCommand(db_path_, "manual", count);
running_ = true;
last_started_at_ = NowSec();
last_command_ = cmd;
last_exit_code_.reset();
}
std::thread([this, command = std::move(cmd)]() {
const int rc = std::system(command.c_str());
std::lock_guard<std::mutex> lock(mu_);
last_exit_code_ = rc;
last_finished_at_ = NowSec();
running_ = false;
}).detach();
return true;
}
void ProblemGenRunner::AutoStartIfEnabled() {
if (!EnvBool("CSP_GEN_AUTO_RUN", true)) return;
const int count = EnvInt("CSP_GEN_COUNT", 1);
if (EnvBool("CSP_GEN_WAIT_FOR_IMPORT", true) &&
ImportRunner::Instance().IsRunning()) {
std::thread([count]() {
using namespace std::chrono_literals;
while (ImportRunner::Instance().IsRunning()) {
std::this_thread::sleep_for(2s);
}
const bool started = ProblemGenRunner::Instance().TriggerAsync("auto", count);
if (started) {
LOG_INFO << "problem generator auto-started after import";
} else {
LOG_INFO << "problem generator delayed auto-start skipped";
}
}).detach();
LOG_INFO << "problem generator waiting for import completion";
return;
}
const bool started = TriggerAsync("auto", count);
if (started) {
LOG_INFO << "problem generator auto-started";
} else {
LOG_INFO << "problem generator auto-start skipped";
}
}
bool ProblemGenRunner::IsRunning() const {
std::lock_guard<std::mutex> lock(mu_);
return running_;
}
std::string ProblemGenRunner::LastCommand() const {
std::lock_guard<std::mutex> lock(mu_);
return last_command_;
}
std::optional<int> ProblemGenRunner::LastExitCode() const {
std::lock_guard<std::mutex> lock(mu_);
return last_exit_code_;
}
int64_t ProblemGenRunner::LastStartedAt() const {
std::lock_guard<std::mutex> lock(mu_);
return last_started_at_;
}
int64_t ProblemGenRunner::LastFinishedAt() const {
std::lock_guard<std::mutex> lock(mu_);
return last_finished_at_;
}
} // namespace csp::services

查看文件

@@ -0,0 +1,201 @@
#include "csp/services/problem_service.h"
#include <sqlite3.h>
#include <algorithm>
#include <cctype>
#include <stdexcept>
#include <string>
#include <vector>
namespace csp::services {
namespace {
void CheckSqlite(int rc, sqlite3* db, const char* what) {
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return;
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
}
std::string ColText(sqlite3_stmt* stmt, int col) {
const unsigned char* txt = sqlite3_column_text(stmt, col);
return txt ? reinterpret_cast<const char*>(txt) : std::string();
}
domain::Problem ReadProblem(sqlite3_stmt* stmt) {
domain::Problem p;
p.id = sqlite3_column_int64(stmt, 0);
p.slug = ColText(stmt, 1);
p.title = ColText(stmt, 2);
p.statement_md = ColText(stmt, 3);
p.difficulty = sqlite3_column_int(stmt, 4);
p.source = ColText(stmt, 5);
p.statement_url = ColText(stmt, 6);
p.llm_profile_json = ColText(stmt, 7);
p.sample_input = ColText(stmt, 8);
p.sample_output = ColText(stmt, 9);
p.created_at = sqlite3_column_int64(stmt, 10);
return p;
}
std::string LowerCopy(const std::string& input) {
std::string out = input;
std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
return out;
}
std::string JoinWithAnd(const std::vector<std::string>& clauses) {
if (clauses.empty()) return "1=1";
std::string out;
for (size_t i = 0; i < clauses.size(); ++i) {
if (i) out += " AND ";
out += clauses[i];
}
return out;
}
struct BindValue {
enum class Type { Text, Int } type = Type::Text;
std::string text;
int value = 0;
};
void BindStmt(sqlite3* db, sqlite3_stmt* stmt, const std::vector<BindValue>& binds) {
int bind_index = 1;
for (const auto& bind : binds) {
if (bind.type == BindValue::Type::Text) {
CheckSqlite(
sqlite3_bind_text(stmt, bind_index++, bind.text.c_str(), -1, SQLITE_TRANSIENT),
db, "bind text");
} else {
CheckSqlite(sqlite3_bind_int(stmt, bind_index++, bind.value), db, "bind int");
}
}
}
std::pair<std::string, std::string> ResolveOrderBy(const ProblemQuery& query) {
const std::string key = LowerCopy(query.order_by);
const std::string dir = LowerCopy(query.order);
std::string order_col = "p.id";
if (key == "difficulty") {
order_col = "p.difficulty";
} else if (key == "created_at") {
order_col = "p.created_at";
} else if (key == "title") {
order_col = "p.title";
}
std::string order_dir = "ASC";
if (dir == "desc") order_dir = "DESC";
return {order_col, order_dir};
}
} // namespace
ProblemListResult ProblemService::List(const ProblemQuery& query) {
sqlite3* db = db_.raw();
std::vector<std::string> clauses;
std::vector<BindValue> binds;
if (!query.q.empty()) {
clauses.push_back("(p.title LIKE ? OR p.statement_md LIKE ?)");
const std::string pat = "%" + query.q + "%";
binds.push_back(BindValue{BindValue::Type::Text, pat, 0});
binds.push_back(BindValue{BindValue::Type::Text, pat, 0});
}
if (query.difficulty > 0) {
clauses.push_back("p.difficulty=?");
binds.push_back(BindValue{BindValue::Type::Int, "", query.difficulty});
}
if (!query.source_prefix.empty()) {
clauses.push_back("p.source LIKE ?");
binds.push_back(
BindValue{BindValue::Type::Text, query.source_prefix + "%", 0});
}
if (!query.tag.empty()) {
clauses.push_back(
"EXISTS(SELECT 1 FROM problem_tags pt1 WHERE pt1.problem_id=p.id AND pt1.tag=?)");
binds.push_back(BindValue{BindValue::Type::Text, query.tag, 0});
}
if (!query.tags.empty()) {
std::string in_clause;
for (size_t i = 0; i < query.tags.size(); ++i) {
if (i) in_clause += ",";
in_clause += "?";
binds.push_back(BindValue{BindValue::Type::Text, query.tags[i], 0});
}
clauses.push_back(
"EXISTS(SELECT 1 FROM problem_tags ptm WHERE ptm.problem_id=p.id AND ptm.tag IN (" +
in_clause + "))");
}
const std::string where_sql = JoinWithAnd(clauses);
const auto [order_col, order_dir] = ResolveOrderBy(query);
const int page = query.page <= 0 ? 1 : query.page;
const int page_size = std::max(1, std::min(200, query.page_size <= 0 ? 20 : query.page_size));
const int offset = (page - 1) * page_size;
ProblemListResult result;
{
sqlite3_stmt* count_stmt = nullptr;
const std::string count_sql = "SELECT COUNT(1) FROM problems p WHERE " + where_sql;
CheckSqlite(sqlite3_prepare_v2(db, count_sql.c_str(), -1, &count_stmt, nullptr),
db, "prepare count problems");
BindStmt(db, count_stmt, binds);
if (sqlite3_step(count_stmt) == SQLITE_ROW) {
result.total_count = sqlite3_column_int(count_stmt, 0);
}
sqlite3_finalize(count_stmt);
}
sqlite3_stmt* stmt = nullptr;
const std::string sql =
"SELECT "
"p.id,p.slug,p.title,p.statement_md,p.difficulty,p.source,p.statement_url,p.llm_profile_json,"
"p.sample_input,p.sample_output,p.created_at "
"FROM problems p "
"WHERE " + where_sql + " "
"ORDER BY " + order_col + " " + order_dir + " "
"LIMIT ? OFFSET ?";
CheckSqlite(sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr), db,
"prepare list problems");
std::vector<BindValue> list_binds = binds;
list_binds.push_back(BindValue{BindValue::Type::Int, "", page_size});
list_binds.push_back(BindValue{BindValue::Type::Int, "", offset});
BindStmt(db, stmt, list_binds);
while (sqlite3_step(stmt) == SQLITE_ROW) {
result.items.push_back(ReadProblem(stmt));
}
sqlite3_finalize(stmt);
return result;
}
std::optional<domain::Problem> ProblemService::GetById(int64_t id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,slug,title,statement_md,difficulty,source,statement_url,llm_profile_json,"
"sample_input,sample_output,created_at "
"FROM problems WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare get problem");
CheckSqlite(sqlite3_bind_int64(stmt, 1, id), db, "bind problem_id");
if (sqlite3_step(stmt) != SQLITE_ROW) {
sqlite3_finalize(stmt);
return std::nullopt;
}
auto p = ReadProblem(stmt);
sqlite3_finalize(stmt);
return p;
}
} // namespace csp::services

查看文件

@@ -0,0 +1,86 @@
#include "csp/services/problem_solution_runner.h"
#include <algorithm>
#include <cstdlib>
#include <filesystem>
#include <thread>
#include <vector>
namespace csp::services {
namespace {
std::string ShellQuote(const std::string& text) {
std::string out = "'";
for (char c : text) {
if (c == '\'') {
out += "'\"'\"'";
} else {
out.push_back(c);
}
}
out.push_back('\'');
return out;
}
std::string ResolveScriptPath() {
const char* env_path = std::getenv("CSP_SOLUTION_SCRIPT_PATH");
if (env_path && std::filesystem::exists(env_path)) return env_path;
const std::vector<std::string> candidates = {
"/app/scripts/generate_problem_solutions.py",
"scripts/generate_problem_solutions.py",
"../scripts/generate_problem_solutions.py",
"../../scripts/generate_problem_solutions.py",
};
for (const auto& p : candidates) {
if (std::filesystem::exists(p)) return p;
}
return "/app/scripts/generate_problem_solutions.py";
}
} // namespace
ProblemSolutionRunner& ProblemSolutionRunner::Instance() {
static ProblemSolutionRunner inst;
return inst;
}
void ProblemSolutionRunner::Configure(std::string db_path) {
std::lock_guard<std::mutex> lock(mu_);
db_path_ = std::move(db_path);
}
bool ProblemSolutionRunner::TriggerAsync(int64_t problem_id,
int64_t job_id,
int max_solutions) {
std::string cmd;
{
std::lock_guard<std::mutex> lock(mu_);
if (db_path_.empty()) return false;
if (running_problem_ids_.count(problem_id) > 0) return false;
running_problem_ids_.insert(problem_id);
const std::string script_path = ResolveScriptPath();
const int clamped = std::max(1, std::min(5, max_solutions));
cmd = "python3 " + ShellQuote(script_path) + " --db-path " +
ShellQuote(db_path_) + " --problem-id " + std::to_string(problem_id) +
" --job-id " + std::to_string(job_id) + " --max-solutions " +
std::to_string(clamped);
}
std::thread([this, problem_id, command = std::move(cmd)]() {
std::system(command.c_str());
std::lock_guard<std::mutex> lock(mu_);
running_problem_ids_.erase(problem_id);
}).detach();
return true;
}
bool ProblemSolutionRunner::IsRunning(int64_t problem_id) const {
std::lock_guard<std::mutex> lock(mu_);
return running_problem_ids_.count(problem_id) > 0;
}
} // namespace csp::services

查看文件

@@ -0,0 +1,212 @@
#include "csp/services/problem_workspace_service.h"
#include <sqlite3.h>
#include <algorithm>
#include <chrono>
#include <stdexcept>
#include <string>
namespace csp::services {
namespace {
int64_t NowSec() {
using namespace std::chrono;
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
}
void CheckSqlite(int rc, sqlite3* db, const char* what) {
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return;
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
}
std::string ColText(sqlite3_stmt* stmt, int col) {
const unsigned char* txt = sqlite3_column_text(stmt, col);
return txt ? reinterpret_cast<const char*>(txt) : std::string();
}
std::optional<int64_t> ColNullableInt64(sqlite3_stmt* stmt, int col) {
if (sqlite3_column_type(stmt, col) == SQLITE_NULL) return std::nullopt;
return sqlite3_column_int64(stmt, col);
}
ProblemSolution ReadSolution(sqlite3_stmt* stmt) {
ProblemSolution s;
s.id = sqlite3_column_int64(stmt, 0);
s.problem_id = sqlite3_column_int64(stmt, 1);
s.variant = sqlite3_column_int(stmt, 2);
s.title = ColText(stmt, 3);
s.idea_md = ColText(stmt, 4);
s.explanation_md = ColText(stmt, 5);
s.code_cpp = ColText(stmt, 6);
s.complexity = ColText(stmt, 7);
s.tags_json = ColText(stmt, 8);
s.source = ColText(stmt, 9);
s.created_at = sqlite3_column_int64(stmt, 10);
s.updated_at = sqlite3_column_int64(stmt, 11);
return s;
}
ProblemSolutionJob ReadSolutionJob(sqlite3_stmt* stmt) {
ProblemSolutionJob j;
j.id = sqlite3_column_int64(stmt, 0);
j.problem_id = sqlite3_column_int64(stmt, 1);
j.status = ColText(stmt, 2);
j.progress = sqlite3_column_int(stmt, 3);
j.message = ColText(stmt, 4);
j.created_by = sqlite3_column_int64(stmt, 5);
j.max_solutions = sqlite3_column_int(stmt, 6);
j.created_at = sqlite3_column_int64(stmt, 7);
j.started_at = ColNullableInt64(stmt, 8);
j.finished_at = ColNullableInt64(stmt, 9);
j.updated_at = sqlite3_column_int64(stmt, 10);
return j;
}
} // namespace
bool ProblemWorkspaceService::ProblemExists(int64_t problem_id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql = "SELECT 1 FROM problems WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare exists problem");
CheckSqlite(sqlite3_bind_int64(stmt, 1, problem_id), db, "bind problem_id");
const bool ok = sqlite3_step(stmt) == SQLITE_ROW;
sqlite3_finalize(stmt);
return ok;
}
void ProblemWorkspaceService::SaveDraft(int64_t user_id,
int64_t problem_id,
const std::string& language,
const std::string& code,
const std::string& stdin_text) {
if (user_id <= 0 || problem_id <= 0) {
throw std::runtime_error("invalid user_id/problem_id");
}
if (code.empty()) {
throw std::runtime_error("code is empty");
}
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"INSERT INTO problem_drafts(user_id,problem_id,language,code,stdin,updated_at,created_at) "
"VALUES(?,?,?,?,?,?,?) "
"ON CONFLICT(user_id,problem_id) DO UPDATE SET "
"language=excluded.language,code=excluded.code,stdin=excluded.stdin,updated_at=excluded.updated_at";
const int64_t now = NowSec();
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare save draft");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
CheckSqlite(sqlite3_bind_text(stmt, 3, language.c_str(), -1, SQLITE_TRANSIENT),
db, "bind language");
CheckSqlite(sqlite3_bind_text(stmt, 4, code.c_str(), -1, SQLITE_TRANSIENT), db,
"bind code");
CheckSqlite(
sqlite3_bind_text(stmt, 5, stdin_text.c_str(), -1, SQLITE_TRANSIENT), db,
"bind stdin");
CheckSqlite(sqlite3_bind_int64(stmt, 6, now), db, "bind updated_at");
CheckSqlite(sqlite3_bind_int64(stmt, 7, now), db, "bind created_at");
CheckSqlite(sqlite3_step(stmt), db, "save draft");
sqlite3_finalize(stmt);
}
std::optional<ProblemDraft> ProblemWorkspaceService::GetDraft(int64_t user_id,
int64_t problem_id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT language,code,stdin,updated_at FROM problem_drafts WHERE user_id=? AND problem_id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare get draft");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
if (sqlite3_step(stmt) != SQLITE_ROW) {
sqlite3_finalize(stmt);
return std::nullopt;
}
ProblemDraft d;
d.language = ColText(stmt, 0);
d.code = ColText(stmt, 1);
d.stdin_text = ColText(stmt, 2);
d.updated_at = sqlite3_column_int64(stmt, 3);
sqlite3_finalize(stmt);
return d;
}
int64_t ProblemWorkspaceService::CreateSolutionJob(int64_t problem_id,
int64_t created_by,
int max_solutions) {
if (problem_id <= 0) throw std::runtime_error("invalid problem_id");
const int clamped = std::max(1, std::min(5, max_solutions));
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const int64_t now = NowSec();
const char* sql =
"INSERT INTO problem_solution_jobs(problem_id,status,progress,message,created_by,max_solutions,"
"created_at,started_at,finished_at,updated_at) "
"VALUES(?,?,?,?,?,?,?,?,?,?)";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare create solution job");
CheckSqlite(sqlite3_bind_int64(stmt, 1, problem_id), db, "bind problem_id");
CheckSqlite(sqlite3_bind_text(stmt, 2, "queued", -1, SQLITE_STATIC), db,
"bind status");
CheckSqlite(sqlite3_bind_int(stmt, 3, 0), db, "bind progress");
CheckSqlite(sqlite3_bind_text(stmt, 4, "", -1, SQLITE_STATIC), db,
"bind message");
CheckSqlite(sqlite3_bind_int64(stmt, 5, created_by), db, "bind created_by");
CheckSqlite(sqlite3_bind_int(stmt, 6, clamped), db, "bind max_solutions");
CheckSqlite(sqlite3_bind_int64(stmt, 7, now), db, "bind created_at");
CheckSqlite(sqlite3_bind_null(stmt, 8), db, "bind started_at");
CheckSqlite(sqlite3_bind_null(stmt, 9), db, "bind finished_at");
CheckSqlite(sqlite3_bind_int64(stmt, 10, now), db, "bind updated_at");
CheckSqlite(sqlite3_step(stmt), db, "create solution job");
sqlite3_finalize(stmt);
return sqlite3_last_insert_rowid(db);
}
std::optional<ProblemSolutionJob> ProblemWorkspaceService::GetLatestSolutionJob(
int64_t problem_id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,problem_id,status,progress,message,created_by,max_solutions,"
"created_at,started_at,finished_at,updated_at "
"FROM problem_solution_jobs WHERE problem_id=? ORDER BY id DESC LIMIT 1";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare latest solution job");
CheckSqlite(sqlite3_bind_int64(stmt, 1, problem_id), db, "bind problem_id");
if (sqlite3_step(stmt) != SQLITE_ROW) {
sqlite3_finalize(stmt);
return std::nullopt;
}
auto row = ReadSolutionJob(stmt);
sqlite3_finalize(stmt);
return row;
}
std::vector<ProblemSolution> ProblemWorkspaceService::ListSolutions(int64_t problem_id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,problem_id,variant,title,idea_md,explanation_md,code_cpp,complexity,tags_json,source,created_at,updated_at "
"FROM problem_solutions WHERE problem_id=? ORDER BY variant ASC, id ASC";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare list solutions");
CheckSqlite(sqlite3_bind_int64(stmt, 1, problem_id), db, "bind problem_id");
std::vector<ProblemSolution> rows;
while (sqlite3_step(stmt) == SQLITE_ROW) {
rows.push_back(ReadSolution(stmt));
}
sqlite3_finalize(stmt);
return rows;
}
} // namespace csp::services

查看文件

@@ -0,0 +1,418 @@
#include "csp/services/submission_service.h"
#include "csp/domain/enum_strings.h"
#include "csp/services/crypto.h"
#include "csp/services/wrong_book_service.h"
#include <sqlite3.h>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <optional>
#include <stdexcept>
#include <string>
#include <sys/wait.h>
namespace csp::services {
namespace {
struct JudgeOutcome {
domain::SubmissionStatus status = domain::SubmissionStatus::Unknown;
int time_ms = 0;
std::string stdout_text;
std::string stderr_text;
std::string compile_log;
};
void CheckSqlite(int rc, sqlite3* db, const char* what) {
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return;
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
}
int64_t NowSec() {
using namespace std::chrono;
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
}
std::string ColText(sqlite3_stmt* stmt, int col) {
const unsigned char* txt = sqlite3_column_text(stmt, col);
return txt ? reinterpret_cast<const char*>(txt) : std::string();
}
std::string ReadFile(const std::filesystem::path& p) {
std::ifstream in(p, std::ios::in | std::ios::binary);
if (!in) return "";
return std::string((std::istreambuf_iterator<char>(in)),
std::istreambuf_iterator<char>());
}
void WriteFile(const std::filesystem::path& p, const std::string& s) {
std::ofstream out(p, std::ios::out | std::ios::binary | std::ios::trunc);
if (!out) throw std::runtime_error("cannot open file: " + p.string());
out << s;
}
std::string Trim(std::string s) {
while (!s.empty() &&
(s.back() == '\n' || s.back() == '\r' || s.back() == ' ' ||
s.back() == '\t')) {
s.pop_back();
}
size_t i = 0;
while (i < s.size() &&
(s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r')) {
++i;
}
return s.substr(i);
}
int ExitCodeFromSystem(int rc) {
if (rc == -1) return -1;
if (WIFEXITED(rc)) return WEXITSTATUS(rc);
if (WIFSIGNALED(rc)) return 128 + WTERMSIG(rc);
return -1;
}
JudgeOutcome JudgeCpp(const std::string& code,
const std::string& input,
const std::optional<std::string>& expected_output) {
namespace fs = std::filesystem;
const fs::path workdir = fs::path("/tmp") / ("csp_judge_" + crypto::RandomHex(8));
fs::create_directories(workdir);
const fs::path src = workdir / "main.cpp";
const fs::path bin = workdir / "main.bin";
const fs::path in = workdir / "input.txt";
const fs::path out = workdir / "output.txt";
const fs::path compile_log = workdir / "compile.log";
const fs::path runtime_log = workdir / "runtime.log";
WriteFile(src, code);
WriteFile(in, input);
JudgeOutcome outcome;
try {
const std::string compile_cmd =
"g++ -std=c++20 -O2 \"" + src.string() + "\" -o \"" + bin.string() +
"\" 2> \"" + compile_log.string() + "\"";
const int compile_rc = std::system(compile_cmd.c_str());
outcome.compile_log = ReadFile(compile_log);
if (ExitCodeFromSystem(compile_rc) != 0) {
outcome.status = domain::SubmissionStatus::CE;
fs::remove_all(workdir);
return outcome;
}
const auto start = std::chrono::steady_clock::now();
const std::string run_cmd =
"/usr/bin/timeout 2s \"" + bin.string() + "\" < \"" + in.string() +
"\" > \"" + out.string() + "\" 2> \"" + runtime_log.string() + "\"";
const int run_rc = std::system(run_cmd.c_str());
const auto end = std::chrono::steady_clock::now();
outcome.time_ms = static_cast<int>(
std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count());
outcome.stdout_text = ReadFile(out);
outcome.stderr_text = ReadFile(runtime_log);
const int code_rc = ExitCodeFromSystem(run_rc);
if (code_rc == 124) {
outcome.status = domain::SubmissionStatus::TLE;
fs::remove_all(workdir);
return outcome;
}
if (code_rc != 0) {
outcome.status = domain::SubmissionStatus::RE;
fs::remove_all(workdir);
return outcome;
}
if (expected_output.has_value()) {
outcome.status =
(Trim(outcome.stdout_text) == Trim(*expected_output))
? domain::SubmissionStatus::AC
: domain::SubmissionStatus::WA;
fs::remove_all(workdir);
return outcome;
}
outcome.status = domain::SubmissionStatus::Running;
fs::remove_all(workdir);
return outcome;
} catch (...) {
fs::remove_all(workdir);
throw;
}
}
std::optional<domain::Problem> GetProblem(sqlite3* db, int64_t problem_id) {
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,slug,title,statement_md,difficulty,source,statement_url,llm_profile_json,"
"sample_input,sample_output,created_at "
"FROM problems WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare submission.get_problem");
CheckSqlite(sqlite3_bind_int64(stmt, 1, problem_id), db, "bind problem_id");
if (sqlite3_step(stmt) != SQLITE_ROW) {
sqlite3_finalize(stmt);
return std::nullopt;
}
domain::Problem p;
p.id = sqlite3_column_int64(stmt, 0);
p.slug = ColText(stmt, 1);
p.title = ColText(stmt, 2);
p.statement_md = ColText(stmt, 3);
p.difficulty = sqlite3_column_int(stmt, 4);
p.source = ColText(stmt, 5);
p.statement_url = ColText(stmt, 6);
p.llm_profile_json = ColText(stmt, 7);
p.sample_input = ColText(stmt, 8);
p.sample_output = ColText(stmt, 9);
p.created_at = sqlite3_column_int64(stmt, 10);
sqlite3_finalize(stmt);
return p;
}
bool HasSolvedBefore(sqlite3* db,
int64_t user_id,
int64_t problem_id,
int64_t submission_id) {
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT 1 FROM submissions WHERE user_id=? AND problem_id=? AND status='AC' AND id<? LIMIT 1";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare has_solved_before");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
CheckSqlite(sqlite3_bind_int64(stmt, 3, submission_id), db,
"bind submission_id");
const bool solved = sqlite3_step(stmt) == SQLITE_ROW;
sqlite3_finalize(stmt);
return solved;
}
void AddRating(sqlite3* db, int64_t user_id, int delta) {
sqlite3_stmt* stmt = nullptr;
const char* sql = "UPDATE users SET rating = rating + ? WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare add_rating");
CheckSqlite(sqlite3_bind_int(stmt, 1, delta), db, "bind delta");
CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id");
CheckSqlite(sqlite3_step(stmt), db, "exec add_rating");
sqlite3_finalize(stmt);
}
std::string ToStatusText(domain::SubmissionStatus s) { return domain::ToString(s); }
} // namespace
domain::Submission SubmissionService::CreateAndJudge(const SubmissionCreateRequest& req) {
if (req.user_id <= 0 || req.problem_id <= 0) {
throw std::runtime_error("invalid user_id/problem_id");
}
if (req.language != "cpp" && req.language != "c++" && req.language != "C++") {
throw std::runtime_error("only cpp language is supported");
}
if (req.code.empty()) {
throw std::runtime_error("code is empty");
}
if (req.code.size() > 200000) {
throw std::runtime_error("code is too large");
}
sqlite3* db = db_.raw();
const auto problem = GetProblem(db, req.problem_id);
if (!problem.has_value()) {
throw std::runtime_error("problem not found");
}
sqlite3_stmt* ins = nullptr;
const char* ins_sql =
"INSERT INTO submissions(user_id,problem_id,contest_id,language,code,status,score,time_ms,memory_kb,compile_log,runtime_log,created_at) "
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?)";
CheckSqlite(sqlite3_prepare_v2(db, ins_sql, -1, &ins, nullptr), db,
"prepare insert submission");
CheckSqlite(sqlite3_bind_int64(ins, 1, req.user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_int64(ins, 2, req.problem_id), db, "bind problem_id");
if (req.contest_id.has_value()) {
CheckSqlite(sqlite3_bind_int64(ins, 3, *req.contest_id), db, "bind contest_id");
} else {
CheckSqlite(sqlite3_bind_null(ins, 3), db, "bind contest_id null");
}
CheckSqlite(sqlite3_bind_text(ins, 4, "cpp", -1, SQLITE_STATIC), db,
"bind language");
CheckSqlite(sqlite3_bind_text(ins, 5, req.code.c_str(), -1, SQLITE_TRANSIENT), db,
"bind code");
CheckSqlite(sqlite3_bind_text(ins, 6, "Pending", -1, SQLITE_STATIC), db,
"bind status");
CheckSqlite(sqlite3_bind_int(ins, 7, 0), db, "bind score");
CheckSqlite(sqlite3_bind_int(ins, 8, 0), db, "bind time_ms");
CheckSqlite(sqlite3_bind_int(ins, 9, 0), db, "bind memory_kb");
CheckSqlite(sqlite3_bind_text(ins, 10, "", -1, SQLITE_STATIC), db,
"bind compile_log");
CheckSqlite(sqlite3_bind_text(ins, 11, "", -1, SQLITE_STATIC), db,
"bind runtime_log");
CheckSqlite(sqlite3_bind_int64(ins, 12, NowSec()), db, "bind created_at");
CheckSqlite(sqlite3_step(ins), db, "insert submission");
sqlite3_finalize(ins);
const int64_t submission_id = sqlite3_last_insert_rowid(db);
JudgeOutcome outcome = JudgeCpp(req.code, problem->sample_input, problem->sample_output);
const int score = outcome.status == domain::SubmissionStatus::AC ? 100 : 0;
sqlite3_stmt* upd = nullptr;
const char* upd_sql =
"UPDATE submissions SET status=?,score=?,time_ms=?,memory_kb=?,compile_log=?,runtime_log=? WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, upd_sql, -1, &upd, nullptr), db,
"prepare update submission");
const auto status_text = ToStatusText(outcome.status);
CheckSqlite(sqlite3_bind_text(upd, 1, status_text.c_str(), -1, SQLITE_TRANSIENT), db,
"bind status");
CheckSqlite(sqlite3_bind_int(upd, 2, score), db, "bind score");
CheckSqlite(sqlite3_bind_int(upd, 3, outcome.time_ms), db, "bind time_ms");
CheckSqlite(sqlite3_bind_int(upd, 4, 0), db, "bind memory_kb");
CheckSqlite(sqlite3_bind_text(upd, 5, outcome.compile_log.c_str(), -1, SQLITE_TRANSIENT),
db, "bind compile_log");
CheckSqlite(sqlite3_bind_text(upd, 6, outcome.stderr_text.c_str(), -1, SQLITE_TRANSIENT),
db, "bind runtime_log");
CheckSqlite(sqlite3_bind_int64(upd, 7, submission_id), db, "bind submission_id");
CheckSqlite(sqlite3_step(upd), db, "update submission");
sqlite3_finalize(upd);
WrongBookService wb(db_);
if (outcome.status == domain::SubmissionStatus::AC) {
wb.Remove(req.user_id, req.problem_id);
if (!HasSolvedBefore(db, req.user_id, req.problem_id, submission_id)) {
AddRating(db, req.user_id, problem->difficulty * 10);
}
} else {
wb.UpsertBySubmission(req.user_id, req.problem_id, submission_id,
"最近一次提交未通过,请复盘题解和思路。");
}
const auto saved = GetById(submission_id);
if (!saved.has_value()) {
throw std::runtime_error("submission saved but reload failed");
}
return *saved;
}
std::vector<domain::Submission> SubmissionService::List(std::optional<int64_t> user_id,
std::optional<int64_t> problem_id,
std::optional<int64_t> contest_id,
int page,
int page_size) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
std::string sql =
"SELECT id,user_id,problem_id,contest_id,language,code,status,score,time_ms,memory_kb,compile_log,runtime_log,created_at "
"FROM submissions WHERE 1=1 ";
if (user_id.has_value()) sql += "AND user_id=? ";
if (problem_id.has_value()) sql += "AND problem_id=? ";
if (contest_id.has_value()) sql += "AND contest_id=? ";
sql += "ORDER BY id DESC LIMIT ? OFFSET ?";
CheckSqlite(sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr), db,
"prepare list submissions");
int idx = 1;
if (user_id.has_value())
CheckSqlite(sqlite3_bind_int64(stmt, idx++, *user_id), db, "bind user_id");
if (problem_id.has_value())
CheckSqlite(sqlite3_bind_int64(stmt, idx++, *problem_id), db, "bind problem_id");
if (contest_id.has_value())
CheckSqlite(sqlite3_bind_int64(stmt, idx++, *contest_id), db, "bind contest_id");
if (page <= 0) page = 1;
if (page_size <= 0) page_size = 20;
const int offset = (page - 1) * page_size;
CheckSqlite(sqlite3_bind_int(stmt, idx++, page_size), db, "bind limit");
CheckSqlite(sqlite3_bind_int(stmt, idx++, offset), db, "bind offset");
std::vector<domain::Submission> out;
while (sqlite3_step(stmt) == SQLITE_ROW) {
domain::Submission s;
s.id = sqlite3_column_int64(stmt, 0);
s.user_id = sqlite3_column_int64(stmt, 1);
s.problem_id = sqlite3_column_int64(stmt, 2);
if (sqlite3_column_type(stmt, 3) == SQLITE_NULL) {
s.contest_id = std::nullopt;
} else {
s.contest_id = sqlite3_column_int64(stmt, 3);
}
s.language = domain::LanguageFromString(ColText(stmt, 4));
s.code = ColText(stmt, 5);
s.status = domain::SubmissionStatusFromString(ColText(stmt, 6));
s.score = sqlite3_column_int(stmt, 7);
s.time_ms = sqlite3_column_int(stmt, 8);
s.memory_kb = sqlite3_column_int(stmt, 9);
s.compile_log = ColText(stmt, 10);
s.runtime_log = ColText(stmt, 11);
s.created_at = sqlite3_column_int64(stmt, 12);
out.push_back(std::move(s));
}
sqlite3_finalize(stmt);
return out;
}
std::optional<domain::Submission> SubmissionService::GetById(int64_t id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,user_id,problem_id,contest_id,language,code,status,score,time_ms,memory_kb,compile_log,runtime_log,created_at "
"FROM submissions WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare get submission");
CheckSqlite(sqlite3_bind_int64(stmt, 1, id), db, "bind submission_id");
if (sqlite3_step(stmt) != SQLITE_ROW) {
sqlite3_finalize(stmt);
return std::nullopt;
}
domain::Submission s;
s.id = sqlite3_column_int64(stmt, 0);
s.user_id = sqlite3_column_int64(stmt, 1);
s.problem_id = sqlite3_column_int64(stmt, 2);
if (sqlite3_column_type(stmt, 3) == SQLITE_NULL) {
s.contest_id = std::nullopt;
} else {
s.contest_id = sqlite3_column_int64(stmt, 3);
}
s.language = domain::LanguageFromString(ColText(stmt, 4));
s.code = ColText(stmt, 5);
s.status = domain::SubmissionStatusFromString(ColText(stmt, 6));
s.score = sqlite3_column_int(stmt, 7);
s.time_ms = sqlite3_column_int(stmt, 8);
s.memory_kb = sqlite3_column_int(stmt, 9);
s.compile_log = ColText(stmt, 10);
s.runtime_log = ColText(stmt, 11);
s.created_at = sqlite3_column_int64(stmt, 12);
sqlite3_finalize(stmt);
return s;
}
RunOnlyResult SubmissionService::RunOnlyCpp(const std::string& code,
const std::string& input) {
if (code.empty()) throw std::runtime_error("code is empty");
if (code.size() > 200000) throw std::runtime_error("code is too large");
auto outcome = JudgeCpp(code, input, std::nullopt);
RunOnlyResult r;
r.status = outcome.status;
r.time_ms = outcome.time_ms;
r.stdout_text = outcome.stdout_text;
r.stderr_text = outcome.stderr_text;
r.compile_log = outcome.compile_log;
return r;
}
} // namespace csp::services

查看文件

@@ -0,0 +1,73 @@
#include "csp/services/user_service.h"
#include <sqlite3.h>
#include <stdexcept>
#include <string>
namespace csp::services {
namespace {
void CheckSqlite(int rc, sqlite3* db, const char* what) {
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return;
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
}
std::string ColText(sqlite3_stmt* stmt, int col) {
const unsigned char* txt = sqlite3_column_text(stmt, col);
return txt ? reinterpret_cast<const char*>(txt) : std::string();
}
} // namespace
std::optional<domain::User> UserService::GetById(int64_t id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,username,password_salt,password_hash,rating,created_at FROM users WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare user get");
CheckSqlite(sqlite3_bind_int64(stmt, 1, id), db, "bind user_id");
if (sqlite3_step(stmt) != SQLITE_ROW) {
sqlite3_finalize(stmt);
return std::nullopt;
}
domain::User u;
u.id = sqlite3_column_int64(stmt, 0);
u.username = ColText(stmt, 1);
u.password_salt = ColText(stmt, 2);
u.password_hash = ColText(stmt, 3);
u.rating = sqlite3_column_int(stmt, 4);
u.created_at = sqlite3_column_int64(stmt, 5);
sqlite3_finalize(stmt);
return u;
}
std::vector<domain::GlobalLeaderboardEntry> UserService::GlobalLeaderboard(int limit) {
if (limit <= 0) limit = 100;
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,username,rating,created_at FROM users ORDER BY rating DESC,id ASC LIMIT ?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare leaderboard");
CheckSqlite(sqlite3_bind_int(stmt, 1, limit), db, "bind limit");
std::vector<domain::GlobalLeaderboardEntry> out;
while (sqlite3_step(stmt) == SQLITE_ROW) {
domain::GlobalLeaderboardEntry e;
e.user_id = sqlite3_column_int64(stmt, 0);
e.username = ColText(stmt, 1);
e.rating = sqlite3_column_int(stmt, 2);
e.created_at = sqlite3_column_int64(stmt, 3);
out.push_back(std::move(e));
}
sqlite3_finalize(stmt);
return out;
}
} // namespace csp::services

查看文件

@@ -0,0 +1,123 @@
#include "csp/services/wrong_book_service.h"
#include <sqlite3.h>
#include <chrono>
#include <stdexcept>
#include <string>
namespace csp::services {
namespace {
void CheckSqlite(int rc, sqlite3* db, const char* what) {
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return;
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
}
int64_t NowSec() {
using namespace std::chrono;
return duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
}
std::string ColText(sqlite3_stmt* stmt, int col) {
const unsigned char* txt = sqlite3_column_text(stmt, col);
return txt ? reinterpret_cast<const char*>(txt) : std::string();
}
} // namespace
std::vector<WrongBookEntry> WrongBookService::ListByUser(int64_t user_id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT w.user_id,w.problem_id,w.last_submission_id,w.note,w.updated_at,p.title "
"FROM wrong_book w "
"JOIN problems p ON p.id=w.problem_id "
"WHERE w.user_id=? ORDER BY w.updated_at DESC";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare wrong_book list");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
std::vector<WrongBookEntry> out;
while (sqlite3_step(stmt) == SQLITE_ROW) {
WrongBookEntry e;
e.item.user_id = sqlite3_column_int64(stmt, 0);
e.item.problem_id = sqlite3_column_int64(stmt, 1);
if (sqlite3_column_type(stmt, 2) == SQLITE_NULL) {
e.item.last_submission_id = std::nullopt;
} else {
e.item.last_submission_id = sqlite3_column_int64(stmt, 2);
}
e.item.note = ColText(stmt, 3);
e.item.updated_at = sqlite3_column_int64(stmt, 4);
e.problem_title = ColText(stmt, 5);
out.push_back(std::move(e));
}
sqlite3_finalize(stmt);
return out;
}
void WrongBookService::UpsertNote(int64_t user_id,
int64_t problem_id,
const std::string& note) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"INSERT INTO wrong_book(user_id,problem_id,last_submission_id,note,updated_at) "
"VALUES(?,?,?,?,?) "
"ON CONFLICT(user_id,problem_id) DO UPDATE SET note=excluded.note,updated_at=excluded.updated_at";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare wrong_book upsert note");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
CheckSqlite(sqlite3_bind_null(stmt, 3), db, "bind last_submission_id");
CheckSqlite(sqlite3_bind_text(stmt, 4, note.c_str(), -1, SQLITE_TRANSIENT), db,
"bind note");
CheckSqlite(sqlite3_bind_int64(stmt, 5, NowSec()), db, "bind updated_at");
CheckSqlite(sqlite3_step(stmt), db, "wrong_book upsert note");
sqlite3_finalize(stmt);
}
void WrongBookService::UpsertBySubmission(int64_t user_id,
int64_t problem_id,
int64_t submission_id,
const std::string& note) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"INSERT INTO wrong_book(user_id,problem_id,last_submission_id,note,updated_at) "
"VALUES(?,?,?,?,?) "
"ON CONFLICT(user_id,problem_id) DO UPDATE SET "
"last_submission_id=excluded.last_submission_id,"
"updated_at=excluded.updated_at,"
"note=CASE WHEN wrong_book.note='' THEN excluded.note ELSE wrong_book.note END";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare wrong_book upsert by submission");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
CheckSqlite(sqlite3_bind_int64(stmt, 3, submission_id), db,
"bind submission_id");
CheckSqlite(sqlite3_bind_text(stmt, 4, note.c_str(), -1, SQLITE_TRANSIENT), db,
"bind note");
CheckSqlite(sqlite3_bind_int64(stmt, 5, NowSec()), db, "bind updated_at");
CheckSqlite(sqlite3_step(stmt), db, "wrong_book upsert by submission");
sqlite3_finalize(stmt);
}
void WrongBookService::Remove(int64_t user_id, int64_t problem_id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql = "DELETE FROM wrong_book WHERE user_id=? AND problem_id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare wrong_book delete");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
CheckSqlite(sqlite3_step(stmt), db, "wrong_book delete");
sqlite3_finalize(stmt);
}
} // namespace csp::services

查看文件

@@ -0,0 +1,94 @@
#include <catch2/catch_test_macros.hpp>
#include "csp/app_state.h"
#include "csp/controllers/contest_controller.h"
#include "csp/services/auth_service.h"
#include <drogon/HttpRequest.h>
#include <future>
namespace {
drogon::HttpResponsePtr CallList(csp::controllers::ContestController& ctl) {
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
std::promise<drogon::HttpResponsePtr> p;
ctl.list(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); });
return p.get_future().get();
}
drogon::HttpResponsePtr CallGet(csp::controllers::ContestController& ctl,
int64_t contest_id,
const std::string& token) {
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
req->addHeader("Authorization", "Bearer " + token);
std::promise<drogon::HttpResponsePtr> p;
ctl.getById(req,
[&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); },
contest_id);
return p.get_future().get();
}
drogon::HttpResponsePtr CallRegister(csp::controllers::ContestController& ctl,
int64_t contest_id,
const std::string& token) {
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Post);
req->addHeader("Authorization", "Bearer " + token);
std::promise<drogon::HttpResponsePtr> p;
ctl.registerForContest(req,
[&p](const drogon::HttpResponsePtr& resp) {
p.set_value(resp);
},
contest_id);
return p.get_future().get();
}
drogon::HttpResponsePtr CallBoard(csp::controllers::ContestController& ctl,
int64_t contest_id) {
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
std::promise<drogon::HttpResponsePtr> p;
ctl.leaderboard(req,
[&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); },
contest_id);
return p.get_future().get();
}
} // namespace
TEST_CASE("contest controller list/get/register/leaderboard") {
csp::AppState::Instance().Init(":memory:");
csp::services::AuthService auth(csp::AppState::Instance().db());
const auto user = auth.Register("contest_http_user", "password123");
csp::controllers::ContestController ctl;
auto list = CallList(ctl);
REQUIRE(list->statusCode() == drogon::k200OK);
auto list_json = list->jsonObject();
REQUIRE(list_json != nullptr);
REQUIRE((*list_json)["data"].isArray());
REQUIRE((*list_json)["data"].size() >= 1);
const int64_t contest_id = (*list_json)["data"][0]["id"].asInt64();
auto get = CallGet(ctl, contest_id, user.token);
REQUIRE(get->statusCode() == drogon::k200OK);
auto reg = CallRegister(ctl, contest_id, user.token);
REQUIRE(reg->statusCode() == drogon::k200OK);
auto board = CallBoard(ctl, contest_id);
REQUIRE(board->statusCode() == drogon::k200OK);
auto board_json = board->jsonObject();
REQUIRE(board_json != nullptr);
REQUIRE((*board_json)["data"].isArray());
}

查看文件

@@ -0,0 +1,75 @@
#include <catch2/catch_test_macros.hpp>
#include "csp/db/sqlite_db.h"
#include "csp/services/auth_service.h"
#include "csp/services/contest_service.h"
#include "csp/services/submission_service.h"
namespace {
const char* kAcCode = R"CPP(#include <bits/stdc++.h>
using namespace std;
int main() {
long long a, b;
if (!(cin >> a >> b)) return 0;
cout << (a + b) << "\n";
return 0;
}
)CPP";
const char* kWaCode = R"CPP(#include <bits/stdc++.h>
using namespace std;
int main() {
cout << 0 << "\n";
return 0;
}
)CPP";
} // namespace
TEST_CASE("contest service leaderboard") {
auto db = csp::db::SqliteDb::OpenMemory();
csp::db::ApplyMigrations(db);
csp::db::SeedDemoData(db);
csp::services::AuthService auth(db);
const auto u1 = auth.Register("contest_user_1", "password123");
const auto u2 = auth.Register("contest_user_2", "password123");
csp::services::ContestService contest(db);
const auto contests = contest.ListContests();
REQUIRE_FALSE(contests.empty());
const int64_t contest_id = contests.front().id;
contest.Register(contest_id, u1.user_id);
contest.Register(contest_id, u2.user_id);
const auto problems = contest.ListContestProblems(contest_id);
REQUIRE_FALSE(problems.empty());
const int64_t problem_id = problems.front().id;
csp::services::SubmissionService submissions(db);
csp::services::SubmissionCreateRequest r1;
r1.user_id = u1.user_id;
r1.problem_id = problem_id;
r1.contest_id = contest_id;
r1.language = "cpp";
r1.code = kAcCode;
const auto s1 = submissions.CreateAndJudge(r1);
REQUIRE(s1.status == csp::domain::SubmissionStatus::AC);
csp::services::SubmissionCreateRequest r2;
r2.user_id = u2.user_id;
r2.problem_id = problem_id;
r2.contest_id = contest_id;
r2.language = "cpp";
r2.code = kWaCode;
const auto s2 = submissions.CreateAndJudge(r2);
REQUIRE(s2.status == csp::domain::SubmissionStatus::WA);
const auto board = contest.Leaderboard(contest_id);
REQUIRE(board.size() >= 2);
REQUIRE(board.front().user_id == u1.user_id);
REQUIRE(board.front().solved >= 1);
}

查看文件

@@ -0,0 +1,60 @@
#include <catch2/catch_test_macros.hpp>
#include "csp/app_state.h"
#include "csp/controllers/import_controller.h"
#include <drogon/HttpRequest.h>
#include <future>
namespace {
drogon::HttpResponsePtr CallLatest(csp::controllers::ImportController& ctl) {
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
std::promise<drogon::HttpResponsePtr> p;
ctl.latestJob(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); });
return p.get_future().get();
}
drogon::HttpResponsePtr CallItems(csp::controllers::ImportController& ctl, int64_t job_id) {
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
req->setParameter("page", "1");
req->setParameter("page_size", "20");
std::promise<drogon::HttpResponsePtr> p;
ctl.jobItems(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); }, job_id);
return p.get_future().get();
}
} // namespace
TEST_CASE("import controller latest and items") {
csp::AppState::Instance().Init(":memory:");
auto& db = csp::AppState::Instance().db();
db.Exec(
"INSERT INTO import_jobs(status,trigger,total_count,processed_count,success_count,"
"failed_count,options_json,last_error,started_at,finished_at,updated_at,created_at)"
"VALUES('success','auto',2,2,2,0,'{\"workers\":3}','',100,120,120,90);");
db.Exec(
"INSERT INTO import_job_items(job_id,source_path,status,title,difficulty,problem_id,"
"error_text,started_at,finished_at,updated_at,created_at)"
"VALUES(1,'CSP-J/2024/Round2/B.pdf','success','B',3,7,'',101,110,120,100);");
csp::controllers::ImportController ctl;
auto latest = CallLatest(ctl);
REQUIRE(latest->statusCode() == drogon::k200OK);
auto latest_json = latest->jsonObject();
REQUIRE(latest_json != nullptr);
REQUIRE((*latest_json)["ok"].asBool());
REQUIRE((*latest_json)["data"]["job"]["id"].asInt64() == 1);
auto items = CallItems(ctl, 1);
REQUIRE(items->statusCode() == drogon::k200OK);
auto items_json = items->jsonObject();
REQUIRE(items_json != nullptr);
REQUIRE((*items_json)["ok"].asBool());
REQUIRE((*items_json)["data"]["items"].isArray());
REQUIRE((*items_json)["data"]["items"].size() == 1);
}

查看文件

@@ -0,0 +1,34 @@
#include <catch2/catch_test_macros.hpp>
#include "csp/db/sqlite_db.h"
#include "csp/services/import_service.h"
TEST_CASE("import service query latest job and items") {
auto db = csp::db::SqliteDb::OpenMemory();
csp::db::ApplyMigrations(db);
db.Exec(
"INSERT INTO import_jobs(status,trigger,total_count,processed_count,success_count,"
"failed_count,options_json,last_error,started_at,finished_at,updated_at,created_at)"
"VALUES('running','manual',10,3,2,1,'{}','',100,null,120,90);");
db.Exec(
"INSERT INTO import_job_items(job_id,source_path,status,title,difficulty,problem_id,"
"error_text,started_at,finished_at,updated_at,created_at)"
"VALUES(1,'CSP-J/2024/Round1/A.pdf','success','A',2,11,'',101,102,120,100);");
csp::services::ImportService svc(db);
const auto latest = svc.GetLatestJob();
REQUIRE(latest.has_value());
REQUIRE(latest->id == 1);
REQUIRE(latest->status == "running");
REQUIRE(latest->processed_count == 3);
csp::services::ImportJobItemQuery q;
q.page = 1;
q.page_size = 20;
const auto items = svc.ListItems(1, q);
REQUIRE(items.size() == 1);
REQUIRE(items[0].source_path == "CSP-J/2024/Round1/A.pdf");
REQUIRE(items[0].status == "success");
REQUIRE(items[0].problem_id.has_value());
}

查看文件

@@ -0,0 +1,18 @@
#include <catch2/catch_test_macros.hpp>
#include "csp/db/sqlite_db.h"
#include "csp/services/kb_service.h"
TEST_CASE("kb service list/detail") {
auto db = csp::db::SqliteDb::OpenMemory();
csp::db::ApplyMigrations(db);
csp::db::SeedDemoData(db);
csp::services::KbService svc(db);
const auto rows = svc.ListArticles();
REQUIRE(rows.size() >= 2);
const auto detail = svc.GetBySlug(rows.front().slug);
REQUIRE(detail.has_value());
REQUIRE(detail->article.slug == rows.front().slug);
}

查看文件

@@ -0,0 +1,105 @@
#include <catch2/catch_test_macros.hpp>
#include "csp/app_state.h"
#include "csp/controllers/me_controller.h"
#include "csp/services/auth_service.h"
#include "csp/services/problem_service.h"
#include <drogon/HttpRequest.h>
#include <future>
namespace {
drogon::HttpResponsePtr CallProfile(csp::controllers::MeController& ctl,
const std::string& token) {
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
req->addHeader("Authorization", "Bearer " + token);
std::promise<drogon::HttpResponsePtr> p;
ctl.profile(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); });
return p.get_future().get();
}
drogon::HttpResponsePtr CallListWrongBook(csp::controllers::MeController& ctl,
const std::string& token) {
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
req->addHeader("Authorization", "Bearer " + token);
std::promise<drogon::HttpResponsePtr> p;
ctl.listWrongBook(req,
[&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); });
return p.get_future().get();
}
drogon::HttpResponsePtr CallPatchWrongBook(csp::controllers::MeController& ctl,
const std::string& token,
int64_t problem_id,
const std::string& note) {
Json::Value body;
body["note"] = note;
auto req = drogon::HttpRequest::newHttpJsonRequest(body);
req->setMethod(drogon::Patch);
req->addHeader("Authorization", "Bearer " + token);
std::promise<drogon::HttpResponsePtr> p;
ctl.upsertWrongBookNote(req,
[&p](const drogon::HttpResponsePtr& resp) {
p.set_value(resp);
},
problem_id);
return p.get_future().get();
}
drogon::HttpResponsePtr CallDeleteWrongBook(csp::controllers::MeController& ctl,
const std::string& token,
int64_t problem_id) {
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Delete);
req->addHeader("Authorization", "Bearer " + token);
std::promise<drogon::HttpResponsePtr> p;
ctl.deleteWrongBookItem(req,
[&p](const drogon::HttpResponsePtr& resp) {
p.set_value(resp);
},
problem_id);
return p.get_future().get();
}
} // namespace
TEST_CASE("me controller profile and wrong-book") {
csp::AppState::Instance().Init(":memory:");
csp::services::AuthService auth(csp::AppState::Instance().db());
const auto login = auth.Register("me_http_user", "password123");
csp::services::ProblemService problems(csp::AppState::Instance().db());
const auto list = problems.List(csp::services::ProblemQuery{});
REQUIRE_FALSE(list.items.empty());
const int64_t problem_id = list.items.front().id;
csp::controllers::MeController ctl;
auto profile = CallProfile(ctl, login.token);
REQUIRE(profile->statusCode() == drogon::k200OK);
auto profile_json = profile->jsonObject();
REQUIRE(profile_json != nullptr);
REQUIRE((*profile_json)["ok"].asBool());
auto patch = CallPatchWrongBook(ctl, login.token, problem_id, "复盘记录");
REQUIRE(patch->statusCode() == drogon::k200OK);
auto list_resp = CallListWrongBook(ctl, login.token);
REQUIRE(list_resp->statusCode() == drogon::k200OK);
auto list_json = list_resp->jsonObject();
REQUIRE(list_json != nullptr);
REQUIRE((*list_json)["data"].isArray());
REQUIRE((*list_json)["data"].size() >= 1);
auto del = CallDeleteWrongBook(ctl, login.token, problem_id);
REQUIRE(del->statusCode() == drogon::k200OK);
}

查看文件

@@ -0,0 +1,61 @@
#include <catch2/catch_test_macros.hpp>
#include "csp/app_state.h"
#include "csp/controllers/problem_controller.h"
#include <drogon/HttpRequest.h>
#include <future>
namespace {
drogon::HttpResponsePtr CallList(csp::controllers::ProblemController& ctl,
const std::string& page_size = "",
const std::string& tags = "") {
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
if (!page_size.empty()) req->setParameter("page_size", page_size);
if (!tags.empty()) req->setParameter("tags", tags);
std::promise<drogon::HttpResponsePtr> p;
ctl.list(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); });
return p.get_future().get();
}
drogon::HttpResponsePtr CallGet(csp::controllers::ProblemController& ctl,
int64_t problem_id) {
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
std::promise<drogon::HttpResponsePtr> p;
ctl.getById(req,
[&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); },
problem_id);
return p.get_future().get();
}
} // namespace
TEST_CASE("problem controller list/get") {
csp::AppState::Instance().Init(":memory:");
csp::controllers::ProblemController ctl;
auto list_resp = CallList(ctl, "1");
REQUIRE(list_resp->statusCode() == drogon::k200OK);
auto list_json = list_resp->jsonObject();
REQUIRE(list_json != nullptr);
REQUIRE((*list_json)["ok"].asBool());
REQUIRE((*list_json)["data"]["items"].isArray());
REQUIRE((*list_json)["data"]["items"].size() == 1);
REQUIRE((*list_json)["data"]["total_count"].asInt() >= 1);
const int64_t problem_id = (*list_json)["data"]["items"][0]["id"].asInt64();
auto get_resp = CallGet(ctl, problem_id);
REQUIRE(get_resp->statusCode() == drogon::k200OK);
auto get_json = get_resp->jsonObject();
REQUIRE(get_json != nullptr);
REQUIRE((*get_json)["data"]["id"].asInt64() == problem_id);
}

查看文件

@@ -0,0 +1,50 @@
#include <catch2/catch_test_macros.hpp>
#include "csp/db/sqlite_db.h"
#include "csp/services/problem_service.h"
TEST_CASE("problem service list/get") {
auto db = csp::db::SqliteDb::OpenMemory();
csp::db::ApplyMigrations(db);
csp::db::SeedDemoData(db);
db.Exec(
"INSERT INTO problems(slug,title,statement_md,difficulty,source,statement_url,llm_profile_json,"
"sample_input,sample_output,created_at)"
"VALUES('luogu-p1000','P1000','x',2,'luogu:P1000','https://www.luogu.com.cn/problem/P1000','{}','','',100);");
db.Exec(
"INSERT INTO problems(slug,title,statement_md,difficulty,source,statement_url,llm_profile_json,"
"sample_input,sample_output,created_at)"
"VALUES('luogu-p1001','P1001','x',3,'luogu:P1001','https://www.luogu.com.cn/problem/P1001','{}','','',100);");
db.Exec("INSERT INTO problem_tags(problem_id,tag) VALUES((SELECT id FROM problems WHERE slug='luogu-p1000'),'csp-j');");
db.Exec("INSERT INTO problem_tags(problem_id,tag) VALUES((SELECT id FROM problems WHERE slug='luogu-p1001'),'csp-s');");
csp::services::ProblemService svc(db);
csp::services::ProblemQuery q_all;
const auto all = svc.List(q_all);
REQUIRE(all.total_count >= 5);
REQUIRE(all.items.size() >= 5);
csp::services::ProblemQuery q_dp;
q_dp.tag = "dp";
const auto dp = svc.List(q_dp);
REQUIRE(dp.total_count >= 1);
REQUIRE(dp.items.size() >= 1);
csp::services::ProblemQuery q_source;
q_source.source_prefix = "luogu:";
const auto luogu = svc.List(q_source);
REQUIRE(luogu.total_count == 2);
REQUIRE(luogu.items.size() == 2);
csp::services::ProblemQuery q_tags;
q_tags.tags = {"csp-j", "csp-s"};
const auto csp = svc.List(q_tags);
REQUIRE(csp.total_count == 2);
REQUIRE(csp.items.size() == 2);
const auto one = svc.GetById(all.items.front().id);
REQUIRE(one.has_value());
REQUIRE(one->id == all.items.front().id);
}

查看文件

@@ -0,0 +1,89 @@
#include <catch2/catch_test_macros.hpp>
#include "csp/app_state.h"
#include "csp/controllers/problem_controller.h"
#include "csp/services/auth_service.h"
#include <drogon/HttpRequest.h>
#include <sqlite3.h>
#include <future>
namespace {
int64_t FirstProblemId() {
sqlite3_stmt* stmt = nullptr;
auto* db = csp::AppState::Instance().db().raw();
REQUIRE(sqlite3_prepare_v2(db, "SELECT id FROM problems ORDER BY id LIMIT 1", -1,
&stmt, nullptr) == SQLITE_OK);
REQUIRE(sqlite3_step(stmt) == SQLITE_ROW);
const auto id = sqlite3_column_int64(stmt, 0);
sqlite3_finalize(stmt);
return id;
}
std::string NewToken() {
csp::services::AuthService auth(csp::AppState::Instance().db());
const auto r = auth.Register("workspace_tester", "password123");
return r.token;
}
} // namespace
TEST_CASE("problem controller draft and solution list") {
csp::AppState::Instance().Init(":memory:");
csp::controllers::ProblemController ctl;
const auto problem_id = FirstProblemId();
const auto token = NewToken();
{
Json::Value body;
body["language"] = "cpp";
body["code"] = "int main(){return 0;}";
body["stdin"] = "1 2\n";
auto req = drogon::HttpRequest::newHttpJsonRequest(body);
req->setMethod(drogon::Put);
req->addHeader("Authorization", "Bearer " + token);
std::promise<drogon::HttpResponsePtr> p;
ctl.saveDraft(req,
[&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); },
problem_id);
auto resp = p.get_future().get();
REQUIRE(resp->statusCode() == drogon::k200OK);
}
{
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
req->addHeader("Authorization", "Bearer " + token);
std::promise<drogon::HttpResponsePtr> p;
ctl.getDraft(req,
[&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); },
problem_id);
auto resp = p.get_future().get();
REQUIRE(resp->statusCode() == drogon::k200OK);
auto json = resp->jsonObject();
REQUIRE(json != nullptr);
REQUIRE((*json)["data"]["code"].asString().find("main") != std::string::npos);
}
{
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
std::promise<drogon::HttpResponsePtr> p;
ctl.listSolutions(
req,
[&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); },
problem_id);
auto resp = p.get_future().get();
REQUIRE(resp->statusCode() == drogon::k200OK);
auto json = resp->jsonObject();
REQUIRE(json != nullptr);
REQUIRE((*json)["data"]["items"].isArray());
}
}

查看文件

@@ -0,0 +1,48 @@
#include <catch2/catch_test_macros.hpp>
#include "csp/db/sqlite_db.h"
#include "csp/services/problem_workspace_service.h"
#include <sqlite3.h>
TEST_CASE("problem workspace service drafts and solution jobs") {
auto db = csp::db::SqliteDb::OpenMemory();
csp::db::ApplyMigrations(db);
csp::db::SeedDemoData(db);
REQUIRE(sqlite3_exec(
db.raw(),
"INSERT INTO users(username,password_salt,password_hash,created_at)"
" VALUES('tester','salt','hash',0)",
nullptr,
nullptr,
nullptr) == SQLITE_OK);
sqlite3_stmt* stmt = nullptr;
REQUIRE(sqlite3_prepare_v2(db.raw(), "SELECT id FROM problems ORDER BY id LIMIT 1",
-1, &stmt, nullptr) == SQLITE_OK);
REQUIRE(sqlite3_step(stmt) == SQLITE_ROW);
const int64_t pid = sqlite3_column_int64(stmt, 0);
sqlite3_finalize(stmt);
REQUIRE(pid > 0);
csp::services::ProblemWorkspaceService svc(db);
REQUIRE(svc.ProblemExists(pid));
svc.SaveDraft(1, pid, "cpp", "int main(){return 0;}", "1 2\n");
const auto draft = svc.GetDraft(1, pid);
REQUIRE(draft.has_value());
REQUIRE(draft->language == "cpp");
REQUIRE(draft->code.find("main") != std::string::npos);
const auto job_id = svc.CreateSolutionJob(pid, 1, 3);
REQUIRE(job_id > 0);
const auto latest = svc.GetLatestSolutionJob(pid);
REQUIRE(latest.has_value());
REQUIRE(latest->id == job_id);
REQUIRE(latest->status == "queued");
REQUIRE(latest->max_solutions == 3);
const auto solutions = svc.ListSolutions(pid);
REQUIRE(solutions.empty());
}

查看文件

@@ -28,6 +28,17 @@ TEST_CASE("migrations create core tables") {
REQUIRE(CountTable(db.raw(), "users") == 1); REQUIRE(CountTable(db.raw(), "users") == 1);
REQUIRE(CountTable(db.raw(), "sessions") == 1); REQUIRE(CountTable(db.raw(), "sessions") == 1);
REQUIRE(CountTable(db.raw(), "problems") == 1); REQUIRE(CountTable(db.raw(), "problems") == 1);
REQUIRE(CountTable(db.raw(), "problem_tags") == 1);
REQUIRE(CountTable(db.raw(), "submissions") == 1); REQUIRE(CountTable(db.raw(), "submissions") == 1);
REQUIRE(CountTable(db.raw(), "wrong_book") == 1); REQUIRE(CountTable(db.raw(), "wrong_book") == 1);
REQUIRE(CountTable(db.raw(), "contests") == 1);
REQUIRE(CountTable(db.raw(), "contest_problems") == 1);
REQUIRE(CountTable(db.raw(), "contest_registrations") == 1);
REQUIRE(CountTable(db.raw(), "kb_articles") == 1);
REQUIRE(CountTable(db.raw(), "kb_article_links") == 1);
REQUIRE(CountTable(db.raw(), "import_jobs") == 1);
REQUIRE(CountTable(db.raw(), "import_job_items") == 1);
REQUIRE(CountTable(db.raw(), "problem_drafts") == 1);
REQUIRE(CountTable(db.raw(), "problem_solution_jobs") == 1);
REQUIRE(CountTable(db.raw(), "problem_solutions") == 1);
} }

查看文件

@@ -0,0 +1,80 @@
#include <catch2/catch_test_macros.hpp>
#include "csp/app_state.h"
#include "csp/controllers/submission_controller.h"
#include "csp/services/auth_service.h"
#include "csp/services/problem_service.h"
#include <drogon/HttpRequest.h>
#include <future>
namespace {
drogon::HttpResponsePtr CallRun(csp::controllers::SubmissionController& ctl,
const std::string& code,
const std::string& input) {
Json::Value body;
body["code"] = code;
body["input"] = input;
auto req = drogon::HttpRequest::newHttpJsonRequest(body);
req->setMethod(drogon::Post);
std::promise<drogon::HttpResponsePtr> p;
ctl.runCpp(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); });
return p.get_future().get();
}
drogon::HttpResponsePtr CallSubmit(csp::controllers::SubmissionController& ctl,
int64_t problem_id,
const std::string& token,
const std::string& code) {
Json::Value body;
body["language"] = "cpp";
body["code"] = code;
auto req = drogon::HttpRequest::newHttpJsonRequest(body);
req->setMethod(drogon::Post);
req->addHeader("Authorization", "Bearer " + token);
std::promise<drogon::HttpResponsePtr> p;
ctl.submitProblem(req,
[&p](const drogon::HttpResponsePtr& resp) {
p.set_value(resp);
},
problem_id);
return p.get_future().get();
}
} // namespace
TEST_CASE("submission controller run and submit") {
csp::AppState::Instance().Init(":memory:");
csp::services::AuthService auth(csp::AppState::Instance().db());
const auto user = auth.Register("submission_http_user", "password123");
csp::services::ProblemService problems(csp::AppState::Instance().db());
const auto list = problems.List(csp::services::ProblemQuery{});
REQUIRE_FALSE(list.items.empty());
csp::controllers::SubmissionController ctl;
auto run = CallRun(ctl,
"#include <iostream>\nint main(){std::cout<<\"ok\\n\";}",
"");
REQUIRE(run->statusCode() == drogon::k200OK);
auto submit = CallSubmit(
ctl,
list.items.front().id,
user.token,
R"CPP(#include <bits/stdc++.h>
using namespace std;
int main(){ long long a,b; if(!(cin>>a>>b)) return 0; cout << (a+b) << "\n"; }
)CPP");
REQUIRE(submit->statusCode() == drogon::k200OK);
auto submit_json = submit->jsonObject();
REQUIRE(submit_json != nullptr);
REQUIRE((*submit_json)["data"]["id"].asInt64() > 0);
}

查看文件

@@ -0,0 +1,86 @@
#include <catch2/catch_test_macros.hpp>
#include "csp/db/sqlite_db.h"
#include "csp/domain/enum_strings.h"
#include "csp/services/auth_service.h"
#include "csp/services/problem_service.h"
#include "csp/services/submission_service.h"
#include "csp/services/wrong_book_service.h"
namespace {
const char* kAcCode = R"CPP(#include <bits/stdc++.h>
using namespace std;
int main() {
long long a, b;
if (!(cin >> a >> b)) return 0;
cout << (a + b) << "\n";
return 0;
}
)CPP";
const char* kWaCode = R"CPP(#include <bits/stdc++.h>
using namespace std;
int main() {
cout << 0 << "\n";
return 0;
}
)CPP";
} // namespace
TEST_CASE("submission service judge and wrong-book flow") {
auto db = csp::db::SqliteDb::OpenMemory();
csp::db::ApplyMigrations(db);
csp::db::SeedDemoData(db);
csp::services::AuthService auth(db);
const auto user = auth.Register("submit_user", "password123");
csp::services::ProblemService problems(db);
csp::services::ProblemQuery q;
const auto list = problems.List(q);
REQUIRE_FALSE(list.items.empty());
const int64_t problem_id = list.items.front().id;
csp::services::SubmissionService submissions(db);
csp::services::WrongBookService wrong_book(db);
csp::services::SubmissionCreateRequest bad;
bad.user_id = user.user_id;
bad.problem_id = problem_id;
bad.language = "cpp";
bad.code = kWaCode;
const auto bad_result = submissions.CreateAndJudge(bad);
REQUIRE(bad_result.status == csp::domain::SubmissionStatus::WA);
const auto wb_after_bad = wrong_book.ListByUser(user.user_id);
REQUIRE_FALSE(wb_after_bad.empty());
csp::services::SubmissionCreateRequest good;
good.user_id = user.user_id;
good.problem_id = problem_id;
good.language = "cpp";
good.code = kAcCode;
const auto good_result = submissions.CreateAndJudge(good);
REQUIRE(good_result.status == csp::domain::SubmissionStatus::AC);
bool still_in_wrong_book = false;
for (const auto& row : wrong_book.ListByUser(user.user_id)) {
if (row.item.problem_id == problem_id) {
still_in_wrong_book = true;
break;
}
}
REQUIRE_FALSE(still_in_wrong_book);
const auto run_only =
submissions.RunOnlyCpp(R"CPP(#include <iostream>
int main(){std::cout<<42<<"\n";}
)CPP",
"");
REQUIRE(run_only.status == csp::domain::SubmissionStatus::Running);
REQUIRE(run_only.stdout_text == "42\n");
}

查看文件

@@ -2,6 +2,18 @@ services:
backend: backend:
env_file: env_file:
- .env - .env
environment:
- OI_IMPORT_AUTO_RUN=true
- OI_IMPORT_WORKERS=3
- OI_IMPORT_SCRIPT_PATH=/app/scripts/import_luogu_csp.py
- OI_IMPORT_CLEAR_ALL_PROBLEMS=true
- OI_IMPORT_CLEAR_EXISTING=true
- "OI_IMPORT_CLEAR_SOURCE_PREFIX=luogu:"
- CSP_GEN_AUTO_RUN=true
- CSP_GEN_COUNT=1
- CSP_GEN_WAIT_FOR_IMPORT=true
- CSP_GEN_SCRIPT_PATH=/app/scripts/generate_cspj_problem_rag.py
- CSP_SOLUTION_SCRIPT_PATH=/app/scripts/generate_problem_solutions.py
build: build:
context: . context: .
dockerfile: Dockerfile.backend dockerfile: Dockerfile.backend

142
docs/API参考.md 普通文件
查看文件

@@ -0,0 +1,142 @@
# API 参考v1
统一前缀:`/api/v1`
> Docker/生产推荐通过前端同域反代访问:`/admin139/api/v1/...`
## 通用约定
- 鉴权头:`Authorization: Bearer <token>`
- 成功响应:`{ "ok": true, "data": ... }`Auth 接口除外)
- 失败响应:`{ "ok": false, "error": "..." }`
---
## 1) Auth
### `POST /auth/register`
请求:
```json
{ "username": "alice", "password": "password123" }
```
响应:
```json
{ "ok": true, "user_id": 1, "token": "...", "expires_at": 1730000000 }
```
### `POST /auth/login`
请求同上,响应同上。
---
## 2) 用户与排行榜
### `GET /me`(需鉴权)
返回当前用户信息。
### `GET /leaderboard/global?limit=100`
返回全站 rating 排行。
---
## 3) 题库
### `GET /problems?q=&tag=&difficulty=&page=&page_size=`
返回题目列表。
### `GET /problems/:id`
返回题目详情。`Problem` 结构包含:
- `statement_url`:原始 PDF 链接
- `llm_profile_json`:固定 JSON 字符串(`title/difficulty/answer/explanation/knowledge_points/tags/...`
---
## 4) 提交与在线运行
### `POST /problems/:id/submit`(需鉴权)
请求:
```json
{
"language": "cpp",
"code": "#include <bits/stdc++.h> ...",
"contest_id": 1
}
```
- `contest_id` 可选;若提交到比赛,需已报名且比赛进行中。
### `GET /submissions?user_id=&problem_id=&contest_id=&page=&page_size=`
返回提交列表。
### `GET /submissions/:id`
返回提交详情(含代码、编译日志、运行日志)。
### `POST /run/cpp`
请求:
```json
{ "code": "...", "input": "1 2\n" }
```
返回运行状态、stdout/stderr、compile_log。
---
## 5) 错题本
### `GET /me/wrong-book`(需鉴权)
返回当前用户错题本。
### `PATCH /me/wrong-book/:problemId`(需鉴权)
请求:
```json
{ "note": "复盘思路" }
```
### `DELETE /me/wrong-book/:problemId`(需鉴权)
删除错题项。
---
## 6) 模拟竞赛
### `GET /contests`
返回比赛列表。
### `GET /contests/:id`
返回比赛详情与题单;若请求带 token,还会返回 `registered`
### `POST /contests/:id/register`(需鉴权)
报名比赛。
### `GET /contests/:id/leaderboard`
比赛排行榜(按 solved desc, penalty asc
---
## 7) 学习知识库
### `GET /kb/articles`
返回知识库文章列表。
### `GET /kb/articles/:slug`
返回文章详情与关联题目。
---
## 8) 题库导入任务PDF + LLM
### `GET /import/jobs/latest`
返回最近一次导入任务及运行状态(`runner_running`)。
### `GET /import/jobs/:id`
返回指定导入任务详情。
### `GET /import/jobs/:id/items?status=&page=&page_size=`
返回任务明细(每个 PDF 的处理状态、结果或错误)。
### `POST /import/jobs/run`
手动触发导入任务(若已有运行中的任务会返回 `409`)。
请求体(可选):
```json
{ "clear_all_problems": true }
```

查看文件

@@ -1,24 +1,137 @@
# Docker Compose 部署 # Docker Compose 部署指南
## 一键启动 ## 1. 启动
```bash ```bash
docker compose up -d --build docker compose up -d --build
``` ```
## 访问 查看状态:
- 前端http://localhost:7888 ```bash
- 后端通过前端反代http://localhost:7888/admin139/api/health docker compose ps
- 后端(注册):`POST http://localhost:7888/admin139/api/v1/auth/register` docker compose logs --tail=100 backend
- 后端(登录):`POST http://localhost:7888/admin139/api/v1/auth/login` docker compose logs --tail=100 frontend
```
## 数据持久化 ## 2. 访问地址
- SQLite 数据库文件compose volume `csp_data` -> 容器内 `/data/csp.db` - 前端:`http://<服务器IP>:7888/`
- 后端健康检查(反代):`http://<服务器IP>:7888/admin139/api/health`
## 停止 示例:
- `http://caddns.pandoras.work:7888/`
- `http://caddns.pandoras.work:7888/admin139/api/health`
## 3. 端口与持久化
- `7888:3000`frontend 对外)
- backend 默认不直接暴露端口(经 frontend 反代)
- 数据卷:`csp_data` -> `/data/csp.db`
## 4. 常用运维命令
```bash ```bash
docker compose down docker compose down
docker compose up -d --build
docker compose restart frontend backend
``` ```
## 4.1 初始化大规模题库winterant/oi
```bash
set -a; source .env; set +a
python3 scripts/import_winterant_oi.py \
--db-path /var/lib/docker/volumes/csp_csp_data/_data/csp.db \
--workers 3 \
--clear-all-problems
```
当前导入流程为:下载 PDF -> LLM 识别 -> 固定 JSON 落库 + 任务状态落库(内置 500/502/503/504 自动重试)。
仅下载导入、跳过 LLM
```bash
python3 scripts/import_winterant_oi.py --skip-llm --workers 3
```
前端可通过 `/imports` 查看导入进度与明细结果。
请在 `.env` 中配置:
```env
OI_LLM_API_URL=https://one.hao.work/v1/chat/completions
OI_LLM_API_KEY=<your_key>
OI_LLM_MODEL=qwen3-max
OI_LLM_STREAM=true
OI_LLM_RETRY_MAX=5
OI_LLM_RETRY_SLEEP_SEC=1.5
OI_PDF_RETRY_MAX=5
OI_PDF_RETRY_SLEEP_SEC=1.5
OI_IMPORT_DIRECT_FALLBACK=true
OI_IMPORT_PREFER_DIRECT=true
OI_IMPORT_AUTO_RUN=true
OI_IMPORT_WORKERS=3
OI_IMPORT_CLEAR_EXISTING=true
OI_IMPORT_CLEAR_SOURCE_PREFIX=winterant/oi
OI_IMPORT_CLEAR_ALL_PROBLEMS=false
```
## 5. 故障排查
### 5.1 无法访问 7888
1) 检查监听:
```bash
ss -ltnp | rg 7888
```
2) 检查容器端口映射:
```bash
docker compose ps
```
3) 本机探活:
```bash
curl -i http://127.0.0.1:7888/
curl -i http://127.0.0.1:7888/admin139/api/health
```
### 5.2 检查是否被 Clash 拦截
通常 inbound 到 `7888` 不会被 Clash 代理规则拦截,但可按下列方式确认:
```bash
ps -ef | rg clash
cat /opt/clash/runtime.yaml | rg "allow-lan|port|socks-port|redir-port"
iptables -t nat -S
nft list ruleset
```
若你希望 Clash 自身也允许局域网访问(与本项目 7888 端口不是同一件事),可设置:
- `/opt/clash/runtime.yaml``allow-lan: true`
- 重启 Clash 服务/进程
### 5.3 Docker 拉取慢/失败
可为 Docker 配置镜像加速,例如:
`/etc/docker/daemon.json`
```json
{
"registry-mirrors": ["https://docker.m.daocloud.io"]
}
```
然后:
```bash
systemctl daemon-reload
systemctl restart docker
```
## 6. 安全建议
- 生产环境建议在 Nginx/Caddy 前加 TLS。
- 在线编译运行属于高风险能力,建议部署到隔离沙箱执行。

查看文件

@@ -1,174 +1,63 @@
# 平台总体设计(草案) # 平台总体设计
> 目标:面向初学者的 OI/CSP 学习知识库 + 日常练习 + 模拟竞赛系统,并提供在线 C++ 编写/编译/调试能力。前后端分离Next.js + C++(Drogon) + SQLite > 目标:围绕 CSP/OI 学习闭环构建一体化系统:知识学习 -> 日常练习 -> 错题复盘 -> 模拟竞赛 -> 排名反馈
## 1. 术语与核心对象 ## 1. 产品模块
- **用户(User)**:注册/登录;拥有积分、等级、学习进度。 1. 用户与鉴权
- **题目(Problem)**:题面、标签、难度、来源(如 CSP/NOIP/自建)。 2. 题库与在线提交
- **提交(Submission)**:用户对题目的一次代码提交(含编译/运行结果、耗时、内存、得分)。 3. 提交记录与结果追踪
- **练习(Practice)**:非比赛场景的做题记录(可以直接通过 submissions 体现)。 4. 错题本与复盘
- **错题本(WrongBook)**:用户在练习/比赛中未通过的题目集合 + 错因备注。 5. 模拟竞赛与排行
- **比赛(Contest)**:模拟 CSP/NOIP 的比赛;包含题目列表、开始/结束、计分规则。 6. 学习知识库
- **排名(Leaderboard)**:全站积分排行、比赛排行。 7. 在线 C++ 编写/编译/运行
- **知识库(KnowledgeBase)**:学习文章/笔记/专题目录;可关联题目。
## 2. 技术架构 ## 2. 技术架构
### 2.1 前端 ### 前端Next.js
- App Router + TypeScript
- 页面模块:`/problems``/submissions``/wrong-book``/contests``/kb``/run``/me``/leaderboard`
- 通过 `/admin139` 反代后端 API,避免跨域复杂度
- Next.js(App Router) + TypeScript + Tailwind ### 后端Drogon C++
- 负责:题库/题面、编辑器页面、提交列表、错题本、排行、比赛大厅、知识库阅读 - ControllerHTTP 路由与请求校验
- Service业务逻辑题库/提交/错题本/竞赛/知识库)
- Domain实体与 JSON 序列化
- DBSQLite + 启动迁移 + 演示数据种子
### 2.2 后端 ### 数据层SQLite
- 单文件数据库,便于快速部署
- 覆盖用户、题库、提交、错题本、竞赛、知识库完整模型
- Drogon (HTTP + JSON) ## 3. 核心业务流
- SQLite单文件数据库,便于部署
- 模块分层(建议):
- `controller/`HTTP 路由
- `service/`:业务逻辑
- `repo/`DB 访问SQL + 映射)
- `domain/`:实体与枚举
- `judge/`:编译与判题执行器(后续)
### 2.3 在线编译/运行(安全边界) ### 3.1 练习流
1) 用户登录
2) 浏览题目并提交
3) 后端调用 `g++` 编译并运行样例
4) 结果写入 `submissions`
5) 若未通过,写入/更新 `wrong_book`
6) 若首次 AC,提升 `users.rating`
- MVP后端在临时目录中调用 `g++` 编译,并用子进程运行,使用 `ulimit`/超时 kill 做基础限制。 ### 3.2 竞赛流
- 生产建议:判题/运行必须放在容器或隔离工具(如 nsjail/isolate中;否则存在逃逸风险。 1) 用户报名比赛
2) 在比赛时间内提交比赛题目
3) 按 AC 数与罚时生成排行榜
## 3. 数据库设计SQLite ### 3.3 学习流
1) 浏览知识库文章
2) 查看文章关联题目
3) 进入题目页面进行练习
### 3.1 表清单 ## 4. 安全与运行边界
1) `users` - 当前在线运行模块用于开发/教学环境。
- `id` INTEGER PK - 生产环境应将编译运行迁移到受限沙箱,避免宿主机风险。
- `username` TEXT UNIQUE
- `password_hash` TEXT
- `created_at` INTEGER
- `rating` INTEGER DEFAULT 0 (综合积分)
2) `problems` ## 5. 可扩展方向
- `id` INTEGER PK
- `slug` TEXT UNIQUE
- `title` TEXT
- `statement_md` TEXT
- `difficulty` INTEGER
- `source` TEXT
- `created_at` INTEGER
3) `problem_tags` - 增加多测试点判题与标准答案管理
- `problem_id` INTEGER - 引入更细粒度比赛规则(罚时、封榜、重判)
- `tag` TEXT - 支持多语言判题
- PK(`problem_id`,`tag`) - 增加学习路径与章节化知识图谱
4) `submissions`
- `id` INTEGER PK
- `user_id` INTEGER
- `problem_id` INTEGER
- `language` TEXT (先支持 cpp
- `code` TEXT
- `status` TEXT Pending/Compiling/Running/AC/WA/TLE/MLE/RE/CE
- `score` INTEGER
- `time_ms` INTEGER
- `memory_kb` INTEGER
- `created_at` INTEGER
5) `wrong_book`
- `user_id` INTEGER
- `problem_id` INTEGER
- `last_submission_id` INTEGER
- `note` TEXT
- `updated_at` INTEGER
- PK(`user_id`,`problem_id`)
6) `contests`
- `id` INTEGER PK
- `title` TEXT
- `starts_at` INTEGER
- `ends_at` INTEGER
- `rule_json` TEXT (计分规则/罚时规则)
7) `contest_problems`
- `contest_id` INTEGER
- `problem_id` INTEGER
- `idx` INTEGER
- PK(`contest_id`,`problem_id`)
8) `contest_registrations`
- `contest_id` INTEGER
- `user_id` INTEGER
- `registered_at` INTEGER
- PK(`contest_id`,`user_id`)
9) `kb_articles`
- `id` INTEGER PK
- `slug` TEXT UNIQUE
- `title` TEXT
- `content_md` TEXT
- `created_at` INTEGER
10) `kb_article_links`
- `article_id` INTEGER
- `problem_id` INTEGER
- PK(`article_id`,`problem_id`)
### 3.2 积分/排行
- 全站排行:按 `users.rating` 降序。
- rating 更新策略MVP
- 练习 AC+difficulty * 常数
- 比赛按名次发放奖励分rule_json 可配置)
> 该策略后续可替换为 ELO/Codeforces 风格。
## 4. HTTP API 设计v1草案
统一前缀:`/api/v1`
### 4.1 Auth
- `POST /auth/register` {username,password}
- `POST /auth/login` {username,password} -> {token}
- 鉴权:`Authorization: Bearer <token>`MVP 可用 HMAC JWT
### 4.2 Problems
- `GET /problems?tag=&difficulty=&q=&page=`
- `GET /problems/:id`
### 4.3 Submissions
- `POST /problems/:id/submit` {language,code}
- `GET /submissions?user_id=&problem_id=&page=`
- `GET /submissions/:id`
### 4.4 WrongBook
- `GET /me/wrong-book`
- `PATCH /me/wrong-book/:problemId` {note}
- `DELETE /me/wrong-book/:problemId`
### 4.5 Contests
- `GET /contests`
- `GET /contests/:id`
- `POST /contests/:id/register`
- `GET /contests/:id/leaderboard`
### 4.6 Leaderboard
- `GET /leaderboard/global`
### 4.7 Knowledge Base
- `GET /kb/articles`
- `GET /kb/articles/:slug`
## 5. 测试策略TDD
- `repo` 层:用内存 SQLite`:memory:`+ 迁移脚本,做 CRUD 单测。
- `service` 层:对积分、错题本更新等写业务单测。
- `controller`Drogon 自带测试/或以 HTTP 集成测试(启动测试服务器,发请求断言 JSON
## 6. 下一步落地顺序MVP
1) 用户注册/登录token
2) 题库(只读)+ 提交记录入库
3) 在线编译(仅编译 + 返回CE/OK,再扩展到运行
4) 错题本(由 WA/未通过自动写入)
5) 积分与全站排行
6) 比赛(创建/报名/排行榜)
7) 知识库文章与题目关联

137
docs/数据库设计.md 普通文件
查看文件

@@ -0,0 +1,137 @@
# 数据库设计SQLite
数据库文件默认位置:`/data/csp.db`Docker volume `csp_data` 持久化)。
## 1. 核心表
### `users`
- `id` INTEGER PK AUTOINCREMENT
- `username` TEXT UNIQUE
- `password_salt` TEXT
- `password_hash` TEXT
- `rating` INTEGER DEFAULT 0
- `created_at` INTEGER
### `sessions`
- `token` TEXT PK
- `user_id` INTEGER FK -> users.id
- `expires_at` INTEGER
- `created_at` INTEGER
### `problems`
- `id` INTEGER PK AUTOINCREMENT
- `slug` TEXT UNIQUE
- `title` TEXT
- `statement_md` TEXT
- `difficulty` INTEGER
- `source` TEXT
- `statement_url` TEXT原始 PDF/题面链接)
- `llm_profile_json` TEXT固定 JSON难度、答案、讲解、知识点、标签等
- `sample_input` TEXT
- `sample_output` TEXT
- `created_at` INTEGER
### `problem_tags`
- `problem_id` INTEGER FK -> problems.id
- `tag` TEXT
- PK(`problem_id`, `tag`)
### `submissions`
- `id` INTEGER PK AUTOINCREMENT
- `user_id` INTEGER FK -> users.id
- `problem_id` INTEGER FK -> problems.id
- `contest_id` INTEGER NULL FK -> contests.id
- `language` TEXT
- `code` TEXT
- `status` TEXT (`Pending/Compiling/Running/AC/WA/TLE/MLE/RE/CE/Unknown`)
- `score` INTEGER
- `time_ms` INTEGER
- `memory_kb` INTEGER
- `compile_log` TEXT
- `runtime_log` TEXT
- `created_at` INTEGER
### `wrong_book`
- `user_id` INTEGER FK -> users.id
- `problem_id` INTEGER FK -> problems.id
- `last_submission_id` INTEGER NULL FK -> submissions.id
- `note` TEXT
- `updated_at` INTEGER
- PK(`user_id`, `problem_id`)
### `contests`
- `id` INTEGER PK AUTOINCREMENT
- `title` TEXT
- `starts_at` INTEGER
- `ends_at` INTEGER
- `rule_json` TEXT
### `contest_problems`
- `contest_id` INTEGER FK -> contests.id
- `problem_id` INTEGER FK -> problems.id
- `idx` INTEGER
- PK(`contest_id`, `problem_id`)
### `contest_registrations`
- `contest_id` INTEGER FK -> contests.id
- `user_id` INTEGER FK -> users.id
- `registered_at` INTEGER
- PK(`contest_id`, `user_id`)
### `kb_articles`
- `id` INTEGER PK AUTOINCREMENT
- `slug` TEXT UNIQUE
- `title` TEXT
- `content_md` TEXT
- `created_at` INTEGER
### `kb_article_links`
- `article_id` INTEGER FK -> kb_articles.id
- `problem_id` INTEGER FK -> problems.id
- PK(`article_id`, `problem_id`)
### `import_jobs`
- `id` INTEGER PK AUTOINCREMENT
- `status` TEXT`running/success/partial_failed/failed`
- `trigger` TEXT`auto/manual`
- `total_count` / `processed_count` / `success_count` / `failed_count`
- `options_json` TEXT本次导入参数快照
- `last_error` TEXT
- `started_at` / `finished_at` / `updated_at` / `created_at`
### `import_job_items`
- `id` INTEGER PK AUTOINCREMENT
- `job_id` INTEGER FK -> import_jobs.id
- `source_path` TEXT
- `status` TEXT`queued/running/success/failed`
- `title` TEXT
- `difficulty` INTEGER
- `problem_id` INTEGER NULL FK -> problems.id
- `error_text` TEXT
- `started_at` / `finished_at` / `updated_at` / `created_at`
- UNIQUE(`job_id`, `source_path`)
## 2. 关键索引
- `idx_submissions_user_created_at`
- `idx_submissions_problem_created_at`
- `idx_submissions_contest_user_created_at`
- `idx_problem_tags_tag`
- `idx_kb_article_links_problem_id`
- `idx_import_jobs_created_at`
- `idx_import_jobs_status`
- `idx_import_job_items_job_status`
## 3. 初始化与演示数据
系统启动时会自动:
1. 执行 `ApplyMigrations`
2. 执行 `SeedDemoData`
演示数据包含:
- 基础题目A+B、Fibonacci、排序
- 题目标签math/dp/sort 等)
- 知识库文章(快速 IO、DP 入门)
- 示例模拟赛(含题目关联)

44
docs/测试与TDD.md 普通文件
查看文件

@@ -0,0 +1,44 @@
# 测试与 TDD
## 1. 测试分层
后端采用 Catch2,按分层设计测试
1. **DB/迁移层**:表结构、索引、迁移兼容性
2. **Service 层**:题库、提交判题、错题本、竞赛、知识库业务逻辑
3. **Controller 层**:鉴权、参数解析、核心 HTTP 路径
## 2. 执行命令
```bash
cmake -S . -B build -G Ninja
cmake --build build
ctest --test-dir build -V
```
## 3. 现有测试覆盖
- `sqlite_db_test.cc`:迁移后核心表存在
- `auth_service_test.cc`:注册/登录/token 校验
- `auth_http_test.cc`Auth 接口
- `problem_service_test.cc`:题库查询
- `submission_service_test.cc`:提交评测 + 错题本流转
- `contest_service_test.cc`:竞赛排行榜逻辑
- `kb_service_test.cc`:知识库读取
- `problem_http_test.cc`:题库 HTTP
- `me_http_test.cc`:个人信息 + 错题本 HTTP
- `contest_http_test.cc`:竞赛 HTTP
- `submission_http_test.cc`:运行与提交 HTTP
## 4. TDD 落地流程建议
1. 先写失败测试(接口契约/业务规则)
2. 实现最小功能通过测试
3. 重构并保持测试全绿
4. 增加边界条件测试(无 token、参数错误、not found、竞赛状态校验
## 5. 注意事项
- 当前判题使用本机 `g++` + `timeout`,用于开发环境。
- 生产环境建议接入隔离沙箱(如 isolate/nsjail/容器沙箱)。

查看文件

@@ -1,36 +1,43 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # Frontend (Next.js)
## Getting Started ## 开发
First, run the development server:
```bash ```bash
npm ci
npm run dev npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 默认访问:`http://localhost:3000`
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. ## 构建
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. ```bash
npm run lint
npm run build
npm run start
```
## Learn More ## 环境变量
To learn more about Next.js, take a look at the following resources: - `NEXT_PUBLIC_API_BASE`:浏览器访问后端 API 的基地址。
- 开发默认:`http://localhost:8080`
- Docker/生产推荐:`/admin139`
- `BACKEND_INTERNAL_URL`Next.js 反向代理后端目标(服务端)
- Docker 默认:`http://backend:8080`
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. ## 页面
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - `/auth` 登录/注册
- `/problems` 题库列表
## Deploy on Vercel - `/problems/:id` 题目详情与提交
- `/submissions` 提交列表
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - `/submissions/:id` 提交详情
- `/wrong-book` 错题本
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. - `/contests` 模拟竞赛列表
- `/contests/:id` 比赛详情/报名/排行榜
- `/kb` 知识库列表
- `/kb/:slug` 文章详情
- `/imports` 题库导入任务状态与结果
- `/run` 在线 C++ 运行
- `/me` 当前用户信息
- `/leaderboard` 全站排行

查看文件

@@ -6,8 +6,11 @@ const nextConfig: NextConfig = {
async rewrites() { async rewrites() {
// Reverse proxy backend under a path prefix, so browser can access backend // Reverse proxy backend under a path prefix, so browser can access backend
// with same-origin (no CORS): http://<host>:7888/admin139/... // with same-origin (no CORS): http://<host>:7888/admin139/...
const backendInternal = process.env.BACKEND_INTERNAL_URL; const backendInternal =
if (!backendInternal) return []; process.env.BACKEND_INTERNAL_URL ??
(process.env.NODE_ENV === "development"
? "http://127.0.0.1:8080"
: "http://backend:8080");
return [ return [
{ {

3632
frontend/package-lock.json 自动生成的

文件差异内容过多而无法显示 加载差异

查看文件

@@ -9,9 +9,18 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0",
"highlight.js": "^11.11.1",
"katex": "^0.16.28",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"swagger-ui-react": "^5.31.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

查看文件

@@ -0,0 +1,21 @@
"use client";
import dynamic from "next/dynamic";
import { useMemo } from "react";
import { API_BASE } from "@/lib/api";
const SwaggerUI = dynamic(() => import("swagger-ui-react"), { ssr: false });
export default function ApiDocsPage() {
const specUrl = useMemo(() => `${API_BASE}/api/openapi.json`, []);
return (
<main className="mx-auto max-w-7xl px-6 py-6">
<h1 className="mb-4 text-2xl font-semibold">API Swagger</h1>
<div className="rounded-xl border bg-white p-2">
<SwaggerUI url={specUrl} docExpansion="list" defaultModelsExpandDepth={1} />
</div>
</main>
);
}

查看文件

@@ -0,0 +1,136 @@
import { createHash } from "crypto";
import { promises as fs } from "fs";
import path from "path";
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
const CACHE_DIR = process.env.CSP_IMAGE_CACHE_DIR ?? "/tmp/csp-image-cache";
const MAX_BYTES = 5 * 1024 * 1024;
function toArrayBuffer(view: Uint8Array): ArrayBuffer {
return view.buffer.slice(
view.byteOffset,
view.byteOffset + view.byteLength
) as ArrayBuffer;
}
function pickExt(urlObj: URL, contentType: string): string {
const fromPath = path.extname(urlObj.pathname || "").toLowerCase();
if (fromPath && fromPath.length <= 10) return fromPath;
if (contentType.includes("image/png")) return ".png";
if (contentType.includes("image/jpeg")) return ".jpg";
if (contentType.includes("image/webp")) return ".webp";
if (contentType.includes("image/gif")) return ".gif";
if (contentType.includes("image/svg+xml")) return ".svg";
return ".img";
}
async function readCachedByKey(
key: string
): Promise<{ data: Uint8Array; contentType: string } | null> {
try {
const files = await fs.readdir(CACHE_DIR);
const hit = files.find((name) => name.startsWith(`${key}.`));
if (!hit) return null;
const ext = path.extname(hit).toLowerCase();
let contentType = "application/octet-stream";
if (ext === ".png") contentType = "image/png";
else if (ext === ".jpg" || ext === ".jpeg") contentType = "image/jpeg";
else if (ext === ".webp") contentType = "image/webp";
else if (ext === ".gif") contentType = "image/gif";
else if (ext === ".svg") contentType = "image/svg+xml";
const data = new Uint8Array(await fs.readFile(path.join(CACHE_DIR, hit)));
return { data, contentType };
} catch {
return null;
}
}
export async function GET(req: NextRequest) {
const raw = req.nextUrl.searchParams.get("url") ?? "";
if (!raw) {
return NextResponse.json({ ok: false, error: "missing url" }, { status: 400 });
}
let target: URL;
try {
target = new URL(raw);
} catch {
return NextResponse.json({ ok: false, error: "invalid url" }, { status: 400 });
}
if (target.protocol !== "http:" && target.protocol !== "https:") {
return NextResponse.json({ ok: false, error: "only http/https allowed" }, { status: 400 });
}
await fs.mkdir(CACHE_DIR, { recursive: true });
const key = createHash("sha1").update(target.toString()).digest("hex");
const probe = await readCachedByKey(key);
if (probe) {
const body = new Blob([toArrayBuffer(probe.data)], { type: probe.contentType });
return new NextResponse(body, {
status: 200,
headers: {
"Content-Type": probe.contentType,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 12000);
try {
const resp = await fetch(target.toString(), {
signal: controller.signal,
headers: {
"User-Agent": "csp-platform-image-cache/1.0",
},
cache: "no-store",
});
if (!resp.ok) {
return NextResponse.json(
{ ok: false, error: `fetch image failed: HTTP ${resp.status}` },
{ status: 502 }
);
}
const contentType = (resp.headers.get("content-type") ?? "").toLowerCase();
if (!contentType.startsWith("image/")) {
return NextResponse.json({ ok: false, error: "url is not an image" }, { status: 400 });
}
const data = new Uint8Array(await resp.arrayBuffer());
if (data.length <= 0 || data.length > MAX_BYTES) {
return NextResponse.json(
{ ok: false, error: "image is empty or too large" },
{ status: 400 }
);
}
const ext = pickExt(target, contentType);
const finalFile = path.join(CACHE_DIR, `${key}${ext}`);
await fs.writeFile(finalFile, data);
const body = new Blob([toArrayBuffer(data)], { type: contentType });
return new NextResponse(body, {
status: 200,
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (e: unknown) {
return NextResponse.json(
{ ok: false, error: `fetch image failed: ${String(e)}` },
{ status: 502 }
);
} finally {
clearTimeout(timer);
}
}

查看文件

@@ -1,38 +1,57 @@
"use client"; "use client";
import Link from "next/link";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { API_BASE, apiFetch } from "@/lib/api";
import { saveToken } from "@/lib/auth";
type AuthOk = { ok: true; user_id: number; token: string; expires_at: number }; type AuthOk = { ok: true; user_id: number; token: string; expires_at: number };
type AuthErr = { ok: false; error: string }; type AuthErr = { ok: false; error: string };
type AuthResp = AuthOk | AuthErr; type AuthResp = AuthOk | AuthErr;
export default function AuthPage() { function passwordScore(password: string): { label: string; color: string } {
const apiBase = useMemo( if (password.length >= 12) return { label: "强", color: "text-emerald-600" };
() => process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8080", if (password.length >= 8) return { label: "中", color: "text-blue-600" };
[] return { label: "弱", color: "text-orange-600" };
); }
const [mode, setMode] = useState<"register" | "login">("register"); export default function AuthPage() {
const [username, setUsername] = useState( const router = useRouter();
process.env.NEXT_PUBLIC_TEST_USERNAME ?? "" const apiBase = useMemo(() => API_BASE, []);
);
const [password, setPassword] = useState( const [mode, setMode] = useState<"register" | "login">("login");
process.env.NEXT_PUBLIC_TEST_PASSWORD ?? "" const [username, setUsername] = useState(process.env.NEXT_PUBLIC_TEST_USERNAME ?? "");
); const [password, setPassword] = useState(process.env.NEXT_PUBLIC_TEST_PASSWORD ?? "");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [resp, setResp] = useState<AuthResp | null>(null); const [resp, setResp] = useState<AuthResp | null>(null);
const usernameErr = username.trim().length < 3 ? "用户名至少 3 位" : "";
const passwordErr = password.length < 6 ? "密码至少 6 位" : "";
const confirmErr =
mode === "register" && password !== confirmPassword ? "两次密码不一致" : "";
const canSubmit = !loading && !usernameErr && !passwordErr && !confirmErr;
async function submit() { async function submit() {
if (!canSubmit) return;
setLoading(true); setLoading(true);
setResp(null); setResp(null);
try { try {
const r = await fetch(`${apiBase}/api/v1/auth/${mode}`, { const j = await apiFetch<AuthResp>(`/api/v1/auth/${mode}`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username.trim(), password }),
body: JSON.stringify({ username, password }),
}); });
const j = (await r.json()) as AuthResp;
setResp(j); setResp(j);
if (j.ok) {
saveToken(j.token);
setTimeout(() => {
router.push("/problems");
}, 350);
}
} catch (e: unknown) { } catch (e: unknown) {
setResp({ ok: false, error: String(e) }); setResp({ ok: false, error: String(e) });
} finally { } finally {
@@ -40,75 +59,140 @@ export default function AuthPage() {
} }
} }
const strength = passwordScore(password);
return ( return (
<div className="min-h-screen bg-zinc-50 text-zinc-900"> <main className="mx-auto max-w-4xl px-6 py-10">
<main className="mx-auto max-w-xl px-6 py-12"> <div className="grid gap-6 md:grid-cols-[1.1fr,1fr]">
<h1 className="text-2xl font-semibold">{mode === "register" ? "注册" : "登录"}</h1> <section className="rounded-2xl border bg-zinc-900 p-6 text-zinc-100">
<p className="mt-2 text-sm text-zinc-600"> <h1 className="text-2xl font-semibold"></h1>
API Base: <span className="font-mono">{apiBase}</span> <p className="mt-3 text-sm text-zinc-300">
</p> 稿
</p>
<div className="mt-6 space-y-2 text-sm text-zinc-300">
<p> CSP-J / CSP-S / NOIP </p>
<p> 稿</p>
<p> </p>
</div>
<p className="mt-6 text-xs text-zinc-400">
API Base: <span className="font-mono">{apiBase}</span>
</p>
</section>
<div className="mt-6 flex gap-2"> <section className="rounded-2xl border bg-white p-6">
<button <div className="grid grid-cols-2 gap-2 rounded-lg bg-zinc-100 p-1 text-sm">
className={`rounded-lg px-3 py-2 text-sm ${ <button
mode === "register" ? "bg-zinc-900 text-white" : "bg-white border" type="button"
}`} className={`rounded-md px-3 py-2 ${
onClick={() => setMode("register")} mode === "login" ? "bg-white shadow-sm" : "text-zinc-600"
disabled={loading} }`}
> onClick={() => {
setMode("login");
</button> setResp(null);
<button }}
className={`rounded-lg px-3 py-2 text-sm ${ disabled={loading}
mode === "login" ? "bg-zinc-900 text-white" : "bg-white border" >
}`}
onClick={() => setMode("login")} </button>
disabled={loading} <button
> type="button"
className={`rounded-md px-3 py-2 ${
</button> mode === "register" ? "bg-white shadow-sm" : "text-zinc-600"
</div> }`}
onClick={() => {
setMode("register");
setResp(null);
}}
disabled={loading}
>
</button>
</div>
<div className="mt-6 rounded-xl border bg-white p-5"> <div className="mt-5 space-y-4">
<label className="block text-sm font-medium"></label> <div>
<input <label className="text-sm font-medium"></label>
className="mt-2 w-full rounded-lg border px-3 py-2" <input
value={username} className="mt-1 w-full rounded-lg border px-3 py-2"
onChange={(e) => setUsername(e.target.value)} value={username}
placeholder="alice" onChange={(e) => setUsername(e.target.value)}
/> placeholder="例如csp_student"
/>
{usernameErr && <p className="mt-1 text-xs text-red-600">{usernameErr}</p>}
</div>
<label className="mt-4 block text-sm font-medium">6</label> <div>
<input <div className="flex items-center justify-between">
type="password" <label className="text-sm font-medium"></label>
className="mt-2 w-full rounded-lg border px-3 py-2" <span className={`text-xs ${strength.color}`}>{strength.label}</span>
value={password} </div>
onChange={(e) => setPassword(e.target.value)} <input
placeholder="password123" type={showPassword ? "text" : "password"}
/> className="mt-1 w-full rounded-lg border px-3 py-2"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="至少 6 位"
/>
{passwordErr && <p className="mt-1 text-xs text-red-600">{passwordErr}</p>}
</div>
<button {mode === "register" && (
className="mt-5 w-full rounded-lg bg-zinc-900 px-4 py-2 text-white hover:bg-zinc-800 disabled:opacity-50" <div>
onClick={submit} <label className="text-sm font-medium"></label>
disabled={loading || !username || !password} <input
> type={showPassword ? "text" : "password"}
{loading ? "提交中..." : mode === "register" ? "注册" : "登录"} className="mt-1 w-full rounded-lg border px-3 py-2"
</button> value={confirmPassword}
</div> onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="再输入一次密码"
/>
{confirmErr && <p className="mt-1 text-xs text-red-600">{confirmErr}</p>}
</div>
)}
<div className="mt-6 rounded-xl border bg-white p-5"> <label className="flex items-center gap-2 text-xs text-zinc-600">
<h2 className="text-sm font-medium"></h2> <input
<pre className="mt-3 overflow-auto rounded-lg bg-zinc-900 p-3 text-xs text-zinc-100"> type="checkbox"
{JSON.stringify(resp, null, 2)} checked={showPassword}
</pre> onChange={(e) => setShowPassword(e.target.checked)}
{resp && resp.ok && ( />
<p className="mt-3 text-xs text-zinc-600">
token </label>
<span className="font-mono"> Authorization: Bearer {resp.token}</span>
</p> <button
className="w-full rounded-lg bg-zinc-900 px-4 py-2 text-white hover:bg-zinc-800 disabled:opacity-50"
onClick={() => void submit()}
disabled={!canSubmit}
>
{loading ? "提交中..." : mode === "register" ? "注册并登录" : "登录"}
</button>
</div>
{resp && (
<div
className={`mt-4 rounded-lg border px-3 py-2 text-sm ${
resp.ok ? "border-emerald-300 bg-emerald-50 text-emerald-700" : "border-red-300 bg-red-50 text-red-700"
}`}
>
{resp.ok
? "登录成功,正在跳转到题库..."
: `操作失败:${resp.error}`}
</div>
)} )}
</div>
</main> <p className="mt-4 text-xs text-zinc-500">
</div> Token localStorage
<Link className="mx-1 underline" href="/problems">
</Link>
<Link className="mx-1 underline" href="/me">
</Link>
</p>
</section>
</div>
</main>
); );
} }

查看文件

@@ -0,0 +1,147 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
type Contest = {
id: number;
title: string;
starts_at: number;
ends_at: number;
rule_json: string;
};
type Problem = {
id: number;
title: string;
difficulty: number;
};
type LeaderboardRow = {
user_id: number;
username: string;
solved: number;
penalty_sec: number;
};
type DetailResp = {
contest: Contest;
problems: Problem[];
registered?: boolean;
};
export default function ContestDetailPage() {
const params = useParams<{ id: string }>();
const contestId = useMemo(() => Number(params.id), [params.id]);
const [detail, setDetail] = useState<DetailResp | null>(null);
const [board, setBoard] = useState<LeaderboardRow[]>([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const load = async () => {
setLoading(true);
setError("");
try {
const token = readToken();
const d = await apiFetch<DetailResp>(`/api/v1/contests/${contestId}`, {}, token || undefined);
const b = await apiFetch<LeaderboardRow[]>(`/api/v1/contests/${contestId}/leaderboard`);
setDetail(d);
setBoard(b);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
useEffect(() => {
if (Number.isFinite(contestId) && contestId > 0) {
void load();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contestId]);
const register = async () => {
try {
const token = readToken();
if (!token) throw new Error("请先登录");
await apiFetch(`/api/v1/contests/${contestId}/register`, { method: "POST" }, token);
await load();
} catch (e: unknown) {
setError(String(e));
}
};
return (
<main className="mx-auto max-w-6xl px-6 py-8">
<h1 className="text-2xl font-semibold"> #{contestId}</h1>
{loading && <p className="mt-3 text-sm text-zinc-500">...</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{detail && (
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<section className="rounded-xl border bg-white p-4">
<h2 className="text-lg font-medium">{detail.contest.title}</h2>
<p className="mt-1 text-xs text-zinc-500">
{new Date(detail.contest.starts_at * 1000).toLocaleString()} - {" "}
{new Date(detail.contest.ends_at * 1000).toLocaleString()}
</p>
<pre className="mt-3 rounded bg-zinc-900 p-3 text-xs text-zinc-100">
{detail.contest.rule_json}
</pre>
<button
className="mt-3 rounded bg-zinc-900 px-4 py-2 text-white"
onClick={() => void register()}
>
{detail.registered ? "已报名(可重复点击刷新)" : "报名比赛"}
</button>
<h3 className="mt-4 text-sm font-medium"></h3>
<ul className="mt-2 space-y-2 text-sm">
{detail.problems.map((p) => (
<li key={p.id} className="rounded border p-2">
#{p.id} {p.title} {p.difficulty}
<Link className="ml-2 text-blue-600 underline" href={`/problems/${p.id}`}>
</Link>
</li>
))}
</ul>
</section>
<section className="rounded-xl border bg-white p-4">
<h3 className="text-sm font-medium"></h3>
<div className="mt-2 overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left">
<tr>
<th className="px-2 py-1">#</th>
<th className="px-2 py-1"></th>
<th className="px-2 py-1">Solved</th>
<th className="px-2 py-1">Penalty(s)</th>
</tr>
</thead>
<tbody>
{board.map((r, idx) => (
<tr key={r.user_id} className="border-t">
<td className="px-2 py-1">{idx + 1}</td>
<td className="px-2 py-1">{r.username}</td>
<td className="px-2 py-1">{r.solved}</td>
<td className="px-2 py-1">{r.penalty_sec}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
)}
</main>
);
}

查看文件

@@ -0,0 +1,58 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
type Contest = {
id: number;
title: string;
starts_at: number;
ends_at: number;
rule_json: string;
};
export default function ContestsPage() {
const [items, setItems] = useState<Contest[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const load = async () => {
setLoading(true);
setError("");
try {
const data = await apiFetch<Contest[]>("/api/v1/contests");
setItems(data);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
void load();
}, []);
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<h1 className="text-2xl font-semibold"></h1>
{loading && <p className="mt-3 text-sm text-zinc-500">...</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<div className="mt-4 space-y-3">
{items.map((c) => (
<Link
key={c.id}
href={`/contests/${c.id}`}
className="block rounded-xl border bg-white p-4 hover:border-zinc-400"
>
<h2 className="text-lg font-medium">{c.title}</h2>
<p className="mt-1 text-xs text-zinc-500">: {new Date(c.starts_at * 1000).toLocaleString()}</p>
<p className="text-xs text-zinc-500">: {new Date(c.ends_at * 1000).toLocaleString()}</p>
</Link>
))}
</div>
</main>
);
}

查看文件

@@ -8,8 +8,9 @@
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: Arial, Helvetica, sans-serif;
--font-mono: var(--font-geist-mono); --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {

查看文件

@@ -0,0 +1,260 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { apiFetch } from "@/lib/api";
type ImportJob = {
id: number;
status: string;
trigger: string;
total_count: number;
processed_count: number;
success_count: number;
failed_count: number;
options_json: string;
last_error: string;
started_at: number;
finished_at: number | null;
updated_at: number;
created_at: number;
};
type ImportItem = {
id: number;
job_id: number;
source_path: string;
status: string;
title: string;
difficulty: number;
problem_id: number | null;
error_text: string;
started_at: number | null;
finished_at: number | null;
updated_at: number;
created_at: number;
};
type LatestResp = {
runner_running: boolean;
job: ImportJob | null;
};
type ItemsResp = {
items: ImportItem[];
page: number;
page_size: number;
};
function fmtTs(v: number | null | undefined): string {
if (!v) return "-";
return new Date(v * 1000).toLocaleString();
}
export default function ImportsPage() {
const [loading, setLoading] = useState(false);
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
const [job, setJob] = useState<ImportJob | null>(null);
const [items, setItems] = useState<ImportItem[]>([]);
const [statusFilter, setStatusFilter] = useState("");
const [pageSize, setPageSize] = useState(100);
const [clearAllBeforeRun, setClearAllBeforeRun] = useState(true);
const progress = useMemo(() => {
if (!job || job.total_count <= 0) return 0;
return Math.min(100, Math.floor((job.processed_count / job.total_count) * 100));
}, [job]);
const loadLatest = async () => {
const latest = await apiFetch<LatestResp>("/api/v1/import/jobs/latest");
setJob(latest.job ?? null);
setRunning(Boolean(latest.runner_running) || latest.job?.status === "running");
return latest.job;
};
const loadItems = async (jobId: number) => {
const params = new URLSearchParams();
params.set("page", "1");
params.set("page_size", String(pageSize));
if (statusFilter) params.set("status", statusFilter);
const data = await apiFetch<ItemsResp>(`/api/v1/import/jobs/${jobId}/items?${params.toString()}`);
setItems(data.items ?? []);
};
const refresh = async () => {
setLoading(true);
setError("");
try {
const latestJob = await loadLatest();
if (latestJob) {
await loadItems(latestJob.id);
} else {
setItems([]);
}
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
const runImport = async () => {
setError("");
try {
await apiFetch<{ started: boolean }>("/api/v1/import/jobs/run", {
method: "POST",
body: JSON.stringify({ clear_all_problems: clearAllBeforeRun }),
});
await refresh();
} catch (e: unknown) {
setError(String(e));
}
};
useEffect(() => {
void refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSize, statusFilter]);
useEffect(() => {
const timer = setInterval(() => {
void refresh();
}, running ? 3000 : 15000);
return () => clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [running, pageSize, statusFilter]);
return (
<main className="mx-auto max-w-7xl px-6 py-8">
<h1 className="text-2xl font-semibold">Luogu CSP J/S</h1>
<div className="mt-4 rounded-xl border bg-white p-4">
<div className="flex flex-wrap items-center gap-3">
<button
className="rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50"
onClick={() => void runImport()}
disabled={loading || running}
>
{running ? "导入中..." : "启动导入任务"}
</button>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={clearAllBeforeRun}
onChange={(e) => setClearAllBeforeRun(e.target.checked)}
/>
</label>
<button className="rounded border px-3 py-2 text-sm" onClick={() => void refresh()} disabled={loading}>
</button>
<span className={`text-sm ${running ? "text-emerald-700" : "text-zinc-600"}`}>
{running ? "运行中" : "空闲"}
</span>
</div>
<p className="mt-2 text-xs text-zinc-500">
3 线 CSP-J/CSP-S/NOIP
</p>
</div>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<section className="mt-4 rounded-xl border bg-white p-4">
<h2 className="text-lg font-medium"></h2>
{!job && <p className="mt-2 text-sm text-zinc-500"></p>}
{job && (
<div className="mt-3 space-y-2 text-sm">
<p>
#{job.id} · <b>{job.status}</b> · {job.trigger}
</p>
<p>
{job.total_count} {job.processed_count} {job.success_count} {job.failed_count}
</p>
<div className="h-2 w-full rounded bg-zinc-100">
<div className="h-2 rounded bg-emerald-500" style={{ width: `${progress}%` }} />
</div>
<p className="text-zinc-600">
{progress}% · {fmtTs(job.started_at)} · {fmtTs(job.finished_at)}
</p>
{job.last_error && <p className="text-red-600">{job.last_error}</p>}
</div>
)}
</section>
<section className="mt-4 rounded-xl border bg-white p-4">
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-lg font-medium"></h2>
<select
className="rounded border px-2 py-1 text-sm"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value=""></option>
<option value="queued">queued</option>
<option value="running">running</option>
<option value="success">success</option>
<option value="failed">failed</option>
</select>
<select
className="rounded border px-2 py-1 text-sm"
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
>
<option value={50}>50 </option>
<option value={100}>100 </option>
<option value={200}>200 </option>
</select>
</div>
<div className="mt-3 overflow-x-auto">
<table className="min-w-full text-xs">
<thead className="bg-zinc-100 text-left">
<tr>
<th className="px-2 py-2">ID</th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2">ID</th>
<th className="px-2 py-2"></th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-t align-top">
<td className="px-2 py-2">{item.id}</td>
<td className="max-w-[400px] px-2 py-2">
<div className="truncate" title={item.source_path}>
{item.source_path}
</div>
</td>
<td className="px-2 py-2">{item.status}</td>
<td className="max-w-[220px] px-2 py-2">
<div className="truncate" title={item.title}>
{item.title || "-"}
</div>
</td>
<td className="px-2 py-2">{item.difficulty || "-"}</td>
<td className="px-2 py-2">{item.problem_id ?? "-"}</td>
<td className="max-w-[320px] px-2 py-2 text-red-600">
<div className="truncate" title={item.error_text}>
{item.error_text || "-"}
</div>
</td>
</tr>
))}
{items.length === 0 && (
<tr>
<td className="px-2 py-4 text-center text-zinc-500" colSpan={7}>
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
</main>
);
}

查看文件

@@ -0,0 +1,75 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { apiFetch } from "@/lib/api";
type Article = {
id: number;
slug: string;
title: string;
content_md: string;
created_at: number;
};
type DetailResp = {
article: Article;
related_problems: { problem_id: number; title: string }[];
};
export default function KbDetailPage() {
const params = useParams<{ slug: string }>();
const slug = useMemo(() => params.slug, [params.slug]);
const [data, setData] = useState<DetailResp | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const load = async () => {
setLoading(true);
setError("");
try {
const detail = await apiFetch<DetailResp>(`/api/v1/kb/articles/${slug}`);
setData(detail);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
if (slug) void load();
}, [slug]);
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<h1 className="text-2xl font-semibold"></h1>
{loading && <p className="mt-3 text-sm text-zinc-500">...</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{data && (
<div className="mt-4 space-y-4">
<section className="rounded-xl border bg-white p-4">
<h2 className="text-xl font-medium">{data.article.title}</h2>
<pre className="mt-3 whitespace-pre-wrap text-sm">{data.article.content_md}</pre>
</section>
<section className="rounded-xl border bg-white p-4">
<h3 className="text-sm font-medium"></h3>
<ul className="mt-2 space-y-2 text-sm">
{data.related_problems.map((p) => (
<li key={p.problem_id}>
<Link className="text-blue-600 underline" href={`/problems/${p.problem_id}`}>
#{p.problem_id} {p.title}
</Link>
</li>
))}
</ul>
</section>
</div>
)}
</main>
);
}

查看文件

@@ -0,0 +1,58 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
type Article = {
id: number;
slug: string;
title: string;
created_at: number;
};
export default function KbListPage() {
const [items, setItems] = useState<Article[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const load = async () => {
setLoading(true);
setError("");
try {
const data = await apiFetch<Article[]>("/api/v1/kb/articles");
setItems(data);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
void load();
}, []);
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<h1 className="text-2xl font-semibold"></h1>
{loading && <p className="mt-3 text-sm text-zinc-500">...</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<div className="mt-4 space-y-3">
{items.map((a) => (
<Link
key={a.slug}
href={`/kb/${a.slug}`}
className="block rounded-xl border bg-white p-4 hover:border-zinc-400"
>
<h2 className="text-lg font-medium">{a.title}</h2>
<p className="mt-1 text-xs text-zinc-500">
slug: {a.slug} · {new Date(a.created_at * 1000).toLocaleString()}
</p>
</Link>
))}
</div>
</main>
);
}

查看文件

@@ -1,20 +1,13 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { AppNav } from "@/components/app-nav";
import "katex/dist/katex.min.css";
import "highlight.js/styles/github-dark.css";
import "swagger-ui-react/swagger-ui.css";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "CSP 在线学习与竞赛平台",
description: "Generated by create next app", description: "题库、错题本、模拟竞赛、知识库与在线 C++ 运行",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -23,10 +16,9 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="zh-CN">
<body <body className="antialiased">
className={`${geistSans.variable} ${geistMono.variable} antialiased`} <AppNav />
>
{children} {children}
</body> </body>
</html> </html>

查看文件

@@ -0,0 +1,67 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
type Row = {
user_id: number;
username: string;
rating: number;
created_at: number;
};
export default function LeaderboardPage() {
const [items, setItems] = useState<Row[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const load = async () => {
setLoading(true);
setError("");
try {
const data = await apiFetch<Row[]>("/api/v1/leaderboard/global?limit=200");
setItems(data);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
void load();
}, []);
return (
<main className="mx-auto max-w-4xl px-6 py-8">
<h1 className="text-2xl font-semibold"></h1>
{loading && <p className="mt-3 text-sm text-zinc-500">...</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<div className="mt-4 overflow-x-auto rounded-xl border bg-white">
<table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left">
<tr>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2">Rating</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{items.map((row, i) => (
<tr key={row.user_id} className="border-t">
<td className="px-3 py-2">{i + 1}</td>
<td className="px-3 py-2">{row.username}</td>
<td className="px-3 py-2">{row.rating}</td>
<td className="px-3 py-2">
{new Date(row.created_at * 1000).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</main>
);
}

查看文件

@@ -0,0 +1,54 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
type Me = {
id: number;
username: string;
rating: number;
created_at: number;
};
export default function MePage() {
const [data, setData] = useState<Me | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
const load = async () => {
setLoading(true);
setError("");
try {
const token = readToken();
if (!token) throw new Error("请先登录");
const d = await apiFetch<Me>("/api/v1/me", {}, token);
setData(d);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
void load();
}, []);
return (
<main className="mx-auto max-w-3xl px-6 py-8">
<h1 className="text-2xl font-semibold"></h1>
{loading && <p className="mt-3 text-sm text-zinc-500">...</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{data && (
<div className="mt-4 rounded-xl border bg-white p-4 text-sm">
<p>ID: {data.id}</p>
<p>: {data.username}</p>
<p>Rating: {data.rating}</p>
<p>: {new Date(data.created_at * 1000).toLocaleString()}</p>
</div>
)}
</main>
);
}

查看文件

@@ -1,48 +1,5 @@
import Link from "next/link"; import { redirect } from "next/navigation";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8080"; export default function Home() {
redirect("/problems");
async function fetchHealth() {
try {
const r = await fetch(`${API_BASE}/api/health`, { cache: "no-store" });
if (!r.ok) return { ok: false, error: `HTTP ${r.status}` } as const;
return (await r.json()) as { ok: boolean; version?: string };
} catch (e: unknown) {
return { ok: false, error: String(e) } as const;
}
}
export default async function Home() {
const health = await fetchHealth();
return (
<div className="min-h-screen bg-zinc-50 text-zinc-900">
<main className="mx-auto max-w-3xl px-6 py-12">
<h1 className="text-3xl font-semibold">CSP 线MVP</h1>
<p className="mt-2 text-sm text-zinc-600">
API Base: <span className="font-mono">{API_BASE}</span>
</p>
<div className="mt-6 rounded-xl border bg-white p-5">
<h2 className="text-lg font-medium"></h2>
<pre className="mt-3 overflow-auto rounded-lg bg-zinc-900 p-3 text-xs text-zinc-100">
{JSON.stringify(health, null, 2)}
</pre>
</div>
<div className="mt-6 flex gap-3">
<Link
className="rounded-lg bg-zinc-900 px-4 py-2 text-white hover:bg-zinc-800"
href="/auth"
>
/
</Link>
</div>
<div className="mt-10 text-sm text-zinc-500">
////
</div>
</main>
</div>
);
} }

查看文件

@@ -0,0 +1,508 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { CodeEditor } from "@/components/code-editor";
import { MarkdownRenderer } from "@/components/markdown-renderer";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
type Problem = {
id: number;
title: string;
statement_md: string;
difficulty: number;
source: string;
statement_url: string;
llm_profile_json: string;
sample_input: string;
sample_output: string;
};
type LlmProfile = {
title?: string;
difficulty?: number;
answer?: string;
explanation?: string;
knowledge_points?: string[];
tags?: string[];
statement_summary_md?: string;
};
type Submission = {
id: number;
status: string;
score: number;
compile_log: string;
runtime_log: string;
created_at: number;
};
type RunResult = {
status: string;
time_ms: number;
stdout: string;
stderr: string;
compile_log: string;
};
type DraftResp = {
language: string;
code: string;
stdin: string;
updated_at: number;
};
type SolutionItem = {
id: number;
problem_id: number;
variant: number;
title: string;
idea_md: string;
explanation_md: string;
code_cpp: string;
complexity: string;
tags_json: string;
source: string;
created_at: number;
updated_at: number;
};
type SolutionJob = {
id: number;
problem_id: number;
status: string;
progress: number;
message: string;
created_at: number;
started_at: number | null;
finished_at: number | null;
};
type SolutionResp = {
items: SolutionItem[];
latest_job: SolutionJob | null;
runner_running: boolean;
};
const starterCode = `#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
return 0;
}
`;
const defaultRunInput = `1 2\n`;
export default function ProblemDetailPage() {
const params = useParams<{ id: string }>();
const id = useMemo(() => Number(params.id), [params.id]);
const [problem, setProblem] = useState<Problem | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [code, setCode] = useState(starterCode);
const [runInput, setRunInput] = useState(defaultRunInput);
const [contestId, setContestId] = useState("");
const [submitLoading, setSubmitLoading] = useState(false);
const [runLoading, setRunLoading] = useState(false);
const [draftLoading, setDraftLoading] = useState(false);
const [submitResp, setSubmitResp] = useState<Submission | null>(null);
const [runResp, setRunResp] = useState<RunResult | null>(null);
const [draftMsg, setDraftMsg] = useState("");
const [showSolutions, setShowSolutions] = useState(false);
const [solutionLoading, setSolutionLoading] = useState(false);
const [solutionData, setSolutionData] = useState<SolutionResp | null>(null);
const [solutionMsg, setSolutionMsg] = useState("");
const llmProfile = useMemo<LlmProfile | null>(() => {
if (!problem?.llm_profile_json) return null;
try {
const parsed = JSON.parse(problem.llm_profile_json);
return typeof parsed === "object" && parsed !== null ? (parsed as LlmProfile) : null;
} catch {
return null;
}
}, [problem?.llm_profile_json]);
useEffect(() => {
const load = async () => {
if (!Number.isFinite(id) || id <= 0) return;
setLoading(true);
setError("");
try {
const data = await apiFetch<Problem>(`/api/v1/problems/${id}`);
setProblem(data);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
void load();
}, [id]);
useEffect(() => {
const loadDraft = async () => {
if (!Number.isFinite(id) || id <= 0) return;
const token = readToken();
if (!token) return;
try {
const draft = await apiFetch<DraftResp>(`/api/v1/problems/${id}/draft`, undefined, token);
if (draft.code) setCode(draft.code);
if (draft.stdin) setRunInput(draft.stdin);
setDraftMsg("已自动加载草稿");
} catch {
// ignore empty draft / unauthorized
}
};
void loadDraft();
}, [id]);
const submit = async () => {
setSubmitLoading(true);
setSubmitResp(null);
setError("");
try {
const token = readToken();
if (!token) throw new Error("请先登录后再提交评测");
const body: Record<string, unknown> = {
language: "cpp",
code,
};
if (contestId) body.contest_id = Number(contestId);
const resp = await apiFetch<Submission>(
`/api/v1/problems/${id}/submit`,
{
method: "POST",
body: JSON.stringify(body),
},
token
);
setSubmitResp(resp);
} catch (e: unknown) {
setError(String(e));
} finally {
setSubmitLoading(false);
}
};
const runCode = async () => {
setRunLoading(true);
setRunResp(null);
setError("");
try {
const resp = await apiFetch<RunResult>("/api/v1/run/cpp", {
method: "POST",
body: JSON.stringify({ code, input: runInput }),
});
setRunResp(resp);
} catch (e: unknown) {
setError(String(e));
} finally {
setRunLoading(false);
}
};
const saveDraft = async () => {
setDraftLoading(true);
setDraftMsg("");
setError("");
try {
const token = readToken();
if (!token) throw new Error("请先登录后再保存草稿");
await apiFetch<{ saved: boolean }>(
`/api/v1/problems/${id}/draft`,
{
method: "PUT",
body: JSON.stringify({ language: "cpp", code, stdin: runInput }),
},
token
);
setDraftMsg("草稿已保存");
} catch (e: unknown) {
setError(String(e));
} finally {
setDraftLoading(false);
}
};
const loadSolutions = async () => {
setSolutionLoading(true);
setSolutionMsg("");
try {
const resp = await apiFetch<SolutionResp>(`/api/v1/problems/${id}/solutions`);
setSolutionData(resp);
} catch (e: unknown) {
setSolutionMsg(`加载题解失败:${String(e)}`);
} finally {
setSolutionLoading(false);
}
};
const triggerSolutions = async () => {
setSolutionLoading(true);
setSolutionMsg("");
try {
const token = readToken();
if (!token) throw new Error("请先登录后再触发题解生成");
await apiFetch<{ started: boolean; job_id: number }>(
`/api/v1/problems/${id}/solutions/generate`,
{
method: "POST",
body: JSON.stringify({ max_solutions: 3 }),
},
token
);
setSolutionMsg("题解生成任务已提交,后台异步处理中...");
await loadSolutions();
} catch (e: unknown) {
setSolutionMsg(`提交失败:${String(e)}`);
} finally {
setSolutionLoading(false);
}
};
useEffect(() => {
if (!showSolutions) return;
void loadSolutions();
const timer = setInterval(() => {
void loadSolutions();
}, 5000);
return () => clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showSolutions, id]);
return (
<main className="mx-auto max-w-[1400px] px-6 py-8">
<h1 className="text-2xl font-semibold"></h1>
{loading && <p className="mt-4 text-sm text-zinc-500">...</p>}
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
{problem && (
<div className="mt-4 grid gap-4 lg:grid-cols-[1.1fr,1fr]">
<section className="rounded-xl border bg-white p-5">
<h2 className="text-xl font-medium">{problem.title}</h2>
<p className="mt-1 text-sm text-zinc-600">
{problem.difficulty} · {problem.source}
</p>
{problem.statement_url && (
<p className="mt-1 text-sm">
<a
className="text-blue-600 underline"
href={problem.statement_url}
target="_blank"
rel="noreferrer"
>
</a>
</p>
)}
<div className="mt-4 rounded-lg border bg-zinc-50 p-4">
<MarkdownRenderer markdown={problem.statement_md} />
</div>
{llmProfile?.knowledge_points && llmProfile.knowledge_points.length > 0 && (
<>
<h3 className="mt-4 text-sm font-medium"></h3>
<div className="mt-1 flex flex-wrap gap-2">
{llmProfile.knowledge_points.map((kp) => (
<span key={kp} className="rounded-full bg-zinc-100 px-2 py-1 text-xs">
{kp}
</span>
))}
</div>
</>
)}
<h3 className="mt-4 text-sm font-medium"></h3>
<pre className="rounded bg-zinc-900 p-3 text-xs text-zinc-100">{problem.sample_input}</pre>
<h3 className="mt-3 text-sm font-medium"></h3>
<pre className="rounded bg-zinc-900 p-3 text-xs text-zinc-100">{problem.sample_output}</pre>
</section>
<section className="rounded-xl border bg-white p-5">
<label className="text-sm font-medium">contest_id</label>
<input
className="mt-1 w-full rounded border px-3 py-2"
placeholder="例如 1"
value={contestId}
onChange={(e) => setContestId(e.target.value)}
/>
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
className="rounded bg-zinc-900 px-4 py-2 text-sm text-white disabled:opacity-50"
onClick={() => void submit()}
disabled={submitLoading}
>
{submitLoading ? "提交中..." : "提交评测"}
</button>
<button
className="rounded border px-4 py-2 text-sm disabled:opacity-50"
onClick={() => void saveDraft()}
disabled={draftLoading}
>
{draftLoading ? "保存中..." : "保存草稿"}
</button>
<button
className="rounded border px-4 py-2 text-sm disabled:opacity-50"
onClick={() => {
setShowSolutions((v) => !v);
}}
disabled={solutionLoading}
>
</button>
</div>
{draftMsg && <p className="mt-2 text-xs text-emerald-700">{draftMsg}</p>}
<label className="mt-4 block text-sm font-medium">C++ + </label>
<div className="mt-1 overflow-hidden rounded border">
<CodeEditor value={code} onChange={setCode} height="420px" />
</div>
<label className="mt-4 block text-sm font-medium"></label>
<textarea
className="mt-1 h-24 w-full rounded border p-2 font-mono text-xs"
value={runInput}
onChange={(e) => setRunInput(e.target.value)}
/>
<button
className="mt-2 rounded border px-4 py-2 text-sm disabled:opacity-50"
onClick={() => void runCode()}
disabled={runLoading}
>
{runLoading ? "试运行中..." : "试运行查看结果"}
</button>
{runResp && (
<div className="mt-3 space-y-2 rounded border p-3 text-xs">
<p>
<b>{runResp.status}</b> · {runResp.time_ms}ms
</p>
{runResp.compile_log && (
<pre className="overflow-auto rounded bg-zinc-900 p-2 text-zinc-100">
{runResp.compile_log}
</pre>
)}
<div>
<p className="font-medium">stdout</p>
<pre className="overflow-auto rounded bg-zinc-900 p-2 text-zinc-100">
{runResp.stdout || "(empty)"}
</pre>
</div>
<div>
<p className="font-medium">stderr</p>
<pre className="overflow-auto rounded bg-zinc-900 p-2 text-zinc-100">
{runResp.stderr || "(empty)"}
</pre>
</div>
</div>
)}
{submitResp && (
<div className="mt-4 space-y-2 rounded border p-3 text-sm">
<p>
<b>{submitResp.status}</b> {submitResp.score}
</p>
<p>
ID
<Link className="ml-1 text-blue-600 underline" href={`/submissions/${submitResp.id}`}>
{submitResp.id}
</Link>
</p>
{submitResp.compile_log && (
<pre className="overflow-auto rounded bg-zinc-900 p-2 text-xs text-zinc-100">
{submitResp.compile_log}
</pre>
)}
{submitResp.runtime_log && (
<pre className="overflow-auto rounded bg-zinc-900 p-2 text-xs text-zinc-100">
{submitResp.runtime_log}
</pre>
)}
</div>
)}
{showSolutions && (
<div className="mt-5 rounded-lg border bg-zinc-50 p-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">/LLM </h3>
<button
className="rounded border px-3 py-1 text-xs disabled:opacity-50"
onClick={() => void triggerSolutions()}
disabled={solutionLoading}
>
</button>
<button
className="rounded border px-3 py-1 text-xs disabled:opacity-50"
onClick={() => void loadSolutions()}
disabled={solutionLoading}
>
</button>
</div>
{solutionMsg && <p className="mt-2 text-xs text-zinc-600">{solutionMsg}</p>}
{solutionData?.latest_job && (
<p className="mt-2 text-xs text-zinc-600">
#{solutionData.latest_job.id} · {solutionData.latest_job.status} ·
{solutionData.latest_job.progress}%
</p>
)}
<div className="mt-3 space-y-3">
{(solutionData?.items ?? []).map((item) => (
<article key={item.id} className="rounded border bg-white p-3">
<h4 className="text-sm font-semibold">
{item.variant}{item.title || "未命名解法"}
</h4>
{item.complexity && (
<p className="mt-1 text-xs text-zinc-600">{item.complexity}</p>
)}
{item.idea_md && (
<div className="mt-2 rounded bg-zinc-50 p-2">
<MarkdownRenderer markdown={item.idea_md} />
</div>
)}
{item.code_cpp && (
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-2 text-xs text-zinc-100">
{item.code_cpp}
</pre>
)}
{item.explanation_md && (
<div className="mt-2 rounded bg-zinc-50 p-2">
<MarkdownRenderer markdown={item.explanation_md} />
</div>
)}
</article>
))}
{!solutionLoading && (solutionData?.items.length ?? 0) === 0 && (
<p className="text-xs text-zinc-500"></p>
)}
</div>
</div>
)}
</section>
</div>
)}
</main>
);
}

查看文件

@@ -0,0 +1,415 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { apiFetch } from "@/lib/api";
type Problem = {
id: number;
title: string;
difficulty: number;
source: string;
llm_profile_json: string;
created_at: number;
};
type ProblemListResp = {
items: Problem[];
total_count: number;
page: number;
page_size: number;
};
type ProblemProfile = {
pid?: string;
tags?: string[];
knowledge_points?: string[];
stats?: {
total_submit?: number;
total_accepted?: number;
};
};
type Preset = {
key: string;
label: string;
sourcePrefix?: string;
tags?: string[];
};
const PRESETS: Preset[] = [
{
key: "csp-beginner-default",
label: "CSP J/S 入门默认",
tags: ["csp-j", "csp-s", "noip-junior", "noip-senior"],
},
{
key: "csp-j",
label: "仅 CSP-J / 普及",
tags: ["csp-j", "noip-junior"],
},
{
key: "csp-s",
label: "仅 CSP-S / 提高",
tags: ["csp-s", "noip-senior"],
},
{
key: "noip-junior",
label: "仅 NOIP 入门",
tags: ["noip-junior"],
},
{
key: "luogu-all",
label: "洛谷导入全部",
sourcePrefix: "luogu:",
tags: [],
},
{
key: "all",
label: "全站全部来源",
tags: [],
},
];
const QUICK_CARDS = [
{
presetKey: "csp-j",
title: "CSP-J 真题",
desc: "普及组入门训练",
},
{
presetKey: "csp-s",
title: "CSP-S 真题",
desc: "提高组进阶训练",
},
{
presetKey: "noip-junior",
title: "NOIP 入门",
desc: "基础算法与思维",
},
] as const;
const DIFFICULTY_OPTIONS = [
{ value: "0", label: "全部难度" },
{ value: "1", label: "1" },
{ value: "2", label: "2" },
{ value: "3", label: "3" },
{ value: "4", label: "4" },
{ value: "5", label: "5" },
{ value: "6", label: "6" },
{ value: "7", label: "7" },
{ value: "8", label: "8" },
{ value: "9", label: "9" },
{ value: "10", label: "10" },
] as const;
function parseProfile(raw: string): ProblemProfile | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
return typeof parsed === "object" && parsed !== null ? (parsed as ProblemProfile) : null;
} catch {
return null;
}
}
function difficultyClass(diff: number): string {
if (diff <= 2) return "text-emerald-600";
if (diff <= 4) return "text-blue-600";
if (diff <= 6) return "text-orange-600";
return "text-rose-600";
}
function resolvePid(problem: Problem, profile: ProblemProfile | null): string {
if (problem.source.startsWith("luogu:")) {
return problem.source.slice("luogu:".length);
}
if (profile?.pid) return profile.pid;
const head = problem.title.split(" ")[0] ?? "";
return /^[A-Za-z]\d+$/.test(head) ? head : String(problem.id);
}
function resolvePassRate(profile: ProblemProfile | null): string {
const accepted = profile?.stats?.total_accepted;
const submitted = profile?.stats?.total_submit;
if (!submitted || submitted <= 0 || accepted === undefined) return "-";
const rate = ((accepted / submitted) * 100).toFixed(1);
return `${accepted}/${submitted} (${rate}%)`;
}
function resolveTags(profile: ProblemProfile | null): string[] {
const tags = (profile?.tags ?? []).slice(0, 3);
if (tags.length > 0) return tags;
return (profile?.knowledge_points ?? []).slice(0, 3);
}
export default function ProblemsPage() {
const [presetKey, setPresetKey] = useState(PRESETS[0].key);
const [keywordInput, setKeywordInput] = useState("");
const [keyword, setKeyword] = useState("");
const [difficulty, setDifficulty] = useState("0");
const [orderBy, setOrderBy] = useState("id");
const [order, setOrder] = useState("asc");
const [pageSize, setPageSize] = useState(50);
const [page, setPage] = useState(1);
const [items, setItems] = useState<Problem[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const preset = useMemo(
() => PRESETS.find((item) => item.key === presetKey) ?? PRESETS[0],
[presetKey]
);
const totalPages = useMemo(() => {
if (totalCount <= 0) return 1;
return Math.max(1, Math.ceil(totalCount / pageSize));
}, [totalCount, pageSize]);
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
params.set("page", String(page));
params.set("page_size", String(pageSize));
params.set("order_by", orderBy);
params.set("order", order);
if (keyword) params.set("q", keyword);
if (difficulty !== "0") params.set("difficulty", difficulty);
if (preset.sourcePrefix) params.set("source_prefix", preset.sourcePrefix);
if (preset.tags && preset.tags.length > 0) params.set("tags", preset.tags.join(","));
const data = await apiFetch<ProblemListResp>(`/api/v1/problems?${params.toString()}`);
setItems(data.items ?? []);
setTotalCount(data.total_count ?? 0);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
}, [difficulty, keyword, order, orderBy, page, pageSize, preset.sourcePrefix, preset.tags]);
useEffect(() => {
void load();
}, [load]);
const rows = useMemo(
() =>
items.map((problem) => {
const profile = parseProfile(problem.llm_profile_json);
return { problem, profile };
}),
[items]
);
const applySearch = () => {
setPage(1);
setKeyword(keywordInput.trim());
};
const selectPreset = (key: string) => {
setPresetKey(key);
setPage(1);
};
return (
<main className="mx-auto max-w-7xl px-6 py-8">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold">CSP J/S </h1>
<p className="mt-1 text-sm text-zinc-600">
CSP-J / CSP-S / NOIP
</p>
</div>
<p className="text-sm text-zinc-600"> {totalCount} </p>
</div>
<section className="mt-4 grid gap-3 md:grid-cols-3">
{QUICK_CARDS.map((card) => {
const active = presetKey === card.presetKey;
return (
<button
key={card.presetKey}
type="button"
className={`rounded-xl border px-4 py-3 text-left transition ${
active
? "border-zinc-900 bg-zinc-900 text-white"
: "bg-white text-zinc-900 hover:border-zinc-400"
}`}
onClick={() => selectPreset(card.presetKey)}
>
<p className="text-base font-semibold">{card.title}</p>
<p className={`mt-1 text-xs ${active ? "text-zinc-200" : "text-zinc-500"}`}>
{card.desc}
</p>
</button>
);
})}
</section>
<section className="mt-4 grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-2 lg:grid-cols-6">
<select
className="rounded border px-3 py-2 text-sm"
value={presetKey}
onChange={(e) => {
selectPreset(e.target.value);
}}
>
{PRESETS.map((item) => (
<option key={item.key} value={item.key}>
{item.label}
</option>
))}
</select>
<input
className="rounded border px-3 py-2 text-sm lg:col-span-2"
placeholder="搜索题号/标题/题面关键词"
value={keywordInput}
onChange={(e) => setKeywordInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") applySearch();
}}
/>
<select
className="rounded border px-3 py-2 text-sm"
value={difficulty}
onChange={(e) => {
setDifficulty(e.target.value);
setPage(1);
}}
>
{DIFFICULTY_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
<select
className="rounded border px-3 py-2 text-sm"
value={`${orderBy}:${order}`}
onChange={(e) => {
const [ob, od] = e.target.value.split(":");
setOrderBy(ob || "id");
setOrder(od || "asc");
setPage(1);
}}
>
<option value="id:asc"></option>
<option value="id:desc"></option>
<option value="difficulty:asc"></option>
<option value="difficulty:desc"></option>
<option value="created_at:desc"></option>
<option value="title:asc"> A-Z</option>
</select>
<button
className="rounded bg-zinc-900 px-4 py-2 text-sm text-white disabled:opacity-50"
onClick={applySearch}
disabled={loading}
>
{loading ? "加载中..." : "搜索"}
</button>
</section>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<section className="mt-4 overflow-x-auto rounded-xl border bg-white">
<table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left text-zinc-700">
<tr>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2">/</th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{rows.map(({ problem, profile }) => {
const pid = resolvePid(problem, profile);
const tags = resolveTags(profile);
return (
<tr key={problem.id} className="border-t hover:bg-zinc-50">
<td className="px-3 py-2 font-medium text-blue-700">{pid}</td>
<td className="px-3 py-2">
<Link className="hover:underline" href={`/problems/${problem.id}`}>
{problem.title}
</Link>
</td>
<td className="px-3 py-2 text-zinc-600">{resolvePassRate(profile)}</td>
<td className={`px-3 py-2 font-semibold ${difficultyClass(problem.difficulty)}`}>
{problem.difficulty}
</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1">
{tags.length === 0 && <span className="text-zinc-400">-</span>}
{tags.map((tag) => (
<span key={tag} className="rounded bg-zinc-100 px-2 py-0.5 text-xs">
{tag}
</span>
))}
</div>
</td>
<td className="px-3 py-2 text-zinc-500">{problem.source || "-"}</td>
</tr>
);
})}
{!loading && rows.length === 0 && (
<tr>
<td className="px-3 py-6 text-center text-zinc-500" colSpan={6}>
</td>
</tr>
)}
</tbody>
</table>
</section>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-sm">
<div className="flex items-center gap-2">
<button
className="rounded border px-3 py-1 disabled:opacity-50"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={loading || page <= 1}
>
</button>
<span>
{page} / {totalPages}
</span>
<button
className="rounded border px-3 py-1 disabled:opacity-50"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={loading || page >= totalPages}
>
</button>
</div>
<div className="flex items-center gap-2">
<span></span>
<select
className="rounded border px-2 py-1"
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value));
setPage(1);
}}
>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
</div>
</main>
);
}

查看文件

@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import { apiFetch } from "@/lib/api";
type RunResult = {
status: string;
time_ms: number;
stdout: string;
stderr: string;
compile_log: string;
};
const starterCode = `#include <bits/stdc++.h>
using namespace std;
int main() {
string s;
getline(cin, s);
cout << s << "\\n";
return 0;
}
`;
export default function RunPage() {
const [code, setCode] = useState(starterCode);
const [input, setInput] = useState("hello csp");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [result, setResult] = useState<RunResult | null>(null);
const run = async () => {
setLoading(true);
setError("");
setResult(null);
try {
const r = await apiFetch<RunResult>("/api/v1/run/cpp", {
method: "POST",
body: JSON.stringify({ code, input }),
});
setResult(r);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
return (
<main className="mx-auto max-w-6xl px-6 py-8">
<h1 className="text-2xl font-semibold">线 C++ / / </h1>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<section className="rounded-xl border bg-white p-4">
<h2 className="text-sm font-medium"></h2>
<textarea
className="mt-2 h-[420px] w-full rounded border p-3 font-mono text-sm"
value={code}
onChange={(e) => setCode(e.target.value)}
/>
</section>
<section className="rounded-xl border bg-white p-4">
<h2 className="text-sm font-medium"></h2>
<textarea
className="mt-2 h-32 w-full rounded border p-3 font-mono text-sm"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<button
className="mt-3 rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50"
onClick={() => void run()}
disabled={loading}
>
{loading ? "运行中..." : "运行"}
</button>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{result && (
<div className="mt-4 space-y-3 text-sm">
<p>
: <b>{result.status}</b> · : {result.time_ms}ms
</p>
<div>
<h3 className="font-medium">stdout</h3>
<pre className="mt-1 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
{result.stdout || "(empty)"}
</pre>
</div>
<div>
<h3 className="font-medium">stderr</h3>
<pre className="mt-1 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
{result.stderr || "(empty)"}
</pre>
</div>
<div>
<h3 className="font-medium">compile_log</h3>
<pre className="mt-1 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
{result.compile_log || "(empty)"}
</pre>
</div>
</div>
)}
</section>
</div>
</main>
);
}

查看文件

@@ -0,0 +1,91 @@
"use client";
import { useParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { apiFetch } from "@/lib/api";
type Submission = {
id: number;
user_id: number;
problem_id: number;
contest_id: number | null;
language: string;
code: string;
status: string;
score: number;
time_ms: number;
memory_kb: number;
compile_log: string;
runtime_log: string;
created_at: number;
};
export default function SubmissionDetailPage() {
const params = useParams<{ id: string }>();
const id = useMemo(() => Number(params.id), [params.id]);
const [data, setData] = useState<Submission | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
const load = async () => {
setLoading(true);
setError("");
try {
const d = await apiFetch<Submission>(`/api/v1/submissions/${id}`);
setData(d);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
if (Number.isFinite(id) && id > 0) void load();
}, [id]);
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<h1 className="text-2xl font-semibold"> #{id}</h1>
{loading && <p className="mt-4 text-sm text-zinc-500">...</p>}
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
{data && (
<div className="mt-4 space-y-4">
<div className="rounded-xl border bg-white p-4 text-sm">
<p>: {data.user_id}</p>
<p>: {data.problem_id}</p>
<p>: {data.contest_id ?? "-"}</p>
<p>: {data.language}</p>
<p>: {data.status}</p>
<p>: {data.score}</p>
<p>: {data.time_ms} ms</p>
<p>: {data.memory_kb} KB</p>
</div>
<section className="rounded-xl border bg-white p-4">
<h2 className="text-sm font-medium"></h2>
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
{data.code}
</pre>
</section>
<section className="rounded-xl border bg-white p-4">
<h2 className="text-sm font-medium"></h2>
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
{data.compile_log || "(empty)"}
</pre>
</section>
<section className="rounded-xl border bg-white p-4">
<h2 className="text-sm font-medium"></h2>
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
{data.runtime_log || "(empty)"}
</pre>
</section>
</div>
)}
</main>
);
}

查看文件

@@ -0,0 +1,119 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
type Submission = {
id: number;
user_id: number;
problem_id: number;
contest_id: number | null;
status: string;
score: number;
time_ms: number;
created_at: number;
};
type ListResp = { items: Submission[]; page: number; page_size: number };
export default function SubmissionsPage() {
const [userId, setUserId] = useState("");
const [problemId, setProblemId] = useState("");
const [contestId, setContestId] = useState("");
const [items, setItems] = useState<Submission[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const load = async () => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (userId) params.set("user_id", userId);
if (problemId) params.set("problem_id", problemId);
if (contestId) params.set("contest_id", contestId);
const data = await apiFetch<ListResp>(`/api/v1/submissions?${params.toString()}`);
setItems(data.items);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className="mx-auto max-w-6xl px-6 py-8">
<h1 className="text-2xl font-semibold"></h1>
<div className="mt-4 grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-4">
<input
className="rounded border px-3 py-2"
placeholder="user_id"
value={userId}
onChange={(e) => setUserId(e.target.value)}
/>
<input
className="rounded border px-3 py-2"
placeholder="problem_id"
value={problemId}
onChange={(e) => setProblemId(e.target.value)}
/>
<input
className="rounded border px-3 py-2"
placeholder="contest_id"
value={contestId}
onChange={(e) => setContestId(e.target.value)}
/>
<button
className="rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50"
onClick={() => void load()}
disabled={loading}
>
{loading ? "加载中..." : "筛选"}
</button>
</div>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<div className="mt-4 overflow-x-auto rounded-xl border bg-white">
<table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left">
<tr>
<th className="px-3 py-2">ID</th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2">(ms)</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{items.map((s) => (
<tr key={s.id} className="border-t">
<td className="px-3 py-2">{s.id}</td>
<td className="px-3 py-2">{s.user_id}</td>
<td className="px-3 py-2">{s.problem_id}</td>
<td className="px-3 py-2">{s.status}</td>
<td className="px-3 py-2">{s.score}</td>
<td className="px-3 py-2">{s.time_ms}</td>
<td className="px-3 py-2">
<Link className="text-blue-600 underline" href={`/submissions/${s.id}`}>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</main>
);
}

查看文件

@@ -0,0 +1,126 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
type WrongBookItem = {
user_id: number;
problem_id: number;
problem_title: string;
last_submission_id: number | null;
note: string;
updated_at: number;
};
export default function WrongBookPage() {
const [token, setToken] = useState("");
const [items, setItems] = useState<WrongBookItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
setToken(readToken());
}, []);
const load = async () => {
setLoading(true);
setError("");
try {
if (!token) throw new Error("请先登录");
const data = await apiFetch<WrongBookItem[]>("/api/v1/me/wrong-book", {}, token);
setItems(data);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
useEffect(() => {
if (token) void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
const updateNote = async (problemId: number, note: string) => {
try {
await apiFetch(`/api/v1/me/wrong-book/${problemId}`, {
method: "PATCH",
body: JSON.stringify({ note }),
}, token);
await load();
} catch (e: unknown) {
setError(String(e));
}
};
const removeItem = async (problemId: number) => {
try {
await apiFetch(`/api/v1/me/wrong-book/${problemId}`, { method: "DELETE" }, token);
await load();
} catch (e: unknown) {
setError(String(e));
}
};
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<h1 className="text-2xl font-semibold"></h1>
<p className="mt-2 text-sm text-zinc-600"></p>
<div className="mt-4">
<button
className="rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50"
onClick={() => void load()}
disabled={loading}
>
{loading ? "刷新中..." : "刷新"}
</button>
</div>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<div className="mt-4 space-y-3">
{items.map((item) => (
<div key={item.problem_id} className="rounded-xl border bg-white p-4">
<div className="flex items-center justify-between gap-2">
<p className="font-medium">
#{item.problem_id} {item.problem_title}
</p>
<button
className="rounded border px-3 py-1 text-sm hover:bg-zinc-100"
onClick={() => void removeItem(item.problem_id)}
>
</button>
</div>
<p className="mt-1 text-xs text-zinc-500">
: {item.last_submission_id ?? "-"}
</p>
<textarea
className="mt-2 h-24 w-full rounded border p-2 text-sm"
value={item.note}
onChange={(e) => {
const next = e.target.value;
setItems((prev) =>
prev.map((x) =>
x.problem_id === item.problem_id ? { ...x, note: next } : x
)
);
}}
/>
<button
className="mt-2 rounded border px-3 py-1 text-sm hover:bg-zinc-100"
onClick={() => void updateNote(item.problem_id, item.note)}
>
</button>
</div>
))}
</div>
</main>
);
}

查看文件

@@ -0,0 +1,68 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { clearToken, readToken } from "@/lib/auth";
const links = [
["首页", "/"],
["登录", "/auth"],
["题库", "/problems"],
["提交", "/submissions"],
["错题本", "/wrong-book"],
["比赛", "/contests"],
["知识库", "/kb"],
["导入任务", "/imports"],
["在线运行", "/run"],
["我的", "/me"],
["排行榜", "/leaderboard"],
["API文档", "/api-docs"],
] as const;
export function AppNav() {
const [hasToken, setHasToken] = useState<boolean>(() => Boolean(readToken()));
useEffect(() => {
const refresh = () => setHasToken(Boolean(readToken()));
window.addEventListener("storage", refresh);
window.addEventListener("focus", refresh);
return () => {
window.removeEventListener("storage", refresh);
window.removeEventListener("focus", refresh);
};
}, []);
return (
<header className="border-b bg-white">
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-2 px-4 py-3">
{links.map(([label, href]) => (
<Link
key={href}
href={href}
className="rounded-md border px-3 py-1 text-sm hover:bg-zinc-100"
>
{label}
</Link>
))}
<div className="ml-auto flex items-center gap-2 text-sm">
<span className={hasToken ? "text-emerald-700" : "text-zinc-500"}>
{hasToken ? "已登录" : "未登录"}
</span>
{hasToken && (
<button
onClick={() => {
clearToken();
setHasToken(false);
}}
className="rounded-md border px-3 py-1 hover:bg-zinc-100"
>
退
</button>
)}
</div>
</div>
</header>
);
}

查看文件

@@ -0,0 +1,69 @@
"use client";
import dynamic from "next/dynamic";
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
type Props = {
value: string;
onChange: (next: string) => void;
height?: string;
};
export function CodeEditor({ value, onChange, height = "420px" }: Props) {
return (
<MonacoEditor
height={height}
language="cpp"
value={value}
options={{
fontSize: 14,
minimap: { enabled: false },
automaticLayout: true,
tabSize: 2,
wordWrap: "on",
suggestOnTriggerCharacters: true,
quickSuggestions: {
other: true,
comments: false,
strings: false,
},
}}
onMount={(editor, monaco) => {
monaco.languages.registerCompletionItemProvider("cpp", {
provideCompletionItems: () => ({
suggestions: [
{
label: "ios",
kind: monaco.languages.CompletionItemKind.Snippet,
insertText:
"ios::sync_with_stdio(false);\\ncin.tie(nullptr);\\n",
documentation: "Fast IO",
},
{
label: "fori",
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: "for (int i = 0; i < ${1:n}; ++i) {\\n ${2}\\n}",
insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: "for loop",
},
{
label: "vector",
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: "vector<int> ${1:arr}(${2:n});",
insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
},
],
}),
});
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
// handled by page-level save button; reserve shortcut for UX consistency.
});
}}
onChange={(next) => onChange(next ?? "")}
/>
);
}

查看文件

@@ -0,0 +1,86 @@
"use client";
import ReactMarkdown from "react-markdown";
import rehypeHighlight from "rehype-highlight";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
type Props = {
markdown: string;
};
function normalizeImageSrc(src: string): string {
if (!src) return src;
if (src.startsWith("http://") || src.startsWith("https://")) {
return `/api/image-cache?url=${encodeURIComponent(src)}`;
}
return src;
}
export function MarkdownRenderer({ markdown }: Props) {
return (
<article className="space-y-3 text-sm leading-7 text-zinc-800">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex, rehypeHighlight]}
components={{
h1: ({ children }) => <h1 className="mt-6 text-2xl font-semibold">{children}</h1>,
h2: ({ children }) => <h2 className="mt-5 text-xl font-semibold">{children}</h2>,
h3: ({ children }) => <h3 className="mt-4 text-lg font-semibold">{children}</h3>,
p: ({ children }) => <p className="whitespace-pre-wrap">{children}</p>,
ul: ({ children }) => <ul className="list-disc space-y-1 pl-5">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal space-y-1 pl-5">{children}</ol>,
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-zinc-300 pl-3 text-zinc-600">{children}</blockquote>
),
table: ({ children }) => (
<div className="overflow-x-auto">
<table className="min-w-full border text-xs">{children}</table>
</div>
),
th: ({ children }) => <th className="border bg-zinc-100 px-2 py-1 text-left">{children}</th>,
td: ({ children }) => <td className="border px-2 py-1 align-top">{children}</td>,
code: ({ className, children, ...props }) => {
const isInline = !className;
if (isInline) {
return (
<code className="rounded bg-zinc-100 px-1 py-0.5 text-xs text-zinc-800" {...props}>
{children}
</code>
);
}
return (
<code className={`${className ?? ""} block overflow-x-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100`} {...props}>
{children}
</code>
);
},
img: ({ src, alt }) => {
const safeSrc = typeof src === "string" ? src : "";
const safeAlt = typeof alt === "string" ? alt : "";
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={normalizeImageSrc(safeSrc)}
alt={safeAlt}
className="my-3 max-h-[480px] rounded border object-contain"
loading="lazy"
/>
);
},
a: ({ href, children }) => {
const safeHref = typeof href === "string" ? href : "#";
return (
<a href={safeHref} target="_blank" rel="noreferrer" className="text-blue-600 underline">
{children}
</a>
);
},
}}
>
{markdown}
</ReactMarkdown>
</article>
);
}

54
frontend/src/lib/api.ts 普通文件
查看文件

@@ -0,0 +1,54 @@
export const API_BASE =
process.env.NEXT_PUBLIC_API_BASE ??
(process.env.NODE_ENV === "development" ? "http://localhost:8080" : "/admin139");
type ApiEnvelope<T> =
| { ok: true; data?: T; [k: string]: unknown }
| { ok: false; error?: string; [k: string]: unknown };
export async function apiFetch<T>(
path: string,
init?: RequestInit,
token?: string
): Promise<T> {
const headers = new Headers(init?.headers);
if (token) headers.set("Authorization", `Bearer ${token}`);
if (init?.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const resp = await fetch(`${API_BASE}${path}`, {
...init,
headers,
cache: "no-store",
});
const text = await resp.text();
let payload: unknown = null;
if (text) {
try {
payload = JSON.parse(text) as unknown;
} catch {
payload = text;
}
}
if (!resp.ok) {
const msg =
typeof payload === "object" && payload !== null && "error" in payload
? String((payload as { error?: unknown }).error ?? `HTTP ${resp.status}`)
: `HTTP ${resp.status}`;
throw new Error(msg);
}
if (typeof payload === "object" && payload !== null && "ok" in payload) {
const env = payload as ApiEnvelope<T>;
if (!env.ok) {
throw new Error(env.error ?? "request failed");
}
if ("data" in env) return (env.data as T) ?? ({} as T);
return payload as T;
}
return payload as T;
}

16
frontend/src/lib/auth.ts 普通文件
查看文件

@@ -0,0 +1,16 @@
const TOKEN_KEY = "csp_token";
export function readToken(): string {
if (typeof window === "undefined") return "";
return window.localStorage.getItem(TOKEN_KEY) ?? "";
}
export function saveToken(token: string): void {
if (typeof window === "undefined") return;
window.localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken(): void {
if (typeof window === "undefined") return;
window.localStorage.removeItem(TOKEN_KEY);
}

查看文件

@@ -0,0 +1,6 @@
declare module "swagger-ui-react" {
import type { ComponentType } from "react";
const SwaggerUI: ComponentType<Record<string, unknown>>;
export default SwaggerUI;
}

某些文件未显示,因为此 diff 中更改的文件太多 显示更多