diff --git a/.gitignore b/.gitignore index 5f7bfcc..5129978 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ .DS_Store .idea/ .vscode/ + +# Local PDF corpus for RAG generation +data/local_pdfs/ diff --git a/README.md b/README.md index 4caf3be..a0b6eb6 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,12 @@ OI_IMPORT_SCRIPT_PATH=/app/scripts/import_luogu_csp.py OI_IMPORT_CLEAR_ALL_PROBLEMS=true ``` +## 6.1 本地 PDF + RAG + LLM 扩充题库(CSP-J/S) + +`/imports` 页面支持切换到 `local_pdf_rag` 模式:从本地 PDF 抽取文本做 RAG,调用 LLM 生成 CSP-J/S 题目,并按现有题库相似度去重,跳过雷同题目,直到目标题量(如 5000)。 + +默认目录:`/data/local_pdfs`(Compose 已挂载 `./data/local_pdfs:/data/local_pdfs`),建议先把 PDF 放到该目录。 + 如果你还要使用旧的 PDF + LLM 导入流程,可手动运行 `scripts/import_winterant_oi.py`。 ## 7. CSP-J 题目自动生成(RAG + 去重) diff --git a/backend/include/csp/controllers/admin_controller.h b/backend/include/csp/controllers/admin_controller.h new file mode 100644 index 0000000..452084b --- /dev/null +++ b/backend/include/csp/controllers/admin_controller.h @@ -0,0 +1,54 @@ +#pragma once + +#include + +#include + +namespace csp::controllers { + +class AdminController : public drogon::HttpController { + public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(AdminController::listUsers, "/api/v1/admin/users", drogon::Get); + ADD_METHOD_TO(AdminController::updateUserRating, + "/api/v1/admin/users/{1}/rating", + drogon::Patch); + ADD_METHOD_TO(AdminController::listRedeemItems, "/api/v1/admin/redeem-items", drogon::Get); + ADD_METHOD_TO(AdminController::createRedeemItem, "/api/v1/admin/redeem-items", drogon::Post); + ADD_METHOD_TO(AdminController::updateRedeemItem, + "/api/v1/admin/redeem-items/{1}", + drogon::Patch); + ADD_METHOD_TO(AdminController::deleteRedeemItem, + "/api/v1/admin/redeem-items/{1}", + drogon::Delete); + ADD_METHOD_TO(AdminController::listRedeemRecords, + "/api/v1/admin/redeem-records", + drogon::Get); + METHOD_LIST_END + + void listUsers(const drogon::HttpRequestPtr& req, + std::function&& cb); + + void updateUserRating(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t user_id); + + void listRedeemItems(const drogon::HttpRequestPtr& req, + std::function&& cb); + + void createRedeemItem(const drogon::HttpRequestPtr& req, + std::function&& cb); + + void updateRedeemItem(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t item_id); + + void deleteRedeemItem(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t item_id); + + void listRedeemRecords(const drogon::HttpRequestPtr& req, + std::function&& cb); +}; + +} // namespace csp::controllers diff --git a/backend/include/csp/controllers/meta_controller.h b/backend/include/csp/controllers/meta_controller.h index 58945de..490f3d5 100644 --- a/backend/include/csp/controllers/meta_controller.h +++ b/backend/include/csp/controllers/meta_controller.h @@ -8,12 +8,32 @@ class MetaController : public drogon::HttpController { public: METHOD_LIST_BEGIN ADD_METHOD_TO(MetaController::openapi, "/api/openapi.json", drogon::Get); + ADD_METHOD_TO(MetaController::backendLogs, "/api/v1/backend/logs", drogon::Get); + ADD_METHOD_TO(MetaController::kbRefreshStatus, "/api/v1/backend/kb/refresh", drogon::Get); + ADD_METHOD_TO(MetaController::triggerKbRefresh, "/api/v1/backend/kb/refresh", drogon::Post); + ADD_METHOD_TO(MetaController::triggerMissingSolutions, "/api/v1/backend/solutions/generate-missing", + drogon::Post); ADD_METHOD_TO(MetaController::mcp, "/api/v1/mcp", drogon::Post); METHOD_LIST_END void openapi(const drogon::HttpRequestPtr& req, std::function&& cb); + void backendLogs(const drogon::HttpRequestPtr& req, + std::function&& cb); + + void kbRefreshStatus( + const drogon::HttpRequestPtr& req, + std::function&& cb); + + void triggerKbRefresh( + const drogon::HttpRequestPtr& req, + std::function&& cb); + + void triggerMissingSolutions( + const drogon::HttpRequestPtr& req, + std::function&& cb); + void mcp(const drogon::HttpRequestPtr& req, std::function&& cb); }; diff --git a/backend/include/csp/controllers/submission_controller.h b/backend/include/csp/controllers/submission_controller.h index 75cedef..b95c90d 100644 --- a/backend/include/csp/controllers/submission_controller.h +++ b/backend/include/csp/controllers/submission_controller.h @@ -12,6 +12,7 @@ class SubmissionController : public drogon::HttpController 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::analyzeSubmission, "/api/v1/submissions/{1}/analysis", drogon::Post); ADD_METHOD_TO(SubmissionController::runCpp, "/api/v1/run/cpp", drogon::Post); METHOD_LIST_END @@ -26,6 +27,10 @@ class SubmissionController : public drogon::HttpController std::function&& cb, int64_t submission_id); + void analyzeSubmission(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t submission_id); + void runCpp(const drogon::HttpRequestPtr& req, std::function&& cb); }; diff --git a/backend/include/csp/domain/entities.h b/backend/include/csp/domain/entities.h index 33f9b9c..59ed047 100644 --- a/backend/include/csp/domain/entities.h +++ b/backend/include/csp/domain/entities.h @@ -73,6 +73,7 @@ struct Submission { std::string code; SubmissionStatus status = SubmissionStatus::Pending; int32_t score = 0; + int32_t rating_delta = 0; int32_t time_ms = 0; int32_t memory_kb = 0; std::string compile_log; diff --git a/backend/include/csp/services/auth_service.h b/backend/include/csp/services/auth_service.h index f8d45be..18da467 100644 --- a/backend/include/csp/services/auth_service.h +++ b/backend/include/csp/services/auth_service.h @@ -20,6 +20,7 @@ class AuthService { // Throws on error; for controller we will catch and convert to JSON. AuthResult Register(const std::string& username, const std::string& password); AuthResult Login(const std::string& username, const std::string& password); + void ResetPassword(const std::string& username, const std::string& new_password); std::optional VerifyToken(const std::string& token); diff --git a/backend/include/csp/services/import_runner.h b/backend/include/csp/services/import_runner.h index e388a00..151678c 100644 --- a/backend/include/csp/services/import_runner.h +++ b/backend/include/csp/services/import_runner.h @@ -8,7 +8,11 @@ namespace csp::services { struct ImportRunOptions { + std::string mode = "luogu"; bool clear_all_problems = false; + std::string local_pdf_dir; + int target_total = 5000; + int workers = 3; }; class ImportRunner { diff --git a/backend/include/csp/services/kb_import_runner.h b/backend/include/csp/services/kb_import_runner.h new file mode 100644 index 0000000..051891a --- /dev/null +++ b/backend/include/csp/services/kb_import_runner.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include + +namespace csp::services { + +class KbImportRunner { + public: + static KbImportRunner& Instance(); + + void Configure(std::string db_path); + bool TriggerAsync(const std::string& trigger); + + bool IsRunning() const; + std::string LastCommand() const; + std::optional LastExitCode() const; + int64_t LastStartedAt() const; + int64_t LastFinishedAt() const; + std::string LastTrigger() const; + + private: + KbImportRunner() = default; + + mutable std::mutex mu_; + std::string db_path_; + bool running_ = false; + std::string last_command_; + std::optional last_exit_code_; + int64_t last_started_at_ = 0; + int64_t last_finished_at_ = 0; + std::string last_trigger_; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/problem_solution_runner.h b/backend/include/csp/services/problem_solution_runner.h index c6961e7..76798ee 100644 --- a/backend/include/csp/services/problem_solution_runner.h +++ b/backend/include/csp/services/problem_solution_runner.h @@ -1,28 +1,59 @@ #pragma once +#include "csp/db/sqlite_db.h" + #include +#include +#include #include #include -#include #include +#include namespace csp::services { class ProblemSolutionRunner { public: + struct TriggerMissingSummary { + int missing_total = 0; + int candidate_count = 0; + int queued_count = 0; + }; + static ProblemSolutionRunner& Instance(); void Configure(std::string db_path); bool TriggerAsync(int64_t problem_id, int64_t job_id, int max_solutions); + TriggerMissingSummary TriggerMissingAsync(db::SqliteDb& db, + int64_t created_by, + int max_solutions, + int limit); + void AutoStartMissingIfEnabled(db::SqliteDb& db); bool IsRunning(int64_t problem_id) const; + size_t PendingCount() const; private: + struct Task { + int64_t problem_id = 0; + int64_t job_id = 0; + int max_solutions = 3; + }; + ProblemSolutionRunner() = default; + void StartWorkerIfNeededLocked(); + void WorkerLoop(); + void RecoverQueuedJobsLocked(); + void StartAutoPumpIfNeeded(db::SqliteDb* db, int max_solutions, int limit, int interval_sec); std::string db_path_; mutable std::mutex mu_; - std::set running_problem_ids_; + std::deque queue_; + std::unordered_map pending_problem_counts_; + size_t pending_jobs_ = 0; + bool worker_running_ = false; + bool recovered_from_db_ = false; + bool auto_pump_started_ = false; }; } // namespace csp::services diff --git a/backend/include/csp/services/problem_workspace_service.h b/backend/include/csp/services/problem_workspace_service.h index 6ea7182..e596dd8 100644 --- a/backend/include/csp/services/problem_workspace_service.h +++ b/backend/include/csp/services/problem_workspace_service.h @@ -34,6 +34,7 @@ struct ProblemSolution { struct ProblemSolutionJob { int64_t id = 0; int64_t problem_id = 0; + std::string problem_title; std::string status; int progress = 0; std::string message; @@ -60,7 +61,13 @@ class ProblemWorkspaceService { int64_t CreateSolutionJob(int64_t problem_id, int64_t created_by, int max_solutions); std::optional GetLatestSolutionJob(int64_t problem_id); + std::vector ListRecentSolutionJobs(int limit); + std::vector ListSolutionJobsByStatus(const std::string& status, + int limit); std::vector ListSolutions(int64_t problem_id); + int CountProblemsWithoutSolutions(); + std::vector ListProblemIdsWithoutSolutions(int limit, + bool exclude_queued_or_running_jobs); private: db::SqliteDb& db_; diff --git a/backend/include/csp/services/redeem_service.h b/backend/include/csp/services/redeem_service.h new file mode 100644 index 0000000..0a68709 --- /dev/null +++ b/backend/include/csp/services/redeem_service.h @@ -0,0 +1,77 @@ +#pragma once + +#include "csp/db/sqlite_db.h" + +#include +#include +#include +#include + +namespace csp::services { + +struct RedeemItem { + int64_t id = 0; + std::string name; + std::string description; + std::string unit_label; + int holiday_cost = 0; + int studyday_cost = 0; + bool is_active = true; + bool is_global = true; + int64_t created_by = 0; + int64_t created_at = 0; + int64_t updated_at = 0; +}; + +struct RedeemItemWrite { + std::string name; + std::string description; + std::string unit_label = "小时"; + int holiday_cost = 5; + int studyday_cost = 25; + bool is_active = true; + bool is_global = true; +}; + +struct RedeemRecord { + int64_t id = 0; + int64_t user_id = 0; + int64_t item_id = 0; + std::string item_name; + int quantity = 1; + std::string day_type; + int unit_cost = 0; + int total_cost = 0; + std::string note; + int64_t created_at = 0; + std::string username; +}; + +struct RedeemRequest { + int64_t user_id = 0; + int64_t item_id = 0; + int quantity = 1; + std::string day_type = "studyday"; + std::string note; +}; + +class RedeemService { + public: + explicit RedeemService(db::SqliteDb& db) : db_(db) {} + + std::vector ListItems(bool include_inactive); + std::optional GetItemById(int64_t item_id); + RedeemItem CreateItem(int64_t admin_user_id, const RedeemItemWrite& input); + RedeemItem UpdateItem(int64_t item_id, const RedeemItemWrite& input); + void DeactivateItem(int64_t item_id); + + std::vector ListRecordsByUser(int64_t user_id, int limit); + std::vector ListRecordsAll(std::optional user_id, int limit); + + RedeemRecord Redeem(const RedeemRequest& request); + + private: + db::SqliteDb& db_; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/solution_access_service.h b/backend/include/csp/services/solution_access_service.h new file mode 100644 index 0000000..fd1ff61 --- /dev/null +++ b/backend/include/csp/services/solution_access_service.h @@ -0,0 +1,44 @@ +#pragma once + +#include "csp/db/sqlite_db.h" + +#include +#include +#include + +namespace csp::services { + +struct SolutionViewChargeResult { + bool granted = true; + bool charged = false; + bool daily_free = false; + int cost = 0; + int rating_before = 0; + int rating_after = 0; + int daily_used_count = 0; + int64_t viewed_at = 0; + std::string day_key; + std::string deny_reason; +}; + +struct SolutionViewStats { + bool has_viewed = false; + int total_views = 0; + int total_cost = 0; + std::optional last_viewed_at; +}; + +class SolutionAccessService { + public: + explicit SolutionAccessService(db::SqliteDb& db) : db_(db) {} + + // Daily policy: first answer view is free, then each full view costs 2 rating. + SolutionViewChargeResult ConsumeSolutionView(int64_t user_id, int64_t problem_id); + + SolutionViewStats QueryUserProblemViewStats(int64_t user_id, int64_t problem_id); + + private: + db::SqliteDb& db_; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/submission_feedback_service.h b/backend/include/csp/services/submission_feedback_service.h new file mode 100644 index 0000000..db27c94 --- /dev/null +++ b/backend/include/csp/services/submission_feedback_service.h @@ -0,0 +1,36 @@ +#pragma once + +#include "csp/db/sqlite_db.h" +#include "csp/domain/entities.h" + +#include +#include +#include + +namespace csp::services { + +struct SubmissionFeedback { + int64_t submission_id = 0; + std::string feedback_md; + std::string links_json; + std::string model_name; + std::string status; + int64_t created_at = 0; + int64_t updated_at = 0; +}; + +class SubmissionFeedbackService { + public: + explicit SubmissionFeedbackService(db::SqliteDb& db) : db_(db) {} + + std::optional GetBySubmissionId(int64_t submission_id); + + SubmissionFeedback GenerateAndSave(const domain::Submission& submission, + const domain::Problem& problem, + bool force_refresh); + + private: + db::SqliteDb& db_; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/user_service.h b/backend/include/csp/services/user_service.h index b6c4d36..7aaf791 100644 --- a/backend/include/csp/services/user_service.h +++ b/backend/include/csp/services/user_service.h @@ -9,12 +9,19 @@ namespace csp::services { +struct UserListResult { + std::vector items; + int total_count = 0; +}; + class UserService { public: explicit UserService(db::SqliteDb& db) : db_(db) {} std::optional GetById(int64_t id); std::vector GlobalLeaderboard(int limit = 100); + UserListResult ListUsers(int page, int page_size); + void SetRating(int64_t user_id, int rating); private: db::SqliteDb& db_; diff --git a/backend/src/controllers/admin_controller.cc b/backend/src/controllers/admin_controller.cc new file mode 100644 index 0000000..865ee60 --- /dev/null +++ b/backend/src/controllers/admin_controller.cc @@ -0,0 +1,320 @@ +#include "csp/controllers/admin_controller.h" + +#include "csp/app_state.h" +#include "csp/services/redeem_service.h" +#include "csp/services/user_service.h" +#include "http_auth.h" + +#include +#include +#include +#include +#include +#include + +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)); +} + +bool ParseBoolLike(const std::string& raw, bool default_value) { + if (raw.empty()) return default_value; + std::string v = raw; + std::transform(v.begin(), v.end(), v.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + if (v == "1" || v == "true" || v == "yes" || v == "on") return true; + if (v == "0" || v == "false" || v == "no" || v == "off") return false; + return default_value; +} + +std::optional ParseOptionalInt64(const std::string& raw) { + if (raw.empty()) return std::nullopt; + return std::stoll(raw); +} + +services::RedeemItemWrite ParseRedeemItemWrite(const Json::Value& json) { + services::RedeemItemWrite write; + write.name = json.get("name", "").asString(); + write.description = json.get("description", "").asString(); + write.unit_label = json.get("unit_label", "小时").asString(); + write.holiday_cost = json.get("holiday_cost", 5).asInt(); + write.studyday_cost = json.get("studyday_cost", 25).asInt(); + write.is_active = json.get("is_active", true).asBool(); + write.is_global = json.get("is_global", true).asBool(); + return write; +} + +Json::Value ToJson(const services::RedeemItem& item) { + Json::Value j; + j["id"] = Json::Int64(item.id); + j["name"] = item.name; + j["description"] = item.description; + j["unit_label"] = item.unit_label; + j["holiday_cost"] = item.holiday_cost; + j["studyday_cost"] = item.studyday_cost; + j["is_active"] = item.is_active; + j["is_global"] = item.is_global; + j["created_by"] = Json::Int64(item.created_by); + j["created_at"] = Json::Int64(item.created_at); + j["updated_at"] = Json::Int64(item.updated_at); + return j; +} + +std::optional RequireAdminUserId( + const drogon::HttpRequestPtr& req, + std::function& 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; + } + + services::UserService users(csp::AppState::Instance().db()); + const auto user = users.GetById(*user_id); + if (!user.has_value() || user->username != "admin") { + cb(JsonError(drogon::k403Forbidden, "admin only")); + return std::nullopt; + } + return user_id; +} + +} // namespace + +void AdminController::listUsers( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + if (!RequireAdminUserId(req, cb).has_value()) return; + + const int page = ParseClampedInt(req->getParameter("page"), 1, 1, 100000); + const int page_size = + ParseClampedInt(req->getParameter("page_size"), 50, 1, 200); + + services::UserService users(csp::AppState::Instance().db()); + const auto result = users.ListUsers(page, page_size); + + Json::Value arr(Json::arrayValue); + for (const auto& item : result.items) { + Json::Value one; + one["id"] = Json::Int64(item.user_id); + one["username"] = item.username; + one["rating"] = item.rating; + one["created_at"] = Json::Int64(item.created_at); + arr.append(one); + } + + Json::Value payload; + payload["items"] = arr; + payload["total_count"] = result.total_count; + 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 AdminController::updateUserRating( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t user_id) { + try { + if (!RequireAdminUserId(req, cb).has_value()) return; + + const auto json = req->getJsonObject(); + if (!json) { + cb(JsonError(drogon::k400BadRequest, "body must be json")); + return; + } + + if (!(*json).isMember("rating")) { + cb(JsonError(drogon::k400BadRequest, "rating is required")); + return; + } + const int rating = (*json)["rating"].asInt(); + if (rating < 0) { + cb(JsonError(drogon::k400BadRequest, "rating must be >= 0")); + return; + } + + services::UserService users(csp::AppState::Instance().db()); + users.SetRating(user_id, rating); + + const auto updated = users.GetById(user_id); + if (!updated.has_value()) { + cb(JsonError(drogon::k404NotFound, "user not found")); + return; + } + + Json::Value payload; + payload["id"] = Json::Int64(updated->id); + payload["username"] = updated->username; + payload["rating"] = updated->rating; + payload["updated"] = true; + cb(JsonOk(payload)); + } catch (const std::invalid_argument&) { + cb(JsonError(drogon::k400BadRequest, "invalid rating")); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void AdminController::listRedeemItems( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + if (!RequireAdminUserId(req, cb).has_value()) return; + + const bool include_inactive = + ParseBoolLike(req->getParameter("include_inactive"), true); + services::RedeemService redeem(csp::AppState::Instance().db()); + const auto items = redeem.ListItems(include_inactive); + + Json::Value arr(Json::arrayValue); + for (const auto& item : items) arr.append(ToJson(item)); + cb(JsonOk(arr)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void AdminController::createRedeemItem( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + const auto admin_user_id = RequireAdminUserId(req, cb); + if (!admin_user_id.has_value()) return; + + const auto json = req->getJsonObject(); + if (!json) { + cb(JsonError(drogon::k400BadRequest, "body must be json")); + return; + } + + const auto input = ParseRedeemItemWrite(*json); + services::RedeemService redeem(csp::AppState::Instance().db()); + const auto item = redeem.CreateItem(*admin_user_id, input); + cb(JsonOk(ToJson(item))); + } catch (const std::runtime_error& e) { + cb(JsonError(drogon::k400BadRequest, e.what())); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void AdminController::updateRedeemItem( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t item_id) { + try { + if (!RequireAdminUserId(req, cb).has_value()) return; + + const auto json = req->getJsonObject(); + if (!json) { + cb(JsonError(drogon::k400BadRequest, "body must be json")); + return; + } + + const auto input = ParseRedeemItemWrite(*json); + services::RedeemService redeem(csp::AppState::Instance().db()); + const auto item = redeem.UpdateItem(item_id, input); + cb(JsonOk(ToJson(item))); + } catch (const std::runtime_error& e) { + cb(JsonError(drogon::k400BadRequest, e.what())); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void AdminController::deleteRedeemItem( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t item_id) { + try { + if (!RequireAdminUserId(req, cb).has_value()) return; + services::RedeemService redeem(csp::AppState::Instance().db()); + redeem.DeactivateItem(item_id); + + Json::Value payload; + payload["id"] = Json::Int64(item_id); + payload["deleted"] = 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 AdminController::listRedeemRecords( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + if (!RequireAdminUserId(req, cb).has_value()) return; + + const auto user_id = ParseOptionalInt64(req->getParameter("user_id")); + const int limit = ParseClampedInt(req->getParameter("limit"), 200, 1, 500); + + services::RedeemService redeem(csp::AppState::Instance().db()); + const auto rows = redeem.ListRecordsAll(user_id, limit); + + Json::Value arr(Json::arrayValue); + for (const auto& row : rows) { + Json::Value j; + j["id"] = Json::Int64(row.id); + j["user_id"] = Json::Int64(row.user_id); + j["username"] = row.username; + j["item_id"] = Json::Int64(row.item_id); + j["item_name"] = row.item_name; + j["quantity"] = row.quantity; + j["day_type"] = row.day_type; + j["unit_cost"] = row.unit_cost; + j["total_cost"] = row.total_cost; + j["note"] = row.note; + j["created_at"] = Json::Int64(row.created_at); + arr.append(j); + } + cb(JsonOk(arr)); + } 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())); + } +} + +} // namespace csp::controllers diff --git a/backend/src/controllers/import_controller.cc b/backend/src/controllers/import_controller.cc index e865601..42d6f1e 100644 --- a/backend/src/controllers/import_controller.cc +++ b/backend/src/controllers/import_controller.cc @@ -3,9 +3,12 @@ #include "csp/app_state.h" #include "csp/services/import_runner.h" #include "csp/services/import_service.h" +#include "csp/services/user_service.h" +#include "http_auth.h" #include #include +#include #include namespace csp::controllers { @@ -40,6 +43,25 @@ int ParsePositiveInt(const std::string& s, return std::max(min_value, std::min(max_value, v)); } +std::optional RequireAdminUserId( + const drogon::HttpRequestPtr& req, + std::function& 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; + } + + services::UserService users(csp::AppState::Instance().db()); + const auto user = users.GetById(*user_id); + if (!user.has_value() || user->username != "admin") { + cb(JsonError(drogon::k403Forbidden, "admin only")); + return std::nullopt; + } + return user_id; +} + Json::Value ToJson(const services::ImportJob& job) { Json::Value j; j["id"] = Json::Int64(job.id); @@ -94,9 +116,11 @@ Json::Value ToJson(const services::ImportJobItem& item) { } // namespace void ImportController::latestJob( - const drogon::HttpRequestPtr& /*req*/, + const drogon::HttpRequestPtr& req, std::function&& cb) { try { + if (!RequireAdminUserId(req, cb).has_value()) return; + services::ImportService svc(csp::AppState::Instance().db()); Json::Value payload; const auto job = svc.GetLatestJob(); @@ -113,10 +137,12 @@ void ImportController::latestJob( } void ImportController::jobById( - const drogon::HttpRequestPtr& /*req*/, + const drogon::HttpRequestPtr& req, std::function&& cb, int64_t job_id) { try { + if (!RequireAdminUserId(req, cb).has_value()) return; + services::ImportService svc(csp::AppState::Instance().db()); const auto job = svc.GetById(job_id); if (!job.has_value()) { @@ -136,6 +162,8 @@ void ImportController::jobItems( std::function&& cb, int64_t job_id) { try { + if (!RequireAdminUserId(req, cb).has_value()) return; + services::ImportJobItemQuery query; query.status = req->getParameter("status"); query.page = ParsePositiveInt(req->getParameter("page"), 1, 1, 100000); @@ -164,12 +192,26 @@ void ImportController::runJob( const drogon::HttpRequestPtr& req, std::function&& cb) { try { + if (!RequireAdminUserId(req, cb).has_value()) return; + services::ImportRunOptions opts; const auto json = req->getJsonObject(); if (json) { + if ((*json).isMember("mode")) { + opts.mode = (*json)["mode"].asString(); + } opts.clear_all_problems = (*json).isMember("clear_all_problems") && (*json)["clear_all_problems"].asBool(); + if ((*json).isMember("local_pdf_dir")) { + opts.local_pdf_dir = (*json)["local_pdf_dir"].asString(); + } + if ((*json).isMember("target_total")) { + opts.target_total = std::max(1, std::min(50000, (*json)["target_total"].asInt())); + } + if ((*json).isMember("workers")) { + opts.workers = std::max(1, std::min(16, (*json)["workers"].asInt())); + } } const bool started = services::ImportRunner::Instance().TriggerAsync("manual", opts); @@ -181,6 +223,7 @@ void ImportController::runJob( Json::Value payload; payload["started"] = true; payload["running"] = true; + payload["mode"] = opts.mode; cb(JsonOk(payload)); } catch (const std::exception& e) { cb(JsonError(drogon::k500InternalServerError, e.what())); diff --git a/backend/src/controllers/meta_controller.cc b/backend/src/controllers/meta_controller.cc index 727f58c..8ed7874 100644 --- a/backend/src/controllers/meta_controller.cc +++ b/backend/src/controllers/meta_controller.cc @@ -9,10 +9,12 @@ #include "csp/services/problem_solution_runner.h" #include "csp/services/problem_workspace_service.h" #include "csp/services/submission_service.h" +#include "csp/services/user_service.h" #include "http_auth.h" #include #include +#include #include namespace csp::controllers { @@ -47,6 +49,25 @@ int ParsePositiveInt(const std::string& s, return std::max(min_value, std::min(max_value, v)); } +std::optional RequireAdminUserId( + const drogon::HttpRequestPtr& req, + std::function& 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; + } + + services::UserService users(csp::AppState::Instance().db()); + const auto user = users.GetById(*user_id); + if (!user.has_value() || user->username != "admin") { + cb(JsonError(drogon::k403Forbidden, "admin only")); + return std::nullopt; + } + return user_id; +} + Json::Value BuildOpenApiSpec() { Json::Value root; root["openapi"] = "3.1.0"; @@ -173,6 +194,8 @@ void MetaController::backendLogs( const drogon::HttpRequestPtr& req, std::function&& cb) { try { + if (!RequireAdminUserId(req, cb).has_value()) return; + const int limit = ParsePositiveInt(req->getParameter("limit"), 100, 1, 500); const int running_limit = @@ -274,9 +297,11 @@ void MetaController::backendLogs( } void MetaController::kbRefreshStatus( - const drogon::HttpRequestPtr& /*req*/, + const drogon::HttpRequestPtr& req, std::function&& cb) { try { + if (!RequireAdminUserId(req, cb).has_value()) return; + const auto& runner = services::KbImportRunner::Instance(); Json::Value payload; payload["running"] = runner.IsRunning(); @@ -299,12 +324,7 @@ void MetaController::triggerKbRefresh( const drogon::HttpRequestPtr& req, std::function&& cb) { 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; - } + if (!RequireAdminUserId(req, cb).has_value()) return; auto& runner = services::KbImportRunner::Instance(); const bool started = runner.TriggerAsync("manual"); @@ -332,12 +352,8 @@ void MetaController::triggerMissingSolutions( const drogon::HttpRequestPtr& req, std::function&& cb) { 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 user_id = RequireAdminUserId(req, cb); + if (!user_id.has_value()) return; int limit = 50000; int max_solutions = 3; diff --git a/backend/src/controllers/problem_controller.cc b/backend/src/controllers/problem_controller.cc index 53a4485..400e9f8 100644 --- a/backend/src/controllers/problem_controller.cc +++ b/backend/src/controllers/problem_controller.cc @@ -5,6 +5,7 @@ #include "csp/services/problem_service.h" #include "csp/services/problem_solution_runner.h" #include "csp/services/problem_workspace_service.h" +#include "csp/services/solution_access_service.h" #include "http_auth.h" #include @@ -192,7 +193,7 @@ void ProblemController::saveDraft( } void ProblemController::listSolutions( - const drogon::HttpRequestPtr& /*req*/, + const drogon::HttpRequestPtr& req, std::function&& cb, int64_t problem_id) { try { @@ -203,28 +204,72 @@ void ProblemController::listSolutions( } const auto rows = svc.ListSolutions(problem_id); + const bool has_solutions = !rows.empty(); const auto latest_job = svc.GetLatestSolutionJob(problem_id); + const std::string mode = req->getParameter("mode"); + const bool need_full = mode == "full"; 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 access(Json::objectValue); + access["required"] = true; + access["daily_free_quota"] = 1; + access["cost_after_free"] = 2; + + if (need_full && has_solutions) { + 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::SolutionAccessService access_svc(csp::AppState::Instance().db()); + const auto charge = access_svc.ConsumeSolutionView(*user_id, problem_id); + if (!charge.granted) { + cb(JsonError(drogon::k402PaymentRequired, + "rating 不足:首次免费后每次查看答案需 2 分")); + return; + } + + access["mode"] = "full"; + access["charged"] = charge.charged; + access["daily_free"] = charge.daily_free; + access["cost"] = charge.cost; + access["day_key"] = charge.day_key; + access["daily_used_count"] = charge.daily_used_count; + access["rating_before"] = charge.rating_before; + access["rating_after"] = charge.rating_after; + access["viewed_at"] = Json::Int64(charge.viewed_at); + + 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); + } + } else { + access["mode"] = "preview"; + access["charged"] = false; + access["daily_free"] = false; + access["cost"] = 0; + access["daily_used_count"] = 0; } Json::Value payload; payload["items"] = arr; + payload["has_solutions"] = has_solutions; + payload["answer_status"] = has_solutions ? "已有" : "待生成"; + payload["access"] = access; payload["runner_running"] = services::ProblemSolutionRunner::Instance().IsRunning(problem_id); if (latest_job.has_value()) { @@ -280,16 +325,20 @@ void ProblemController::generateSolutions( } const int64_t job_id = svc.CreateSolutionJob(problem_id, *user_id, max_solutions); - const bool started = services::ProblemSolutionRunner::Instance().TriggerAsync( + auto& runner = services::ProblemSolutionRunner::Instance(); + const bool queued = runner.TriggerAsync( problem_id, job_id, max_solutions); - if (!started) { - cb(JsonError(drogon::k409Conflict, "solution generation is already running")); + if (!queued) { + cb(JsonError(drogon::k500InternalServerError, + "solution generation queue is unavailable")); return; } Json::Value payload; + payload["queued"] = true; payload["started"] = true; payload["job_id"] = Json::Int64(job_id); + payload["pending_jobs"] = Json::UInt64(runner.PendingCount()); cb(JsonOk(payload)); } catch (const std::exception& e) { cb(JsonError(drogon::k500InternalServerError, e.what())); diff --git a/backend/src/controllers/submission_controller.cc b/backend/src/controllers/submission_controller.cc index d058d20..1136f94 100644 --- a/backend/src/controllers/submission_controller.cc +++ b/backend/src/controllers/submission_controller.cc @@ -4,11 +4,16 @@ #include "csp/domain/enum_strings.h" #include "csp/domain/json.h" #include "csp/services/contest_service.h" +#include "csp/services/problem_service.h" +#include "csp/services/solution_access_service.h" +#include "csp/services/submission_feedback_service.h" #include "csp/services/submission_service.h" #include "http_auth.h" #include +#include #include +#include #include #include @@ -49,6 +54,32 @@ std::optional ParseOptionalInt64(const std::string& s) { return std::stoll(s); } +bool ParseBoolLike(const std::string& s, bool default_value) { + if (s.empty()) return default_value; + std::string v = s; + std::transform(v.begin(), v.end(), v.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + if (v == "1" || v == "true" || v == "yes" || v == "on") return true; + if (v == "0" || v == "false" || v == "no" || v == "off") return false; + return default_value; +} + +Json::Value ParseLinksArray(const std::string& links_json) { + Json::CharReaderBuilder builder; + Json::Value parsed; + std::string errs; + std::unique_ptr reader(builder.newCharReader()); + if (!reader->parse(links_json.data(), + links_json.data() + links_json.size(), + &parsed, + &errs) || + !parsed.isArray()) { + return Json::Value(Json::arrayValue); + } + return parsed; +} + } // namespace void SubmissionController::submitProblem( @@ -153,7 +184,91 @@ void SubmissionController::getSubmission( cb(JsonError(drogon::k404NotFound, "submission not found")); return; } - cb(JsonOk(domain::ToJson(*s))); + Json::Value payload = domain::ToJson(*s); + payload["code"] = s->code; + + services::SolutionAccessService access_svc(csp::AppState::Instance().db()); + const auto stats = + access_svc.QueryUserProblemViewStats(s->user_id, s->problem_id); + payload["has_viewed_answer"] = stats.has_viewed; + payload["answer_view_count"] = stats.total_views; + payload["answer_view_total_cost"] = stats.total_cost; + if (stats.last_viewed_at.has_value()) { + payload["last_answer_view_at"] = Json::Int64(*stats.last_viewed_at); + } else { + payload["last_answer_view_at"] = Json::nullValue; + } + + services::SubmissionFeedbackService feedback_svc(csp::AppState::Instance().db()); + if (const auto feedback = feedback_svc.GetBySubmissionId(s->id); + feedback.has_value()) { + Json::Value analysis; + analysis["feedback_md"] = feedback->feedback_md; + analysis["links"] = ParseLinksArray(feedback->links_json); + analysis["model_name"] = feedback->model_name; + analysis["status"] = feedback->status; + analysis["created_at"] = Json::Int64(feedback->created_at); + analysis["updated_at"] = Json::Int64(feedback->updated_at); + payload["analysis"] = analysis; + } else { + payload["analysis"] = Json::nullValue; + } + + cb(JsonOk(payload)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void SubmissionController::analyzeSubmission( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t submission_id) { + try { + std::string auth_error; + if (!GetAuthedUserId(req, auth_error).has_value()) { + cb(JsonError(drogon::k401Unauthorized, auth_error)); + return; + } + + bool force_refresh = ParseBoolLike(req->getParameter("refresh"), false); + if (const auto json = req->getJsonObject(); + json && (*json).isMember("refresh")) { + force_refresh = (*json)["refresh"].asBool(); + } + + services::SubmissionService submission_svc(csp::AppState::Instance().db()); + const auto submission = submission_svc.GetById(submission_id); + if (!submission.has_value()) { + cb(JsonError(drogon::k404NotFound, "submission not found")); + return; + } + + services::ProblemService problem_svc(csp::AppState::Instance().db()); + const auto problem = problem_svc.GetById(submission->problem_id); + if (!problem.has_value()) { + cb(JsonError(drogon::k404NotFound, "problem not found")); + return; + } + + services::SubmissionFeedbackService feedback_svc(csp::AppState::Instance().db()); + const auto feedback = + feedback_svc.GenerateAndSave(*submission, *problem, force_refresh); + + Json::Value payload; + payload["submission_id"] = Json::Int64(feedback.submission_id); + payload["feedback_md"] = feedback.feedback_md; + payload["links"] = ParseLinksArray(feedback.links_json); + payload["model_name"] = feedback.model_name; + payload["status"] = feedback.status; + payload["created_at"] = Json::Int64(feedback.created_at); + payload["updated_at"] = Json::Int64(feedback.updated_at); + payload["refresh"] = force_refresh; + cb(JsonOk(payload)); + } catch (const std::invalid_argument&) { + cb(JsonError(drogon::k400BadRequest, "invalid request field")); + } catch (const std::runtime_error& e) { + cb(JsonError(drogon::k400BadRequest, e.what())); } catch (const std::exception& e) { cb(JsonError(drogon::k500InternalServerError, e.what())); } diff --git a/backend/src/domain/json.cc b/backend/src/domain/json.cc index 87a1894..4cbea49 100644 --- a/backend/src/domain/json.cc +++ b/backend/src/domain/json.cc @@ -40,6 +40,7 @@ Json::Value ToJson(const Submission& s) { j["language"] = ToString(s.language); j["status"] = ToString(s.status); j["score"] = s.score; + j["rating_delta"] = s.rating_delta; j["time_ms"] = s.time_ms; j["memory_kb"] = s.memory_kb; j["compile_log"] = s.compile_log; diff --git a/backend/src/main.cc b/backend/src/main.cc index 21a59ed..6fa72a1 100644 --- a/backend/src/main.cc +++ b/backend/src/main.cc @@ -3,6 +3,7 @@ #include "csp/app_state.h" #include "csp/services/auth_service.h" #include "csp/services/import_runner.h" +#include "csp/services/kb_import_runner.h" #include "csp/services/problem_gen_runner.h" #include "csp/services/problem_solution_runner.h" @@ -16,6 +17,7 @@ int main(int argc, char** argv) { csp::AppState::Instance().Init(db_path); csp::services::ImportRunner::Instance().Configure(db_path); + csp::services::KbImportRunner::Instance().Configure(db_path); csp::services::ProblemSolutionRunner::Instance().Configure(db_path); csp::services::ProblemGenRunner::Instance().Configure(db_path); @@ -26,10 +28,14 @@ int main(int argc, char** argv) { if (u && p && std::string(u).size() > 0 && std::string(p).size() > 0) { try { csp::services::AuthService auth(csp::AppState::Instance().db()); - auth.Register(u, p); - LOG_INFO << "seed admin user created: " << u; + try { + auth.Register(u, p); + LOG_INFO << "seed admin user created: " << u; + } catch (const std::exception&) { + auth.ResetPassword(u, p); + LOG_INFO << "seed admin password reset: " << u; + } } catch (const std::exception& e) { - // Most likely UNIQUE constraint (already exists) LOG_INFO << "seed admin user skipped: " << e.what(); } } @@ -37,6 +43,9 @@ int main(int argc, char** argv) { // Auto-run PDF -> LLM import workflow on startup unless explicitly disabled. csp::services::ImportRunner::Instance().AutoStartIfEnabled(); + // Auto-queue missing problem solutions on startup (C++14 async generation). + csp::services::ProblemSolutionRunner::Instance().AutoStartMissingIfEnabled( + csp::AppState::Instance().db()); // Auto-generate one CSP-J new problem (RAG + dedupe) on startup by default. csp::services::ProblemGenRunner::Instance().AutoStartIfEnabled(); diff --git a/backend/src/services/import_runner.cc b/backend/src/services/import_runner.cc index 5b59b35..c57853c 100644 --- a/backend/src/services/import_runner.cc +++ b/backend/src/services/import_runner.cc @@ -1,11 +1,16 @@ #include "csp/services/import_runner.h" #include +#include +#include +#include #include #include #include #include +#include +#include #include #include #include @@ -39,6 +44,13 @@ int EnvInt(const char* key, int default_value) { } } +std::string EnvString(const char* key, const std::string& default_value) { + const char* raw = std::getenv(key); + if (!raw) return default_value; + const std::string value(raw); + return value.empty() ? default_value : value; +} + std::string ShellQuote(const std::string& text) { std::string out = "'"; for (char c : text) { @@ -52,7 +64,16 @@ std::string ShellQuote(const std::string& text) { return out; } -std::string ResolveScriptPath() { +std::string NormalizeMode(std::string mode) { + for (auto& c : mode) c = static_cast(::tolower(static_cast(c))); + if (mode == "local_pdf_rag" || mode == "local-pdf-rag" || mode == "local_rag" || + mode == "rag") { + return "local_pdf_rag"; + } + return "luogu"; +} + +std::string ResolveLuoguScriptPath() { const char* env_path = std::getenv("OI_IMPORT_SCRIPT_PATH"); if (env_path && std::filesystem::exists(env_path)) return env_path; @@ -72,11 +93,28 @@ std::string ResolveScriptPath() { 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)); +std::string ResolveLocalRagScriptPath() { + const char* env_path = std::getenv("OI_IMPORT_LOCAL_RAG_SCRIPT_PATH"); + if (env_path && std::filesystem::exists(env_path)) return env_path; + + const std::vector candidates = { + "/app/scripts/import_local_pdf_rag.py", + "scripts/import_local_pdf_rag.py", + "../scripts/import_local_pdf_rag.py", + "../../scripts/import_local_pdf_rag.py", + }; + for (const auto& p : candidates) { + if (std::filesystem::exists(p)) return p; + } + return "/app/scripts/import_local_pdf_rag.py"; +} + +std::string BuildLuoguCommand(const std::string& db_path, + const std::string& trigger, + const ImportRunOptions& options) { + const std::string script_path = ResolveLuoguScriptPath(); + const int workers = + std::max(1, options.workers > 0 ? options.workers : 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); @@ -88,9 +126,8 @@ std::string BuildCommand(const std::string& db_path, : 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); + 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"; @@ -107,6 +144,125 @@ std::string BuildCommand(const std::string& db_path, return cmd; } +std::string BuildLocalRagCommand(const std::string& db_path, + const std::string& trigger, + const ImportRunOptions& options) { + const std::string script_path = ResolveLocalRagScriptPath(); + const int workers = + std::max(1, options.workers > 0 ? options.workers : EnvInt("OI_IMPORT_WORKERS", 3)); + const int target_total = std::max( + 1, + options.target_total > 0 ? options.target_total : EnvInt("OI_IMPORT_TARGET_TOTAL", 5000)); + const int attempt_multiplier = + std::max(2, EnvInt("OI_IMPORT_RAG_ATTEMPT_MULTIPLIER", 8)); + const std::string pdf_dir = options.local_pdf_dir.empty() + ? EnvString("OI_LOCAL_PDF_DIR", "/data/local_pdfs") + : options.local_pdf_dir; + + std::string cmd = "python3 " + ShellQuote(script_path) + " --db-path " + + ShellQuote(db_path) + " --workers " + std::to_string(workers) + + " --target-total " + std::to_string(target_total) + + " --job-trigger " + ShellQuote(trigger) + + " --max-attempt-multiplier " + + std::to_string(attempt_multiplier); + + if (!pdf_dir.empty()) { + cmd += " --pdf-dir " + ShellQuote(pdf_dir); + } + + const char* dedupe = std::getenv("OI_IMPORT_RAG_DEDUPE_THRESHOLD"); + if (dedupe && std::string(dedupe).size() > 0) { + cmd += " --dedupe-threshold " + ShellQuote(dedupe); + } + + return cmd; +} + +std::string BuildCommand(const std::string& db_path, + const std::string& trigger, + const ImportRunOptions& options) { + const std::string mode = NormalizeMode(options.mode); + if (mode == "local_pdf_rag") { + return BuildLocalRagCommand(db_path, trigger, options); + } + return BuildLuoguCommand(db_path, trigger, options); +} + +std::optional ParseRunOptionsFromJson(const std::string& raw_json) { + if (raw_json.empty()) return std::nullopt; + + Json::CharReaderBuilder builder; + Json::Value root; + std::string errs; + std::unique_ptr reader(builder.newCharReader()); + if (!reader->parse(raw_json.data(), raw_json.data() + raw_json.size(), &root, + &errs) || + !root.isObject()) { + return std::nullopt; + } + + ImportRunOptions opts; + opts.mode = NormalizeMode(root.get("mode", root.get("source", "luogu")).asString()); + opts.clear_all_problems = root.get("clear_all_problems", false).asBool(); + opts.workers = std::max(1, root.get("workers", 3).asInt()); + opts.target_total = std::max(1, root.get("target_total", 5000).asInt()); + if (root.isMember("local_pdf_dir")) { + opts.local_pdf_dir = root["local_pdf_dir"].asString(); + } else if (root.isMember("pdf_dir")) { + opts.local_pdf_dir = root["pdf_dir"].asString(); + } + return opts; +} + +std::optional RecoverInterruptedRunOptions(const std::string& db_path) { + if (!EnvBool("OI_IMPORT_RESUME_ON_RESTART", true)) return std::nullopt; + + sqlite3* db = nullptr; + if (sqlite3_open_v2(db_path.c_str(), &db, SQLITE_OPEN_READWRITE, nullptr) != + SQLITE_OK) { + if (db) sqlite3_close(db); + return std::nullopt; + } + sqlite3_busy_timeout(db, 5000); + + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT options_json FROM import_jobs WHERE status='running' ORDER BY id DESC LIMIT 1"; + if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return std::nullopt; + } + + std::optional options; + if (sqlite3_step(stmt) == SQLITE_ROW) { + const unsigned char* txt = sqlite3_column_text(stmt, 0); + const std::string raw = txt ? reinterpret_cast(txt) : std::string(); + options = ParseRunOptionsFromJson(raw); + if (!options.has_value()) { + ImportRunOptions fallback; + fallback.mode = "luogu"; + options = fallback; + } + } + sqlite3_finalize(stmt); + + if (options.has_value()) { + sqlite3_exec( + db, + "UPDATE import_jobs " + "SET status='interrupted'," + "last_error='backend restarted, auto-resume scheduled'," + "finished_at=strftime('%s','now'),updated_at=strftime('%s','now') " + "WHERE status='running'", + nullptr, + nullptr, + nullptr); + } + + sqlite3_close(db); + return options; +} + } // namespace ImportRunner& ImportRunner::Instance() { @@ -115,8 +271,21 @@ ImportRunner& ImportRunner::Instance() { } void ImportRunner::Configure(std::string db_path) { - std::lock_guard lock(mu_); - db_path_ = std::move(db_path); + std::optional resume_opts; + { + std::lock_guard lock(mu_); + db_path_ = std::move(db_path); + resume_opts = RecoverInterruptedRunOptions(db_path_); + } + + if (resume_opts.has_value()) { + const bool started = TriggerAsync("resume", *resume_opts); + if (started) { + LOG_INFO << "import runner resumed interrupted job"; + } else { + LOG_INFO << "import runner resume skipped"; + } + } } bool ImportRunner::TriggerAsync(const std::string& trigger, @@ -147,7 +316,14 @@ bool ImportRunner::TriggerAsync(const std::string& trigger, void ImportRunner::AutoStartIfEnabled() { if (!EnvBool("OI_IMPORT_AUTO_RUN", true)) return; - const bool started = TriggerAsync("auto", ImportRunOptions{}); + + ImportRunOptions opts; + opts.mode = NormalizeMode(EnvString("OI_IMPORT_AUTO_MODE", "luogu")); + opts.workers = std::max(1, EnvInt("OI_IMPORT_WORKERS", 3)); + opts.target_total = std::max(1, EnvInt("OI_IMPORT_TARGET_TOTAL", 5000)); + opts.local_pdf_dir = EnvString("OI_LOCAL_PDF_DIR", "/data/local_pdfs"); + + const bool started = TriggerAsync("auto", opts); if (started) { LOG_INFO << "import runner auto-started"; } else { diff --git a/backend/src/services/kb_import_runner.cc b/backend/src/services/kb_import_runner.cc new file mode 100644 index 0000000..49ed6c3 --- /dev/null +++ b/backend/src/services/kb_import_runner.cc @@ -0,0 +1,130 @@ +#include "csp/services/kb_import_runner.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace csp::services { + +namespace { + +int64_t NowSec() { + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()).count(); +} + +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_KB_IMPORT_SCRIPT_PATH"); + if (env_path && std::filesystem::exists(env_path)) return env_path; + + const std::vector candidates = { + "/app/scripts/import_kb_learning_resources.py", + "scripts/import_kb_learning_resources.py", + "../scripts/import_kb_learning_resources.py", + "../../scripts/import_kb_learning_resources.py", + }; + for (const auto& path : candidates) { + if (std::filesystem::exists(path)) return path; + } + + return "/app/scripts/import_kb_learning_resources.py"; +} + +std::string BuildCommand(const std::string& db_path) { + const std::string script_path = ResolveScriptPath(); + return "python3 " + ShellQuote(script_path) + " --db-path " + + ShellQuote(db_path) + + " --timeout 20 --retries 5 --max-sources-per-track 3 --max-chars-per-source 900"; +} + +} // namespace + +KbImportRunner& KbImportRunner::Instance() { + static KbImportRunner inst; + return inst; +} + +void KbImportRunner::Configure(std::string db_path) { + std::lock_guard lock(mu_); + db_path_ = std::move(db_path); +} + +bool KbImportRunner::TriggerAsync(const std::string& trigger) { + std::string command; + { + std::lock_guard lock(mu_); + if (running_) return false; + if (db_path_.empty()) return false; + + command = BuildCommand(db_path_); + running_ = true; + last_command_ = command; + last_trigger_ = trigger; + last_started_at_ = NowSec(); + last_exit_code_.reset(); + } + + std::thread([this, command = std::move(command)]() { + LOG_INFO << "kb import started: " << command; + const int rc = std::system(command.c_str()); + { + std::lock_guard lock(mu_); + running_ = false; + last_exit_code_ = rc; + last_finished_at_ = NowSec(); + } + LOG_INFO << "kb import finished, code=" << rc; + }).detach(); + + return true; +} + +bool KbImportRunner::IsRunning() const { + std::lock_guard lock(mu_); + return running_; +} + +std::string KbImportRunner::LastCommand() const { + std::lock_guard lock(mu_); + return last_command_; +} + +std::optional KbImportRunner::LastExitCode() const { + std::lock_guard lock(mu_); + return last_exit_code_; +} + +int64_t KbImportRunner::LastStartedAt() const { + std::lock_guard lock(mu_); + return last_started_at_; +} + +int64_t KbImportRunner::LastFinishedAt() const { + std::lock_guard lock(mu_); + return last_finished_at_; +} + +std::string KbImportRunner::LastTrigger() const { + std::lock_guard lock(mu_); + return last_trigger_; +} + +} // namespace csp::services diff --git a/backend/src/services/problem_solution_runner.cc b/backend/src/services/problem_solution_runner.cc index 54868ce..d84c134 100644 --- a/backend/src/services/problem_solution_runner.cc +++ b/backend/src/services/problem_solution_runner.cc @@ -1,6 +1,13 @@ #include "csp/services/problem_solution_runner.h" +#include "csp/services/problem_workspace_service.h" + +#include +#include + #include +#include +#include #include #include #include @@ -39,6 +46,43 @@ std::string ResolveScriptPath() { return "/app/scripts/generate_problem_solutions.py"; } +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(::tolower(static_cast(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; + } +} + +bool IsDatabaseLockedMessage(const std::string& msg) { + return msg.find("database is locked") != std::string::npos || + msg.find("database is busy") != std::string::npos; +} + +std::string BuildCommand(const std::string& db_path, + int64_t problem_id, + int64_t job_id, + int max_solutions) { + const std::string script_path = ResolveScriptPath(); + const int clamped = std::max(1, std::min(5, max_solutions)); + return "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); +} + } // namespace ProblemSolutionRunner& ProblemSolutionRunner::Instance() { @@ -49,38 +93,225 @@ ProblemSolutionRunner& ProblemSolutionRunner::Instance() { void ProblemSolutionRunner::Configure(std::string db_path) { std::lock_guard lock(mu_); db_path_ = std::move(db_path); + RecoverQueuedJobsLocked(); + StartWorkerIfNeededLocked(); } bool ProblemSolutionRunner::TriggerAsync(int64_t problem_id, int64_t job_id, int max_solutions) { - std::string cmd; - { - std::lock_guard 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 lock(mu_); - running_problem_ids_.erase(problem_id); - }).detach(); - + std::lock_guard lock(mu_); + if (db_path_.empty()) return false; + Task task; + task.problem_id = problem_id; + task.job_id = job_id; + task.max_solutions = std::max(1, std::min(5, max_solutions)); + queue_.push_back(task); + ++pending_problem_counts_[problem_id]; + ++pending_jobs_; + StartWorkerIfNeededLocked(); return true; } +ProblemSolutionRunner::TriggerMissingSummary +ProblemSolutionRunner::TriggerMissingAsync(db::SqliteDb& db, + int64_t created_by, + int max_solutions, + int limit) { + services::ProblemWorkspaceService workspace(db); + TriggerMissingSummary summary; + summary.missing_total = workspace.CountProblemsWithoutSolutions(); + const int clamped_limit = std::max(1, std::min(200000, limit)); + const int clamped_max_solutions = std::max(1, std::min(5, max_solutions)); + const auto problem_ids = workspace.ListProblemIdsWithoutSolutions( + clamped_limit, true); + summary.candidate_count = static_cast(problem_ids.size()); + for (int64_t problem_id : problem_ids) { + int64_t job_id = 0; + bool created = false; + for (int attempt = 1; attempt <= 4; ++attempt) { + try { + job_id = workspace.CreateSolutionJob( + problem_id, created_by, clamped_max_solutions); + created = true; + break; + } catch (const std::exception& e) { + const std::string msg = e.what(); + if (!IsDatabaseLockedMessage(msg) || attempt >= 4) { + LOG_ERROR << "create solution job failed for problem_id=" << problem_id + << ", err=" << msg; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(120 * attempt)); + } + } + if (!created) continue; + if (TriggerAsync(problem_id, job_id, clamped_max_solutions)) { + ++summary.queued_count; + } + } + return summary; +} + +void ProblemSolutionRunner::AutoStartMissingIfEnabled(db::SqliteDb& db) { + try { + if (!EnvBool("CSP_SOLUTION_AUTO_RUN_MISSING", true)) { + LOG_INFO << "solution auto-run missing skipped by env"; + return; + } + const int limit = std::max(1, std::min(200000, EnvInt("CSP_SOLUTION_AUTO_LIMIT", 50000))); + const int max_solutions = + std::max(1, std::min(5, EnvInt("CSP_SOLUTION_AUTO_MAX_SOLUTIONS", 3))); + const int interval_sec = + std::max(3, std::min(300, EnvInt("CSP_SOLUTION_AUTO_INTERVAL_SEC", 12))); + const auto summary = TriggerMissingAsync(db, /*created_by=*/0, max_solutions, limit); + LOG_INFO << "solution auto-run missing queued=" << summary.queued_count + << ", candidates=" << summary.candidate_count + << ", missing_total=" << summary.missing_total + << ", pending_jobs=" << PendingCount(); + StartAutoPumpIfNeeded(&db, max_solutions, limit, interval_sec); + } catch (const std::exception& e) { + LOG_ERROR << "solution auto-run missing failed: " << e.what(); + } +} + bool ProblemSolutionRunner::IsRunning(int64_t problem_id) const { std::lock_guard lock(mu_); - return running_problem_ids_.count(problem_id) > 0; + return pending_problem_counts_.count(problem_id) > 0; +} + +size_t ProblemSolutionRunner::PendingCount() const { + std::lock_guard lock(mu_); + return pending_jobs_; +} + +void ProblemSolutionRunner::StartWorkerIfNeededLocked() { + if (worker_running_ || queue_.empty()) return; + worker_running_ = true; + std::thread([this]() { WorkerLoop(); }).detach(); +} + +void ProblemSolutionRunner::WorkerLoop() { + while (true) { + Task task; + std::string db_path; + { + std::lock_guard lock(mu_); + if (queue_.empty()) { + worker_running_ = false; + return; + } + task = queue_.front(); + queue_.pop_front(); + db_path = db_path_; + } + + const std::string cmd = + BuildCommand(db_path, task.problem_id, task.job_id, task.max_solutions); + const int rc = std::system(cmd.c_str()); + if (rc != 0) { + LOG_INFO << "solution job " << task.job_id + << " finished with non-zero rc: " << rc; + } + + std::lock_guard lock(mu_); + auto it = pending_problem_counts_.find(task.problem_id); + if (it != pending_problem_counts_.end()) { + if (it->second <= 1) { + pending_problem_counts_.erase(it); + } else { + --it->second; + } + } + if (pending_jobs_ > 0) --pending_jobs_; + } +} + +void ProblemSolutionRunner::RecoverQueuedJobsLocked() { + if (recovered_from_db_ || db_path_.empty()) return; + recovered_from_db_ = true; + + sqlite3* db = nullptr; + if (sqlite3_open_v2(db_path_.c_str(), &db, SQLITE_OPEN_READWRITE, nullptr) != + SQLITE_OK) { + if (db) sqlite3_close(db); + return; + } + sqlite3_busy_timeout(db, 5000); + + sqlite3_exec( + db, + "UPDATE problem_solution_jobs " + "SET status='queued',progress=0,message='requeued after backend restart'," + "started_at=NULL,finished_at=NULL,updated_at=strftime('%s','now') " + "WHERE status='running'", + nullptr, + nullptr, + nullptr); + + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,problem_id,max_solutions FROM problem_solution_jobs " + "WHERE status='queued' ORDER BY id ASC"; + if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return; + } + + int recovered = 0; + while (sqlite3_step(stmt) == SQLITE_ROW) { + Task task; + task.job_id = sqlite3_column_int64(stmt, 0); + task.problem_id = sqlite3_column_int64(stmt, 1); + task.max_solutions = sqlite3_column_int(stmt, 2); + queue_.push_back(task); + ++pending_problem_counts_[task.problem_id]; + ++pending_jobs_; + ++recovered; + } + sqlite3_finalize(stmt); + sqlite3_close(db); + + if (recovered > 0) { + LOG_INFO << "recovered queued solution jobs: " << recovered; + } +} + +void ProblemSolutionRunner::StartAutoPumpIfNeeded(db::SqliteDb* db, + int max_solutions, + int limit, + int interval_sec) { + std::lock_guard lock(mu_); + if (auto_pump_started_ || !db) return; + auto_pump_started_ = true; + + std::thread([this, db, max_solutions, limit, interval_sec]() { + while (true) { + std::this_thread::sleep_for(std::chrono::seconds(interval_sec)); + + if (!EnvBool("CSP_SOLUTION_AUTO_RUN_MISSING", true)) continue; + + bool idle = false; + { + std::lock_guard inner(mu_); + idle = pending_jobs_ == 0; + } + if (!idle) continue; + + try { + const auto summary = TriggerMissingAsync( + *db, /*created_by=*/0, max_solutions, limit); + if (summary.queued_count > 0) { + LOG_INFO << "solution auto-pump queued=" << summary.queued_count + << ", candidates=" << summary.candidate_count + << ", missing_total=" << summary.missing_total + << ", pending_jobs=" << PendingCount(); + } + } catch (const std::exception& e) { + LOG_ERROR << "solution auto-pump failed: " << e.what(); + } + } + }).detach(); } } // namespace csp::services diff --git a/backend/src/services/problem_workspace_service.cc b/backend/src/services/problem_workspace_service.cc index 1e1ce8b..26b6186 100644 --- a/backend/src/services/problem_workspace_service.cc +++ b/backend/src/services/problem_workspace_service.cc @@ -6,6 +6,7 @@ #include #include #include +#include namespace csp::services { @@ -191,6 +192,116 @@ std::optional ProblemWorkspaceService::GetLatestSolutionJob( return row; } +std::vector ProblemWorkspaceService::ListRecentSolutionJobs( + int limit) { + const int capped = std::max(1, std::min(500, limit)); + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT j.id,j.problem_id,j.status,j.progress,j.message,j.created_by,j.max_solutions," + "j.created_at,j.started_at,j.finished_at,j.updated_at,p.title " + "FROM problem_solution_jobs j " + "LEFT JOIN problems p ON p.id=j.problem_id " + "ORDER BY j.id DESC LIMIT ?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare recent solution jobs"); + CheckSqlite(sqlite3_bind_int(stmt, 1, capped), db, "bind recent jobs limit"); + + std::vector rows; + while (sqlite3_step(stmt) == SQLITE_ROW) { + auto row = ReadSolutionJob(stmt); + row.problem_title = ColText(stmt, 11); + rows.push_back(std::move(row)); + } + sqlite3_finalize(stmt); + return rows; +} + +std::vector ProblemWorkspaceService::ListSolutionJobsByStatus( + const std::string& status, + int limit) { + const int capped = std::max(1, std::min(1000, limit)); + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const bool queued = status == "queued"; + const char* sql = queued + ? "SELECT j.id,j.problem_id,j.status,j.progress,j.message,j.created_by,j.max_solutions," + "j.created_at,j.started_at,j.finished_at,j.updated_at,p.title " + "FROM problem_solution_jobs j " + "LEFT JOIN problems p ON p.id=j.problem_id " + "WHERE j.status=? " + "ORDER BY j.id ASC LIMIT ?" + : "SELECT j.id,j.problem_id,j.status,j.progress,j.message,j.created_by,j.max_solutions," + "j.created_at,j.started_at,j.finished_at,j.updated_at,p.title " + "FROM problem_solution_jobs j " + "LEFT JOIN problems p ON p.id=j.problem_id " + "WHERE j.status=? " + "ORDER BY j.updated_at DESC, j.id DESC LIMIT ?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare solution jobs by status"); + CheckSqlite(sqlite3_bind_text(stmt, 1, status.c_str(), -1, SQLITE_TRANSIENT), db, + "bind solution jobs status"); + CheckSqlite(sqlite3_bind_int(stmt, 2, capped), db, "bind solution jobs limit"); + + std::vector rows; + while (sqlite3_step(stmt) == SQLITE_ROW) { + auto row = ReadSolutionJob(stmt); + row.problem_title = ColText(stmt, 11); + rows.push_back(std::move(row)); + } + sqlite3_finalize(stmt); + return rows; +} + +int ProblemWorkspaceService::CountProblemsWithoutSolutions() { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT COUNT(*) " + "FROM problems p " + "WHERE NOT EXISTS (SELECT 1 FROM problem_solutions s WHERE s.problem_id=p.id)"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare count missing solutions"); + CheckSqlite(sqlite3_step(stmt), db, "step count missing solutions"); + const int count = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + return count; +} + +std::vector ProblemWorkspaceService::ListProblemIdsWithoutSolutions( + int limit, + bool exclude_queued_or_running_jobs) { + const int capped = std::max(1, std::min(200000, limit)); + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql_all = + "SELECT p.id " + "FROM problems p " + "WHERE NOT EXISTS (SELECT 1 FROM problem_solutions s WHERE s.problem_id=p.id) " + "ORDER BY p.id ASC LIMIT ?"; + const char* sql_skip_busy = + "SELECT p.id " + "FROM problems p " + "WHERE NOT EXISTS (SELECT 1 FROM problem_solutions s WHERE s.problem_id=p.id) " + "AND NOT EXISTS (" + " SELECT 1 FROM problem_solution_jobs j " + " WHERE j.problem_id=p.id AND j.status IN ('queued','running')" + ") " + "ORDER BY p.id ASC LIMIT ?"; + const char* sql = exclude_queued_or_running_jobs ? sql_skip_busy : sql_all; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare list missing solution problem ids"); + CheckSqlite(sqlite3_bind_int(stmt, 1, capped), db, + "bind list missing solution problem ids limit"); + + std::vector rows; + while (sqlite3_step(stmt) == SQLITE_ROW) { + rows.push_back(sqlite3_column_int64(stmt, 0)); + } + sqlite3_finalize(stmt); + return rows; +} + std::vector ProblemWorkspaceService::ListSolutions(int64_t problem_id) { sqlite3* db = db_.raw(); sqlite3_stmt* stmt = nullptr; diff --git a/backend/src/services/redeem_service.cc b/backend/src/services/redeem_service.cc new file mode 100644 index 0000000..329a673 --- /dev/null +++ b/backend/src/services/redeem_service.cc @@ -0,0 +1,360 @@ +#include "csp/services/redeem_service.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace csp::services { + +namespace { + +int64_t NowSec() { + using namespace std::chrono; + return duration_cast(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(txt) : std::string(); +} + +RedeemItem ReadItem(sqlite3_stmt* stmt) { + RedeemItem item; + item.id = sqlite3_column_int64(stmt, 0); + item.name = ColText(stmt, 1); + item.description = ColText(stmt, 2); + item.unit_label = ColText(stmt, 3); + item.holiday_cost = sqlite3_column_int(stmt, 4); + item.studyday_cost = sqlite3_column_int(stmt, 5); + item.is_active = sqlite3_column_int(stmt, 6) != 0; + item.is_global = sqlite3_column_int(stmt, 7) != 0; + item.created_by = sqlite3_column_int64(stmt, 8); + item.created_at = sqlite3_column_int64(stmt, 9); + item.updated_at = sqlite3_column_int64(stmt, 10); + return item; +} + +RedeemRecord ReadRecord(sqlite3_stmt* stmt) { + RedeemRecord row; + row.id = sqlite3_column_int64(stmt, 0); + row.user_id = sqlite3_column_int64(stmt, 1); + row.item_id = sqlite3_column_int64(stmt, 2); + row.item_name = ColText(stmt, 3); + row.quantity = sqlite3_column_int(stmt, 4); + row.day_type = ColText(stmt, 5); + row.unit_cost = sqlite3_column_int(stmt, 6); + row.total_cost = sqlite3_column_int(stmt, 7); + row.note = ColText(stmt, 8); + row.created_at = sqlite3_column_int64(stmt, 9); + row.username = ColText(stmt, 10); + return row; +} + +std::string NormalizeDayType(std::string day_type) { + for (auto& c : day_type) { + c = static_cast(std::tolower(static_cast(c))); + } + if (day_type == "holiday" || day_type == "vacation" || day_type == "weekend") { + return "holiday"; + } + return "studyday"; +} + +void ValidateItemWrite(const RedeemItemWrite& input) { + if (input.name.empty()) throw std::runtime_error("item name required"); + if (input.name.size() > 120) throw std::runtime_error("item name too long"); + if (input.description.size() > 1000) throw std::runtime_error("description too long"); + if (input.unit_label.empty() || input.unit_label.size() > 20) { + throw std::runtime_error("invalid unit label"); + } + if (input.holiday_cost < 0 || input.studyday_cost < 0) { + throw std::runtime_error("cost must be >= 0"); + } +} + +int QueryUserRating(sqlite3* db, int64_t user_id) { + sqlite3_stmt* stmt = nullptr; + const char* sql = "SELECT rating FROM users WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare query user rating"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + throw std::runtime_error("user not found"); + } + const int rating = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + return rating; +} + +void DeductUserRating(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 deduct user 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 deduct user rating"); + sqlite3_finalize(stmt); +} + +} // namespace + +std::vector RedeemService::ListItems(bool include_inactive) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql_all = + "SELECT id,name,description,unit_label,holiday_cost,studyday_cost,is_active,is_global,created_by,created_at,updated_at " + "FROM redeem_items ORDER BY id ASC"; + const char* sql_active = + "SELECT id,name,description,unit_label,holiday_cost,studyday_cost,is_active,is_global,created_by,created_at,updated_at " + "FROM redeem_items WHERE is_active=1 ORDER BY id ASC"; + CheckSqlite(sqlite3_prepare_v2(db, include_inactive ? sql_all : sql_active, -1, &stmt, nullptr), + db, + "prepare list redeem items"); + + std::vector out; + while (sqlite3_step(stmt) == SQLITE_ROW) { + out.push_back(ReadItem(stmt)); + } + sqlite3_finalize(stmt); + return out; +} + +std::optional RedeemService::GetItemById(int64_t item_id) { + if (item_id <= 0) return std::nullopt; + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,name,description,unit_label,holiday_cost,studyday_cost,is_active,is_global,created_by,created_at,updated_at " + "FROM redeem_items WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare get redeem item"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, item_id), db, "bind item_id"); + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + return std::nullopt; + } + auto item = ReadItem(stmt); + sqlite3_finalize(stmt); + return item; +} + +RedeemItem RedeemService::CreateItem(int64_t admin_user_id, const RedeemItemWrite& input) { + ValidateItemWrite(input); + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const int64_t now = NowSec(); + const char* sql = + "INSERT INTO redeem_items(name,description,unit_label,holiday_cost,studyday_cost,is_active,is_global,created_by,created_at,updated_at) " + "VALUES(?,?,?,?,?,?,?,?,?,?)"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare create redeem item"); + CheckSqlite(sqlite3_bind_text(stmt, 1, input.name.c_str(), -1, SQLITE_TRANSIENT), db, + "bind name"); + CheckSqlite(sqlite3_bind_text(stmt, 2, input.description.c_str(), -1, SQLITE_TRANSIENT), db, + "bind description"); + CheckSqlite(sqlite3_bind_text(stmt, 3, input.unit_label.c_str(), -1, SQLITE_TRANSIENT), db, + "bind unit_label"); + CheckSqlite(sqlite3_bind_int(stmt, 4, input.holiday_cost), db, "bind holiday_cost"); + CheckSqlite(sqlite3_bind_int(stmt, 5, input.studyday_cost), db, "bind studyday_cost"); + CheckSqlite(sqlite3_bind_int(stmt, 6, input.is_active ? 1 : 0), db, "bind is_active"); + CheckSqlite(sqlite3_bind_int(stmt, 7, input.is_global ? 1 : 0), db, "bind is_global"); + CheckSqlite(sqlite3_bind_int64(stmt, 8, admin_user_id), db, "bind created_by"); + CheckSqlite(sqlite3_bind_int64(stmt, 9, now), db, "bind created_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 10, now), db, "bind updated_at"); + CheckSqlite(sqlite3_step(stmt), db, "create redeem item"); + sqlite3_finalize(stmt); + + const auto item = GetItemById(sqlite3_last_insert_rowid(db)); + if (!item.has_value()) throw std::runtime_error("create redeem item failed"); + return *item; +} + +RedeemItem RedeemService::UpdateItem(int64_t item_id, const RedeemItemWrite& input) { + ValidateItemWrite(input); + if (item_id <= 0) throw std::runtime_error("invalid item_id"); + + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const int64_t now = NowSec(); + const char* sql = + "UPDATE redeem_items " + "SET name=?,description=?,unit_label=?,holiday_cost=?,studyday_cost=?,is_active=?,is_global=?,updated_at=? " + "WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare update redeem item"); + CheckSqlite(sqlite3_bind_text(stmt, 1, input.name.c_str(), -1, SQLITE_TRANSIENT), db, + "bind name"); + CheckSqlite(sqlite3_bind_text(stmt, 2, input.description.c_str(), -1, SQLITE_TRANSIENT), db, + "bind description"); + CheckSqlite(sqlite3_bind_text(stmt, 3, input.unit_label.c_str(), -1, SQLITE_TRANSIENT), db, + "bind unit_label"); + CheckSqlite(sqlite3_bind_int(stmt, 4, input.holiday_cost), db, "bind holiday_cost"); + CheckSqlite(sqlite3_bind_int(stmt, 5, input.studyday_cost), db, "bind studyday_cost"); + CheckSqlite(sqlite3_bind_int(stmt, 6, input.is_active ? 1 : 0), db, "bind is_active"); + CheckSqlite(sqlite3_bind_int(stmt, 7, input.is_global ? 1 : 0), db, "bind is_global"); + CheckSqlite(sqlite3_bind_int64(stmt, 8, now), db, "bind updated_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 9, item_id), db, "bind item_id"); + CheckSqlite(sqlite3_step(stmt), db, "update redeem item"); + sqlite3_finalize(stmt); + if (sqlite3_changes(db) <= 0) throw std::runtime_error("redeem item not found"); + + const auto item = GetItemById(item_id); + if (!item.has_value()) throw std::runtime_error("reload redeem item failed"); + return *item; +} + +void RedeemService::DeactivateItem(int64_t item_id) { + if (item_id <= 0) throw std::runtime_error("invalid item_id"); + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = "UPDATE redeem_items SET is_active=0,updated_at=? WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare deactivate redeem item"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, NowSec()), db, "bind updated_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, item_id), db, "bind item_id"); + CheckSqlite(sqlite3_step(stmt), db, "deactivate redeem item"); + sqlite3_finalize(stmt); + if (sqlite3_changes(db) <= 0) throw std::runtime_error("redeem item not found"); +} + +std::vector RedeemService::ListRecordsByUser(int64_t user_id, int limit) { + return ListRecordsAll(user_id, limit); +} + +std::vector RedeemService::ListRecordsAll(std::optional user_id, + int limit) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const int safe_limit = std::max(1, std::min(500, limit)); + + const char* sql_all = + "SELECT r.id,r.user_id,r.item_id,r.item_name,r.quantity,r.day_type,r.unit_cost,r.total_cost,r.note,r.created_at," + "COALESCE(u.username,'') " + "FROM redeem_records r LEFT JOIN users u ON u.id=r.user_id " + "ORDER BY r.id DESC LIMIT ?"; + const char* sql_by_user = + "SELECT r.id,r.user_id,r.item_id,r.item_name,r.quantity,r.day_type,r.unit_cost,r.total_cost,r.note,r.created_at," + "COALESCE(u.username,'') " + "FROM redeem_records r LEFT JOIN users u ON u.id=r.user_id " + "WHERE r.user_id=? ORDER BY r.id DESC LIMIT ?"; + + CheckSqlite(sqlite3_prepare_v2(db, user_id.has_value() ? sql_by_user : sql_all, -1, &stmt, nullptr), + db, + "prepare list redeem records"); + + if (user_id.has_value()) { + CheckSqlite(sqlite3_bind_int64(stmt, 1, *user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_int(stmt, 2, safe_limit), db, "bind limit"); + } else { + CheckSqlite(sqlite3_bind_int(stmt, 1, safe_limit), db, "bind limit"); + } + + std::vector out; + while (sqlite3_step(stmt) == SQLITE_ROW) { + out.push_back(ReadRecord(stmt)); + } + sqlite3_finalize(stmt); + return out; +} + +RedeemRecord RedeemService::Redeem(const RedeemRequest& request) { + if (request.user_id <= 0 || request.item_id <= 0) { + throw std::runtime_error("invalid user_id/item_id"); + } + if (request.quantity <= 0 || request.quantity > 24) { + throw std::runtime_error("quantity must be in [1,24]"); + } + if (request.note.size() > 1000) { + throw std::runtime_error("note too long"); + } + + sqlite3* db = db_.raw(); + db_.Exec("BEGIN IMMEDIATE"); + bool committed = false; + try { + const auto item = GetItemById(request.item_id); + if (!item.has_value()) { + throw std::runtime_error("redeem item not found"); + } + if (!item->is_active) { + throw std::runtime_error("redeem item is inactive"); + } + + const std::string day_type = NormalizeDayType(request.day_type); + const int unit_cost = day_type == "holiday" ? item->holiday_cost : item->studyday_cost; + const int total_cost = unit_cost * request.quantity; + + const int current_rating = QueryUserRating(db, request.user_id); + if (current_rating < total_cost) { + throw std::runtime_error("rating not enough"); + } + + DeductUserRating(db, request.user_id, total_cost); + + sqlite3_stmt* stmt = nullptr; + const int64_t now = NowSec(); + const char* sql = + "INSERT INTO redeem_records(user_id,item_id,item_name,quantity,day_type,unit_cost,total_cost,note,created_at) " + "VALUES(?,?,?,?,?,?,?,?,?)"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare insert redeem record"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, request.user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, request.item_id), db, "bind item_id"); + CheckSqlite(sqlite3_bind_text(stmt, 3, item->name.c_str(), -1, SQLITE_TRANSIENT), db, + "bind item_name"); + CheckSqlite(sqlite3_bind_int(stmt, 4, request.quantity), db, "bind quantity"); + CheckSqlite(sqlite3_bind_text(stmt, 5, day_type.c_str(), -1, SQLITE_TRANSIENT), db, + "bind day_type"); + CheckSqlite(sqlite3_bind_int(stmt, 6, unit_cost), db, "bind unit_cost"); + CheckSqlite(sqlite3_bind_int(stmt, 7, total_cost), db, "bind total_cost"); + CheckSqlite(sqlite3_bind_text(stmt, 8, request.note.c_str(), -1, SQLITE_TRANSIENT), db, + "bind note"); + CheckSqlite(sqlite3_bind_int64(stmt, 9, now), db, "bind created_at"); + CheckSqlite(sqlite3_step(stmt), db, "insert redeem record"); + sqlite3_finalize(stmt); + + const int64_t record_id = sqlite3_last_insert_rowid(db); + db_.Exec("COMMIT"); + committed = true; + + auto rows = ListRecordsAll(request.user_id, 1); + if (!rows.empty() && rows.front().id == record_id) { + return rows.front(); + } + + RedeemRecord fallback; + fallback.id = record_id; + fallback.user_id = request.user_id; + fallback.item_id = request.item_id; + fallback.item_name = item->name; + fallback.quantity = request.quantity; + fallback.day_type = day_type; + fallback.unit_cost = unit_cost; + fallback.total_cost = total_cost; + fallback.note = request.note; + fallback.created_at = now; + return fallback; + } catch (...) { + if (!committed) { + try { + db_.Exec("ROLLBACK"); + } catch (...) { + } + } + throw; + } +} + +} // namespace csp::services diff --git a/backend/src/services/solution_access_service.cc b/backend/src/services/solution_access_service.cc new file mode 100644 index 0000000..1562161 --- /dev/null +++ b/backend/src/services/solution_access_service.cc @@ -0,0 +1,203 @@ +#include "csp/services/solution_access_service.h" + +#include + +#include +#include +#include +#include + +namespace csp::services { + +namespace { + +constexpr int kViewCost = 2; + +int64_t NowSec() { + using namespace std::chrono; + return duration_cast(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(txt) : std::string(); +} + +std::string BuildDayKeyChina(int64_t ts_sec) { + const std::time_t shifted = static_cast(ts_sec + 8 * 3600); + std::tm tm {}; + gmtime_r(&shifted, &tm); + char buf[16] = {0}; + std::strftime(buf, sizeof(buf), "%Y-%m-%d", &tm); + return std::string(buf); +} + +int QueryRating(sqlite3* db, int64_t user_id) { + sqlite3_stmt* stmt = nullptr; + const char* sql = "SELECT rating FROM users WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare query rating"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + throw std::runtime_error("user not found"); + } + const int rating = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + return rating; +} + +int QueryDailyUsage(sqlite3* db, int64_t user_id, const std::string& day_key) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT COUNT(1) FROM problem_solution_view_logs WHERE user_id=? AND day_key=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare query daily usage"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_text(stmt, 2, day_key.c_str(), -1, SQLITE_TRANSIENT), db, + "bind day_key"); + CheckSqlite(sqlite3_step(stmt), db, "step query daily usage"); + const int used = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + return used; +} + +void InsertViewLog(sqlite3* db, + int64_t user_id, + int64_t problem_id, + const std::string& day_key, + int64_t viewed_at, + bool charged, + int cost) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "INSERT INTO problem_solution_view_logs(user_id,problem_id,day_key,viewed_at,charged,cost,created_at) " + "VALUES(?,?,?,?,?,?,?)"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare insert solution view log"); + 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, day_key.c_str(), -1, SQLITE_TRANSIENT), db, + "bind day_key"); + CheckSqlite(sqlite3_bind_int64(stmt, 4, viewed_at), db, "bind viewed_at"); + CheckSqlite(sqlite3_bind_int(stmt, 5, charged ? 1 : 0), db, "bind charged"); + CheckSqlite(sqlite3_bind_int(stmt, 6, cost), db, "bind cost"); + CheckSqlite(sqlite3_bind_int64(stmt, 7, viewed_at), db, "bind created_at"); + CheckSqlite(sqlite3_step(stmt), db, "insert solution view log"); + sqlite3_finalize(stmt); +} + +void DeductRating(sqlite3* db, int64_t user_id, int cost) { + 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 deduct rating"); + CheckSqlite(sqlite3_bind_int(stmt, 1, cost), db, "bind cost"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_step(stmt), db, "deduct rating"); + sqlite3_finalize(stmt); +} + +} // namespace + +SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView( + int64_t user_id, + int64_t problem_id) { + if (user_id <= 0 || problem_id <= 0) { + throw std::runtime_error("invalid user_id/problem_id"); + } + + sqlite3* db = db_.raw(); + const int64_t now = NowSec(); + const std::string day_key = BuildDayKeyChina(now); + + db_.Exec("BEGIN IMMEDIATE"); + bool committed = false; + try { + SolutionViewChargeResult result; + result.day_key = day_key; + result.viewed_at = now; + + const int used_before = QueryDailyUsage(db, user_id, day_key); + result.daily_used_count = used_before; + const int rating_before = QueryRating(db, user_id); + result.rating_before = rating_before; + + if (used_before <= 0) { + result.daily_free = true; + result.charged = false; + result.cost = 0; + result.rating_after = rating_before; + } else { + if (rating_before < kViewCost) { + result.granted = false; + result.charged = false; + result.cost = kViewCost; + result.rating_after = rating_before; + result.deny_reason = "rating not enough"; + db_.Exec("ROLLBACK"); + return result; + } + result.daily_free = false; + result.charged = true; + result.cost = kViewCost; + result.rating_after = rating_before - kViewCost; + DeductRating(db, user_id, kViewCost); + } + + InsertViewLog(db, + user_id, + problem_id, + day_key, + now, + result.charged, + result.cost); + result.daily_used_count = used_before + 1; + + db_.Exec("COMMIT"); + committed = true; + return result; + } catch (...) { + if (!committed) { + try { + db_.Exec("ROLLBACK"); + } catch (...) { + } + } + throw; + } +} + +SolutionViewStats SolutionAccessService::QueryUserProblemViewStats( + int64_t user_id, + int64_t problem_id) { + SolutionViewStats stats; + if (user_id <= 0 || problem_id <= 0) return stats; + + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT COUNT(1),COALESCE(SUM(cost),0),MAX(viewed_at) " + "FROM problem_solution_view_logs WHERE user_id=? AND problem_id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare query solution view stats"); + 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, "step query solution view stats"); + stats.total_views = sqlite3_column_int(stmt, 0); + stats.total_cost = sqlite3_column_int(stmt, 1); + if (sqlite3_column_type(stmt, 2) != SQLITE_NULL) { + stats.last_viewed_at = sqlite3_column_int64(stmt, 2); + } + stats.has_viewed = stats.total_views > 0; + sqlite3_finalize(stmt); + return stats; +} + +} // namespace csp::services diff --git a/backend/src/services/submission_feedback_service.cc b/backend/src/services/submission_feedback_service.cc new file mode 100644 index 0000000..e8bd6e0 --- /dev/null +++ b/backend/src/services/submission_feedback_service.cc @@ -0,0 +1,228 @@ +#include "csp/services/submission_feedback_service.h" + +#include "csp/domain/enum_strings.h" +#include "csp/services/crypto.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace csp::services { + +namespace { + +int64_t NowSec() { + using namespace std::chrono; + return duration_cast(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(txt) : std::string(); +} + +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_SUBMISSION_ANALYSIS_SCRIPT_PATH"); + if (env_path && std::filesystem::exists(env_path)) return env_path; + + const std::vector candidates = { + "/app/scripts/analyze_submission_feedback.py", + "scripts/analyze_submission_feedback.py", + "../scripts/analyze_submission_feedback.py", + "../../scripts/analyze_submission_feedback.py", + }; + for (const auto& path : candidates) { + if (std::filesystem::exists(path)) return path; + } + return "/app/scripts/analyze_submission_feedback.py"; +} + +int ExitCodeFromSystem(int rc) { + if (rc == -1) return -1; + if (WIFEXITED(rc)) return WEXITSTATUS(rc); + if (WIFSIGNALED(rc)) return 128 + WTERMSIG(rc); + return -1; +} + +SubmissionFeedback ReadSubmissionFeedback(sqlite3_stmt* stmt) { + SubmissionFeedback row; + row.submission_id = sqlite3_column_int64(stmt, 0); + row.feedback_md = ColText(stmt, 1); + row.links_json = ColText(stmt, 2); + row.model_name = ColText(stmt, 3); + row.status = ColText(stmt, 4); + row.created_at = sqlite3_column_int64(stmt, 5); + row.updated_at = sqlite3_column_int64(stmt, 6); + return row; +} + +std::string JsonToString(const Json::Value& value) { + Json::StreamWriterBuilder builder; + builder["indentation"] = ""; + return Json::writeString(builder, value); +} + +} // namespace + +std::optional SubmissionFeedbackService::GetBySubmissionId( + int64_t submission_id) { + if (submission_id <= 0) return std::nullopt; + + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT submission_id,feedback_md,links_json,model_name,status,created_at,updated_at " + "FROM submission_feedback WHERE submission_id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare get submission feedback"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, submission_id), db, + "bind submission_id"); + + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + return std::nullopt; + } + + auto row = ReadSubmissionFeedback(stmt); + sqlite3_finalize(stmt); + return row; +} + +SubmissionFeedback SubmissionFeedbackService::GenerateAndSave( + const domain::Submission& submission, + const domain::Problem& problem, + bool force_refresh) { + if (!force_refresh) { + if (const auto existing = GetBySubmissionId(submission.id); existing.has_value()) { + return *existing; + } + } + + Json::Value input; + input["submission_id"] = Json::Int64(submission.id); + input["problem_id"] = Json::Int64(problem.id); + input["problem_title"] = problem.title; + input["problem_statement"] = problem.statement_md; + input["sample_input"] = problem.sample_input; + input["sample_output"] = problem.sample_output; + input["status"] = domain::ToString(submission.status); + input["score"] = submission.score; + input["time_ms"] = submission.time_ms; + input["language"] = domain::ToString(submission.language); + input["code"] = submission.code; + input["compile_log"] = submission.compile_log; + input["runtime_log"] = submission.runtime_log; + + namespace fs = std::filesystem; + const fs::path temp_file = + fs::path("/tmp") / ("csp_submission_feedback_" + crypto::RandomHex(8) + ".json"); + { + std::ofstream out(temp_file, std::ios::out | std::ios::binary | std::ios::trunc); + if (!out) throw std::runtime_error("failed to create temp input file"); + out << JsonToString(input); + } + + const std::string script = ResolveScriptPath(); + const std::string cmd = + "/usr/bin/timeout 45s python3 " + ShellQuote(script) + " --input-file " + + ShellQuote(temp_file.string()) + " 2>&1"; + + std::string output; + int exit_code = -1; + { + FILE* pipe = popen(cmd.c_str(), "r"); + if (!pipe) { + fs::remove(temp_file); + throw std::runtime_error("failed to start feedback script"); + } + char buffer[4096]; + while (fgets(buffer, sizeof(buffer), pipe) != nullptr) { + output += buffer; + } + exit_code = ExitCodeFromSystem(pclose(pipe)); + } + fs::remove(temp_file); + + if (exit_code != 0) { + throw std::runtime_error("feedback script failed: " + output); + } + + Json::CharReaderBuilder builder; + Json::Value parsed; + std::string errs; + std::unique_ptr reader(builder.newCharReader()); + if (!reader->parse(output.data(), output.data() + output.size(), &parsed, &errs) || + !parsed.isObject()) { + throw std::runtime_error("feedback script returned invalid json"); + } + + const std::string feedback_md = parsed.get("feedback_md", "").asString(); + const std::string model_name = parsed.get("model_name", "").asString(); + const std::string status = parsed.get("status", "ready").asString(); + const Json::Value links = parsed.isMember("links") ? parsed["links"] : Json::Value(Json::arrayValue); + const std::string links_json = JsonToString(links); + + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const int64_t now = NowSec(); + const char* upsert_sql = + "INSERT INTO submission_feedback(submission_id,feedback_md,links_json,model_name,status,created_at,updated_at) " + "VALUES(?,?,?,?,?,?,?) " + "ON CONFLICT(submission_id) DO UPDATE SET " + "feedback_md=excluded.feedback_md," + "links_json=excluded.links_json," + "model_name=excluded.model_name," + "status=excluded.status," + "updated_at=excluded.updated_at"; + CheckSqlite(sqlite3_prepare_v2(db, upsert_sql, -1, &stmt, nullptr), db, + "prepare upsert submission feedback"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, submission.id), db, "bind submission_id"); + CheckSqlite(sqlite3_bind_text(stmt, 2, feedback_md.c_str(), -1, SQLITE_TRANSIENT), db, + "bind feedback_md"); + CheckSqlite(sqlite3_bind_text(stmt, 3, links_json.c_str(), -1, SQLITE_TRANSIENT), db, + "bind links_json"); + CheckSqlite(sqlite3_bind_text(stmt, 4, model_name.c_str(), -1, SQLITE_TRANSIENT), db, + "bind model_name"); + CheckSqlite(sqlite3_bind_text(stmt, 5, status.c_str(), -1, SQLITE_TRANSIENT), db, + "bind status"); + CheckSqlite(sqlite3_bind_int64(stmt, 6, now), db, "bind created_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 7, now), db, "bind updated_at"); + CheckSqlite(sqlite3_step(stmt), db, "upsert submission feedback"); + sqlite3_finalize(stmt); + + const auto saved = GetBySubmissionId(submission.id); + if (!saved.has_value()) { + throw std::runtime_error("submission feedback saved but reload failed"); + } + return *saved; +} + +} // namespace csp::services diff --git a/backend/src/services/submission_service.cc b/backend/src/services/submission_service.cc index 2687f0b..b78873e 100644 --- a/backend/src/services/submission_service.cc +++ b/backend/src/services/submission_service.cc @@ -341,12 +341,16 @@ std::vector SubmissionService::List(std::optional u 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 ?"; + "SELECT s.id,s.user_id,s.problem_id,s.contest_id,s.language,s.code,s.status,s.score,s.time_ms,s.memory_kb,s.compile_log,s.runtime_log,s.created_at," + "CASE WHEN s.status='AC' AND NOT EXISTS (" + " SELECT 1 FROM submissions s2 " + " WHERE s2.user_id=s.user_id AND s2.problem_id=s.problem_id AND s2.status='AC' AND s2.id SubmissionService::List(std::optional u s.compile_log = ColText(stmt, 10); s.runtime_log = ColText(stmt, 11); s.created_at = sqlite3_column_int64(stmt, 12); + s.rating_delta = sqlite3_column_int(stmt, 13); out.push_back(std::move(s)); } sqlite3_finalize(stmt); @@ -395,8 +400,12 @@ std::optional 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=?"; + "SELECT s.id,s.user_id,s.problem_id,s.contest_id,s.language,s.code,s.status,s.score,s.time_ms,s.memory_kb,s.compile_log,s.runtime_log,s.created_at," + "CASE WHEN s.status='AC' AND NOT EXISTS (" + " SELECT 1 FROM submissions s2 " + " WHERE s2.user_id=s.user_id AND s2.problem_id=s.problem_id AND s2.status='AC' AND s2.id SubmissionService::GetById(int64_t id) { s.compile_log = ColText(stmt, 10); s.runtime_log = ColText(stmt, 11); s.created_at = sqlite3_column_int64(stmt, 12); + s.rating_delta = sqlite3_column_int(stmt, 13); sqlite3_finalize(stmt); return s; } diff --git a/backend/src/services/user_service.cc b/backend/src/services/user_service.cc index fcc632d..613544a 100644 --- a/backend/src/services/user_service.cc +++ b/backend/src/services/user_service.cc @@ -2,8 +2,10 @@ #include +#include #include #include +#include namespace csp::services { @@ -70,4 +72,58 @@ std::vector UserService::GlobalLeaderboard(int l return out; } +UserListResult UserService::ListUsers(int page, int page_size) { + const int safe_page = std::max(1, page); + const int safe_size = std::max(1, std::min(200, page_size)); + const int offset = (safe_page - 1) * safe_size; + + sqlite3* db = db_.raw(); + UserListResult result; + + { + sqlite3_stmt* count_stmt = nullptr; + const char* count_sql = "SELECT COUNT(1) FROM users"; + CheckSqlite(sqlite3_prepare_v2(db, count_sql, -1, &count_stmt, nullptr), db, + "prepare count users"); + CheckSqlite(sqlite3_step(count_stmt), db, "step count users"); + result.total_count = sqlite3_column_int(count_stmt, 0); + sqlite3_finalize(count_stmt); + } + + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,username,rating,created_at FROM users ORDER BY id ASC LIMIT ? OFFSET ?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare list users"); + CheckSqlite(sqlite3_bind_int(stmt, 1, safe_size), db, "bind limit"); + CheckSqlite(sqlite3_bind_int(stmt, 2, offset), db, "bind offset"); + + 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); + result.items.push_back(std::move(e)); + } + sqlite3_finalize(stmt); + return result; +} + +void UserService::SetRating(int64_t user_id, int rating) { + if (user_id <= 0) throw std::runtime_error("invalid user_id"); + if (rating < 0) throw std::runtime_error("rating must be >= 0"); + + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = "UPDATE users SET rating=? WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare set user rating"); + CheckSqlite(sqlite3_bind_int(stmt, 1, rating), db, "bind rating"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_step(stmt), db, "exec set user rating"); + sqlite3_finalize(stmt); + if (sqlite3_changes(db) <= 0) throw std::runtime_error("user not found"); +} + } // namespace csp::services diff --git a/backend/tests/problem_workspace_service_test.cc b/backend/tests/problem_workspace_service_test.cc index 6932aef..5926004 100644 --- a/backend/tests/problem_workspace_service_test.cc +++ b/backend/tests/problem_workspace_service_test.cc @@ -42,7 +42,21 @@ TEST_CASE("problem workspace service drafts and solution jobs") { REQUIRE(latest->id == job_id); REQUIRE(latest->status == "queued"); REQUIRE(latest->max_solutions == 3); + REQUIRE(latest->problem_title.empty()); + + const auto recent = svc.ListRecentSolutionJobs(10); + REQUIRE(recent.size() == 1); + REQUIRE(recent.front().id == job_id); + REQUIRE(recent.front().problem_id == pid); + REQUIRE(!recent.front().problem_title.empty()); const auto solutions = svc.ListSolutions(pid); REQUIRE(solutions.empty()); + + REQUIRE(svc.CountProblemsWithoutSolutions() >= 1); + const auto missing_all = svc.ListProblemIdsWithoutSolutions(10, false); + REQUIRE(!missing_all.empty()); + const auto missing_skip_busy = svc.ListProblemIdsWithoutSolutions(10, true); + REQUIRE(!missing_skip_busy.empty()); + REQUIRE(missing_skip_busy.size() < missing_all.size()); } diff --git a/docker-compose.yml b/docker-compose.yml index 17251c6..9f0ad4a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,16 +4,24 @@ services: - .env environment: - OI_IMPORT_AUTO_RUN=true + - OI_IMPORT_AUTO_MODE=luogu - 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_LOCAL_RAG_SCRIPT_PATH=/app/scripts/import_local_pdf_rag.py + - OI_LOCAL_PDF_DIR=/data/local_pdfs + - OI_IMPORT_TARGET_TOTAL=5000 + - OI_IMPORT_RESUME_ON_RESTART=true + - OI_IMPORT_CLEAR_ALL_PROBLEMS=false + - OI_IMPORT_CLEAR_EXISTING=false - "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 + - CSP_SOLUTION_AUTO_RUN_MISSING=true + - CSP_SOLUTION_AUTO_LIMIT=50000 + - CSP_SOLUTION_AUTO_MAX_SOLUTIONS=3 build: context: . dockerfile: Dockerfile.backend @@ -22,6 +30,7 @@ services: # - "8080:8080" volumes: - csp_data:/data + - ./data/local_pdfs:/data/local_pdfs restart: unless-stopped frontend: diff --git a/docs/API参考.md b/docs/API参考.md index 77af0cb..ee3aed1 100644 --- a/docs/API参考.md +++ b/docs/API参考.md @@ -140,3 +140,20 @@ ```json { "clear_all_problems": true } ``` + +也支持本地 PDF + RAG + LLM 出题模式(异步执行,进度同样写入 `import_jobs/import_job_items`): +```json +{ + "mode": "local_pdf_rag", + "workers": 3, + "local_pdf_dir": "/data/local_pdfs", + "target_total": 5000 +} +``` + +--- + +## 9) 后台日志(题解异步队列) + +### `GET /backend/logs?limit=100` +返回最近题解生成任务日志(按任务 ID 倒序),并包含当前排队任务数 `pending_jobs`。 diff --git a/frontend/src/app/admin-redeem/page.tsx b/frontend/src/app/admin-redeem/page.tsx new file mode 100644 index 0000000..9682a4d --- /dev/null +++ b/frontend/src/app/admin-redeem/page.tsx @@ -0,0 +1,348 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { apiFetch } from "@/lib/api"; +import { readToken } from "@/lib/auth"; +import { useI18nText } from "@/lib/i18n"; + +type RedeemItem = { + id: number; + name: string; + description: string; + unit_label: string; + holiday_cost: number; + studyday_cost: number; + is_active: boolean; + is_global: boolean; + created_at: number; + updated_at: number; +}; + +type RedeemRecord = { + id: number; + user_id: number; + username: string; + item_id: number; + item_name: string; + quantity: number; + day_type: string; + unit_cost: number; + total_cost: number; + note: string; + created_at: number; +}; + +type ItemForm = { + name: string; + description: string; + unit_label: string; + holiday_cost: number; + studyday_cost: number; + is_active: boolean; + is_global: boolean; +}; + +const DEFAULT_FORM: ItemForm = { + name: "", + description: "", + unit_label: "hour", + holiday_cost: 5, + studyday_cost: 25, + is_active: true, + is_global: true, +}; + +function fmtTs(v: number | null | undefined): string { + if (!v) return "-"; + return new Date(v * 1000).toLocaleString(); +} + +export default function AdminRedeemPage() { + const { tx } = useI18nText(); + const [token, setToken] = useState(""); + const [items, setItems] = useState([]); + const [records, setRecords] = useState([]); + const [form, setForm] = useState(DEFAULT_FORM); + const [editingId, setEditingId] = useState(null); + + const [recordUserId, setRecordUserId] = useState(""); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [msg, setMsg] = useState(""); + + const load = async () => { + setLoading(true); + setError(""); + try { + const tk = readToken(); + setToken(tk); + if (!tk) throw new Error(tx("请先登录管理员账号", "Please sign in with admin account first")); + + const recordsQs = new URLSearchParams({ limit: "200" }); + if (recordUserId.trim()) recordsQs.set("user_id", recordUserId.trim()); + + const [itemRows, recordRows] = await Promise.all([ + apiFetch("/api/v1/admin/redeem-items?include_inactive=1", {}, tk), + apiFetch(`/api/v1/admin/redeem-records?${recordsQs.toString()}`, {}, tk), + ]); + setItems(itemRows ?? []); + setRecords(recordRows ?? []); + } catch (e: unknown) { + setError(String(e)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const submit = async () => { + setSaving(true); + setError(""); + setMsg(""); + try { + if (!token) throw new Error(tx("请先登录管理员账号", "Please sign in with admin account first")); + if (!form.name.trim()) throw new Error(tx("物品名称不能为空", "Item name is required")); + const payload = { + ...form, + name: form.name.trim(), + description: form.description.trim(), + unit_label: form.unit_label.trim() || tx("小时", "hour"), + }; + + if (editingId) { + await apiFetch(`/api/v1/admin/redeem-items/${editingId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }, token); + setMsg(tx(`已更新兑换物品 #${editingId}`, `Updated redeem item #${editingId}`)); + } else { + await apiFetch("/api/v1/admin/redeem-items", { + method: "POST", + body: JSON.stringify(payload), + }, token); + setMsg(tx("已新增兑换物品", "Added redeem item")); + } + + setForm(DEFAULT_FORM); + setEditingId(null); + await load(); + } catch (e: unknown) { + setError(String(e)); + } finally { + setSaving(false); + } + }; + + const edit = (item: RedeemItem) => { + setEditingId(item.id); + setForm({ + name: item.name, + description: item.description, + unit_label: item.unit_label, + holiday_cost: item.holiday_cost, + studyday_cost: item.studyday_cost, + is_active: item.is_active, + is_global: item.is_global, + }); + }; + + const deactivate = async (id: number) => { + setError(""); + setMsg(""); + try { + if (!token) throw new Error(tx("请先登录管理员账号", "Please sign in with admin account first")); + await apiFetch(`/api/v1/admin/redeem-items/${id}`, { method: "DELETE" }, token); + setMsg(tx(`已下架兑换物品 #${id}`, `Disabled redeem item #${id}`)); + await load(); + } catch (e: unknown) { + setError(String(e)); + } + }; + + return ( +
+

+ {tx("管理员:积分兑换管理", "Admin: Redeem Management")} +

+

+ {tx( + "可在此添加/修改/下架全局兑换物品,并查看全站兑换记录。", + "Add/update/disable global redeem items and view all redeem records here." + )} +

+ + {error &&

{error}

} + {msg &&

{msg}

} + +
+

{tx("兑换物品表单(增删改查)", "Redeem Item Form (CRUD)")}

+
+ setForm((prev) => ({ ...prev, name: e.target.value }))} + /> + setForm((prev) => ({ ...prev, unit_label: e.target.value }))} + /> + + setForm((prev) => ({ ...prev, holiday_cost: Math.max(0, Number(e.target.value) || 0) })) + } + /> + + setForm((prev) => ({ ...prev, studyday_cost: Math.max(0, Number(e.target.value) || 0) })) + } + /> +