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 \
libpq-dev libmariadb-dev libmariadb-dev-compat \
libsqlite3-dev sqlite3 \
libssl-dev && \
libssl-dev uuid-dev libbrotli-dev catch2 && \
rm -rf /var/lib/apt/lists/*
WORKDIR /src
@@ -24,11 +24,13 @@ FROM ubuntu:24.04 AS runtime
RUN apt-get update -y && \
DEBIAN_FRONTEND=noninteractive apt-get install -y \
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/*
WORKDIR /app
COPY --from=build /src/build/backend/csp_server /app/csp_server
COPY scripts/ /app/scripts/
EXPOSE 8080

153
README.md
查看文件

@@ -1,45 +1,148 @@
# CSP 在线练习 / 模拟竞赛平台(前后端分离)
# CSP 在线学习与竞赛平台
- 前端Next.js目录`frontend/`
- 后端C++20 + Drogon + SQLite目录`backend/`
面向 OI/CSP 学习场景的全栈 Web 系统(前后端分离):
## 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
./scripts/bootstrap_ubuntu.sh
```
### 1.2 构建与运行后端
```bash
cmake -S . -B build -G Ninja
cmake --build build
ctest --test-dir build -V
./build/backend/csp_server
# http://localhost:8080/api/health
```
### 1.3 运行前端
前端:
```bash
cd frontend
npm run dev
# http://localhost:3000
npm --prefix frontend ci
npm --prefix frontend run dev
```
## 2. 目录结构
- `backend/` 后端 C++ 服务与测试
- `frontend/` 前端 Next.js
- `docs/` 开发文档(设计、接口、部署等)
- `scripts/` 一键脚本
- `backend/`Drogon 后端(控制器/服务/领域模型/SQLite
- `frontend/`Next.js 前端
- `docs/`架构、API、数据库、测试、部署文档
- `scripts/`:开发与初始化脚本
## 3. 当前状态
## 3. API 入口说明
已完成工程骨架
- 后端Drogon 服务启动 + `/api/health`
- 后端Catch2 单测接入(`ctest` 可跑)
- 前端Next.js 工程初始化
生产/Compose 场景建议统一通过前端同域反代访问后端
- 浏览器访问:`/admin139/...`
- 例如:`/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/services/crypto.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/json.cc
)
@@ -30,6 +41,15 @@ target_link_libraries(csp_core PUBLIC
add_library(csp_web
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
)
@@ -53,7 +73,7 @@ target_include_directories(csp_server PRIVATE
target_link_libraries(csp_server PRIVATE
Drogon::Drogon
csp_core
csp_web
"$<LINK_LIBRARY:WHOLE_ARCHIVE,csp_web>"
)
enable_testing()
@@ -64,6 +84,18 @@ add_executable(csp_tests
tests/auth_service_test.cc
tests/auth_http_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
@@ -74,7 +106,7 @@ target_link_libraries(csp_tests PRIVATE
Catch2::Catch2WithMain
Drogon::Drogon
csp_core
csp_web
"$<LINK_LIBRARY:WHOLE_ARCHIVE,csp_web>"
)
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.
void ApplyMigrations(SqliteDb& db);
void SeedDemoData(SqliteDb& db);
} // namespace csp::db

查看文件

@@ -33,6 +33,10 @@ struct Problem {
std::string statement_md;
int32_t difficulty = 1;
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;
};
@@ -64,12 +68,15 @@ struct Submission {
int64_t id = 0;
int64_t user_id = 0;
int64_t problem_id = 0;
std::optional<int64_t> contest_id;
Language language = Language::Cpp;
std::string code;
SubmissionStatus status = SubmissionStatus::Pending;
int32_t score = 0;
int32_t time_ms = 0;
int32_t memory_kb = 0;
std::string compile_log;
std::string runtime_log;
int64_t created_at = 0;
};
@@ -81,4 +88,51 @@ struct WrongBookItem {
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

查看文件

@@ -14,5 +14,9 @@ Json::Value ToPublicJson(const User& u);
Json::Value ToJson(const Problem& p);
Json::Value ToJson(const Submission& s);
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

查看文件

@@ -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,
statement_md TEXT NOT NULL,
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
);
@@ -39,22 +43,26 @@ CREATE TABLE IF NOT EXISTS submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
problem_id INTEGER NOT NULL,
contest_id INTEGER,
language TEXT NOT NULL,
code TEXT NOT NULL,
status TEXT NOT NULL,
score INTEGER NOT NULL DEFAULT 0,
time_ms INTEGER NOT NULL DEFAULT 0,
memory_kb INTEGER NOT NULL DEFAULT 0,
compile_log TEXT NOT NULL DEFAULT '',
runtime_log TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE
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 (
user_id INTEGER NOT NULL,
problem_id INTEGER NOT NULL,
last_submission_id INTEGER,
note TEXT NOT NULL DEFAULT "",
note TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL,
PRIMARY KEY(user_id, problem_id),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
@@ -62,5 +70,133 @@ CREATE TABLE IF NOT EXISTS wrong_book (
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_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));
}
csp::db::ApplyMigrations(*db_);
csp::db::SeedDemoData(*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 <chrono>
#include <optional>
#include <stdexcept>
#include <string>
#include <utility>
namespace csp::db {
@@ -8,11 +11,203 @@ namespace csp::db {
namespace {
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) : "";
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
SqliteDb SqliteDb::OpenFile(const std::string& path) {
@@ -60,11 +255,9 @@ void SqliteDb::Exec(const std::string& sql) {
}
void ApplyMigrations(SqliteDb& db) {
// Keep it simple for MVP: apply the bundled init SQL.
// In later iterations we'll add a migrations table + incremental runner.
// Keep it simple for MVP: create missing tables, then patch missing columns.
db.Exec("PRAGMA foreign_keys = ON;");
// 001_init.sql (embedded)
db.Exec(R"SQL(
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -90,6 +283,10 @@ CREATE TABLE IF NOT EXISTS problems (
statement_md TEXT NOT NULL,
difficulty INTEGER NOT NULL DEFAULT 1,
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
);
@@ -104,15 +301,19 @@ CREATE TABLE IF NOT EXISTS submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
problem_id INTEGER NOT NULL,
contest_id INTEGER,
language TEXT NOT NULL,
code TEXT NOT NULL,
status TEXT NOT NULL,
score INTEGER NOT NULL DEFAULT 0,
time_ms INTEGER NOT NULL DEFAULT 0,
memory_kb INTEGER NOT NULL DEFAULT 0,
compile_log TEXT NOT NULL DEFAULT "",
runtime_log TEXT NOT NULL DEFAULT "",
created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE
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 (
@@ -127,9 +328,280 @@ CREATE TABLE IF NOT EXISTS wrong_book (
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_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");
}
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

查看文件

@@ -19,6 +19,10 @@ Json::Value ToJson(const Problem& p) {
j["statement_md"] = p.statement_md;
j["difficulty"] = p.difficulty;
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);
return j;
}
@@ -28,11 +32,18 @@ Json::Value ToJson(const Submission& s) {
j["id"] = Json::Int64(s.id);
j["user_id"] = Json::Int64(s.user_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["status"] = ToString(s.status);
j["score"] = s.score;
j["time_ms"] = s.time_ms;
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);
return j;
}
@@ -51,4 +62,42 @@ Json::Value ToJson(const WrongBookItem& w) {
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

查看文件

@@ -2,6 +2,9 @@
#include "csp/app_state.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 <filesystem>
@@ -12,6 +15,9 @@ int main(int argc, char** argv) {
if (!parent.empty()) std::filesystem::create_directories(parent);
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.
{
@@ -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.
drogon::app().registerPreRoutingAdvice([](const drogon::HttpRequestPtr& req,
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(), "sessions") == 1);
REQUIRE(CountTable(db.raw(), "problems") == 1);
REQUIRE(CountTable(db.raw(), "problem_tags") == 1);
REQUIRE(CountTable(db.raw(), "submissions") == 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:
env_file:
- .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:
context: .
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
docker compose up -d --build
```
## 访问
查看状态:
- 前端http://localhost:7888
- 后端通过前端反代http://localhost:7888/admin139/api/health
- 后端(注册):`POST http://localhost:7888/admin139/api/v1/auth/register`
- 后端(登录):`POST http://localhost:7888/admin139/api/v1/auth/login`
```bash
docker compose ps
docker compose logs --tail=100 backend
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
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)**:注册/登录;拥有积分、等级、学习进度。
- **题目(Problem)**:题面、标签、难度、来源(如 CSP/NOIP/自建)。
- **提交(Submission)**:用户对题目的一次代码提交(含编译/运行结果、耗时、内存、得分)。
- **练习(Practice)**:非比赛场景的做题记录(可以直接通过 submissions 体现)。
- **错题本(WrongBook)**:用户在练习/比赛中未通过的题目集合 + 错因备注。
- **比赛(Contest)**:模拟 CSP/NOIP 的比赛;包含题目列表、开始/结束、计分规则。
- **排名(Leaderboard)**:全站积分排行、比赛排行。
- **知识库(KnowledgeBase)**:学习文章/笔记/专题目录;可关联题目。
1. 用户与鉴权
2. 题库与在线提交
3. 提交记录与结果追踪
4. 错题本与复盘
5. 模拟竞赛与排行
6. 学习知识库
7. 在线 C++ 编写/编译/运行
## 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)
- SQLite单文件数据库,便于部署
- 模块分层(建议):
- `controller/`HTTP 路由
- `service/`:业务逻辑
- `repo/`DB 访问SQL + 映射)
- `domain/`:实体与枚举
- `judge/`:编译与判题执行器(后续)
## 3. 核心业务流
### 2.3 在线编译/运行(安全边界)
### 3.1 练习流
1) 用户登录
2) 浏览题目并提交
3) 后端调用 `g++` 编译并运行样例
4) 结果写入 `submissions`
5) 若未通过,写入/更新 `wrong_book`
6) 若首次 AC,提升 `users.rating`
- MVP后端在临时目录中调用 `g++` 编译,并用子进程运行,使用 `ulimit`/超时 kill 做基础限制。
- 生产建议:判题/运行必须放在容器或隔离工具(如 nsjail/isolate中;否则存在逃逸风险。
### 3.2 竞赛流
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`
- `id` INTEGER PK
- `slug` TEXT UNIQUE
- `title` TEXT
- `statement_md` TEXT
- `difficulty` INTEGER
- `source` TEXT
- `created_at` INTEGER
## 5. 可扩展方向
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
npm ci
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!
## Deploy on Vercel
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.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
- `/auth` 登录/注册
- `/problems` 题库列表
- `/problems/:id` 题目详情与提交
- `/submissions` 提交列表
- `/submissions/:id` 提交详情
- `/wrong-book` 错题本
- `/contests` 模拟竞赛列表
- `/contests/:id` 比赛详情/报名/排行榜
- `/kb` 知识库列表
- `/kb/:slug` 文章详情
- `/imports` 题库导入任务状态与结果
- `/run` 在线 C++ 运行
- `/me` 当前用户信息
- `/leaderboard` 全站排行

查看文件

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

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

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

查看文件

@@ -9,9 +9,18 @@
"lint": "eslint"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"highlight.js": "^11.11.1",
"katex": "^0.16.28",
"next": "16.1.6",
"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": {
"@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";
import Link from "next/link";
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 AuthErr = { ok: false; error: string };
type AuthResp = AuthOk | AuthErr;
export default function AuthPage() {
const apiBase = useMemo(
() => process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8080",
[]
);
function passwordScore(password: string): { label: string; color: string } {
if (password.length >= 12) return { label: "强", color: "text-emerald-600" };
if (password.length >= 8) return { label: "中", color: "text-blue-600" };
return { label: "弱", color: "text-orange-600" };
}
const [mode, setMode] = useState<"register" | "login">("register");
const [username, setUsername] = useState(
process.env.NEXT_PUBLIC_TEST_USERNAME ?? ""
);
const [password, setPassword] = useState(
process.env.NEXT_PUBLIC_TEST_PASSWORD ?? ""
);
export default function AuthPage() {
const router = useRouter();
const apiBase = useMemo(() => API_BASE, []);
const [mode, setMode] = useState<"register" | "login">("login");
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 [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() {
if (!canSubmit) return;
setLoading(true);
setResp(null);
try {
const r = await fetch(`${apiBase}/api/v1/auth/${mode}`, {
const j = await apiFetch<AuthResp>(`/api/v1/auth/${mode}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
body: JSON.stringify({ username: username.trim(), password }),
});
const j = (await r.json()) as AuthResp;
setResp(j);
if (j.ok) {
saveToken(j.token);
setTimeout(() => {
router.push("/problems");
}, 350);
}
} catch (e: unknown) {
setResp({ ok: false, error: String(e) });
} finally {
@@ -40,75 +59,140 @@ export default function AuthPage() {
}
}
const strength = passwordScore(password);
return (
<div className="min-h-screen bg-zinc-50 text-zinc-900">
<main className="mx-auto max-w-xl px-6 py-12">
<h1 className="text-2xl font-semibold">{mode === "register" ? "注册" : "登录"}</h1>
<p className="mt-2 text-sm text-zinc-600">
<main className="mx-auto max-w-4xl px-6 py-10">
<div className="grid gap-6 md:grid-cols-[1.1fr,1fr]">
<section className="rounded-2xl border bg-zinc-900 p-6 text-zinc-100">
<h1 className="text-2xl font-semibold"></h1>
<p className="mt-3 text-sm text-zinc-300">
稿
</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">
<div className="grid grid-cols-2 gap-2 rounded-lg bg-zinc-100 p-1 text-sm">
<button
className={`rounded-lg px-3 py-2 text-sm ${
mode === "register" ? "bg-zinc-900 text-white" : "bg-white border"
type="button"
className={`rounded-md px-3 py-2 ${
mode === "login" ? "bg-white shadow-sm" : "text-zinc-600"
}`}
onClick={() => setMode("register")}
disabled={loading}
>
</button>
<button
className={`rounded-lg px-3 py-2 text-sm ${
mode === "login" ? "bg-zinc-900 text-white" : "bg-white border"
}`}
onClick={() => setMode("login")}
onClick={() => {
setMode("login");
setResp(null);
}}
disabled={loading}
>
</button>
</div>
<div className="mt-6 rounded-xl border bg-white p-5">
<label className="block text-sm font-medium"></label>
<input
className="mt-2 w-full rounded-lg border px-3 py-2"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="alice"
/>
<label className="mt-4 block text-sm font-medium">6</label>
<input
type="password"
className="mt-2 w-full rounded-lg border px-3 py-2"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="password123"
/>
<button
className="mt-5 w-full rounded-lg bg-zinc-900 px-4 py-2 text-white hover:bg-zinc-800 disabled:opacity-50"
onClick={submit}
disabled={loading || !username || !password}
type="button"
className={`rounded-md px-3 py-2 ${
mode === "register" ? "bg-white shadow-sm" : "text-zinc-600"
}`}
onClick={() => {
setMode("register");
setResp(null);
}}
disabled={loading}
>
{loading ? "提交中..." : mode === "register" ? "注册" : "登录"}
</button>
</div>
<div className="mt-6 rounded-xl border bg-white p-5">
<h2 className="text-sm font-medium"></h2>
<pre className="mt-3 overflow-auto rounded-lg bg-zinc-900 p-3 text-xs text-zinc-100">
{JSON.stringify(resp, null, 2)}
</pre>
{resp && resp.ok && (
<p className="mt-3 text-xs text-zinc-600">
token
<span className="font-mono"> Authorization: Bearer {resp.token}</span>
</p>
<div className="mt-5 space-y-4">
<div>
<label className="text-sm font-medium"></label>
<input
className="mt-1 w-full rounded-lg border px-3 py-2"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="例如csp_student"
/>
{usernameErr && <p className="mt-1 text-xs text-red-600">{usernameErr}</p>}
</div>
<div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium"></label>
<span className={`text-xs ${strength.color}`}>{strength.label}</span>
</div>
<input
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>
{mode === "register" && (
<div>
<label className="text-sm font-medium"></label>
<input
type={showPassword ? "text" : "password"}
className="mt-1 w-full rounded-lg border px-3 py-2"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="再输入一次密码"
/>
{confirmErr && <p className="mt-1 text-xs text-red-600">{confirmErr}</p>}
</div>
)}
<label className="flex items-center gap-2 text-xs text-zinc-600">
<input
type="checkbox"
checked={showPassword}
onChange={(e) => setShowPassword(e.target.checked)}
/>
</label>
<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>
)}
<p className="mt-4 text-xs text-zinc-500">
Token localStorage
<Link className="mx-1 underline" href="/problems">
</Link>
<Link className="mx-1 underline" href="/me">
</Link>
</p>
</section>
</div>
</main>
</div>
);
}

查看文件

@@ -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 {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-sans: Arial, Helvetica, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
}
@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 { 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";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "CSP 在线学习与竞赛平台",
description: "题库、错题本、模拟竞赛、知识库与在线 C++ 运行",
};
export default function RootLayout({
@@ -23,10 +16,9 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<html lang="zh-CN">
<body className="antialiased">
<AppNav />
{children}
</body>
</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";
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>
);
export default function Home() {
redirect("/problems");
}

查看文件

@@ -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 中更改的文件太多 显示更多