diff --git a/backend/CMakeLists.txt b/backend/CMakeLists.txt index e348490..3de9416 100644 --- a/backend/CMakeLists.txt +++ b/backend/CMakeLists.txt @@ -14,6 +14,7 @@ add_library(csp_core src/app_state.cc src/services/crypto.cc src/services/auth_service.cc + src/services/experience_service.cc src/services/problem_service.cc src/services/user_service.cc src/services/wrong_book_service.cc @@ -23,15 +24,21 @@ add_library(csp_core src/services/submission_service.cc src/services/solution_access_service.cc src/services/redeem_service.cc + src/services/season_service.cc src/services/problem_workspace_service.cc + src/services/db_lock_guard.cc + src/services/crawler_service.cc + src/services/crawler_runner.cc src/services/problem_solution_runner.cc src/services/kb_import_runner.cc src/services/problem_gen_runner.cc src/services/submission_feedback_service.cc src/services/learning_note_scoring_service.cc + src/services/source_crystal_service.cc src/services/submission_feedback_runner.cc src/services/import_service.cc src/services/import_runner.cc + src/services/lark_bot_service.cc src/domain/enum_strings.cc src/domain/json.cc ) @@ -55,8 +62,11 @@ add_library(csp_web src/controllers/contest_controller.cc src/controllers/leaderboard_controller.cc src/controllers/admin_controller.cc + src/controllers/season_controller.cc src/controllers/kb_controller.cc src/controllers/import_controller.cc + src/controllers/crawler_controller.cc + src/controllers/lark_controller.cc src/controllers/meta_controller.cc src/controllers/problem_gen_controller.cc src/health_controller.cc @@ -91,6 +101,7 @@ add_executable(csp_tests tests/version_test.cc tests/sqlite_db_test.cc tests/auth_service_test.cc + tests/experience_service_test.cc tests/auth_http_test.cc tests/domain_test.cc tests/problem_service_test.cc @@ -103,7 +114,12 @@ add_executable(csp_tests tests/problem_workspace_service_test.cc tests/problem_workspace_http_test.cc tests/contest_http_test.cc + tests/season_service_test.cc + tests/season_http_test.cc tests/submission_http_test.cc + tests/lark_http_test.cc + tests/crawler_service_test.cc + tests/source_crystal_service_test.cc tests/import_service_test.cc tests/import_http_test.cc ) diff --git a/backend/include/csp/controllers/admin_controller.h b/backend/include/csp/controllers/admin_controller.h index c0cddc6..c7deab8 100644 --- a/backend/include/csp/controllers/admin_controller.h +++ b/backend/include/csp/controllers/admin_controller.h @@ -13,6 +13,15 @@ class AdminController : public drogon::HttpController { ADD_METHOD_TO(AdminController::updateUserRating, "/api/v1/admin/users/{1}/rating", drogon::Patch); + ADD_METHOD_TO(AdminController::getUserSourceCrystalSummary, + "/api/v1/admin/users/{1}/source-crystal", + drogon::Get); + ADD_METHOD_TO(AdminController::listUserSourceCrystalRecords, + "/api/v1/admin/users/{1}/source-crystal/records", + drogon::Get); + ADD_METHOD_TO(AdminController::depositUserSourceCrystal, + "/api/v1/admin/users/{1}/source-crystal/deposit", + drogon::Post); ADD_METHOD_TO(AdminController::deleteUser, "/api/v1/admin/users/{1}", drogon::Delete); @@ -27,6 +36,22 @@ class AdminController : public drogon::HttpController { ADD_METHOD_TO(AdminController::listRedeemRecords, "/api/v1/admin/redeem-records", drogon::Get); + ADD_METHOD_TO(AdminController::getSourceCrystalSettings, + "/api/v1/admin/source-crystal/settings", + drogon::Get); + ADD_METHOD_TO(AdminController::updateSourceCrystalSettings, + "/api/v1/admin/source-crystal/settings", + drogon::Patch); + ADD_METHOD_TO(AdminController::createSeason, "/api/v1/admin/seasons", drogon::Post); + ADD_METHOD_TO(AdminController::updateSeason, + "/api/v1/admin/seasons/{1}", + drogon::Patch); + ADD_METHOD_TO(AdminController::createContestModifier, + "/api/v1/admin/contests/{1}/modifiers", + drogon::Post); + ADD_METHOD_TO(AdminController::updateContestModifier, + "/api/v1/admin/contests/{1}/modifiers/{2}", + drogon::Patch); ADD_METHOD_TO(AdminController::userRatingHistory, "/api/v1/admin/users/{1}/rating-history", drogon::Get); @@ -38,6 +63,18 @@ class AdminController : public drogon::HttpController { void updateUserRating(const drogon::HttpRequestPtr& req, std::function&& cb, int64_t user_id); + void getUserSourceCrystalSummary( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t user_id); + void listUserSourceCrystalRecords( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t user_id); + void depositUserSourceCrystal( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t user_id); void deleteUser(const drogon::HttpRequestPtr& req, std::function&& cb, int64_t user_id); @@ -59,6 +96,30 @@ class AdminController : public drogon::HttpController { void listRedeemRecords(const drogon::HttpRequestPtr& req, std::function&& cb); + void getSourceCrystalSettings( + const drogon::HttpRequestPtr& req, + std::function&& cb); + + void updateSourceCrystalSettings( + const drogon::HttpRequestPtr& req, + std::function&& cb); + + void createSeason(const drogon::HttpRequestPtr& req, + std::function&& cb); + + void updateSeason(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t season_id); + + void createContestModifier(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t contest_id); + + void updateContestModifier(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t contest_id, + int64_t modifier_id); + void userRatingHistory(const drogon::HttpRequestPtr& req, std::function&& cb, int64_t user_id); diff --git a/backend/include/csp/controllers/contest_controller.h b/backend/include/csp/controllers/contest_controller.h index 2a6736b..74d3d73 100644 --- a/backend/include/csp/controllers/contest_controller.h +++ b/backend/include/csp/controllers/contest_controller.h @@ -13,6 +13,7 @@ class ContestController : public drogon::HttpController { ADD_METHOD_TO(ContestController::getById, "/api/v1/contests/{1}", drogon::Get); ADD_METHOD_TO(ContestController::registerForContest, "/api/v1/contests/{1}/register", drogon::Post); ADD_METHOD_TO(ContestController::leaderboard, "/api/v1/contests/{1}/leaderboard", drogon::Get); + ADD_METHOD_TO(ContestController::modifiers, "/api/v1/contests/{1}/modifiers", drogon::Get); METHOD_LIST_END void list(const drogon::HttpRequestPtr& req, @@ -29,6 +30,10 @@ class ContestController : public drogon::HttpController { void leaderboard(const drogon::HttpRequestPtr& req, std::function&& cb, int64_t contest_id); + + void modifiers(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t contest_id); }; } // namespace csp::controllers diff --git a/backend/include/csp/controllers/crawler_controller.h b/backend/include/csp/controllers/crawler_controller.h new file mode 100644 index 0000000..c50708f --- /dev/null +++ b/backend/include/csp/controllers/crawler_controller.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +namespace csp::controllers { + +class CrawlerController : public drogon::HttpController { + public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(CrawlerController::listTargets, "/api/v1/admin/crawlers", drogon::Get); + ADD_METHOD_TO(CrawlerController::createTarget, "/api/v1/admin/crawlers", drogon::Post); + ADD_METHOD_TO(CrawlerController::queueTarget, + "/api/v1/admin/crawlers/{1}/queue", + drogon::Post); + ADD_METHOD_TO(CrawlerController::listRuns, + "/api/v1/admin/crawlers/{1}/runs", + drogon::Get); + ADD_METHOD_TO(CrawlerController::status, + "/api/v1/backend/crawler-guard/status", + drogon::Get); + METHOD_LIST_END + + void listTargets(const drogon::HttpRequestPtr& req, + std::function&& cb); + + void createTarget(const drogon::HttpRequestPtr& req, + std::function&& cb); + + void queueTarget(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t target_id); + + void listRuns(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t target_id); + + void status(const drogon::HttpRequestPtr& req, + std::function&& cb); +}; + +} // namespace csp::controllers diff --git a/backend/include/csp/controllers/kb_controller.h b/backend/include/csp/controllers/kb_controller.h index 9ae5ec4..fc35603 100644 --- a/backend/include/csp/controllers/kb_controller.h +++ b/backend/include/csp/controllers/kb_controller.h @@ -11,6 +11,10 @@ class KbController : public drogon::HttpController { METHOD_LIST_BEGIN ADD_METHOD_TO(KbController::listArticles, "/api/v1/kb/articles", drogon::Get); ADD_METHOD_TO(KbController::getArticle, "/api/v1/kb/articles/{1}", drogon::Get); + ADD_METHOD_TO(KbController::listClaims, "/api/v1/kb/articles/{1}/claims", drogon::Get); + ADD_METHOD_TO(KbController::claimSkillPoint, "/api/v1/kb/articles/{1}/claim", drogon::Post); + ADD_METHOD_TO(KbController::weeklyPlan, "/api/v1/kb/weekly-plan", drogon::Get); + ADD_METHOD_TO(KbController::claimWeeklyBonus, "/api/v1/kb/weekly-bonus/claim", drogon::Post); METHOD_LIST_END void listArticles(const drogon::HttpRequestPtr& req, @@ -19,6 +23,20 @@ class KbController : public drogon::HttpController { void getArticle(const drogon::HttpRequestPtr& req, std::function&& cb, std::string slug); + + void listClaims(const drogon::HttpRequestPtr& req, + std::function&& cb, + std::string slug); + + void claimSkillPoint(const drogon::HttpRequestPtr& req, + std::function&& cb, + std::string slug); + + void weeklyPlan(const drogon::HttpRequestPtr& req, + std::function&& cb); + + void claimWeeklyBonus(const drogon::HttpRequestPtr& req, + std::function&& cb); }; } // namespace csp::controllers diff --git a/backend/include/csp/controllers/lark_controller.h b/backend/include/csp/controllers/lark_controller.h new file mode 100644 index 0000000..813965c --- /dev/null +++ b/backend/include/csp/controllers/lark_controller.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace csp::controllers { + +class LarkController : public drogon::HttpController { + public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(LarkController::events, "/api/v1/lark/events", drogon::Post); + METHOD_LIST_END + + void events(const drogon::HttpRequestPtr& req, + std::function&& cb); +}; + +} // namespace csp::controllers + diff --git a/backend/include/csp/controllers/me_controller.h b/backend/include/csp/controllers/me_controller.h index b88a162..ee03da4 100644 --- a/backend/include/csp/controllers/me_controller.h +++ b/backend/include/csp/controllers/me_controller.h @@ -12,10 +12,29 @@ public: ADD_METHOD_TO(MeController::profile, "/api/v1/me", drogon::Get); ADD_METHOD_TO(MeController::listRedeemItems, "/api/v1/me/redeem/items", drogon::Get); + ADD_METHOD_TO(MeController::getRedeemDayType, "/api/v1/me/redeem/day-type", + drogon::Get); ADD_METHOD_TO(MeController::listRedeemRecords, "/api/v1/me/redeem/records", drogon::Get); ADD_METHOD_TO(MeController::createRedeemRecord, "/api/v1/me/redeem/records", drogon::Post); + ADD_METHOD_TO(MeController::sourceCrystalSummary, "/api/v1/me/source-crystal", + drogon::Get); + ADD_METHOD_TO(MeController::listSourceCrystalRecords, + "/api/v1/me/source-crystal/records", + drogon::Get); + ADD_METHOD_TO(MeController::sourceCrystalDeposit, + "/api/v1/me/source-crystal/deposit", + drogon::Post); + ADD_METHOD_TO(MeController::sourceCrystalWithdraw, + "/api/v1/me/source-crystal/withdraw", + drogon::Post); + ADD_METHOD_TO(MeController::experienceSummary, "/api/v1/me/experience", + drogon::Get); + ADD_METHOD_TO(MeController::experienceHistory, "/api/v1/me/experience/history", + drogon::Get); + ADD_METHOD_TO(MeController::listLootDrops, "/api/v1/me/loot-drops", + drogon::Get); ADD_METHOD_TO(MeController::listDailyTasks, "/api/v1/me/daily-tasks", drogon::Get); ADD_METHOD_TO(MeController::listWrongBook, "/api/v1/me/wrong-book", @@ -41,6 +60,10 @@ public: listRedeemItems(const drogon::HttpRequestPtr &req, std::function &&cb); + void + getRedeemDayType(const drogon::HttpRequestPtr &req, + std::function &&cb); + void listRedeemRecords(const drogon::HttpRequestPtr &req, std::function &&cb); @@ -49,6 +72,34 @@ public: createRedeemRecord(const drogon::HttpRequestPtr &req, std::function &&cb); + void sourceCrystalSummary( + const drogon::HttpRequestPtr &req, + std::function &&cb); + + void listSourceCrystalRecords( + const drogon::HttpRequestPtr &req, + std::function &&cb); + + void sourceCrystalDeposit( + const drogon::HttpRequestPtr &req, + std::function &&cb); + + void sourceCrystalWithdraw( + const drogon::HttpRequestPtr &req, + std::function &&cb); + + void experienceSummary( + const drogon::HttpRequestPtr &req, + std::function &&cb); + + void experienceHistory( + const drogon::HttpRequestPtr &req, + std::function &&cb); + + void + listLootDrops(const drogon::HttpRequestPtr &req, + std::function &&cb); + void listDailyTasks(const drogon::HttpRequestPtr &req, std::function &&cb); diff --git a/backend/include/csp/controllers/meta_controller.h b/backend/include/csp/controllers/meta_controller.h index 490f3d5..81840fb 100644 --- a/backend/include/csp/controllers/meta_controller.h +++ b/backend/include/csp/controllers/meta_controller.h @@ -9,6 +9,8 @@ class MetaController : public drogon::HttpController { 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::dbLockGuardStatus, "/api/v1/backend/db-lock-guard/status", + 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", @@ -22,6 +24,10 @@ class MetaController : public drogon::HttpController { void backendLogs(const drogon::HttpRequestPtr& req, std::function&& cb); + void dbLockGuardStatus( + const drogon::HttpRequestPtr& req, + std::function&& cb); + void kbRefreshStatus( const drogon::HttpRequestPtr& req, std::function&& cb); diff --git a/backend/include/csp/controllers/season_controller.h b/backend/include/csp/controllers/season_controller.h new file mode 100644 index 0000000..7a6c458 --- /dev/null +++ b/backend/include/csp/controllers/season_controller.h @@ -0,0 +1,38 @@ +#pragma once + +#include + +#include + +namespace csp::controllers { + +class SeasonController : public drogon::HttpController { + public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(SeasonController::currentSeason, + "/api/v1/seasons/current", + drogon::Get); + ADD_METHOD_TO(SeasonController::mySeasonProgress, + "/api/v1/seasons/{1}/me", + drogon::Get); + ADD_METHOD_TO(SeasonController::claimSeasonReward, + "/api/v1/seasons/{1}/claim", + drogon::Post); + METHOD_LIST_END + + void currentSeason( + const drogon::HttpRequestPtr& req, + std::function&& cb); + + void mySeasonProgress( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t season_id); + + void claimSeasonReward( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t season_id); +}; + +} // namespace csp::controllers diff --git a/backend/include/csp/domain/entities.h b/backend/include/csp/domain/entities.h index 9a98ec8..a4ca23d 100644 --- a/backend/include/csp/domain/entities.h +++ b/backend/include/csp/domain/entities.h @@ -114,6 +114,70 @@ struct ContestRegistration { int64_t registered_at = 0; }; +struct ContestModifier { + int64_t id = 0; + int64_t contest_id = 0; + std::string code; + std::string title; + std::string description; + std::string rule_json; + bool is_active = true; + int64_t created_at = 0; + int64_t updated_at = 0; +}; + +struct Season { + int64_t id = 0; + std::string key; + std::string title; + int64_t starts_at = 0; + int64_t ends_at = 0; + std::string status; + std::string pass_json; + int64_t created_at = 0; + int64_t updated_at = 0; +}; + +struct SeasonRewardTrack { + int64_t id = 0; + int64_t season_id = 0; + int32_t tier_no = 0; + int32_t required_xp = 0; + std::string reward_type; + int32_t reward_value = 0; + std::string reward_meta_json; +}; + +struct SeasonUserProgress { + int64_t season_id = 0; + int64_t user_id = 0; + int32_t xp = 0; + int32_t level = 0; + int64_t updated_at = 0; +}; + +struct SeasonRewardClaim { + int64_t id = 0; + int64_t season_id = 0; + int64_t user_id = 0; + int32_t tier_no = 0; + std::string reward_type; + int64_t claimed_at = 0; +}; + +struct LootDropLog { + int64_t id = 0; + int64_t user_id = 0; + std::string source_type; + int64_t source_id = 0; + std::string item_code; + std::string item_name; + std::string rarity; + int32_t amount = 0; + std::string meta_json; + int64_t created_at = 0; +}; + struct KbArticle { int64_t id = 0; std::string slug; @@ -132,6 +196,7 @@ struct GlobalLeaderboardEntry { std::string username; int32_t rating = 0; int64_t created_at = 0; + int32_t period_score = 0; int32_t total_submissions = 0; int32_t total_ac = 0; }; diff --git a/backend/include/csp/domain/json.h b/backend/include/csp/domain/json.h index 614b329..2e58805 100644 --- a/backend/include/csp/domain/json.h +++ b/backend/include/csp/domain/json.h @@ -15,6 +15,12 @@ Json::Value ToJson(const Problem& p); Json::Value ToJson(const Submission& s); Json::Value ToJson(const WrongBookItem& w); Json::Value ToJson(const Contest& c); +Json::Value ToJson(const ContestModifier& c); +Json::Value ToJson(const Season& s); +Json::Value ToJson(const SeasonRewardTrack& t); +Json::Value ToJson(const SeasonUserProgress& p); +Json::Value ToJson(const SeasonRewardClaim& c); +Json::Value ToJson(const LootDropLog& l); Json::Value ToJson(const KbArticle& a); Json::Value ToJson(const GlobalLeaderboardEntry& e); Json::Value ToJson(const ContestLeaderboardEntry& e); diff --git a/backend/include/csp/services/auth_service.h b/backend/include/csp/services/auth_service.h index 18da467..44095aa 100644 --- a/backend/include/csp/services/auth_service.h +++ b/backend/include/csp/services/auth_service.h @@ -22,6 +22,9 @@ class AuthService { AuthResult Login(const std::string& username, const std::string& password); void ResetPassword(const std::string& username, const std::string& new_password); + // Verify username/password without creating session. + std::optional VerifyCredentials(const std::string& username, + const std::string& password); std::optional VerifyToken(const std::string& token); private: diff --git a/backend/include/csp/services/crawler_runner.h b/backend/include/csp/services/crawler_runner.h new file mode 100644 index 0000000..573244e --- /dev/null +++ b/backend/include/csp/services/crawler_runner.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include +#include + +namespace csp::services { + +class CrawlerRunner { + public: + struct Status { + bool enabled = true; + bool started = false; + int interval_sec = 20; + int active_requeue_interval_sec = 43200; + bool running = false; + int64_t processed_count = 0; + int64_t success_count = 0; + int64_t failed_count = 0; + int64_t last_started_at = 0; + int64_t last_finished_at = 0; + int64_t last_success_at = 0; + int64_t last_failure_at = 0; + std::string last_error; + int64_t current_target_id = 0; + }; + + static CrawlerRunner& Instance(); + + void Configure(std::string db_path); + void StartIfEnabled(); + Status GetStatus(); + void WakeUp(); + + private: + CrawlerRunner() = default; + + void WorkerLoop(); + + std::mutex mu_; + std::condition_variable cv_; + std::string db_path_; + bool enabled_ = true; + bool started_ = false; + int interval_sec_ = 20; + int active_requeue_interval_sec_ = 43200; + int fetch_timeout_sec_ = 20; + + std::string script_dir_ = "data/crawlers"; + std::string llm_api_url_; + std::string llm_api_key_; + std::string llm_model_ = "qwen3-max"; + std::string llm_system_prompt_; + int llm_timeout_sec_ = 30; + + bool running_ = false; + int64_t processed_count_ = 0; + int64_t success_count_ = 0; + int64_t failed_count_ = 0; + int64_t last_started_at_ = 0; + int64_t last_finished_at_ = 0; + int64_t last_success_at_ = 0; + int64_t last_failure_at_ = 0; + std::string last_error_; + int64_t current_target_id_ = 0; + bool wake_requested_ = false; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/crawler_service.h b/backend/include/csp/services/crawler_service.h new file mode 100644 index 0000000..a7f5288 --- /dev/null +++ b/backend/include/csp/services/crawler_service.h @@ -0,0 +1,79 @@ +#pragma once + +#include "csp/db/sqlite_db.h" + +#include +#include +#include +#include + +namespace csp::services { + +struct CrawlerTarget { + int64_t id = 0; + std::string url; + std::string normalized_url; + std::string status; + std::string submit_source; + std::string submitter_id; + std::string submitter_name; + std::string rule_json; + std::string script_path; + std::string last_error; + std::optional last_test_at; + std::optional last_run_at; + int64_t created_at = 0; + int64_t updated_at = 0; +}; + +struct CrawlerRun { + int64_t id = 0; + int64_t target_id = 0; + std::string status; + int32_t http_status = 0; + std::string output_json; + std::string error_text; + int64_t created_at = 0; +}; + +struct UpsertCrawlerTargetResult { + CrawlerTarget target; + bool inserted = false; +}; + +class CrawlerService { + public: + explicit CrawlerService(db::SqliteDb& db) : db_(db) {} + + UpsertCrawlerTargetResult UpsertTarget(const std::string& raw_url, + const std::string& submit_source, + const std::string& submitter_id, + const std::string& submitter_name); + + std::optional GetTargetById(int64_t target_id); + std::vector ListTargets(const std::string& status, int limit); + std::vector ListRuns(int64_t target_id, int limit); + + bool EnqueueTarget(int64_t target_id); + bool EnqueueDueActiveTarget(int interval_sec, int64_t now_sec, CrawlerTarget& out); + bool ClaimNextTarget(CrawlerTarget& out); + void UpdateGenerated(int64_t target_id, + const std::string& rule_json, + const std::string& script_path); + void MarkTesting(int64_t target_id); + void MarkActive(int64_t target_id, int64_t run_at); + void MarkFailed(int64_t target_id, const std::string& error); + void InsertRun(int64_t target_id, + const std::string& status, + int http_status, + const std::string& output_json, + const std::string& error_text); + + static std::string NormalizeUrl(const std::string& raw_url); + static std::vector ExtractUrls(const std::string& text); + + private: + db::SqliteDb& db_; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/db_lock_guard.h b/backend/include/csp/services/db_lock_guard.h new file mode 100644 index 0000000..6acb5f1 --- /dev/null +++ b/backend/include/csp/services/db_lock_guard.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include + +namespace csp::services { + +// Background guardian for SQLite lock contention. +// It periodically probes writer lock health and performs best-effort +// maintenance when lock contention appears to be stuck. +class DbLockGuard { + public: + struct Status { + bool enabled = true; + bool started = false; + int interval_sec = 20; + int probe_busy_timeout_ms = 2000; + int busy_streak_trigger = 3; + int busy_streak = 0; + int64_t last_probe_at = 0; + int last_probe_rc = 0; + std::string last_probe_error; + int64_t last_repair_at = 0; + int64_t repair_count = 0; + }; + + static DbLockGuard& Instance(); + + void Configure(std::string db_path); + void StartIfEnabled(); + Status GetStatus(); + + private: + DbLockGuard() = default; + + void WorkerLoop(); + + std::mutex mu_; + std::string db_path_; + bool enabled_ = true; + bool started_ = false; + int interval_sec_ = 20; + int probe_busy_timeout_ms_ = 2000; + int busy_streak_trigger_ = 3; + int busy_streak_ = 0; + int64_t last_probe_at_ = 0; + int last_probe_rc_ = 0; + std::string last_probe_error_; + int64_t last_repair_at_ = 0; + int64_t repair_count_ = 0; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/experience_service.h b/backend/include/csp/services/experience_service.h new file mode 100644 index 0000000..f9e920c --- /dev/null +++ b/backend/include/csp/services/experience_service.h @@ -0,0 +1,39 @@ +#pragma once + +#include "csp/db/sqlite_db.h" + +#include +#include +#include + +namespace csp::services { + +struct ExperienceSummary { + int64_t user_id = 0; + int experience = 0; + int64_t updated_at = 0; +}; + +struct ExperienceHistoryItem { + int64_t id = 0; + int64_t user_id = 0; + int xp_delta = 0; + int rating_before = 0; + int rating_after = 0; + std::string source; + std::string note; + int64_t created_at = 0; +}; + +class ExperienceService { + public: + explicit ExperienceService(db::SqliteDb& db) : db_(db) {} + + ExperienceSummary GetSummary(int64_t user_id); + std::vector ListHistory(int64_t user_id, int limit); + + private: + db::SqliteDb& db_; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/kb_service.h b/backend/include/csp/services/kb_service.h index eab4963..622c697 100644 --- a/backend/include/csp/services/kb_service.h +++ b/backend/include/csp/services/kb_service.h @@ -13,6 +13,62 @@ namespace csp::services { struct KbArticleDetail { domain::KbArticle article; std::vector> related_problems; + struct SkillPoint { + std::string key; + std::string title; + std::string description; + std::string difficulty; + int reward = 1; + std::vector prerequisites; + }; + std::vector skill_points; +}; + +struct KbClaimSummary { + std::vector claimed_keys; + int total_reward = 0; + int total_count = 0; +}; + +struct KbClaimResult { + bool claimed = false; + int reward = 0; + int rating_after = 0; + int total_claimed = 0; +}; + +struct KbWeeklyTask { + int64_t id = 0; + std::string week_key; + int64_t article_id = 0; + std::string article_slug; + std::string article_title; + std::string knowledge_key; + std::string knowledge_title; + std::string knowledge_description; + std::string difficulty; + int reward = 0; + std::vector prerequisites; + bool completed = false; + int64_t completed_at = 0; +}; + +struct KbWeeklyPlan { + std::string week_key; + std::vector tasks; + int total_reward = 0; + int gained_reward = 0; + int bonus_reward = 100; + bool bonus_claimed = false; + int completion_percent = 0; +}; + +struct KbWeeklyBonusResult { + bool claimed = false; + int reward = 0; + int rating_after = 0; + int completion_percent = 0; + std::string week_key; }; class KbService { @@ -21,8 +77,21 @@ class KbService { std::vector ListArticles(); std::optional GetBySlug(const std::string& slug); + KbClaimSummary ListClaims(int64_t user_id, int64_t article_id); + KbClaimResult ClaimSkillPoint(int64_t user_id, + int64_t article_id, + const std::string& slug, + const std::string& skill_key); + KbWeeklyPlan GetWeeklyPlan(int64_t user_id); + KbWeeklyBonusResult ClaimWeeklyBonus(int64_t user_id); private: + std::vector SkillPointsBySlug(const std::string& slug); + std::vector ClaimedKeysByUser(int64_t user_id); + std::vector ClaimedKeysByArticle(int64_t user_id, int64_t article_id); + std::string CurrentWeekKey() const; + void EnsureWeeklyTasksGenerated(int64_t user_id, const std::string& week_key); + db::SqliteDb& db_; }; diff --git a/backend/include/csp/services/lark_bot_service.h b/backend/include/csp/services/lark_bot_service.h new file mode 100644 index 0000000..450003c --- /dev/null +++ b/backend/include/csp/services/lark_bot_service.h @@ -0,0 +1,85 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace csp::services { + +class LarkBotService { + public: + struct IncomingTextEvent { + std::string event_id; + std::string message_id; + std::string chat_id; + std::string sender_id; + std::string text; + }; + + static LarkBotService& Instance(); + + // Read runtime options from environment variables. + void ConfigureFromEnv(); + + bool Enabled() const; + bool VerifyToken(const std::string& token) const; + + // Fire-and-forget message handling. Returns immediately. + void HandleEventAsync(IncomingTextEvent event); + // Fire-and-forget plain text reply without LLM roundtrip. + void ReplyTextAsync(const std::string& message_id, const std::string& text); + + private: + LarkBotService() = default; + + struct ChatTurn { + std::string role; + std::string content; + }; + + struct ParsedUrl { + std::string origin; + std::string path; + }; + + std::string BuildReplyWithLlm(const std::string& session_key, + const std::string& user_text, + std::string& err); + std::string FallbackReply() const; + bool SendReplyToLark(const std::string& message_id, + const std::string& text, + std::string& err); + bool ObtainTenantToken(std::string& token, std::string& err); + bool HttpPostJson(const ParsedUrl& endpoint, + const std::string& body, + const std::unordered_map& headers, + double timeout_sec, + std::string& response_body, + std::string& err) const; + static std::string Trim(const std::string& s); + static std::string ClipUtf8(const std::string& s, size_t max_bytes); + static bool ParseUrl(const std::string& url, ParsedUrl& out); + + mutable std::mutex mu_; + bool enabled_ = false; + std::string verification_token_; + std::string app_id_; + std::string app_secret_; + std::string open_base_url_ = "https://open.feishu.cn"; + std::string llm_api_url_; + std::string llm_api_key_; + std::string llm_model_ = "qwen3-max"; + std::string llm_system_prompt_; + int llm_timeout_sec_ = 30; + int lark_timeout_sec_ = 15; + int memory_turns_ = 6; + size_t max_reply_chars_ = 1200; + + std::string tenant_access_token_; + int64_t tenant_access_token_expire_at_ = 0; + std::unordered_map> conversations_; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/redeem_service.h b/backend/include/csp/services/redeem_service.h index 46cb4b7..3b5ec83 100644 --- a/backend/include/csp/services/redeem_service.h +++ b/backend/include/csp/services/redeem_service.h @@ -49,11 +49,20 @@ struct RedeemRecord { std::string username; }; +struct RedeemDayTypeDecision { + std::string day_type; // holiday / studyday + bool is_holiday = false; + std::string reason; + std::string source; + std::string date_ymd; + int64_t checked_at = 0; +}; + struct RedeemRequest { int64_t user_id = 0; int64_t item_id = 0; int quantity = 1; - std::string day_type = "studyday"; + std::string day_type = "studyday"; // kept for compatibility, ignored now. std::string note; }; @@ -70,6 +79,7 @@ class RedeemService { std::vector ListRecordsByUser(int64_t user_id, int limit); std::vector ListRecordsAll(std::optional user_id, int limit); + RedeemDayTypeDecision ResolveCurrentDayType(); RedeemRecord Redeem(const RedeemRequest& request); private: diff --git a/backend/include/csp/services/season_service.h b/backend/include/csp/services/season_service.h new file mode 100644 index 0000000..965ebdc --- /dev/null +++ b/backend/include/csp/services/season_service.h @@ -0,0 +1,102 @@ +#pragma once + +#include "csp/db/sqlite_db.h" +#include "csp/domain/entities.h" + +#include +#include +#include +#include + +namespace csp::services { + +struct SeasonWrite { + std::string key; + std::string title; + int64_t starts_at = 0; + int64_t ends_at = 0; + std::string status = "draft"; + std::string pass_json = "{}"; +}; + +struct SeasonPatch { + std::optional key; + std::optional title; + std::optional starts_at; + std::optional ends_at; + std::optional status; + std::optional pass_json; +}; + +struct SeasonRewardTrackWrite { + int tier_no = 0; + int required_xp = 0; + std::string reward_type = "free"; + int reward_value = 0; + std::string reward_meta_json = "{}"; +}; + +struct SeasonClaimResult { + bool claimed = false; + domain::SeasonRewardTrack track; + std::optional claim; + domain::SeasonUserProgress progress; + int rating_after = 0; +}; + +struct ContestModifierWrite { + std::string code; + std::string title; + std::string description; + std::string rule_json = "{}"; + bool is_active = true; +}; + +struct ContestModifierPatch { + std::optional code; + std::optional title; + std::optional description; + std::optional rule_json; + std::optional is_active; +}; + +class SeasonService { + public: + explicit SeasonService(db::SqliteDb& db) : db_(db) {} + + std::optional GetCurrentSeason(); + std::optional GetSeasonById(int64_t season_id); + std::vector ListRewardTracks(int64_t season_id); + std::vector ListUserClaims(int64_t season_id, + int64_t user_id); + domain::SeasonUserProgress GetOrSyncUserProgress(int64_t season_id, + int64_t user_id); + SeasonClaimResult ClaimReward(int64_t season_id, + int64_t user_id, + int tier_no, + const std::string& reward_type); + + std::vector ListLootDropsByUser(int64_t user_id, int limit); + + domain::Season CreateSeason(const SeasonWrite& input, + const std::vector& tracks); + domain::Season UpdateSeason( + int64_t season_id, + const SeasonPatch& patch, + const std::optional>& replace_tracks); + + std::vector ListContestModifiers( + int64_t contest_id, + bool include_inactive); + domain::ContestModifier CreateContestModifier(int64_t contest_id, + const ContestModifierWrite& input); + domain::ContestModifier UpdateContestModifier( + int64_t contest_id, + int64_t modifier_id, + const ContestModifierPatch& patch); + + private: + db::SqliteDb& db_; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/source_crystal_service.h b/backend/include/csp/services/source_crystal_service.h new file mode 100644 index 0000000..24d170a --- /dev/null +++ b/backend/include/csp/services/source_crystal_service.h @@ -0,0 +1,56 @@ +#pragma once + +#include "csp/db/sqlite_db.h" + +#include +#include +#include + +namespace csp::services { + +struct SourceCrystalSettings { + double monthly_interest_rate = 0.02; + int64_t updated_at = 0; +}; + +struct SourceCrystalAccountSummary { + int64_t user_id = 0; + double balance = 0.0; + double monthly_interest_rate = 0.02; + int64_t last_interest_at = 0; + int64_t updated_at = 0; +}; + +struct SourceCrystalTransaction { + int64_t id = 0; + int64_t user_id = 0; + std::string tx_type; // deposit / withdraw / interest + double amount = 0.0; + double balance_after = 0.0; + std::string note; + int64_t created_at = 0; +}; + +class SourceCrystalService { + public: + explicit SourceCrystalService(db::SqliteDb& db) : db_(db) {} + + SourceCrystalSettings GetSettings(); + SourceCrystalSettings UpdateMonthlyInterestRate(double monthly_interest_rate); + + SourceCrystalAccountSummary GetSummary(int64_t user_id); + std::vector ListTransactions(int64_t user_id, + int limit); + + SourceCrystalTransaction Deposit(int64_t user_id, + double amount, + const std::string& note); + SourceCrystalTransaction Withdraw(int64_t user_id, + double amount, + const std::string& note); + + private: + db::SqliteDb& db_; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/submission_service.h b/backend/include/csp/services/submission_service.h index 95a7526..be0e103 100644 --- a/backend/include/csp/services/submission_service.h +++ b/backend/include/csp/services/submission_service.h @@ -26,6 +26,11 @@ struct RunOnlyResult { std::string compile_log; }; +struct SubmissionSiblingIds { + std::optional prev_id; + std::optional next_id; +}; + class SubmissionService { public: explicit SubmissionService(db::SqliteDb& db) : db_(db) {} @@ -35,9 +40,12 @@ class SubmissionService { std::vector List(std::optional user_id, std::optional problem_id, std::optional contest_id, + std::optional created_from, + std::optional created_to, int page, int page_size); std::optional GetById(int64_t id); + SubmissionSiblingIds GetSiblingIds(int64_t user_id, int64_t submission_id); RunOnlyResult RunOnlyCpp(const std::string& code, const std::string& input); diff --git a/backend/include/csp/services/user_service.h b/backend/include/csp/services/user_service.h index 35c4959..86a861f 100644 --- a/backend/include/csp/services/user_service.h +++ b/backend/include/csp/services/user_service.h @@ -5,6 +5,7 @@ #include #include +#include #include namespace csp::services { @@ -19,7 +20,8 @@ class UserService { explicit UserService(db::SqliteDb& db) : db_(db) {} std::optional GetById(int64_t id); - std::vector GlobalLeaderboard(int limit = 100); + std::vector GlobalLeaderboard(int limit = 100, + const std::string& scope = "all"); UserListResult ListUsers(int page, int page_size); void SetRating(int64_t user_id, int rating); void DeleteUser(int64_t user_id); diff --git a/backend/include/csp/services/wrong_book_service.h b/backend/include/csp/services/wrong_book_service.h index 7a33d30..d8050a9 100644 --- a/backend/include/csp/services/wrong_book_service.h +++ b/backend/include/csp/services/wrong_book_service.h @@ -20,6 +20,7 @@ class WrongBookService { std::vector ListByUser(int64_t user_id); void UpsertNote(int64_t user_id, int64_t problem_id, const std::string& note); + std::string GetNote(int64_t user_id, int64_t problem_id); std::string GetNoteImagesJson(int64_t user_id, int64_t problem_id); void SetNoteImagesJson(int64_t user_id, int64_t problem_id, const std::string& note_images_json); void UpsertNoteScore(int64_t user_id, diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql index 2aeb2df..af35356 100644 --- a/backend/migrations/001_init.sql +++ b/backend/migrations/001_init.sql @@ -18,6 +18,25 @@ CREATE TABLE IF NOT EXISTS sessions ( FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS user_experience ( + user_id INTEGER PRIMARY KEY, + xp INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS user_experience_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + xp_delta INTEGER NOT NULL, + rating_before INTEGER NOT NULL DEFAULT 0, + rating_after INTEGER NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT 'users.rating', + note TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS problems ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL UNIQUE, @@ -96,6 +115,81 @@ CREATE TABLE IF NOT EXISTS contest_registrations ( FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS contest_modifiers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contest_id INTEGER NOT NULL, + code TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + rule_json TEXT NOT NULL DEFAULT '{}', + is_active INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY(contest_id) REFERENCES contests(id) ON DELETE CASCADE, + UNIQUE(contest_id, code) +); + +CREATE TABLE IF NOT EXISTS seasons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + starts_at INTEGER NOT NULL, + ends_at INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'draft', + pass_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS season_reward_tracks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + season_id INTEGER NOT NULL, + tier_no INTEGER NOT NULL, + required_xp INTEGER NOT NULL DEFAULT 0, + reward_type TEXT NOT NULL DEFAULT 'free', + reward_value INTEGER NOT NULL DEFAULT 0, + reward_meta_json TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY(season_id) REFERENCES seasons(id) ON DELETE CASCADE, + UNIQUE(season_id, tier_no, reward_type) +); + +CREATE TABLE IF NOT EXISTS season_user_progress ( + season_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + xp INTEGER NOT NULL DEFAULT 0, + level INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + PRIMARY KEY(season_id, user_id), + FOREIGN KEY(season_id) REFERENCES seasons(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS season_reward_claims ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + season_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + tier_no INTEGER NOT NULL, + reward_type TEXT NOT NULL DEFAULT 'free', + claimed_at INTEGER NOT NULL, + FOREIGN KEY(season_id) REFERENCES seasons(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(season_id, user_id, tier_no, reward_type) +); + +CREATE TABLE IF NOT EXISTS loot_drop_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + source_type TEXT NOT NULL, + source_id INTEGER NOT NULL DEFAULT 0, + item_code TEXT NOT NULL, + item_name TEXT NOT NULL DEFAULT '', + rarity TEXT NOT NULL DEFAULT 'common', + amount INTEGER NOT NULL DEFAULT 0, + meta_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS kb_articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL UNIQUE, @@ -112,6 +206,49 @@ CREATE TABLE IF NOT EXISTS kb_article_links ( FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS kb_knowledge_claims ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + article_id INTEGER NOT NULL, + knowledge_key TEXT NOT NULL, + reward INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(article_id) REFERENCES kb_articles(id) ON DELETE CASCADE, + UNIQUE(user_id, article_id, knowledge_key) +); + +CREATE TABLE IF NOT EXISTS kb_weekly_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + week_key TEXT NOT NULL, + article_id INTEGER NOT NULL, + article_slug TEXT NOT NULL, + article_title TEXT NOT NULL, + knowledge_key TEXT NOT NULL, + knowledge_title TEXT NOT NULL, + knowledge_description TEXT NOT NULL DEFAULT '', + difficulty TEXT NOT NULL DEFAULT 'bronze', + reward INTEGER NOT NULL DEFAULT 1, + prerequisites TEXT NOT NULL DEFAULT '', + order_no INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + completed_at INTEGER, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(article_id) REFERENCES kb_articles(id) ON DELETE CASCADE, + UNIQUE(user_id, week_key, article_id, knowledge_key) +); + +CREATE TABLE IF NOT EXISTS kb_weekly_bonus_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + week_key TEXT NOT NULL, + reward INTEGER NOT NULL DEFAULT 100, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id, week_key) +); + CREATE TABLE IF NOT EXISTS import_jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, status TEXT NOT NULL, @@ -145,6 +282,34 @@ CREATE TABLE IF NOT EXISTS import_job_items ( UNIQUE(job_id, source_path) ); +CREATE TABLE IF NOT EXISTS crawler_targets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL, + normalized_url TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'queued', + submit_source TEXT NOT NULL DEFAULT 'manual', + submitter_id TEXT NOT NULL DEFAULT '', + submitter_name TEXT NOT NULL DEFAULT '', + rule_json TEXT NOT NULL DEFAULT '{}', + script_path TEXT NOT NULL DEFAULT '', + last_error TEXT NOT NULL DEFAULT '', + last_test_at INTEGER, + last_run_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS crawler_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_id INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + http_status INTEGER NOT NULL DEFAULT 0, + output_json TEXT NOT NULL DEFAULT '{}', + error_text TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + FOREIGN KEY(target_id) REFERENCES crawler_targets(id) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS problem_drafts ( user_id INTEGER NOT NULL, problem_id INTEGER NOT NULL, @@ -189,14 +354,96 @@ CREATE TABLE IF NOT EXISTS problem_solutions ( FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS source_crystal_settings ( + id INTEGER PRIMARY KEY CHECK(id = 1), + monthly_interest_rate REAL NOT NULL DEFAULT 0.02, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS source_crystal_accounts ( + user_id INTEGER PRIMARY KEY, + balance REAL NOT NULL DEFAULT 0, + last_interest_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS source_crystal_transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + tx_type TEXT NOT NULL, + amount REAL NOT NULL, + balance_after REAL NOT NULL, + note TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +INSERT OR IGNORE INTO source_crystal_settings(id, monthly_interest_rate, updated_at) +VALUES(1, 0.02, strftime('%s','now')); + +INSERT OR IGNORE INTO user_experience(user_id, xp, updated_at) +SELECT id, CASE WHEN rating > 0 THEN rating ELSE 0 END, strftime('%s','now') +FROM users; + +CREATE TRIGGER IF NOT EXISTS trg_users_init_experience +AFTER INSERT ON users +BEGIN + INSERT OR IGNORE INTO user_experience(user_id, xp, updated_at) + VALUES(NEW.id, CASE WHEN NEW.rating > 0 THEN NEW.rating ELSE 0 END, strftime('%s','now')); + INSERT INTO user_experience_logs(user_id, xp_delta, rating_before, rating_after, source, note, created_at) + SELECT NEW.id, + NEW.rating, + 0, + NEW.rating, + 'users.insert', + 'initial rating gain', + strftime('%s','now') + WHERE NEW.rating > 0; +END; + +CREATE TRIGGER IF NOT EXISTS trg_users_rating_gain_to_experience +AFTER UPDATE OF rating ON users +WHEN NEW.rating > OLD.rating +BEGIN + INSERT OR IGNORE INTO user_experience(user_id, xp, updated_at) + VALUES(NEW.id, 0, strftime('%s','now')); + UPDATE user_experience + SET xp = xp + (NEW.rating - OLD.rating), + updated_at = strftime('%s','now') + WHERE user_id = NEW.id; + INSERT INTO user_experience_logs(user_id, xp_delta, rating_before, rating_after, source, note, created_at) + VALUES(NEW.id, + (NEW.rating - OLD.rating), + OLD.rating, + NEW.rating, + 'users.rating', + 'rating gain', + strftime('%s','now')); +END; + CREATE INDEX IF NOT EXISTS idx_submissions_user_created_at ON submissions(user_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_submissions_problem_created_at ON submissions(problem_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_submissions_contest_user_created_at ON submissions(contest_id, user_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_problem_tags_tag ON problem_tags(tag); +CREATE INDEX IF NOT EXISTS idx_contest_modifiers_contest_active ON contest_modifiers(contest_id, is_active, id); +CREATE INDEX IF NOT EXISTS idx_seasons_status_range ON seasons(status, starts_at, ends_at); +CREATE INDEX IF NOT EXISTS idx_season_reward_tracks_season_tier ON season_reward_tracks(season_id, tier_no, required_xp); +CREATE INDEX IF NOT EXISTS idx_season_user_progress_user ON season_user_progress(user_id, season_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_season_reward_claims_user ON season_reward_claims(user_id, season_id, claimed_at DESC); +CREATE INDEX IF NOT EXISTS idx_loot_drop_logs_user_created ON loot_drop_logs(user_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_kb_article_links_problem_id ON kb_article_links(problem_id); +CREATE INDEX IF NOT EXISTS idx_kb_knowledge_claims_user_article ON kb_knowledge_claims(user_id, article_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_kb_weekly_tasks_user_week_order ON kb_weekly_tasks(user_id, week_key, order_no, id); +CREATE INDEX IF NOT EXISTS idx_kb_weekly_tasks_user_week_completed ON kb_weekly_tasks(user_id, week_key, completed_at); +CREATE INDEX IF NOT EXISTS idx_kb_weekly_bonus_logs_user_week ON kb_weekly_bonus_logs(user_id, week_key); CREATE INDEX IF NOT EXISTS idx_import_jobs_created_at ON import_jobs(created_at DESC); CREATE INDEX IF NOT EXISTS idx_import_jobs_status ON import_jobs(status, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_import_job_items_job_status ON import_job_items(job_id, status, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_crawler_targets_status_updated ON crawler_targets(status, updated_at ASC); +CREATE INDEX IF NOT EXISTS idx_crawler_runs_target_created ON crawler_runs(target_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_problem_drafts_updated ON problem_drafts(updated_at DESC); CREATE INDEX IF NOT EXISTS idx_problem_solution_jobs_problem ON problem_solution_jobs(problem_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_problem_solutions_problem ON problem_solutions(problem_id, variant, id); +CREATE INDEX IF NOT EXISTS idx_user_experience_logs_user_created ON user_experience_logs(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_source_crystal_tx_user_created ON source_crystal_transactions(user_id, created_at DESC); diff --git a/backend/src/controllers/admin_controller.cc b/backend/src/controllers/admin_controller.cc index 8315eb5..e80169a 100644 --- a/backend/src/controllers/admin_controller.cc +++ b/backend/src/controllers/admin_controller.cc @@ -1,8 +1,11 @@ #include "csp/controllers/admin_controller.h" #include "csp/app_state.h" +#include "csp/domain/json.h" #include "csp/services/redeem_service.h" +#include "csp/services/season_service.h" #include "csp/services/solution_access_service.h" +#include "csp/services/source_crystal_service.h" #include "csp/services/user_service.h" #include "http_auth.h" @@ -12,6 +15,7 @@ #include #include #include +#include namespace csp::controllers { @@ -74,6 +78,103 @@ services::RedeemItemWrite ParseRedeemItemWrite(const Json::Value& json) { return write; } +std::string JsonToRawString(const Json::Value& value) { + Json::StreamWriterBuilder wb; + wb["indentation"] = ""; + return Json::writeString(wb, value); +} + +std::optional ParseOptionalBool(const Json::Value& json, + const char* key) { + if (!json.isMember(key)) return std::nullopt; + return json[key].asBool(); +} + +std::optional ParseOptionalString(const Json::Value& json, + const char* key) { + if (!json.isMember(key)) return std::nullopt; + return json[key].asString(); +} + +std::optional ParseOptionalInt64Field(const Json::Value& json, + const char* key) { + if (!json.isMember(key)) return std::nullopt; + return json[key].asInt64(); +} + +std::string ParseJsonOrTextField(const Json::Value& json, + const char* key, + const std::string& default_value) { + if (!json.isMember(key)) return default_value; + const auto& v = json[key]; + if (v.isObject() || v.isArray()) return JsonToRawString(v); + if (v.isString()) return v.asString(); + return default_value; +} + +services::SeasonWrite ParseSeasonWrite(const Json::Value& json) { + services::SeasonWrite write; + write.key = json.get("key", "").asString(); + write.title = json.get("title", "").asString(); + write.starts_at = json.get("starts_at", 0).asInt64(); + write.ends_at = json.get("ends_at", 0).asInt64(); + write.status = json.get("status", "draft").asString(); + write.pass_json = ParseJsonOrTextField(json, "pass_json", "{}"); + return write; +} + +services::SeasonPatch ParseSeasonPatch(const Json::Value& json) { + services::SeasonPatch patch; + patch.key = ParseOptionalString(json, "key"); + patch.title = ParseOptionalString(json, "title"); + patch.starts_at = ParseOptionalInt64Field(json, "starts_at"); + patch.ends_at = ParseOptionalInt64Field(json, "ends_at"); + patch.status = ParseOptionalString(json, "status"); + if (json.isMember("pass_json")) { + patch.pass_json = ParseJsonOrTextField(json, "pass_json", "{}"); + } + return patch; +} + +std::vector ParseSeasonTracks( + const Json::Value& json) { + std::vector tracks; + if (!json.isArray()) return tracks; + tracks.reserve(json.size()); + for (const auto& item : json) { + services::SeasonRewardTrackWrite t; + t.tier_no = item.get("tier_no", 0).asInt(); + t.required_xp = item.get("required_xp", 0).asInt(); + t.reward_type = item.get("reward_type", "free").asString(); + t.reward_value = item.get("reward_value", 0).asInt(); + t.reward_meta_json = ParseJsonOrTextField(item, "reward_meta_json", "{}"); + tracks.push_back(std::move(t)); + } + return tracks; +} + +services::ContestModifierWrite ParseContestModifierWrite(const Json::Value& json) { + services::ContestModifierWrite write; + write.code = json.get("code", "").asString(); + write.title = json.get("title", "").asString(); + write.description = json.get("description", "").asString(); + write.rule_json = ParseJsonOrTextField(json, "rule_json", "{}"); + write.is_active = json.get("is_active", true).asBool(); + return write; +} + +services::ContestModifierPatch ParseContestModifierPatch(const Json::Value& json) { + services::ContestModifierPatch patch; + patch.code = ParseOptionalString(json, "code"); + patch.title = ParseOptionalString(json, "title"); + patch.description = ParseOptionalString(json, "description"); + if (json.isMember("rule_json")) { + patch.rule_json = ParseJsonOrTextField(json, "rule_json", "{}"); + } + patch.is_active = ParseOptionalBool(json, "is_active"); + return patch; +} + Json::Value ToJson(const services::RedeemItem& item) { Json::Value j; j["id"] = Json::Int64(item.id); @@ -197,6 +298,98 @@ void AdminController::updateUserRating( } } +void AdminController::getUserSourceCrystalSummary( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t user_id) { + try { + if (!RequireAdminUserId(req, cb).has_value()) return; + + services::SourceCrystalService crystal(csp::AppState::Instance().db()); + const auto summary = crystal.GetSummary(user_id); + + Json::Value data; + data["user_id"] = Json::Int64(summary.user_id); + data["balance"] = summary.balance; + data["monthly_interest_rate"] = summary.monthly_interest_rate; + data["last_interest_at"] = Json::Int64(summary.last_interest_at); + data["updated_at"] = Json::Int64(summary.updated_at); + cb(JsonOk(data)); + } 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::listUserSourceCrystalRecords( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t user_id) { + try { + if (!RequireAdminUserId(req, cb).has_value()) return; + + const int limit = ParseClampedInt(req->getParameter("limit"), 100, 1, 500); + services::SourceCrystalService crystal(csp::AppState::Instance().db()); + const auto rows = crystal.ListTransactions(user_id, limit); + + Json::Value arr(Json::arrayValue); + for (const auto& row : rows) { + Json::Value one; + one["id"] = Json::Int64(row.id); + one["user_id"] = Json::Int64(row.user_id); + one["tx_type"] = row.tx_type; + one["amount"] = row.amount; + one["balance_after"] = row.balance_after; + one["note"] = row.note; + one["created_at"] = Json::Int64(row.created_at); + arr.append(one); + } + cb(JsonOk(arr)); + } 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::depositUserSourceCrystal( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t user_id) { + try { + if (!RequireAdminUserId(req, cb).has_value()) return; + + const auto body = req->getJsonObject(); + if (!body) { + cb(JsonError(drogon::k400BadRequest, "body must be json")); + return; + } + + const double amount = (*body).get("amount", 0.0).asDouble(); + const std::string note = (*body).get("note", "").asString(); + + services::SourceCrystalService crystal(csp::AppState::Instance().db()); + const auto tx = crystal.Deposit(user_id, amount, note); + const auto summary = crystal.GetSummary(user_id); + + Json::Value data; + data["id"] = Json::Int64(tx.id); + data["user_id"] = Json::Int64(tx.user_id); + data["tx_type"] = tx.tx_type; + data["amount"] = tx.amount; + data["balance_after"] = tx.balance_after; + data["note"] = tx.note; + data["created_at"] = Json::Int64(tx.created_at); + data["balance"] = summary.balance; + cb(JsonOk(data)); + } 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::deleteUser( const drogon::HttpRequestPtr& req, std::function&& cb, @@ -358,6 +551,192 @@ void AdminController::listRedeemRecords( } } +void AdminController::getSourceCrystalSettings( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + if (!RequireAdminUserId(req, cb).has_value()) return; + + services::SourceCrystalService crystal(csp::AppState::Instance().db()); + const auto settings = crystal.GetSettings(); + Json::Value data; + data["monthly_interest_rate"] = settings.monthly_interest_rate; + data["updated_at"] = Json::Int64(settings.updated_at); + cb(JsonOk(data)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void AdminController::updateSourceCrystalSettings( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + if (!RequireAdminUserId(req, cb).has_value()) return; + const auto body = req->getJsonObject(); + if (!body) { + cb(JsonError(drogon::k400BadRequest, "body must be json")); + return; + } + if (!(*body).isMember("monthly_interest_rate")) { + cb(JsonError(drogon::k400BadRequest, "monthly_interest_rate is required")); + return; + } + const double rate = (*body)["monthly_interest_rate"].asDouble(); + + services::SourceCrystalService crystal(csp::AppState::Instance().db()); + const auto settings = crystal.UpdateMonthlyInterestRate(rate); + + Json::Value data; + data["monthly_interest_rate"] = settings.monthly_interest_rate; + data["updated_at"] = Json::Int64(settings.updated_at); + cb(JsonOk(data)); + } 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::createSeason( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + if (!RequireAdminUserId(req, cb).has_value()) return; + + const auto body = req->getJsonObject(); + if (!body) { + cb(JsonError(drogon::k400BadRequest, "body must be json")); + return; + } + + const auto season_input = ParseSeasonWrite(*body); + std::vector tracks; + if ((*body).isMember("reward_tracks")) { + tracks = ParseSeasonTracks((*body)["reward_tracks"]); + } + + services::SeasonService seasons(csp::AppState::Instance().db()); + const auto created = seasons.CreateSeason(season_input, tracks); + + Json::Value payload; + payload["season"] = domain::ToJson(created); + Json::Value tracks_json(Json::arrayValue); + for (const auto& t : seasons.ListRewardTracks(created.id)) { + tracks_json.append(domain::ToJson(t)); + } + payload["reward_tracks"] = tracks_json; + 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::updateSeason( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t season_id) { + try { + if (!RequireAdminUserId(req, cb).has_value()) return; + + const auto body = req->getJsonObject(); + if (!body) { + cb(JsonError(drogon::k400BadRequest, "body must be json")); + return; + } + + const auto patch = ParseSeasonPatch(*body); + std::optional> tracks = + std::nullopt; + if ((*body).isMember("reward_tracks")) { + tracks = ParseSeasonTracks((*body)["reward_tracks"]); + } + + services::SeasonService seasons(csp::AppState::Instance().db()); + const auto updated = seasons.UpdateSeason(season_id, patch, tracks); + + Json::Value payload; + payload["season"] = domain::ToJson(updated); + Json::Value tracks_json(Json::arrayValue); + for (const auto& t : seasons.ListRewardTracks(updated.id)) { + tracks_json.append(domain::ToJson(t)); + } + payload["reward_tracks"] = tracks_json; + cb(JsonOk(payload)); + } catch (const std::runtime_error& e) { + const std::string msg = e.what(); + if (msg.find("not found") != std::string::npos) { + cb(JsonError(drogon::k404NotFound, msg)); + return; + } + cb(JsonError(drogon::k400BadRequest, msg)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void AdminController::createContestModifier( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t contest_id) { + try { + if (!RequireAdminUserId(req, cb).has_value()) return; + + const auto body = req->getJsonObject(); + if (!body) { + cb(JsonError(drogon::k400BadRequest, "body must be json")); + return; + } + + const auto input = ParseContestModifierWrite(*body); + services::SeasonService seasons(csp::AppState::Instance().db()); + const auto created = seasons.CreateContestModifier(contest_id, input); + cb(JsonOk(domain::ToJson(created))); + } catch (const std::runtime_error& e) { + const std::string msg = e.what(); + if (msg.find("not found") != std::string::npos) { + cb(JsonError(drogon::k404NotFound, msg)); + return; + } + cb(JsonError(drogon::k400BadRequest, msg)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void AdminController::updateContestModifier( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t contest_id, + int64_t modifier_id) { + try { + if (!RequireAdminUserId(req, cb).has_value()) return; + + const auto body = req->getJsonObject(); + if (!body) { + cb(JsonError(drogon::k400BadRequest, "body must be json")); + return; + } + + const auto patch = ParseContestModifierPatch(*body); + services::SeasonService seasons(csp::AppState::Instance().db()); + const auto updated = + seasons.UpdateContestModifier(contest_id, modifier_id, patch); + cb(JsonOk(domain::ToJson(updated))); + } catch (const std::runtime_error& e) { + const std::string msg = e.what(); + if (msg.find("not found") != std::string::npos) { + cb(JsonError(drogon::k404NotFound, msg)); + return; + } + cb(JsonError(drogon::k400BadRequest, msg)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + void AdminController::userRatingHistory( const drogon::HttpRequestPtr& req, std::function&& cb, diff --git a/backend/src/controllers/contest_controller.cc b/backend/src/controllers/contest_controller.cc index 5e7a513..a7b22a0 100644 --- a/backend/src/controllers/contest_controller.cc +++ b/backend/src/controllers/contest_controller.cc @@ -3,6 +3,7 @@ #include "csp/app_state.h" #include "csp/domain/json.h" #include "csp/services/contest_service.h" +#include "csp/services/season_service.h" #include "http_auth.h" #include @@ -130,4 +131,28 @@ void ContestController::leaderboard( } } +void ContestController::modifiers( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t contest_id) { + try { + services::ContestService contests(csp::AppState::Instance().db()); + if (!contests.GetContest(contest_id).has_value()) { + cb(JsonError(drogon::k404NotFound, "contest not found")); + return; + } + + const bool include_inactive = + req->getParameter("include_inactive") == "1" || + req->getParameter("include_inactive") == "true"; + services::SeasonService seasons(csp::AppState::Instance().db()); + const auto rows = seasons.ListContestModifiers(contest_id, include_inactive); + Json::Value arr(Json::arrayValue); + for (const auto& row : rows) arr.append(domain::ToJson(row)); + cb(JsonOk(arr)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + } // namespace csp::controllers diff --git a/backend/src/controllers/crawler_controller.cc b/backend/src/controllers/crawler_controller.cc new file mode 100644 index 0000000..170dafd --- /dev/null +++ b/backend/src/controllers/crawler_controller.cc @@ -0,0 +1,234 @@ +#include "csp/controllers/crawler_controller.h" + +#include "csp/app_state.h" +#include "csp/services/crawler_runner.h" +#include "csp/services/crawler_service.h" +#include "csp/services/user_service.h" +#include "http_auth.h" + +#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 ParsePositiveInt(const std::string& s, + int default_value, + int min_value, + int max_value) { + if (s.empty()) return default_value; + const int parsed = std::stoi(s); + return std::max(min_value, std::min(max_value, parsed)); +} + +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::CrawlerTarget& t) { + Json::Value j; + j["id"] = Json::Int64(t.id); + j["url"] = t.url; + j["normalized_url"] = t.normalized_url; + j["status"] = t.status; + j["submit_source"] = t.submit_source; + j["submitter_id"] = t.submitter_id; + j["submitter_name"] = t.submitter_name; + j["rule_json"] = t.rule_json; + j["script_path"] = t.script_path; + j["last_error"] = t.last_error; + j["last_test_at"] = + t.last_test_at.has_value() ? Json::Value(Json::Int64(*t.last_test_at)) + : Json::Value(Json::nullValue); + j["last_run_at"] = + t.last_run_at.has_value() ? Json::Value(Json::Int64(*t.last_run_at)) + : Json::Value(Json::nullValue); + j["created_at"] = Json::Int64(t.created_at); + j["updated_at"] = Json::Int64(t.updated_at); + return j; +} + +Json::Value ToJson(const services::CrawlerRun& r) { + Json::Value j; + j["id"] = Json::Int64(r.id); + j["target_id"] = Json::Int64(r.target_id); + j["status"] = r.status; + j["http_status"] = r.http_status; + j["output_json"] = r.output_json; + j["error_text"] = r.error_text; + j["created_at"] = Json::Int64(r.created_at); + return j; +} + +Json::Value ToJson(const services::CrawlerRunner::Status& s) { + Json::Value j; + j["enabled"] = s.enabled; + j["started"] = s.started; + j["interval_sec"] = s.interval_sec; + j["active_requeue_interval_sec"] = s.active_requeue_interval_sec; + j["running"] = s.running; + j["processed_count"] = Json::Int64(s.processed_count); + j["success_count"] = Json::Int64(s.success_count); + j["failed_count"] = Json::Int64(s.failed_count); + j["last_started_at"] = Json::Int64(s.last_started_at); + j["last_finished_at"] = Json::Int64(s.last_finished_at); + j["last_success_at"] = Json::Int64(s.last_success_at); + j["last_failure_at"] = Json::Int64(s.last_failure_at); + j["last_error"] = s.last_error; + j["current_target_id"] = Json::Int64(s.current_target_id); + return j; +} + +} // namespace + +void CrawlerController::listTargets( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + if (!RequireAdminUserId(req, cb).has_value()) return; + + const std::string status = req->getParameter("status"); + const int limit = ParsePositiveInt(req->getParameter("limit"), 50, 1, 500); + + services::CrawlerService crawler(csp::AppState::Instance().db()); + const auto rows = crawler.ListTargets(status, limit); + Json::Value data; + Json::Value items(Json::arrayValue); + for (const auto& row : rows) items.append(ToJson(row)); + data["items"] = items; + data["count"] = static_cast(rows.size()); + cb(JsonOk(data)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void CrawlerController::createTarget( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + const auto user_id = RequireAdminUserId(req, cb); + if (!user_id.has_value()) return; + const auto body = req->getJsonObject(); + if (!body || !body->isMember("url") || !(*body)["url"].isString()) { + cb(JsonError(drogon::k400BadRequest, "url is required")); + return; + } + + services::UserService users(csp::AppState::Instance().db()); + const auto me = users.GetById(*user_id); + const std::string submitter_name = me.has_value() ? me->username : "admin"; + + services::CrawlerService crawler(csp::AppState::Instance().db()); + const auto result = crawler.UpsertTarget((*body)["url"].asString(), + "manual", + std::to_string(*user_id), + submitter_name); + services::CrawlerRunner::Instance().WakeUp(); + + Json::Value data; + data["inserted"] = result.inserted; + data["item"] = ToJson(result.target); + cb(JsonOk(data)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k400BadRequest, e.what())); + } +} + +void CrawlerController::queueTarget( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t target_id) { + try { + if (!RequireAdminUserId(req, cb).has_value()) return; + services::CrawlerService crawler(csp::AppState::Instance().db()); + if (!crawler.EnqueueTarget(target_id)) { + cb(JsonError(drogon::k404NotFound, "crawler target not found")); + return; + } + services::CrawlerRunner::Instance().WakeUp(); + auto target = crawler.GetTargetById(target_id); + Json::Value data; + data["item"] = target.has_value() ? ToJson(*target) : Json::Value(Json::nullValue); + cb(JsonOk(data)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void CrawlerController::listRuns( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t target_id) { + try { + if (!RequireAdminUserId(req, cb).has_value()) return; + + services::CrawlerService crawler(csp::AppState::Instance().db()); + const auto target = crawler.GetTargetById(target_id); + if (!target.has_value()) { + cb(JsonError(drogon::k404NotFound, "crawler target not found")); + return; + } + const int limit = ParsePositiveInt(req->getParameter("limit"), 20, 1, 200); + const auto runs = crawler.ListRuns(target_id, limit); + Json::Value data; + data["item"] = ToJson(*target); + Json::Value arr(Json::arrayValue); + for (const auto& run : runs) arr.append(ToJson(run)); + data["runs"] = arr; + data["count"] = static_cast(runs.size()); + cb(JsonOk(data)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void CrawlerController::status( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + if (!RequireAdminUserId(req, cb).has_value()) return; + Json::Value data = ToJson(services::CrawlerRunner::Instance().GetStatus()); + cb(JsonOk(data)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +} // namespace csp::controllers diff --git a/backend/src/controllers/http_auth.h b/backend/src/controllers/http_auth.h index 8a06d32..c95bbae 100644 --- a/backend/src/controllers/http_auth.h +++ b/backend/src/controllers/http_auth.h @@ -5,33 +5,112 @@ #include +#include +#include #include #include namespace csp::controllers { +inline std::optional Base64Decode(const std::string& input) { + auto decode_char = [](char c) -> int { + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + if (c == '+') return 62; + if (c == '/') return 63; + if (c == '=') return -2; + return -1; + }; + + std::string clean; + clean.reserve(input.size()); + for (char c : input) { + if (!std::isspace(static_cast(c))) clean.push_back(c); + } + if (clean.empty() || clean.size() % 4 != 0) return std::nullopt; + + std::string out; + out.reserve((clean.size() / 4) * 3); + for (size_t i = 0; i < clean.size(); i += 4) { + const int a = decode_char(clean[i]); + const int b = decode_char(clean[i + 1]); + const int c = decode_char(clean[i + 2]); + const int d = decode_char(clean[i + 3]); + if (a < 0 || b < 0 || c == -1 || d == -1) return std::nullopt; + if (c == -2 && d != -2) return std::nullopt; + + const uint32_t bits = + (static_cast(a) << 18) | + (static_cast(b) << 12) | + (static_cast(c > 0 ? c : 0) << 6) | + static_cast(d > 0 ? d : 0); + + out.push_back(static_cast((bits >> 16) & 0xFF)); + if (c != -2) out.push_back(static_cast((bits >> 8) & 0xFF)); + if (d != -2) out.push_back(static_cast(bits & 0xFF)); + } + return out; +} + inline std::optional GetAuthedUserId(const drogon::HttpRequestPtr& req, std::string& error) { const std::string authz = req->getHeader("Authorization"); - const std::string prefix = "Bearer "; - if (authz.rfind(prefix, 0) != 0) { - error = "missing Authorization: Bearer "; - return std::nullopt; - } - - const std::string token = authz.substr(prefix.size()); - if (token.empty()) { - error = "empty bearer token"; + if (authz.empty()) { + error = + "missing Authorization header; use Bearer or Basic "; return std::nullopt; } services::AuthService auth(csp::AppState::Instance().db()); - const auto user_id = auth.VerifyToken(token); - if (!user_id.has_value()) { - error = "invalid or expired token"; - return std::nullopt; + const std::string bearer_prefix = "Bearer "; + if (authz.rfind(bearer_prefix, 0) == 0) { + const std::string token = authz.substr(bearer_prefix.size()); + if (token.empty()) { + error = "empty bearer token"; + return std::nullopt; + } + + const auto user_id = auth.VerifyToken(token); + if (!user_id.has_value()) { + error = "invalid or expired token"; + return std::nullopt; + } + return static_cast(*user_id); } - return static_cast(*user_id); + + const std::string basic_prefix = "Basic "; + if (authz.rfind(basic_prefix, 0) == 0) { + const std::string payload = authz.substr(basic_prefix.size()); + const auto decoded = Base64Decode(payload); + if (!decoded.has_value()) { + error = "invalid basic auth encoding"; + return std::nullopt; + } + + const auto sep = decoded->find(':'); + if (sep == std::string::npos) { + error = "invalid basic auth payload"; + return std::nullopt; + } + const std::string username = decoded->substr(0, sep); + const std::string password = decoded->substr(sep + 1); + if (username.empty() || password.empty()) { + error = "invalid basic auth payload"; + return std::nullopt; + } + + const auto user_id = auth.VerifyCredentials(username, password); + if (!user_id.has_value()) { + error = "invalid username or password"; + return std::nullopt; + } + return static_cast(*user_id); + } + + error = + "unsupported Authorization scheme; use Bearer or Basic "; + return std::nullopt; } } // namespace csp::controllers diff --git a/backend/src/controllers/kb_controller.cc b/backend/src/controllers/kb_controller.cc index f6c3996..95254a3 100644 --- a/backend/src/controllers/kb_controller.cc +++ b/backend/src/controllers/kb_controller.cc @@ -3,8 +3,10 @@ #include "csp/app_state.h" #include "csp/domain/json.h" #include "csp/services/kb_service.h" +#include "http_auth.h" #include +#include #include namespace csp::controllers { @@ -71,6 +73,20 @@ void KbController::getArticle( rel.append(item); } data["related_problems"] = rel; + Json::Value skills(Json::arrayValue); + for (const auto& point : detail->skill_points) { + Json::Value one; + one["key"] = point.key; + one["title"] = point.title; + one["description"] = point.description; + one["difficulty"] = point.difficulty; + one["reward"] = point.reward; + Json::Value prerequisites(Json::arrayValue); + for (const auto& pre : point.prerequisites) prerequisites.append(pre); + one["prerequisites"] = prerequisites; + skills.append(one); + } + data["skill_points"] = skills; cb(JsonOk(data)); } catch (const std::exception& e) { @@ -78,4 +94,168 @@ void KbController::getArticle( } } +void KbController::listClaims( + const drogon::HttpRequestPtr& req, + std::function&& cb, + std::string slug) { + try { + std::string auth_error; + const auto user_id = GetAuthedUserId(req, auth_error); + if (!user_id.has_value()) { + cb(JsonError(drogon::k401Unauthorized, auth_error)); + return; + } + + services::KbService svc(csp::AppState::Instance().db()); + const auto detail = svc.GetBySlug(slug); + if (!detail.has_value()) { + cb(JsonError(drogon::k404NotFound, "article not found")); + return; + } + const auto claims = svc.ListClaims(*user_id, detail->article.id); + + Json::Value data; + data["article_id"] = Json::Int64(detail->article.id); + data["slug"] = detail->article.slug; + data["total_reward"] = claims.total_reward; + data["total_count"] = claims.total_count; + Json::Value keys(Json::arrayValue); + for (const auto& key : claims.claimed_keys) keys.append(key); + data["claimed_keys"] = keys; + cb(JsonOk(data)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void KbController::claimSkillPoint( + const drogon::HttpRequestPtr& req, + std::function&& cb, + std::string slug) { + try { + std::string auth_error; + const auto user_id = GetAuthedUserId(req, auth_error); + if (!user_id.has_value()) { + cb(JsonError(drogon::k401Unauthorized, auth_error)); + return; + } + + const auto json = req->getJsonObject(); + if (!json) { + cb(JsonError(drogon::k400BadRequest, "body must be json")); + return; + } + const std::string skill_key = (*json).get("knowledge_key", "").asString(); + if (skill_key.empty()) { + cb(JsonError(drogon::k400BadRequest, "knowledge_key required")); + return; + } + + services::KbService svc(csp::AppState::Instance().db()); + const auto detail = svc.GetBySlug(slug); + if (!detail.has_value()) { + cb(JsonError(drogon::k404NotFound, "article not found")); + return; + } + const auto result = svc.ClaimSkillPoint(*user_id, detail->article.id, detail->article.slug, skill_key); + + Json::Value data; + data["article_id"] = Json::Int64(detail->article.id); + data["slug"] = detail->article.slug; + data["knowledge_key"] = skill_key; + data["claimed"] = result.claimed; + data["reward"] = result.reward; + data["rating_after"] = result.rating_after; + data["total_claimed"] = result.total_claimed; + cb(JsonOk(data)); + } catch (const std::runtime_error& e) { + cb(JsonError(drogon::k400BadRequest, e.what())); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void KbController::weeklyPlan( + 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; + } + + services::KbService svc(csp::AppState::Instance().db()); + const auto plan = svc.GetWeeklyPlan(*user_id); + + Json::Value tasks(Json::arrayValue); + for (const auto& t : plan.tasks) { + Json::Value one; + one["id"] = Json::Int64(t.id); + one["week_key"] = t.week_key; + one["article_id"] = Json::Int64(t.article_id); + one["article_slug"] = t.article_slug; + one["article_title"] = t.article_title; + one["knowledge_key"] = t.knowledge_key; + one["knowledge_title"] = t.knowledge_title; + one["knowledge_description"] = t.knowledge_description; + one["difficulty"] = t.difficulty; + one["reward"] = t.reward; + one["completed"] = t.completed; + if (t.completed) { + one["completed_at"] = Json::Int64(t.completed_at); + } else { + one["completed_at"] = Json::nullValue; + } + Json::Value prerequisites(Json::arrayValue); + for (const auto& pre : t.prerequisites) prerequisites.append(pre); + one["prerequisites"] = prerequisites; + tasks.append(one); + } + + Json::Value data; + data["week_key"] = plan.week_key; + data["tasks"] = tasks; + data["total_reward"] = plan.total_reward; + data["gained_reward"] = plan.gained_reward; + data["bonus_reward"] = plan.bonus_reward; + data["bonus_claimed"] = plan.bonus_claimed; + data["completion_percent"] = plan.completion_percent; + cb(JsonOk(data)); + } catch (const std::runtime_error& e) { + cb(JsonError(drogon::k400BadRequest, e.what())); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void KbController::claimWeeklyBonus( + 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; + } + + services::KbService svc(csp::AppState::Instance().db()); + const auto result = svc.ClaimWeeklyBonus(*user_id); + + Json::Value data; + data["claimed"] = result.claimed; + data["reward"] = result.reward; + data["rating_after"] = result.rating_after; + data["completion_percent"] = result.completion_percent; + data["week_key"] = result.week_key; + cb(JsonOk(data)); + } catch (const std::runtime_error& e) { + cb(JsonError(drogon::k400BadRequest, e.what())); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + } // namespace csp::controllers diff --git a/backend/src/controllers/lark_controller.cc b/backend/src/controllers/lark_controller.cc new file mode 100644 index 0000000..d560b1d --- /dev/null +++ b/backend/src/controllers/lark_controller.cc @@ -0,0 +1,264 @@ +#include "csp/controllers/lark_controller.h" + +#include "csp/app_state.h" +#include "csp/services/crawler_runner.h" +#include "csp/services/crawler_service.h" +#include "csp/services/lark_bot_service.h" + +#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) { + auto resp = drogon::HttpResponse::newHttpJsonResponse(data); + resp->setStatusCode(drogon::k200OK); + return resp; +} + +std::string Trim(const std::string& input) { + const auto begin = input.find_first_not_of(" \t\r\n"); + if (begin == std::string::npos) return {}; + const auto end = input.find_last_not_of(" \t\r\n"); + return input.substr(begin, end - begin + 1); +} + +std::string ReadEventToken(const Json::Value& root) { + if (root.isMember("token") && root["token"].isString()) { + return root["token"].asString(); + } + if (root.isMember("header") && root["header"].isObject() && + root["header"].isMember("token") && root["header"]["token"].isString()) { + return root["header"]["token"].asString(); + } + return {}; +} + +std::string ReadEventType(const Json::Value& root) { + if (root.isMember("header") && root["header"].isObject() && + root["header"].isMember("event_type") && + root["header"]["event_type"].isString()) { + return root["header"]["event_type"].asString(); + } + if (root.isMember("type") && root["type"].isString()) { + return root["type"].asString(); + } + return {}; +} + +std::string ReadEventId(const Json::Value& root) { + if (root.isMember("header") && root["header"].isObject() && + root["header"].isMember("event_id") && + root["header"]["event_id"].isString()) { + return root["header"]["event_id"].asString(); + } + return {}; +} + +std::string ReadSenderId(const Json::Value& event) { + if (!event.isMember("sender") || !event["sender"].isObject()) return {}; + const auto& sender = event["sender"]; + if (sender.isMember("sender_id") && sender["sender_id"].isObject()) { + const auto& sid = sender["sender_id"]; + if (sid.isMember("open_id") && sid["open_id"].isString()) { + return sid["open_id"].asString(); + } + if (sid.isMember("user_id") && sid["user_id"].isString()) { + return sid["user_id"].asString(); + } + if (sid.isMember("union_id") && sid["union_id"].isString()) { + return sid["union_id"].asString(); + } + } + return {}; +} + +std::string ReadSenderType(const Json::Value& event) { + if (!event.isMember("sender") || !event["sender"].isObject()) return {}; + const auto& sender = event["sender"]; + if (sender.isMember("sender_type") && sender["sender_type"].isString()) { + return sender["sender_type"].asString(); + } + return {}; +} + +std::string ReadSenderName(const Json::Value& event) { + if (!event.isMember("sender") || !event["sender"].isObject()) return {}; + const auto& sender = event["sender"]; + if (sender.isMember("sender_name") && sender["sender_name"].isString()) { + return sender["sender_name"].asString(); + } + return {}; +} + +} // namespace + +void LarkController::events( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + const auto json = req->getJsonObject(); + if (!json) { + cb(JsonError(drogon::k400BadRequest, "body must be json")); + return; + } + + auto& bot = services::LarkBotService::Instance(); + const std::string token = ReadEventToken(*json); + + // URL verification handshake. + const std::string challenge = json->get("challenge", "").asString(); + if (!challenge.empty()) { + if (!bot.VerifyToken(token)) { + cb(JsonError(drogon::k401Unauthorized, "invalid token")); + return; + } + Json::Value out; + out["challenge"] = challenge; + cb(JsonOk(out)); + return; + } + + if (!bot.Enabled()) { + Json::Value out; + out["code"] = 0; + out["msg"] = "ignored: lark bot disabled"; + cb(JsonOk(out)); + return; + } + + if (!bot.VerifyToken(token)) { + cb(JsonError(drogon::k401Unauthorized, "invalid token")); + return; + } + + const std::string event_type = ReadEventType(*json); + if (event_type != "im.message.receive_v1") { + Json::Value out; + out["code"] = 0; + out["msg"] = "ignored"; + cb(JsonOk(out)); + return; + } + + const auto& event = (*json)["event"]; + if (!event.isObject() || !event.isMember("message") || + !event["message"].isObject()) { + Json::Value out; + out["code"] = 0; + out["msg"] = "ignored: no message"; + cb(JsonOk(out)); + return; + } + + const auto& message = event["message"]; + if (message.get("message_type", "").asString() != "text") { + Json::Value out; + out["code"] = 0; + out["msg"] = "ignored: non-text"; + cb(JsonOk(out)); + return; + } + + Json::Value content_json; + const std::string content_raw = message.get("content", "").asString(); + if (!content_raw.empty()) { + Json::CharReaderBuilder rb; + std::string errs; + std::unique_ptr reader(rb.newCharReader()); + reader->parse(content_raw.data(), + content_raw.data() + content_raw.size(), + &content_json, + &errs); + } + + const std::string text = Trim(content_json.get("text", "").asString()); + if (text.empty()) { + Json::Value out; + out["code"] = 0; + out["msg"] = "ignored: empty text"; + cb(JsonOk(out)); + return; + } + + const std::string sender_type = ReadSenderType(event); + if (sender_type == "app" || sender_type == "bot") { + Json::Value out; + out["code"] = 0; + out["msg"] = "ignored: bot self message"; + cb(JsonOk(out)); + return; + } + + const std::string message_id = message.get("message_id", "").asString(); + const std::string sender_id = ReadSenderId(event); + const std::string sender_name = ReadSenderName(event); + const std::string chat_id = message.get("chat_id", "").asString(); + + const auto urls = services::CrawlerService::ExtractUrls(text); + if (!urls.empty()) { + services::CrawlerService crawlers(csp::AppState::Instance().db()); + int inserted = 0; + Json::Value targets(Json::arrayValue); + for (const auto& url : urls) { + auto result = crawlers.UpsertTarget(url, + "lark:" + chat_id, + sender_id, + sender_name); + if (result.inserted) inserted += 1; + Json::Value item; + item["id"] = Json::Int64(result.target.id); + item["url"] = result.target.normalized_url; + item["status"] = result.target.status; + item["inserted"] = result.inserted; + targets.append(item); + } + services::CrawlerRunner::Instance().WakeUp(); + + const std::string ack = + "已收录 " + std::to_string(static_cast(urls.size())) + + " 个地址到爬虫列表(新增 " + std::to_string(inserted) + + " 个)。系统将自动生成规则、自动测试并自动运行。"; + bot.ReplyTextAsync(message_id, ack); + + Json::Value out; + out["code"] = 0; + out["msg"] = "crawler targets queued"; + out["targets"] = targets; + cb(JsonOk(out)); + return; + } + + services::LarkBotService::IncomingTextEvent msg; + msg.event_id = ReadEventId(*json); + msg.message_id = message_id; + msg.chat_id = chat_id; + msg.sender_id = sender_id; + msg.text = text; + bot.HandleEventAsync(std::move(msg)); + + Json::Value out; + out["code"] = 0; + out["msg"] = "ok"; + cb(JsonOk(out)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +} // namespace csp::controllers diff --git a/backend/src/controllers/leaderboard_controller.cc b/backend/src/controllers/leaderboard_controller.cc index 97749ae..0c4b6d8 100644 --- a/backend/src/controllers/leaderboard_controller.cc +++ b/backend/src/controllers/leaderboard_controller.cc @@ -42,9 +42,15 @@ void LeaderboardController::global( if (!limit_str.empty()) { limit = std::max(1, std::min(500, std::stoi(limit_str))); } + std::string scope = req->getParameter("scope"); + if (scope.empty()) scope = "all"; + if (scope != "all" && scope != "week" && scope != "today") { + cb(JsonError(drogon::k400BadRequest, "invalid scope")); + return; + } services::UserService users(csp::AppState::Instance().db()); - const auto rows = users.GlobalLeaderboard(limit); + const auto rows = users.GlobalLeaderboard(limit, scope); Json::Value arr(Json::arrayValue); for (const auto& r : rows) arr.append(domain::ToJson(r)); diff --git a/backend/src/controllers/me_controller.cc b/backend/src/controllers/me_controller.cc index 6034e77..df98b81 100644 --- a/backend/src/controllers/me_controller.cc +++ b/backend/src/controllers/me_controller.cc @@ -4,12 +4,15 @@ #include "csp/domain/json.h" #include "csp/services/daily_task_service.h" #include "csp/services/redeem_service.h" +#include "csp/services/season_service.h" #include "csp/services/solution_access_service.h" +#include "csp/services/experience_service.h" #include "csp/services/user_service.h" #include "csp/services/wrong_book_service.h" #include "csp/services/crypto.h" #include "csp/services/problem_service.h" #include "csp/services/learning_note_scoring_service.h" +#include "csp/services/source_crystal_service.h" #include "http_auth.h" #include @@ -54,6 +57,17 @@ RequireAuth(const drogon::HttpRequestPtr &req, cb(JsonError(drogon::k401Unauthorized, auth_error)); return std::nullopt; } + + // Keep daily login check-in consistent for long-lived sessions. + // Users should not need to manually logout/login every day to finish + // the "login_checkin" task. + try { + services::DailyTaskService daily(csp::AppState::Instance().db()); + daily.CompleteTaskIfFirstToday(*user_id, + services::DailyTaskService::kTaskLoginCheckin); + } catch (...) { + // Auth should not fail because daily task reward is optional. + } return user_id; } @@ -65,6 +79,30 @@ int ParseClampedInt(const std::string &s, int default_value, int min_value, return std::max(min_value, std::min(max_value, value)); } +std::string TrimSpaces(const std::string &input) { + const auto begin = input.find_first_not_of(" \t\r\n"); + if (begin == std::string::npos) + return {}; + const auto end = input.find_last_not_of(" \t\r\n"); + return input.substr(begin, end - begin + 1); +} + +constexpr int kExperiencePerLevel = 100; + +int ExperienceLevel(int experience) { + const int safe_exp = std::max(0, experience); + return safe_exp / kExperiencePerLevel + 1; +} + +int ExperienceCurrentLevelBase(int experience) { + const int safe_exp = std::max(0, experience); + return (safe_exp / kExperiencePerLevel) * kExperiencePerLevel; +} + +int ExperienceNextLevelExperience(int experience) { + return ExperienceCurrentLevelBase(experience) + kExperiencePerLevel; +} + } // namespace void MeController::profile( @@ -82,7 +120,24 @@ void MeController::profile( return; } - cb(JsonOk(domain::ToPublicJson(*user))); + Json::Value data = domain::ToPublicJson(*user); + try { + services::SourceCrystalService crystal(csp::AppState::Instance().db()); + const auto summary = crystal.GetSummary(*user_id); + data["source_crystal_balance"] = summary.balance; + data["source_crystal_monthly_interest_rate"] = summary.monthly_interest_rate; + } catch (...) { + // Keep profile stable even if source crystal module is unavailable. + } + try { + services::ExperienceService experience(csp::AppState::Instance().db()); + const auto summary = experience.GetSummary(*user_id); + data["experience"] = summary.experience; + data["experience_level"] = ExperienceLevel(summary.experience); + } catch (...) { + // Keep profile stable even if experience module is unavailable. + } + cb(JsonOk(data)); } catch (const std::exception &e) { cb(JsonError(drogon::k500InternalServerError, e.what())); } @@ -119,6 +174,29 @@ void MeController::listRedeemItems( } } +void MeController::getRedeemDayType( + const drogon::HttpRequestPtr &req, + std::function &&cb) { + try { + if (!RequireAuth(req, cb).has_value()) + return; + + services::RedeemService redeem(csp::AppState::Instance().db()); + const auto decision = redeem.ResolveCurrentDayType(); + + Json::Value data; + data["day_type"] = decision.day_type; + data["is_holiday"] = decision.is_holiday; + data["reason"] = decision.reason; + data["source"] = decision.source; + data["date_ymd"] = decision.date_ymd; + data["checked_at"] = Json::Int64(decision.checked_at); + cb(JsonOk(data)); + } catch (const std::exception &e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + void MeController::listRedeemRecords( const drogon::HttpRequestPtr &req, std::function &&cb) { @@ -174,7 +252,6 @@ void MeController::createRedeemRecord( request.user_id = *user_id; request.item_id = (*json).get("item_id", 0).asInt64(); request.quantity = (*json).get("quantity", 1).asInt(); - request.day_type = (*json).get("day_type", "studyday").asString(); request.note = (*json).get("note", "").asString(); services::RedeemService redeem(csp::AppState::Instance().db()); @@ -205,6 +282,232 @@ void MeController::createRedeemRecord( } } +void MeController::sourceCrystalSummary( + const drogon::HttpRequestPtr &req, + std::function &&cb) { + try { + const auto user_id = RequireAuth(req, cb); + if (!user_id.has_value()) + return; + + services::SourceCrystalService crystal(csp::AppState::Instance().db()); + const auto summary = crystal.GetSummary(*user_id); + + Json::Value data; + data["user_id"] = Json::Int64(summary.user_id); + data["balance"] = summary.balance; + data["monthly_interest_rate"] = summary.monthly_interest_rate; + data["last_interest_at"] = Json::Int64(summary.last_interest_at); + data["updated_at"] = Json::Int64(summary.updated_at); + cb(JsonOk(data)); + } catch (const std::runtime_error &e) { + cb(JsonError(drogon::k400BadRequest, e.what())); + } catch (const std::exception &e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void MeController::listSourceCrystalRecords( + const drogon::HttpRequestPtr &req, + std::function &&cb) { + try { + const auto user_id = RequireAuth(req, cb); + if (!user_id.has_value()) + return; + + const int limit = ParseClampedInt(req->getParameter("limit"), 100, 1, 500); + services::SourceCrystalService crystal(csp::AppState::Instance().db()); + const auto rows = crystal.ListTransactions(*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["tx_type"] = row.tx_type; + j["amount"] = row.amount; + j["balance_after"] = row.balance_after; + j["note"] = row.note; + j["created_at"] = Json::Int64(row.created_at); + arr.append(j); + } + cb(JsonOk(arr)); + } catch (const std::runtime_error &e) { + cb(JsonError(drogon::k400BadRequest, e.what())); + } catch (const std::exception &e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void MeController::sourceCrystalDeposit( + const drogon::HttpRequestPtr &req, + std::function &&cb) { + try { + const auto user_id = RequireAuth(req, cb); + if (!user_id.has_value()) + return; + + services::UserService users(csp::AppState::Instance().db()); + const auto user = users.GetById(*user_id); + if (!user.has_value() || user->username != "admin") { + cb(JsonError(drogon::k403Forbidden, + "source crystal deposit is admin-only; use admin-users page")); + return; + } + + const auto json = req->getJsonObject(); + if (!json) { + cb(JsonError(drogon::k400BadRequest, "body must be json")); + return; + } + + const double amount = (*json).get("amount", 0.0).asDouble(); + const std::string note = (*json).get("note", "").asString(); + + services::SourceCrystalService crystal(csp::AppState::Instance().db()); + const auto tx = crystal.Deposit(*user_id, amount, note); + const auto summary = crystal.GetSummary(*user_id); + + Json::Value data; + data["id"] = Json::Int64(tx.id); + data["tx_type"] = tx.tx_type; + data["amount"] = tx.amount; + data["balance_after"] = tx.balance_after; + data["note"] = tx.note; + data["created_at"] = Json::Int64(tx.created_at); + data["balance"] = summary.balance; + data["monthly_interest_rate"] = summary.monthly_interest_rate; + cb(JsonOk(data)); + } catch (const std::runtime_error &e) { + cb(JsonError(drogon::k400BadRequest, e.what())); + } catch (const std::exception &e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void MeController::sourceCrystalWithdraw( + const drogon::HttpRequestPtr &req, + std::function &&cb) { + try { + const auto user_id = RequireAuth(req, cb); + if (!user_id.has_value()) + return; + + const auto json = req->getJsonObject(); + if (!json) { + cb(JsonError(drogon::k400BadRequest, "body must be json")); + return; + } + + const double amount = (*json).get("amount", 0.0).asDouble(); + const std::string note = (*json).get("note", "").asString(); + + services::SourceCrystalService crystal(csp::AppState::Instance().db()); + const auto tx = crystal.Withdraw(*user_id, amount, note); + const auto summary = crystal.GetSummary(*user_id); + + Json::Value data; + data["id"] = Json::Int64(tx.id); + data["tx_type"] = tx.tx_type; + data["amount"] = tx.amount; + data["balance_after"] = tx.balance_after; + data["note"] = tx.note; + data["created_at"] = Json::Int64(tx.created_at); + data["balance"] = summary.balance; + data["monthly_interest_rate"] = summary.monthly_interest_rate; + cb(JsonOk(data)); + } catch (const std::runtime_error &e) { + cb(JsonError(drogon::k400BadRequest, e.what())); + } catch (const std::exception &e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void MeController::experienceSummary( + const drogon::HttpRequestPtr &req, + std::function &&cb) { + try { + const auto user_id = RequireAuth(req, cb); + if (!user_id.has_value()) + return; + + services::ExperienceService experience(csp::AppState::Instance().db()); + const auto summary = experience.GetSummary(*user_id); + + Json::Value data; + data["user_id"] = Json::Int64(summary.user_id); + data["experience"] = summary.experience; + data["level"] = ExperienceLevel(summary.experience); + data["current_level_base"] = ExperienceCurrentLevelBase(summary.experience); + data["next_level_experience"] = ExperienceNextLevelExperience(summary.experience); + data["updated_at"] = Json::Int64(summary.updated_at); + cb(JsonOk(data)); + } catch (const std::runtime_error &e) { + cb(JsonError(drogon::k400BadRequest, e.what())); + } catch (const std::exception &e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void MeController::experienceHistory( + const drogon::HttpRequestPtr &req, + std::function &&cb) { + try { + const auto user_id = RequireAuth(req, cb); + if (!user_id.has_value()) + return; + + const int limit = ParseClampedInt(req->getParameter("limit"), 100, 1, 500); + services::ExperienceService experience(csp::AppState::Instance().db()); + const auto rows = experience.ListHistory(*user_id, limit); + + Json::Value arr(Json::arrayValue); + for (const auto &row : rows) { + Json::Value one; + one["id"] = Json::Int64(row.id); + one["user_id"] = Json::Int64(row.user_id); + one["xp_delta"] = row.xp_delta; + one["rating_before"] = row.rating_before; + one["rating_after"] = row.rating_after; + one["source"] = row.source; + one["note"] = row.note; + one["created_at"] = Json::Int64(row.created_at); + arr.append(one); + } + cb(JsonOk(arr)); + } catch (const std::runtime_error &e) { + cb(JsonError(drogon::k400BadRequest, e.what())); + } catch (const std::exception &e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void MeController::listLootDrops( + const drogon::HttpRequestPtr &req, + std::function &&cb) { + try { + const auto user_id = RequireAuth(req, cb); + if (!user_id.has_value()) + return; + + const int limit = ParseClampedInt(req->getParameter("limit"), 100, 1, 500); + services::SeasonService seasons(csp::AppState::Instance().db()); + const auto rows = seasons.ListLootDropsByUser(*user_id, limit); + + Json::Value arr(Json::arrayValue); + for (const auto &row : rows) { + arr.append(domain::ToJson(row)); + } + 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())); + } +} + void MeController::listDailyTasks( const drogon::HttpRequestPtr &req, std::function &&cb) { @@ -314,20 +617,7 @@ void MeController::scoreWrongBookNote( if (!user_id.has_value()) return; const auto json = req->getJsonObject(); - if (!json) { - cb(JsonError(drogon::k400BadRequest, "body must be json")); - return; - } - - const std::string note = (*json).get("note", "").asString(); - if (note.empty()) { - cb(JsonError(drogon::k400BadRequest, "note required")); - return; - } - if (note.size() > 8000) { - cb(JsonError(drogon::k400BadRequest, "note too long")); - return; - } + const std::string input_note = json ? (*json).get("note", "").asString() : std::string(); services::ProblemService problem_svc(csp::AppState::Instance().db()); const auto p = problem_svc.GetById(problem_id); @@ -336,12 +626,23 @@ void MeController::scoreWrongBookNote( return; } + services::WrongBookService wrong_book(csp::AppState::Instance().db()); + std::string note = input_note; + if (TrimSpaces(note).empty()) { + note = wrong_book.GetNote(*user_id, problem_id); + } + if (note.size() > 8000) { + cb(JsonError(drogon::k400BadRequest, "note too long")); + return; + } + services::LearningNoteScoringService scorer(csp::AppState::Instance().db()); const auto result = scorer.Score(note, *p); - services::WrongBookService wrong_book(csp::AppState::Instance().db()); - // ensure note saved - wrong_book.UpsertNote(*user_id, problem_id, note); + // Only overwrite note when client explicitly submits non-empty note. + if (!TrimSpaces(input_note).empty()) { + wrong_book.UpsertNote(*user_id, problem_id, input_note); + } // Get previous score to calculate rating delta const int prev_rating = wrong_book.GetNoteRating(*user_id, problem_id); diff --git a/backend/src/controllers/meta_controller.cc b/backend/src/controllers/meta_controller.cc index 03adc16..8899396 100644 --- a/backend/src/controllers/meta_controller.cc +++ b/backend/src/controllers/meta_controller.cc @@ -4,6 +4,7 @@ #include "csp/domain/enum_strings.h" #include "csp/domain/json.h" #include "csp/services/kb_import_runner.h" +#include "csp/services/db_lock_guard.h" #include "csp/services/problem_gen_runner.h" #include "csp/services/problem_service.h" #include "csp/services/problem_solution_runner.h" @@ -74,7 +75,8 @@ Json::Value BuildOpenApiSpec() { root["info"]["title"] = "CSP Platform API"; root["info"]["version"] = "1.0.0"; root["info"]["description"] = - "CSP 训练平台接口文档,含认证、题库、评测、导入、MCP。"; + "CSP 训练平台接口文档,含认证、题库、评测、导入、MCP。" + "鉴权支持 Bearer Token 与 Basic(账号密码)。"; root["servers"][0]["url"] = "/"; @@ -100,33 +102,72 @@ Json::Value BuildOpenApiSpec() { paths["/api/v1/submissions/{id}/analysis"]["post"]["summary"] = "提交评测建议(LLM)"; paths["/api/v1/admin/users"]["get"]["summary"] = "管理员用户列表"; paths["/api/v1/admin/users/{id}/rating"]["patch"]["summary"] = "管理员修改用户积分"; + paths["/api/v1/admin/users/{id}/source-crystal"]["get"]["summary"] = "管理员查看用户源晶账户"; + paths["/api/v1/admin/users/{id}/source-crystal/records"]["get"]["summary"] = "管理员查看用户源晶流水"; + paths["/api/v1/admin/users/{id}/source-crystal/deposit"]["post"]["summary"] = "管理员为用户存入源晶"; paths["/api/v1/admin/users/{id}"]["delete"]["summary"] = "管理员删除用户"; paths["/api/v1/admin/redeem-items"]["get"]["summary"] = "管理员查看积分兑换物品"; paths["/api/v1/admin/redeem-items"]["post"]["summary"] = "管理员新增积分兑换物品"; paths["/api/v1/admin/redeem-items/{id}"]["patch"]["summary"] = "管理员修改积分兑换物品"; paths["/api/v1/admin/redeem-items/{id}"]["delete"]["summary"] = "管理员下架积分兑换物品"; paths["/api/v1/admin/redeem-records"]["get"]["summary"] = "管理员查看兑换记录"; + paths["/api/v1/admin/source-crystal/settings"]["get"]["summary"] = "管理员查看源晶月利率"; + paths["/api/v1/admin/source-crystal/settings"]["patch"]["summary"] = "管理员修改源晶月利率"; + paths["/api/v1/admin/seasons"]["post"]["summary"] = "管理员创建赛季"; + paths["/api/v1/admin/seasons/{id}"]["patch"]["summary"] = "管理员更新赛季"; + paths["/api/v1/admin/crawlers"]["get"]["summary"] = "管理员查看爬虫列表"; + paths["/api/v1/admin/crawlers"]["post"]["summary"] = "管理员新增爬虫目标"; + paths["/api/v1/admin/crawlers/{id}/queue"]["post"]["summary"] = "管理员重新排队爬虫任务"; + paths["/api/v1/admin/crawlers/{id}/runs"]["get"]["summary"] = "管理员查看爬虫运行记录"; + paths["/api/v1/admin/contests/{id}/modifiers"]["post"]["summary"] = "管理员创建副本词缀"; + paths["/api/v1/admin/contests/{id}/modifiers/{modifier_id}"]["patch"]["summary"] = + "管理员更新副本词缀"; paths["/api/v1/me/redeem/items"]["get"]["summary"] = "我的可兑换物品列表"; + paths["/api/v1/me/redeem/day-type"]["get"]["summary"] = "自动判定今日兑换日类型"; paths["/api/v1/me/redeem/records"]["get"]["summary"] = "我的兑换记录"; paths["/api/v1/me/redeem/records"]["post"]["summary"] = "创建兑换记录"; + paths["/api/v1/me/source-crystal"]["get"]["summary"] = "我的源晶账户"; + paths["/api/v1/me/source-crystal/records"]["get"]["summary"] = "我的源晶流水"; + paths["/api/v1/me/source-crystal/deposit"]["post"]["summary"] = "存入源晶(仅管理员)"; + paths["/api/v1/me/source-crystal/withdraw"]["post"]["summary"] = "取出源晶"; + paths["/api/v1/me/experience"]["get"]["summary"] = "我的经验值概览"; + paths["/api/v1/me/experience/history"]["get"]["summary"] = "我的经验值历史"; + paths["/api/v1/me/loot-drops"]["get"]["summary"] = "我的掉落日志"; paths["/api/v1/me/daily-tasks"]["get"]["summary"] = "我的每日任务列表"; + paths["/api/v1/seasons/current"]["get"]["summary"] = "当前赛季信息"; + paths["/api/v1/seasons/{id}/me"]["get"]["summary"] = "我的赛季进度"; + paths["/api/v1/seasons/{id}/claim"]["post"]["summary"] = "领取赛季奖励"; + paths["/api/v1/contests/{id}/modifiers"]["get"]["summary"] = "副本词缀列表"; + paths["/api/v1/kb/weekly-plan"]["get"]["summary"] = "我的每周学习任务"; + paths["/api/v1/kb/weekly-bonus/claim"]["post"]["summary"] = "领取每周学习任务 100% 完成奖励"; paths["/api/v1/import/jobs/latest"]["get"]["summary"] = "最新导入任务"; paths["/api/v1/import/jobs/run"]["post"]["summary"] = "触发导入任务"; paths["/api/v1/problem-gen/status"]["get"]["summary"] = "CSP-J 生成任务状态"; paths["/api/v1/problem-gen/run"]["post"]["summary"] = "触发生成新题(RAG+去重)"; paths["/api/v1/backend/logs"]["get"]["summary"] = "后台日志(题解任务队列)"; + paths["/api/v1/backend/db-lock-guard/status"]["get"]["summary"] = + "SQLite 锁守护状态"; + paths["/api/v1/backend/crawler-guard/status"]["get"]["summary"] = "网站爬虫守护状态"; paths["/api/v1/backend/kb/refresh"]["get"]["summary"] = "知识库资料更新状态"; paths["/api/v1/backend/kb/refresh"]["post"]["summary"] = "手动一键更新知识库资料"; paths["/api/v1/backend/solutions/generate-missing"]["post"]["summary"] = "异步补全所有缺失题解"; + paths["/api/v1/lark/events"]["post"]["summary"] = + "Lark 机器人事件回调(URL 校验/消息对话)"; paths["/api/v1/mcp"]["post"]["summary"] = "MCP JSON-RPC 入口"; auto& components = root["components"]; components["securitySchemes"]["bearerAuth"]["type"] = "http"; components["securitySchemes"]["bearerAuth"]["scheme"] = "bearer"; - components["securitySchemes"]["bearerAuth"]["bearerFormat"] = "JWT"; + components["securitySchemes"]["bearerAuth"]["bearerFormat"] = "opaque-token"; + components["securitySchemes"]["bearerAuth"]["description"] = + "推荐方式:Authorization: Bearer 。token 通过 /api/v1/auth/login 获取。"; + components["securitySchemes"]["basicAuth"]["type"] = "http"; + components["securitySchemes"]["basicAuth"]["scheme"] = "basic"; + components["securitySchemes"]["basicAuth"]["description"] = + "第三方集成可直接使用 Basic 认证:Authorization: Basic base64(username:password)。"; return root; } @@ -297,6 +338,32 @@ void MetaController::backendLogs( } } +void MetaController::dbLockGuardStatus( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + if (!RequireAdminUserId(req, cb).has_value()) return; + + const auto status = services::DbLockGuard::Instance().GetStatus(); + + Json::Value payload; + payload["enabled"] = status.enabled; + payload["started"] = status.started; + payload["interval_sec"] = status.interval_sec; + payload["probe_busy_timeout_ms"] = status.probe_busy_timeout_ms; + payload["busy_streak_trigger"] = status.busy_streak_trigger; + payload["busy_streak"] = status.busy_streak; + payload["last_probe_at"] = Json::Int64(status.last_probe_at); + payload["last_probe_rc"] = status.last_probe_rc; + payload["last_probe_error"] = status.last_probe_error; + payload["last_repair_at"] = Json::Int64(status.last_repair_at); + payload["repair_count"] = Json::Int64(status.repair_count); + cb(JsonOk(payload)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + void MetaController::kbRefreshStatus( const drogon::HttpRequestPtr& req, std::function&& cb) { diff --git a/backend/src/controllers/season_controller.cc b/backend/src/controllers/season_controller.cc new file mode 100644 index 0000000..362233b --- /dev/null +++ b/backend/src/controllers/season_controller.cc @@ -0,0 +1,201 @@ +#include "csp/controllers/season_controller.h" + +#include "csp/app_state.h" +#include "csp/domain/json.h" +#include "csp/services/season_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; +} + +std::optional RequireAuth( + 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; + } + return user_id; +} + +std::string RewardTrackKey(int tier_no, const std::string& reward_type) { + std::string lower = reward_type; + std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return std::to_string(tier_no) + ":" + lower; +} + +} // namespace + +void SeasonController::currentSeason( + const drogon::HttpRequestPtr& /*req*/, + std::function&& cb) { + try { + services::SeasonService seasons(csp::AppState::Instance().db()); + const auto season = seasons.GetCurrentSeason(); + if (!season.has_value()) { + cb(JsonError(drogon::k404NotFound, "season not found")); + return; + } + + Json::Value tracks(Json::arrayValue); + for (const auto& t : seasons.ListRewardTracks(season->id)) { + tracks.append(domain::ToJson(t)); + } + + Json::Value data; + data["season"] = domain::ToJson(*season); + data["reward_tracks"] = tracks; + cb(JsonOk(data)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void SeasonController::mySeasonProgress( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t season_id) { + try { + const auto user_id = RequireAuth(req, cb); + if (!user_id.has_value()) return; + + services::SeasonService seasons(csp::AppState::Instance().db()); + const auto season = seasons.GetSeasonById(season_id); + if (!season.has_value()) { + cb(JsonError(drogon::k404NotFound, "season not found")); + return; + } + + const auto progress = seasons.GetOrSyncUserProgress(season_id, *user_id); + const auto claims = seasons.ListUserClaims(season_id, *user_id); + const auto tracks = seasons.ListRewardTracks(season_id); + + std::unordered_set claim_set; + for (const auto& c : claims) { + claim_set.insert(RewardTrackKey(c.tier_no, c.reward_type)); + } + + Json::Value tracks_json(Json::arrayValue); + int claimable_count = 0; + int claimed_count = 0; + for (const auto& t : tracks) { + Json::Value one = domain::ToJson(t); + const bool claimable = progress.xp >= t.required_xp; + const bool claimed = claim_set.count(RewardTrackKey(t.tier_no, t.reward_type)) > 0; + one["claimable"] = claimable; + one["claimed"] = claimed; + tracks_json.append(one); + if (claimable) claimable_count += 1; + if (claimed) claimed_count += 1; + } + + Json::Value claims_json(Json::arrayValue); + for (const auto& c : claims) { + claims_json.append(domain::ToJson(c)); + } + + Json::Value data; + data["season"] = domain::ToJson(*season); + data["progress"] = domain::ToJson(progress); + data["reward_tracks"] = tracks_json; + data["claims"] = claims_json; + data["track_total"] = static_cast(tracks.size()); + data["claimed_count"] = claimed_count; + data["claimable_count"] = claimable_count; + if (!tracks.empty()) { + data["completion_percent"] = claimed_count * 100 / static_cast(tracks.size()); + } else { + data["completion_percent"] = 0; + } + cb(JsonOk(data)); + } catch (const std::runtime_error& e) { + const std::string msg = e.what(); + if (msg.find("not found") != std::string::npos) { + cb(JsonError(drogon::k404NotFound, msg)); + return; + } + cb(JsonError(drogon::k400BadRequest, msg)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void SeasonController::claimSeasonReward( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t season_id) { + try { + const auto user_id = RequireAuth(req, cb); + if (!user_id.has_value()) return; + + const auto body = req->getJsonObject(); + if (!body) { + cb(JsonError(drogon::k400BadRequest, "body must be json")); + return; + } + if (!(*body).isMember("tier_no")) { + cb(JsonError(drogon::k400BadRequest, "tier_no is required")); + return; + } + const int tier_no = (*body).get("tier_no", 0).asInt(); + const std::string reward_type = (*body).get("reward_type", "free").asString(); + + services::SeasonService seasons(csp::AppState::Instance().db()); + const auto result = + seasons.ClaimReward(season_id, *user_id, tier_no, reward_type); + + Json::Value data; + data["claimed"] = result.claimed; + if (result.claim.has_value()) { + data["claim"] = domain::ToJson(*result.claim); + } else { + data["claim"] = Json::nullValue; + } + data["track"] = domain::ToJson(result.track); + data["progress"] = domain::ToJson(result.progress); + data["rating_after"] = result.rating_after; + cb(JsonOk(data)); + } catch (const std::runtime_error& e) { + const std::string msg = e.what(); + if (msg.find("not found") != std::string::npos) { + cb(JsonError(drogon::k404NotFound, msg)); + return; + } + cb(JsonError(drogon::k400BadRequest, msg)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +} // namespace csp::controllers diff --git a/backend/src/controllers/submission_controller.cc b/backend/src/controllers/submission_controller.cc index 89b41b9..c6092fd 100644 --- a/backend/src/controllers/submission_controller.cc +++ b/backend/src/controllers/submission_controller.cc @@ -154,12 +154,15 @@ void SubmissionController::listSubmissions( const auto user_id = ParseOptionalInt64(req->getParameter("user_id")); const auto problem_id = ParseOptionalInt64(req->getParameter("problem_id")); const auto contest_id = ParseOptionalInt64(req->getParameter("contest_id")); + const auto created_from = ParseOptionalInt64(req->getParameter("created_from")); + const auto created_to = ParseOptionalInt64(req->getParameter("created_to")); const int page = ParseClampedInt(req->getParameter("page"), 1, 1, 100000); const int page_size = ParseClampedInt(req->getParameter("page_size"), 20, 1, 200); services::SubmissionService svc(csp::AppState::Instance().db()); - const auto rows = svc.List(user_id, problem_id, contest_id, page, page_size); + const auto rows = + svc.List(user_id, problem_id, contest_id, created_from, created_to, page, page_size); Json::Value arr(Json::arrayValue); for (const auto& s : rows) arr.append(domain::ToJson(s)); @@ -191,6 +194,13 @@ void SubmissionController::getSubmission( } Json::Value payload = domain::ToJson(*s); payload["code"] = s->code; + const auto siblings = svc.GetSiblingIds(s->user_id, s->id); + payload["same_user_prev_submission_id"] = + siblings.prev_id.has_value() ? Json::Value(Json::Int64(*siblings.prev_id)) + : Json::Value(Json::nullValue); + payload["same_user_next_submission_id"] = + siblings.next_id.has_value() ? Json::Value(Json::Int64(*siblings.next_id)) + : Json::Value(Json::nullValue); // Attach problem title for frontend linking. { diff --git a/backend/src/db/sqlite_db.cc b/backend/src/db/sqlite_db.cc index 5d3b283..a968237 100644 --- a/backend/src/db/sqlite_db.cc +++ b/backend/src/db/sqlite_db.cc @@ -1,6 +1,7 @@ #include "csp/db/sqlite_db.h" #include +#include #include #include #include @@ -21,6 +22,40 @@ int64_t NowSec() { return duration_cast(system_clock::now().time_since_epoch()).count(); } +void ExecSqlite(sqlite3* db, const char* sql, const char* what) { + char* err = nullptr; + const int rc = sqlite3_exec(db, sql, nullptr, nullptr, &err); + if (rc != SQLITE_OK) { + std::string msg = err ? err : ""; + sqlite3_free(err); + throw std::runtime_error(std::string(what) + ": " + msg); + } +} + +int ResolveBusyTimeoutMs() { + constexpr int kDefaultTimeoutMs = 15000; + const char* raw = std::getenv("CSP_SQLITE_BUSY_TIMEOUT_MS"); + if (!raw || std::string(raw).empty()) return kDefaultTimeoutMs; + try { + const int parsed = std::stoi(raw); + if (parsed < 100) return 100; + if (parsed > 120000) return 120000; + return parsed; + } catch (...) { + return kDefaultTimeoutMs; + } +} + +void ConfigureConnection(sqlite3* db, bool memory_db) { + ThrowSqlite(sqlite3_busy_timeout(db, ResolveBusyTimeoutMs()), db, + "sqlite3_busy_timeout"); + ExecSqlite(db, "PRAGMA foreign_keys = ON;", "pragma foreign_keys"); + if (!memory_db) { + ExecSqlite(db, "PRAGMA journal_mode = WAL;", "pragma journal_mode"); + ExecSqlite(db, "PRAGMA synchronous = NORMAL;", "pragma synchronous"); + } +} + bool ColumnExists(sqlite3* db, const char* table, const char* col) { sqlite3_stmt* stmt = nullptr; const std::string sql = std::string("PRAGMA table_info(") + table + ")"; @@ -153,6 +188,53 @@ void InsertKbArticle(sqlite3* db, sqlite3_finalize(stmt); } +std::optional FindKbArticleId(sqlite3* db, const std::string& slug) { + sqlite3_stmt* stmt = nullptr; + const char* sql = "SELECT id FROM kb_articles WHERE slug=? LIMIT 1"; + const int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr); + if (rc != SQLITE_OK) { + if (stmt) sqlite3_finalize(stmt); + return std::nullopt; + } + ThrowSqlite(sqlite3_bind_text(stmt, 1, slug.c_str(), -1, SQLITE_TRANSIENT), db, + "bind find kb slug"); + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + return std::nullopt; + } + const auto id = sqlite3_column_int64(stmt, 0); + sqlite3_finalize(stmt); + return id; +} + +int64_t EnsureKbArticle(sqlite3* db, + const std::string& slug, + const std::string& title, + const std::string& content_md, + int64_t updated_at) { + if (const auto existing = FindKbArticleId(db, slug); existing.has_value()) { + sqlite3_stmt* stmt = nullptr; + const char* sql = "UPDATE kb_articles SET title=?,content_md=?,created_at=? WHERE id=?"; + ThrowSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare update kb article"); + ThrowSqlite(sqlite3_bind_text(stmt, 1, title.c_str(), -1, SQLITE_TRANSIENT), db, + "bind update kb title"); + ThrowSqlite(sqlite3_bind_text(stmt, 2, content_md.c_str(), -1, SQLITE_TRANSIENT), db, + "bind update kb content"); + ThrowSqlite(sqlite3_bind_int64(stmt, 3, updated_at), db, "bind update kb time"); + ThrowSqlite(sqlite3_bind_int64(stmt, 4, *existing), db, "bind update kb id"); + ThrowSqlite(sqlite3_step(stmt), db, "update kb article"); + sqlite3_finalize(stmt); + return *existing; + } + InsertKbArticle(db, slug, title, content_md, updated_at); + const auto inserted = FindKbArticleId(db, slug); + if (!inserted.has_value()) { + throw std::runtime_error("ensure kb article failed"); + } + return *inserted; +} + void InsertKbLink(sqlite3* db, int64_t article_id, int64_t problem_id) { sqlite3_stmt* stmt = nullptr; const char* sql = @@ -208,6 +290,105 @@ void InsertContestProblem(sqlite3* db, sqlite3_finalize(stmt); } +void InsertContestModifier(sqlite3* db, + int64_t contest_id, + const std::string& code, + const std::string& title, + const std::string& description, + const std::string& rule_json, + int is_active, + int64_t created_at) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "INSERT OR IGNORE INTO contest_modifiers(" + "contest_id,code,title,description,rule_json,is_active,created_at,updated_at" + ") VALUES(?,?,?,?,?,?,?,?)"; + ThrowSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare insert contest_modifier"); + ThrowSqlite(sqlite3_bind_int64(stmt, 1, contest_id), db, + "bind contest_modifier.contest_id"); + ThrowSqlite(sqlite3_bind_text(stmt, 2, code.c_str(), -1, SQLITE_TRANSIENT), db, + "bind contest_modifier.code"); + ThrowSqlite(sqlite3_bind_text(stmt, 3, title.c_str(), -1, SQLITE_TRANSIENT), db, + "bind contest_modifier.title"); + ThrowSqlite(sqlite3_bind_text(stmt, 4, description.c_str(), -1, SQLITE_TRANSIENT), db, + "bind contest_modifier.description"); + ThrowSqlite(sqlite3_bind_text(stmt, 5, rule_json.c_str(), -1, SQLITE_TRANSIENT), db, + "bind contest_modifier.rule_json"); + ThrowSqlite(sqlite3_bind_int(stmt, 6, is_active), db, + "bind contest_modifier.is_active"); + ThrowSqlite(sqlite3_bind_int64(stmt, 7, created_at), db, + "bind contest_modifier.created_at"); + ThrowSqlite(sqlite3_bind_int64(stmt, 8, created_at), db, + "bind contest_modifier.updated_at"); + ThrowSqlite(sqlite3_step(stmt), db, "insert contest_modifier"); + sqlite3_finalize(stmt); +} + +void InsertSeason(sqlite3* db, + const std::string& key, + const std::string& title, + int64_t starts_at, + int64_t ends_at, + const std::string& status, + const std::string& pass_json, + int64_t created_at) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "INSERT INTO seasons(key,title,starts_at,ends_at,status,pass_json,created_at,updated_at) " + "VALUES(?,?,?,?,?,?,?,?)"; + ThrowSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare insert season"); + ThrowSqlite(sqlite3_bind_text(stmt, 1, key.c_str(), -1, SQLITE_TRANSIENT), db, + "bind season.key"); + ThrowSqlite(sqlite3_bind_text(stmt, 2, title.c_str(), -1, SQLITE_TRANSIENT), db, + "bind season.title"); + ThrowSqlite(sqlite3_bind_int64(stmt, 3, starts_at), db, + "bind season.starts_at"); + ThrowSqlite(sqlite3_bind_int64(stmt, 4, ends_at), db, + "bind season.ends_at"); + ThrowSqlite(sqlite3_bind_text(stmt, 5, status.c_str(), -1, SQLITE_TRANSIENT), db, + "bind season.status"); + ThrowSqlite(sqlite3_bind_text(stmt, 6, pass_json.c_str(), -1, SQLITE_TRANSIENT), db, + "bind season.pass_json"); + ThrowSqlite(sqlite3_bind_int64(stmt, 7, created_at), db, + "bind season.created_at"); + ThrowSqlite(sqlite3_bind_int64(stmt, 8, created_at), db, + "bind season.updated_at"); + ThrowSqlite(sqlite3_step(stmt), db, "insert season"); + sqlite3_finalize(stmt); +} + +void InsertSeasonRewardTrack(sqlite3* db, + int64_t season_id, + int tier_no, + int required_xp, + const std::string& reward_type, + int reward_value, + const std::string& reward_meta_json) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "INSERT OR IGNORE INTO season_reward_tracks(" + "season_id,tier_no,required_xp,reward_type,reward_value,reward_meta_json" + ") VALUES(?,?,?,?,?,?)"; + ThrowSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare insert season_reward_track"); + ThrowSqlite(sqlite3_bind_int64(stmt, 1, season_id), db, + "bind season_reward_track.season_id"); + ThrowSqlite(sqlite3_bind_int(stmt, 2, tier_no), db, + "bind season_reward_track.tier_no"); + ThrowSqlite(sqlite3_bind_int(stmt, 3, required_xp), db, + "bind season_reward_track.required_xp"); + ThrowSqlite(sqlite3_bind_text(stmt, 4, reward_type.c_str(), -1, SQLITE_TRANSIENT), + db, "bind season_reward_track.reward_type"); + ThrowSqlite(sqlite3_bind_int(stmt, 5, reward_value), db, + "bind season_reward_track.reward_value"); + ThrowSqlite(sqlite3_bind_text(stmt, 6, reward_meta_json.c_str(), -1, SQLITE_TRANSIENT), + db, "bind season_reward_track.reward_meta_json"); + ThrowSqlite(sqlite3_step(stmt), db, "insert season_reward_track"); + sqlite3_finalize(stmt); +} + void InsertRedeemItem(sqlite3* db, const std::string& name, const std::string& description, @@ -259,6 +440,12 @@ SqliteDb SqliteDb::OpenFile(const std::string& path) { if (db) sqlite3_close(db); throw std::runtime_error(std::string("sqlite3_open failed: ") + msg); } + try { + ConfigureConnection(db, false); + } catch (...) { + sqlite3_close(db); + throw; + } return SqliteDb(db); } @@ -266,6 +453,12 @@ SqliteDb SqliteDb::OpenMemory() { sqlite3* db = nullptr; const int rc = sqlite3_open(":memory:", &db); ThrowSqlite(rc, db, "sqlite3_open(:memory:) failed"); + try { + ConfigureConnection(db, true); + } catch (...) { + sqlite3_close(db); + throw; + } return SqliteDb(db); } @@ -317,6 +510,25 @@ CREATE TABLE IF NOT EXISTS sessions ( FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS user_experience ( + user_id INTEGER PRIMARY KEY, + xp INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS user_experience_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + xp_delta INTEGER NOT NULL, + rating_before INTEGER NOT NULL DEFAULT 0, + rating_after INTEGER NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT "users.rating", + note TEXT NOT NULL DEFAULT "", + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS problems ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL UNIQUE, @@ -399,6 +611,81 @@ CREATE TABLE IF NOT EXISTS contest_registrations ( FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS contest_modifiers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contest_id INTEGER NOT NULL, + code TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT "", + rule_json TEXT NOT NULL DEFAULT "{}", + is_active INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY(contest_id) REFERENCES contests(id) ON DELETE CASCADE, + UNIQUE(contest_id, code) +); + +CREATE TABLE IF NOT EXISTS seasons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + starts_at INTEGER NOT NULL, + ends_at INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT "draft", + pass_json TEXT NOT NULL DEFAULT "{}", + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS season_reward_tracks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + season_id INTEGER NOT NULL, + tier_no INTEGER NOT NULL, + required_xp INTEGER NOT NULL DEFAULT 0, + reward_type TEXT NOT NULL DEFAULT "free", + reward_value INTEGER NOT NULL DEFAULT 0, + reward_meta_json TEXT NOT NULL DEFAULT "{}", + FOREIGN KEY(season_id) REFERENCES seasons(id) ON DELETE CASCADE, + UNIQUE(season_id, tier_no, reward_type) +); + +CREATE TABLE IF NOT EXISTS season_user_progress ( + season_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + xp INTEGER NOT NULL DEFAULT 0, + level INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + PRIMARY KEY(season_id, user_id), + FOREIGN KEY(season_id) REFERENCES seasons(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS season_reward_claims ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + season_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + tier_no INTEGER NOT NULL, + reward_type TEXT NOT NULL DEFAULT "free", + claimed_at INTEGER NOT NULL, + FOREIGN KEY(season_id) REFERENCES seasons(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(season_id, user_id, tier_no, reward_type) +); + +CREATE TABLE IF NOT EXISTS loot_drop_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + source_type TEXT NOT NULL, + source_id INTEGER NOT NULL DEFAULT 0, + item_code TEXT NOT NULL, + item_name TEXT NOT NULL DEFAULT "", + rarity TEXT NOT NULL DEFAULT "common", + amount INTEGER NOT NULL DEFAULT 0, + meta_json TEXT NOT NULL DEFAULT "{}", + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS kb_articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL UNIQUE, @@ -415,6 +702,49 @@ CREATE TABLE IF NOT EXISTS kb_article_links ( FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS kb_knowledge_claims ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + article_id INTEGER NOT NULL, + knowledge_key TEXT NOT NULL, + reward INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(article_id) REFERENCES kb_articles(id) ON DELETE CASCADE, + UNIQUE(user_id, article_id, knowledge_key) +); + +CREATE TABLE IF NOT EXISTS kb_weekly_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + week_key TEXT NOT NULL, + article_id INTEGER NOT NULL, + article_slug TEXT NOT NULL, + article_title TEXT NOT NULL, + knowledge_key TEXT NOT NULL, + knowledge_title TEXT NOT NULL, + knowledge_description TEXT NOT NULL DEFAULT "", + difficulty TEXT NOT NULL DEFAULT "bronze", + reward INTEGER NOT NULL DEFAULT 1, + prerequisites TEXT NOT NULL DEFAULT "", + order_no INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + completed_at INTEGER, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(article_id) REFERENCES kb_articles(id) ON DELETE CASCADE, + UNIQUE(user_id, week_key, article_id, knowledge_key) +); + +CREATE TABLE IF NOT EXISTS kb_weekly_bonus_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + week_key TEXT NOT NULL, + reward INTEGER NOT NULL DEFAULT 100, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id, week_key) +); + CREATE TABLE IF NOT EXISTS import_jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, status TEXT NOT NULL, @@ -448,6 +778,34 @@ CREATE TABLE IF NOT EXISTS import_job_items ( UNIQUE(job_id, source_path) ); +CREATE TABLE IF NOT EXISTS crawler_targets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL, + normalized_url TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT "queued", + submit_source TEXT NOT NULL DEFAULT "manual", + submitter_id TEXT NOT NULL DEFAULT "", + submitter_name TEXT NOT NULL DEFAULT "", + rule_json TEXT NOT NULL DEFAULT "{}", + script_path TEXT NOT NULL DEFAULT "", + last_error TEXT NOT NULL DEFAULT "", + last_test_at INTEGER, + last_run_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS crawler_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_id INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT "queued", + http_status INTEGER NOT NULL DEFAULT 0, + output_json TEXT NOT NULL DEFAULT "{}", + error_text TEXT NOT NULL DEFAULT "", + created_at INTEGER NOT NULL, + FOREIGN KEY(target_id) REFERENCES crawler_targets(id) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS problem_drafts ( user_id INTEGER NOT NULL, problem_id INTEGER NOT NULL, @@ -547,6 +905,31 @@ CREATE TABLE IF NOT EXISTS redeem_records ( FOREIGN KEY(item_id) REFERENCES redeem_items(id) ON DELETE RESTRICT ); +CREATE TABLE IF NOT EXISTS source_crystal_settings ( + id INTEGER PRIMARY KEY CHECK(id=1), + monthly_interest_rate REAL NOT NULL DEFAULT 0.02, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS source_crystal_accounts ( + user_id INTEGER PRIMARY KEY, + balance REAL NOT NULL DEFAULT 0, + last_interest_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS source_crystal_transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + tx_type TEXT NOT NULL, + amount REAL NOT NULL, + balance_after REAL NOT NULL, + note TEXT NOT NULL DEFAULT "", + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS daily_task_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, @@ -557,6 +940,42 @@ CREATE TABLE IF NOT EXISTS daily_task_logs ( FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, UNIQUE(user_id, task_code, day_key) ); + +CREATE TRIGGER IF NOT EXISTS trg_users_init_experience +AFTER INSERT ON users +BEGIN + INSERT OR IGNORE INTO user_experience(user_id, xp, updated_at) + VALUES(NEW.id, CASE WHEN NEW.rating > 0 THEN NEW.rating ELSE 0 END, strftime('%s','now')); + INSERT INTO user_experience_logs(user_id, xp_delta, rating_before, rating_after, source, note, created_at) + SELECT NEW.id, + NEW.rating, + 0, + NEW.rating, + 'users.insert', + 'initial rating gain', + strftime('%s','now') + WHERE NEW.rating > 0; +END; + +CREATE TRIGGER IF NOT EXISTS trg_users_rating_gain_to_experience +AFTER UPDATE OF rating ON users +WHEN NEW.rating > OLD.rating +BEGIN + INSERT OR IGNORE INTO user_experience(user_id, xp, updated_at) + VALUES(NEW.id, 0, strftime('%s','now')); + UPDATE user_experience + SET xp = xp + (NEW.rating - OLD.rating), + updated_at = strftime('%s','now') + WHERE user_id = NEW.id; + INSERT INTO user_experience_logs(user_id, xp_delta, rating_before, rating_after, source, note, created_at) + VALUES(NEW.id, + (NEW.rating - OLD.rating), + OLD.rating, + NEW.rating, + 'users.rating', + 'rating gain', + strftime('%s','now')); +END; )SQL"); // Backward-compatible schema upgrades for existing deployments. @@ -605,10 +1024,22 @@ CREATE INDEX IF NOT EXISTS idx_submissions_user_created_at ON submissions(user_i CREATE INDEX IF NOT EXISTS idx_submissions_problem_created_at ON submissions(problem_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_submissions_contest_user_created_at ON submissions(contest_id, user_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_problem_tags_tag ON problem_tags(tag); +CREATE INDEX IF NOT EXISTS idx_contest_modifiers_contest_active ON contest_modifiers(contest_id, is_active, id); +CREATE INDEX IF NOT EXISTS idx_seasons_status_range ON seasons(status, starts_at, ends_at); +CREATE INDEX IF NOT EXISTS idx_season_reward_tracks_season_tier ON season_reward_tracks(season_id, tier_no, required_xp); +CREATE INDEX IF NOT EXISTS idx_season_user_progress_user ON season_user_progress(user_id, season_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_season_reward_claims_user ON season_reward_claims(user_id, season_id, claimed_at DESC); +CREATE INDEX IF NOT EXISTS idx_loot_drop_logs_user_created ON loot_drop_logs(user_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_kb_article_links_problem_id ON kb_article_links(problem_id); +CREATE INDEX IF NOT EXISTS idx_kb_knowledge_claims_user_article ON kb_knowledge_claims(user_id, article_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_kb_weekly_tasks_user_week_order ON kb_weekly_tasks(user_id, week_key, order_no, id); +CREATE INDEX IF NOT EXISTS idx_kb_weekly_tasks_user_week_completed ON kb_weekly_tasks(user_id, week_key, completed_at); +CREATE INDEX IF NOT EXISTS idx_kb_weekly_bonus_logs_user_week ON kb_weekly_bonus_logs(user_id, week_key); CREATE INDEX IF NOT EXISTS idx_import_jobs_created_at ON import_jobs(created_at DESC); CREATE INDEX IF NOT EXISTS idx_import_jobs_status ON import_jobs(status, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_import_job_items_job_status ON import_job_items(job_id, status, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_crawler_targets_status_updated ON crawler_targets(status, updated_at ASC); +CREATE INDEX IF NOT EXISTS idx_crawler_runs_target_created ON crawler_runs(target_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_problem_drafts_updated ON problem_drafts(updated_at DESC); CREATE INDEX IF NOT EXISTS idx_problem_solution_jobs_problem ON problem_solution_jobs(problem_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_problem_solutions_problem ON problem_solutions(problem_id, variant, id); @@ -616,8 +1047,18 @@ CREATE INDEX IF NOT EXISTS idx_solution_view_logs_user_problem ON problem_soluti CREATE INDEX IF NOT EXISTS idx_solution_view_logs_user_day ON problem_solution_view_logs(user_id, day_key, viewed_at DESC); CREATE INDEX IF NOT EXISTS idx_redeem_items_active ON redeem_items(is_active, id); CREATE INDEX IF NOT EXISTS idx_redeem_records_user_created ON redeem_records(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_user_experience_logs_user_created ON user_experience_logs(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_source_crystal_tx_user_created ON source_crystal_transactions(user_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_daily_task_logs_user_day ON daily_task_logs(user_id, day_key, created_at DESC); )SQL"); + + db.Exec( + "INSERT OR IGNORE INTO user_experience(user_id,xp,updated_at) " + "SELECT id, CASE WHEN rating>0 THEN rating ELSE 0 END, strftime('%s','now') FROM users;"); + + db.Exec( + "INSERT OR IGNORE INTO source_crystal_settings(id,monthly_interest_rate,updated_at) " + "VALUES(1,0.02,strftime('%s','now'));"); } void SeedDemoData(SqliteDb& db) { @@ -709,6 +1150,357 @@ void SeedDemoData(SqliteDb& db) { if (a2 && p2) InsertKbLink(raw, *a2, *p2); } + // Curated skill-tree knowledge base (always upsert to keep latest structure). + { + const std::string cpp14_md = R"MD( +# C++14 全栈技能树(面向 CSP) + +## 目标与使用方式 +- 目标:把 C++14 从“能写”升级到“稳、快、可评测”。 +- 使用方式:每学完一个知识点,就在页面里领取对应知识点奖励。 +- 评测规范:默认按 C++14 思维编码,避免 C++17 特性;`long long` 使用 `%lld`;`main` 返回 `int` 且 `return 0;`。 + +## 知识图谱(从入门到进阶) +### 1. 语法与输入输出基础 +- `cin/cout` 快速 IO、`scanf/printf` 基本格式。 +- 字符串读取(`getline`)和空白字符处理。 +- 常见格式坑:多组输入、行尾空格、输出格式严格匹配。 + +### 2. 数据结构基础 +- 数组、`vector`、`string`。 +- `map/set/unordered_map` 适用场景。 +- 栈队列(`stack/queue/deque/priority_queue`)模板化思维。 + +### 3. 算法基础 +- 排序、二分、贪心直觉。 +- 前缀和、差分、双指针、滑窗。 +- DFS/BFS 基础搜索框架与剪枝。 + +### 4. 动态规划与复杂度控制 +- 一维/二维 DP 的状态设计。 +- 转移方程推导顺序。 +- 内存压缩、滚动数组、复杂度上界估算。 + +## 详细知识点清单(建议打卡顺序) +1. 输入输出与格式化:掌握 `%d/%lld`、读整行、格式严格匹配。 +2. 类型系统与边界:掌握整型溢出与强转风险。 +3. 函数与引用:掌握参数传递、函数拆分和复用。 +4. 数组与字符串:掌握下标边界、初始化、防越界。 +5. STL 容器与算法:掌握 `sort/lower_bound`、迭代器使用。 +6. 排序与贪心:掌握比较器、贪心反例验证。 +7. 前缀和与差分:掌握区间查询/更新模型。 +8. 动态规划入门:掌握状态与转移设计。 +9. DFS/BFS:掌握搜索顺序、剪枝、去重。 +10. 调试与复杂度:掌握测试数据设计与超时定位。 + +## 训练节奏建议(4 周) +- 第 1 周:语法+IO+数组字符串,每天 2 题。 +- 第 2 周:STL+排序+二分,每天 2~3 题。 +- 第 3 周:前缀和+搜索,每天 2 题。 +- 第 4 周:DP 入门+综合复盘,每天 1~2 题。 + +## 失分点与避坑 +- 边界漏判:`n=0/1`、负数、重复值。 +- 复杂度超限:未提前估算 O(n^2) 是否可过。 +- 提交错误:未清理调试输出、输入输出格式不一致。 + +## 实战检查清单 +- [ ] 代码符合 C++14(无 C++17 语法)。 +- [ ] main 函数返回 int 且 return 0。 +- [ ] long long 的输入输出格式正确。 +- [ ] 关键循环复杂度可解释。 +- [ ] 边界样例已自测。 +)MD"; + + const std::string github_md = R"MD( +# GitHub 仓库协作技能树(竞赛团队与项目实战) + +## 目标 +- 能独立完成:分支开发 → 提交 → PR → Review → 合并发布。 +- 形成稳定协作规范,避免“代码在我机子上能跑”问题。 + +## 分层知识 +### 1. 仓库结构与分支策略 +- 主分支 `main`:可发布版本。 +- 开发分支 `dev`:集成测试版本。 +- 功能分支 `feature/*`:每个功能独立开发。 + +### 2. 提交规范 +- 一次提交只做一件事(原子提交)。 +- 提交信息模板:`type(scope): summary`。 +- 提交前自检:lint/test/build。 + +### 3. PR 与 Review +- PR 描述包含:背景、改动点、风险、验证结果。 +- Review 关注:正确性、可维护性、回归风险。 +- 评论处理:逐条回应并补充验证。 + +### 4. 冲突处理与历史整理 +- 学会 `rebase`,减少无意义 merge commit。 +- 冲突处理后必须重新跑测试。 +- 出问题可回滚到 tag 或稳定提交。 + +## 详细技能点清单 +1. 仓库与分支模型。 +2. 提交规范与代码整洁。 +3. PR 模板与审查习惯。 +4. rebase 与冲突处理。 +5. tag 与版本发布。 +6. CI 流水线基础。 +7. 密钥与权限安全。 + +## 团队协作红线 +- 不在 `main` 直接开发。 +- 不提交大段无关重构与功能混改。 +- 不上传密钥、token、服务器密码。 +- 不跳过 review 直接合并高风险改动。 +)MD"; + + const std::string linux_md = R"MD( +# Linux 服务器基础技能树(开发与运维入门) + +## 目标 +- 会看日志、会查端口、会看进程、会重启服务、会排查故障。 +- 形成“先观测、再操作、可回滚”的稳定运维习惯。 + +## 核心模块 +### 1. Shell 与文件系统 +- `pwd/ls/cd/cat/less/tail/head` +- `grep/rg/find` 定位配置和错误日志。 +- 目录权限与用户归属(`chmod/chown`)。 + +### 2. 进程与资源 +- `ps/top/htop` 看 CPU/内存占用。 +- `free/df/du` 看内存和磁盘。 +- OOM 与磁盘打满的典型处理流程。 + +### 3. 网络与端口 +- `ss -lntp` 看监听端口。 +- `curl/wget` 验证服务连通性。 +- 反向代理、内网服务、端口映射基本概念。 + +### 4. 服务管理 +- `systemctl start/stop/restart/status` +- `journalctl -u ` 排查启动失败。 +- 开机自启与配置变更后的重载策略。 + +### 5. 备份恢复 +- 数据目录与配置目录分离。 +- 定时备份 + 恢复演练 + 校验。 +- 事故时优先保证可恢复与数据一致性。 + +## 详细技能点清单 +1. Shell 基础命令。 +2. 进程与资源监控。 +3. 网络与端口排查。 +4. systemd 服务管理。 +5. 权限与用户组。 +6. 日志与故障定位。 +7. 备份与恢复演练。 + +## 故障排查顺序建议 +1. 服务是否在运行。 +2. 端口是否监听。 +3. 反代是否转发。 +4. 日志是否出现错误栈。 +5. 最近变更(配置/发布)是否引入问题。 +)MD"; + + const std::string cs_md = R"MD( +# 计算机基础技能树(算法学习必备底座) + +## 目标 +- 建立“算法 + 系统 + 工程”的统一认知,不只会刷题,也能解释程序为什么这样运行。 + +## 模块一:数据表示 +- 二进制、补码、位运算。 +- 字符编码(ASCII/UTF-8)。 +- 浮点误差与比较策略。 + +## 模块二:程序执行模型 +- 栈、堆、静态区。 +- 函数调用、参数压栈、递归深度。 +- 越界、悬垂引用、未定义行为风险。 + +## 模块三:复杂度与性能 +- 时间复杂度、空间复杂度。 +- 输入规模与算法选择。 +- 常数优化与缓存友好性直觉。 + +## 模块四:操作系统与网络 +- 进程/线程、上下文切换。 +- 文件系统与 IO。 +- TCP/UDP、HTTP 请求链路。 + +## 模块五:数据库与安全基础 +- 索引、事务、锁。 +- 注入风险、鉴权边界、最小权限。 +- 日志脱敏与凭据保护。 + +## 详细技能点清单 +1. 二进制与编码。 +2. 内存模型与指针意识。 +3. 复杂度与可扩展性。 +4. 操作系统基本机制。 +5. 网络协议基础。 +6. 数据库基础。 +7. 安全基本面。 + +## 学习建议 +- 每周固定 2 次“基础课”,每次 45 分钟。 +- 每个知识点都配 1 个小实验(命令验证或小程序验证)。 +- 和题目训练联动:每周总结“这周哪道题体现了哪个基础知识点”。 +)MD"; + + const std::string web_cpp_md = R"MD( +# C++ Web 开发技能树(从算法到可上线服务) + +## 目标 +- 用 C++ 做服务端开发,完成“接口设计 → 编码实现 → 本地联调 → 线上部署”闭环。 +- 保持 CSP/OI 风格:重视性能、边界与可观测性。 + +## C++ 视角的 Web 基础 +### 1) 协议与请求模型 +- 理解 HTTP 请求/响应、状态码、Header、Body。 +- 区分 GET/POST/PUT/PATCH/DELETE 的语义。 +- 学会设计稳定 JSON 响应格式(`ok/data/error`)。 + +### 2) 路由与控制器 +- 学会把 URL 映射到 C++ 处理函数(Controller)。 +- 参数校验:路径参数、query 参数、JSON Body。 +- 错误返回统一化:400/401/403/404/500。 + +### 3) 数据库与事务 +- 用 SQLite/MySQL 做增删改查。 +- 理解事务边界与并发写入冲突。 +- 学会索引设计与慢查询定位。 + +### 4) 鉴权与安全 +- 账号密码 + token/session 模型。 +- 最小权限:普通用户与管理员隔离。 +- 输入校验、防注入、敏感信息脱敏。 + +### 5) 工程化与上线 +- 日志分级(info/warn/error)与错误追踪。 +- Nginx 反代、HTTPS、证书续期。 +- 健康检查、重启策略、自动化部署。 + +## C++14 最小接口示例(伪代码) +```cpp +void GetProfile(const HttpRequestPtr& req, Callback&& cb) { + auto uid = RequireAuth(req); + if (!uid) return cb(JsonError(401, "unauthorized")); + auto user = user_service.GetById(*uid); + if (!user) return cb(JsonError(404, "user not found")); + Json::Value data; + data["id"] = Json::Int64(user->id); + data["username"] = user->username; + return cb(JsonOk(data)); +} +``` + +## 学习路线(4 周) +- 第 1 周:HTTP + 路由 + JSON。 +- 第 2 周:数据库 CRUD + 参数校验。 +- 第 3 周:鉴权 + 权限 + 日志。 +- 第 4 周:部署 + 监控 + 故障演练。 + +## 常见坑 +- 把异常直接暴露给前端(不安全)。 +- 接口返回结构不统一,前端处理复杂。 +- 数据库锁冲突时缺少重试与告警。 + +## 建议实践任务 +- 实现一个“题目收藏”接口(增删查)。 +- 实现一个“用户提交记录”分页接口(按时间筛选)。 +- 加入统一错误码与请求日志追踪字段。 +)MD"; + + const std::string game_cpp_md = R"MD( +# C++ 游戏开发技能树(从算法训练到实时交互) + +## 目标 +- 用 C++ 构建可运行的小型 2D 游戏原型。 +- 建立“数据结构 + 数学 + 渲染 + 性能”的整体认知。 + +## C++ 视角的游戏核心 +### 1) 游戏循环(Game Loop) +- 固定更新 + 渲染分离。 +- 处理输入、更新状态、绘制画面。 +- 使用 `deltaTime` 避免帧率依赖。 + +### 2) 数学基础 +- 向量与坐标系(位置、速度、加速度)。 +- 碰撞检测(AABB、圆形碰撞)与简单响应。 +- 插值、角度、方向单位向量。 + +### 3) 资源与场景 +- 贴图、音频、字体加载与生命周期管理。 +- 场景切换(主菜单/关卡/结算)。 +- 对象管理:玩家、敌人、子弹、道具。 + +### 4) 架构与代码组织 +- 从面向过程到组件化(ECS 思维入门)。 +- 把“渲染、逻辑、输入”拆分为模块。 +- 配置与常量表驱动,减少硬编码。 + +### 5) 调试与优化 +- 帧时间统计与瓶颈定位。 +- 减少频繁内存分配,复用对象池。 +- 用日志和调试可视化定位状态异常。 + +## C++14 最小循环示例(伪代码) +```cpp +while (running) { + float dt = timer.Tick(); + HandleInput(); + UpdateWorld(dt); + RenderFrame(); +} +``` + +## 学习路线(4 周) +- 第 1 周:循环 + 输入 + 基础渲染。 +- 第 2 周:角色移动 + 碰撞系统。 +- 第 3 周:关卡与 UI + 存档。 +- 第 4 周:性能优化 + 发布打包。 + +## 常见坑 +- 逻辑与渲染强耦合,后期难扩展。 +- 帧率变化导致移动速度异常。 +- 资源重复加载导致卡顿与内存上涨。 + +## 建议实践任务 +- 做一个“躲避方块”小游戏(计时 + 计分)。 +- 做一个“像素迷宫”小游戏(碰撞 + 关卡)。 +- 增加“暂停、继续、重开”状态机。 +)MD"; + + const auto a_cpp14 = EnsureKbArticle(raw, "cpp14-skill-tree", "C++14 全栈技能树(CSP)", cpp14_md, now); + const auto a_git = EnsureKbArticle(raw, "github-collaboration-basics", "GitHub 仓库协作基础", github_md, now); + const auto a_linux = EnsureKbArticle(raw, "linux-server-basics", "Linux 服务器基础", linux_md, now); + const auto a_cs = EnsureKbArticle(raw, "computer-fundamentals-for-oi", "计算机基础(OI 视角)", cs_md, now); + const auto a_web_cpp = EnsureKbArticle(raw, "cpp-web-development-basics", "Web 开发(C++ 基础)", web_cpp_md, now); + const auto a_game_cpp = EnsureKbArticle(raw, "cpp-game-development-basics", "游戏开发(C++ 基础)", game_cpp_md, now); + + const auto p1 = QueryOneId(raw, "SELECT id FROM problems WHERE slug='a-plus-b'"); + const auto p2 = QueryOneId(raw, "SELECT id FROM problems WHERE slug='fibonacci-n'"); + const auto p3 = QueryOneId(raw, "SELECT id FROM problems WHERE slug='sort-numbers'"); + const auto p_any1 = QueryOneId(raw, "SELECT id FROM problems ORDER BY id LIMIT 1"); + const auto p_any2 = QueryOneId(raw, "SELECT id FROM problems ORDER BY id LIMIT 1 OFFSET 1"); + if (p1) InsertKbLink(raw, a_cpp14, *p1); + if (p2) InsertKbLink(raw, a_cpp14, *p2); + if (p3) InsertKbLink(raw, a_cpp14, *p3); + if (p1) InsertKbLink(raw, a_cs, *p1); + if (p2) InsertKbLink(raw, a_cs, *p2); + if (p3) InsertKbLink(raw, a_web_cpp, *p3); + else if (p_any1) InsertKbLink(raw, a_web_cpp, *p_any1); + if (p2) InsertKbLink(raw, a_game_cpp, *p2); + else if (p_any2) InsertKbLink(raw, a_game_cpp, *p_any2); + (void)a_git; + (void)a_linux; + } + if (CountRows(raw, "contests") == 0) { InsertContest( raw, @@ -726,6 +1518,63 @@ void SeedDemoData(SqliteDb& db) { if (contest_id && p2) InsertContestProblem(raw, *contest_id, *p2, 2); } + if (CountRows(raw, "contest_modifiers") == 0) { + const auto contest_id = QueryOneId(raw, "SELECT id FROM contests ORDER BY id LIMIT 1"); + if (contest_id.has_value()) { + InsertContestModifier( + raw, + *contest_id, + "cpp14_only", + "远古工艺:仅 C++14", + "副本中请使用 C++14 语法,不可依赖更高标准特性。", + R"({"language":"cpp14","forbid":["concepts","ranges","coroutine"]})", + 1, + now); + } + } + + if (CountRows(raw, "seasons") == 0) { + InsertSeason( + raw, + "season-2026-s1", + "2026 第一赛季:像素远征", + now - 7 * 24 * 3600, + now + 60 * 24 * 3600, + "active", + R"({"name":"像素远征","theme":"minecraft","season_pass":true})", + now); + } + + if (CountRows(raw, "season_reward_tracks") == 0) { + const auto season_id = QueryOneId(raw, "SELECT id FROM seasons WHERE status='active' ORDER BY id DESC LIMIT 1"); + if (season_id.has_value()) { + InsertSeasonRewardTrack( + raw, + *season_id, + 1, + 0, + "free", + 5, + R"({"item_name":"绿宝石补给","rarity":"common"})"); + InsertSeasonRewardTrack( + raw, + *season_id, + 2, + 30, + "free", + 15, + R"({"item_name":"铁质宝箱","rarity":"rare"})"); + InsertSeasonRewardTrack( + raw, + *season_id, + 3, + 80, + "free", + 30, + R"({"item_name":"钻石宝箱","rarity":"epic"})"); + } + } + if (CountRows(raw, "redeem_items") == 0) { InsertRedeemItem( raw, diff --git a/backend/src/domain/json.cc b/backend/src/domain/json.cc index 5c28ff8..763491d 100644 --- a/backend/src/domain/json.cc +++ b/backend/src/domain/json.cc @@ -90,6 +90,82 @@ Json::Value ToJson(const Contest& c) { return j; } +Json::Value ToJson(const ContestModifier& c) { + Json::Value j; + j["id"] = Json::Int64(c.id); + j["contest_id"] = Json::Int64(c.contest_id); + j["code"] = c.code; + j["title"] = c.title; + j["description"] = c.description; + j["rule_json"] = c.rule_json; + j["is_active"] = c.is_active; + j["created_at"] = Json::Int64(c.created_at); + j["updated_at"] = Json::Int64(c.updated_at); + return j; +} + +Json::Value ToJson(const Season& s) { + Json::Value j; + j["id"] = Json::Int64(s.id); + j["key"] = s.key; + j["title"] = s.title; + j["starts_at"] = Json::Int64(s.starts_at); + j["ends_at"] = Json::Int64(s.ends_at); + j["status"] = s.status; + j["pass_json"] = s.pass_json; + j["created_at"] = Json::Int64(s.created_at); + j["updated_at"] = Json::Int64(s.updated_at); + return j; +} + +Json::Value ToJson(const SeasonRewardTrack& t) { + Json::Value j; + j["id"] = Json::Int64(t.id); + j["season_id"] = Json::Int64(t.season_id); + j["tier_no"] = t.tier_no; + j["required_xp"] = t.required_xp; + j["reward_type"] = t.reward_type; + j["reward_value"] = t.reward_value; + j["reward_meta_json"] = t.reward_meta_json; + return j; +} + +Json::Value ToJson(const SeasonUserProgress& p) { + Json::Value j; + j["season_id"] = Json::Int64(p.season_id); + j["user_id"] = Json::Int64(p.user_id); + j["xp"] = p.xp; + j["level"] = p.level; + j["updated_at"] = Json::Int64(p.updated_at); + return j; +} + +Json::Value ToJson(const SeasonRewardClaim& c) { + Json::Value j; + j["id"] = Json::Int64(c.id); + j["season_id"] = Json::Int64(c.season_id); + j["user_id"] = Json::Int64(c.user_id); + j["tier_no"] = c.tier_no; + j["reward_type"] = c.reward_type; + j["claimed_at"] = Json::Int64(c.claimed_at); + return j; +} + +Json::Value ToJson(const LootDropLog& l) { + Json::Value j; + j["id"] = Json::Int64(l.id); + j["user_id"] = Json::Int64(l.user_id); + j["source_type"] = l.source_type; + j["source_id"] = Json::Int64(l.source_id); + j["item_code"] = l.item_code; + j["item_name"] = l.item_name; + j["rarity"] = l.rarity; + j["amount"] = l.amount; + j["meta_json"] = l.meta_json; + j["created_at"] = Json::Int64(l.created_at); + return j; +} + Json::Value ToJson(const KbArticle& a) { Json::Value j; j["id"] = Json::Int64(a.id); @@ -105,6 +181,7 @@ Json::Value ToJson(const GlobalLeaderboardEntry& e) { j["user_id"] = Json::Int64(e.user_id); j["username"] = e.username; j["rating"] = e.rating; + j["period_score"] = e.period_score; j["created_at"] = Json::Int64(e.created_at); return j; } diff --git a/backend/src/main.cc b/backend/src/main.cc index 6bf5ee9..c08172b 100644 --- a/backend/src/main.cc +++ b/backend/src/main.cc @@ -2,8 +2,11 @@ #include "csp/app_state.h" #include "csp/services/auth_service.h" +#include "csp/services/crawler_runner.h" +#include "csp/services/db_lock_guard.h" #include "csp/services/import_runner.h" #include "csp/services/kb_import_runner.h" +#include "csp/services/lark_bot_service.h" #include "csp/services/problem_gen_runner.h" #include "csp/services/problem_solution_runner.h" #include "csp/services/submission_feedback_runner.h" @@ -22,6 +25,9 @@ int main(int argc, char** argv) { csp::services::ProblemSolutionRunner::Instance().Configure(db_path); csp::services::ProblemGenRunner::Instance().Configure(db_path); csp::services::SubmissionFeedbackRunner::Instance().Configure(db_path); + csp::services::DbLockGuard::Instance().Configure(db_path); + csp::services::CrawlerRunner::Instance().Configure(db_path); + csp::services::LarkBotService::Instance().ConfigureFromEnv(); // Optional seed admin user for dev/test. { @@ -53,6 +59,10 @@ int main(int argc, char** argv) { // Auto-queue submission feedback generation for submissions without feedback. csp::services::SubmissionFeedbackRunner::Instance().AutoStartIfEnabled( csp::AppState::Instance().db()); + // Periodic SQLite lock guardian (best-effort self-healing). + csp::services::DbLockGuard::Instance().StartIfEnabled(); + // Periodic crawler generator/tester/runner for submitted website URLs. + csp::services::CrawlerRunner::Instance().StartIfEnabled(); // CORS (dev-friendly). In production, prefer reverse proxy same-origin. drogon::app().registerPreRoutingAdvice([](const drogon::HttpRequestPtr& req, diff --git a/backend/src/services/auth_service.cc b/backend/src/services/auth_service.cc index 6eba367..28bf227 100644 --- a/backend/src/services/auth_service.cc +++ b/backend/src/services/auth_service.cc @@ -72,31 +72,13 @@ AuthResult AuthService::Register(const std::string& username, AuthResult AuthService::Login(const std::string& username, const std::string& password) { + const auto user_id_opt = VerifyCredentials(username, password); + if (!user_id_opt.has_value()) { + throw std::runtime_error("invalid credentials"); + } + const int user_id = *user_id_opt; + sqlite3* db = db_.raw(); - sqlite3_stmt* stmt = nullptr; - const char* sql = - "SELECT id,password_salt,password_hash FROM users WHERE username=?"; - CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, - "prepare select user"); - CheckSqlite(sqlite3_bind_text(stmt, 1, username.c_str(), -1, SQLITE_TRANSIENT), - db, "bind username"); - - const int rc = sqlite3_step(stmt); - if (rc != SQLITE_ROW) { - sqlite3_finalize(stmt); - throw std::runtime_error("invalid credentials"); - } - - const int user_id = sqlite3_column_int(stmt, 0); - const auto salt = StepText(stmt, 1); - const auto stored = StepText(stmt, 2); - sqlite3_finalize(stmt); - - const auto computed = crypto::Sha256Hex(salt + ":" + password); - if (computed != stored) { - throw std::runtime_error("invalid credentials"); - } - const auto token = crypto::RandomHex(32); const auto now = NowSec(); const auto expires = now + 7 * 24 * 3600; @@ -124,6 +106,37 @@ AuthResult AuthService::Login(const std::string& username, return AuthResult{.user_id = user_id, .token = token, .expires_at = expires}; } +std::optional AuthService::VerifyCredentials(const std::string& username, + const std::string& password) { + if (username.empty() || password.empty()) return std::nullopt; + + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,password_salt,password_hash FROM users WHERE username=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare select user"); + CheckSqlite(sqlite3_bind_text(stmt, 1, username.c_str(), -1, SQLITE_TRANSIENT), + db, "bind username"); + + const int rc = sqlite3_step(stmt); + if (rc != SQLITE_ROW) { + sqlite3_finalize(stmt); + return std::nullopt; + } + + const int user_id = sqlite3_column_int(stmt, 0); + const auto salt = StepText(stmt, 1); + const auto stored = StepText(stmt, 2); + sqlite3_finalize(stmt); + + const auto computed = crypto::Sha256Hex(salt + ":" + password); + if (computed != stored) { + return std::nullopt; + } + return user_id; +} + void AuthService::ResetPassword(const std::string& username, const std::string& new_password) { if (username.empty() || new_password.size() < 6) { diff --git a/backend/src/services/crawler_runner.cc b/backend/src/services/crawler_runner.cc new file mode 100644 index 0000000..c172ff5 --- /dev/null +++ b/backend/src/services/crawler_runner.cc @@ -0,0 +1,589 @@ +#include "csp/services/crawler_runner.h" + +#include "csp/db/sqlite_db.h" +#include "csp/services/crawler_service.h" + +#include +#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(); +} + +std::string Trim(const std::string& s) { + const auto begin = s.find_first_not_of(" \t\r\n"); + if (begin == std::string::npos) return {}; + const auto end = s.find_last_not_of(" \t\r\n"); + return s.substr(begin, end - begin + 1); +} + +std::string EnvStr(const char* key, const std::string& default_value) { + const char* raw = std::getenv(key); + if (!raw) return default_value; + return std::string(raw); +} + +bool EnvBool(const char* key, bool default_value) { + const std::string raw = Trim(EnvStr(key, "")); + if (raw.empty()) return default_value; + std::string val; + val.reserve(raw.size()); + for (char c : raw) { + val.push_back(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, int min_value, int max_value) { + const std::string raw = Trim(EnvStr(key, "")); + if (raw.empty()) return default_value; + try { + const int parsed = std::stoi(raw); + if (parsed < min_value) return min_value; + if (parsed > max_value) return max_value; + return parsed; + } catch (...) { + return default_value; + } +} + +std::string JsonToString(const Json::Value& value) { + Json::StreamWriterBuilder wb; + wb["indentation"] = ""; + return Json::writeString(wb, value); +} + +bool ParseJson(const std::string& text, Json::Value& out) { + Json::CharReaderBuilder rb; + std::string errs; + std::unique_ptr reader(rb.newCharReader()); + return reader->parse(text.data(), text.data() + text.size(), &out, &errs); +} + +struct ParsedUrl { + std::string origin; + std::string path; +}; + +bool ParseUrl(const std::string& url, ParsedUrl& out) { + const std::string u = Trim(url); + const auto scheme_pos = u.find("://"); + if (scheme_pos == std::string::npos) return false; + const auto path_pos = u.find('/', scheme_pos + 3); + if (path_pos == std::string::npos) { + out.origin = u; + out.path = "/"; + return true; + } + out.origin = u.substr(0, path_pos); + out.path = u.substr(path_pos); + return !out.origin.empty() && !out.path.empty(); +} + +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 ReadFile(const std::string& path) { + std::ifstream in(path); + if (!in.good()) return {}; + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); +} + +std::string ClipUtf8(const std::string& s, size_t max_bytes) { + if (s.size() <= max_bytes) return s; + size_t cut = max_bytes; + while (cut > 0 && (static_cast(s[cut]) & 0xC0) == 0x80) { + --cut; + } + return s.substr(0, cut); +} + +std::string ExtractLlmText(const Json::Value& root) { + if (root.isMember("choices") && root["choices"].isArray() && + root["choices"].size() > 0) { + const auto& first = root["choices"][0]; + if (first.isMember("message") && first["message"].isObject()) { + const auto& content = first["message"]["content"]; + if (content.isString()) return content.asString(); + if (content.isArray()) { + std::string combined; + for (const auto& part : content) { + if (part.isString()) { + combined += part.asString(); + } else if (part.isObject() && part.isMember("text") && + part["text"].isString()) { + combined += part["text"].asString(); + } + } + return combined; + } + } + if (first.isMember("text") && first["text"].isString()) { + return first["text"].asString(); + } + } + if (root.isMember("output_text") && root["output_text"].isString()) { + return root["output_text"].asString(); + } + return {}; +} + +std::string ExtractJsonObject(const std::string& text) { + const auto begin = text.find('{'); + if (begin == std::string::npos) return {}; + + int depth = 0; + bool in_string = false; + bool escaped = false; + for (size_t i = begin; i < text.size(); ++i) { + const char c = text[i]; + if (escaped) { + escaped = false; + continue; + } + if (c == '\\') { + escaped = true; + continue; + } + if (c == '"') { + in_string = !in_string; + continue; + } + if (in_string) continue; + if (c == '{') { + ++depth; + } else if (c == '}') { + --depth; + if (depth == 0) { + return text.substr(begin, i - begin + 1); + } + } + } + return {}; +} + +Json::Value BuildFallbackRule(const std::string& url) { + Json::Value rule; + rule["start_url"] = url; + rule["extract_strategy"] = "generic_html"; + Json::Value block(Json::arrayValue); + block.append("登录"); + block.append("注册"); + block.append("广告"); + rule["block_keywords"] = block; + rule["note"] = "fallback rule"; + return rule; +} + +bool HttpPostJson(const ParsedUrl& endpoint, + const std::string& body, + const std::vector>& headers, + double timeout_sec, + std::string& response_body, + std::string& err) { + auto client = drogon::HttpClient::newHttpClient(endpoint.origin); + if (!client) { + err = "http client init failed"; + return false; + } + auto req = drogon::HttpRequest::newHttpRequest(); + req->setMethod(drogon::Post); + req->setPath(endpoint.path); + req->setBody(body); + for (const auto& kv : headers) req->addHeader(kv.first, kv.second); + + const auto result = client->sendRequest(req, timeout_sec); + if (result.first != drogon::ReqResult::Ok || !result.second) { + err = "http request failed"; + return false; + } + response_body = result.second->body(); + if (result.second->statusCode() < 200 || result.second->statusCode() >= 300) { + err = "http status " + std::to_string(static_cast(result.second->statusCode())); + return false; + } + return true; +} + +std::string BuildScriptContent(const std::string& rule_json) { + std::ostringstream ss; + ss << "#!/usr/bin/env python3\n"; + ss << "import argparse\n"; + ss << "import json\n"; + ss << "import re\n"; + ss << "import sys\n"; + ss << "import urllib.request\n\n"; + ss << "RULE = json.loads(r'''"; + ss << rule_json; + ss << "''')\n\n"; + ss << "def strip_html(html: str) -> str:\n"; + ss << " html = re.sub(r'(?is).*?', ' ', html)\n"; + ss << " html = re.sub(r'(?is).*?', ' ', html)\n"; + ss << " html = re.sub(r'(?s)<[^>]+>', ' ', html)\n"; + ss << " return re.sub(r'\\s+', ' ', html).strip()\n\n"; + ss << "def extract_title(html: str) -> str:\n"; + ss << " m = re.search(r'(?is)]*>(.*?)', html)\n"; + ss << " if not m:\n"; + ss << " return ''\n"; + ss << " return re.sub(r'\\s+', ' ', m.group(1)).strip()\n\n"; + ss << "def main() -> int:\n"; + ss << " parser = argparse.ArgumentParser()\n"; + ss << " parser.add_argument('--url', required=True)\n"; + ss << " parser.add_argument('--timeout', type=int, default=20)\n"; + ss << " parser.add_argument('--max-chars', type=int, default=4000)\n"; + ss << " args = parser.parse_args()\n\n"; + ss << " out = {'ok': False, 'http_status': 0, 'title': '', 'excerpt': '', " + "'length': 0, 'error': ''}\n"; + ss << " try:\n"; + ss << " req = urllib.request.Request(args.url, headers={" + "'User-Agent': 'CSPCrawler/1.0'})\n"; + ss << " with urllib.request.urlopen(req, timeout=max(3, args.timeout)) as resp:\n"; + ss << " body = resp.read()\n"; + ss << " charset = None\n"; + ss << " if resp.headers:\n"; + ss << " charset = resp.headers.get_content_charset()\n"; + ss << " if not charset:\n"; + ss << " charset = 'utf-8'\n"; + ss << " html = body.decode(charset, errors='ignore')\n"; + ss << " text = strip_html(html)\n"; + ss << " for kw in RULE.get('block_keywords', []):\n"; + ss << " if isinstance(kw, str) and kw:\n"; + ss << " text = text.replace(kw, ' ')\n"; + ss << " text = re.sub(r'\\s+', ' ', text).strip()\n"; + ss << " out['ok'] = True\n"; + ss << " out['http_status'] = getattr(resp, 'status', 200)\n"; + ss << " out['title'] = extract_title(html)[:200]\n"; + ss << " out['excerpt'] = text[:max(0, args.max_chars)]\n"; + ss << " out['length'] = len(text)\n"; + ss << " out['rule'] = RULE\n"; + ss << " except Exception as e:\n"; + ss << " out['error'] = str(e)\n"; + ss << " print(json.dumps(out, ensure_ascii=False))\n"; + ss << " return 0 if out.get('ok') else 2\n\n"; + ss << "if __name__ == '__main__':\n"; + ss << " sys.exit(main())\n"; + return ss.str(); +} + +} // namespace + +CrawlerRunner& CrawlerRunner::Instance() { + static CrawlerRunner inst; + return inst; +} + +void CrawlerRunner::Configure(std::string db_path) { + std::lock_guard lock(mu_); + db_path_ = std::move(db_path); + enabled_ = EnvBool("CSP_CRAWLER_ENABLED", true); + interval_sec_ = EnvInt("CSP_CRAWLER_INTERVAL_SEC", 15, 3, 600); + active_requeue_interval_sec_ = + EnvInt("CSP_CRAWLER_REQUEUE_INTERVAL_SEC", 43200, 0, 7 * 24 * 3600); + fetch_timeout_sec_ = EnvInt("CSP_CRAWLER_FETCH_TIMEOUT_SEC", 20, 5, 120); + script_dir_ = Trim(EnvStr("CSP_CRAWLER_SCRIPT_DIR", "/data/crawlers")); + llm_api_url_ = Trim(EnvStr("CSP_CRAWLER_LLM_API_URL", EnvStr("OI_LLM_API_URL", ""))); + llm_api_key_ = Trim(EnvStr("CSP_CRAWLER_LLM_API_KEY", EnvStr("OI_LLM_API_KEY", ""))); + llm_model_ = Trim(EnvStr("CSP_CRAWLER_LLM_MODEL", EnvStr("OI_LLM_MODEL", "qwen3-max"))); + llm_system_prompt_ = EnvStr( + "CSP_CRAWLER_LLM_SYSTEM_PROMPT", + "你是资深爬虫工程师。请根据给定 URL 返回 JSON 规则,字段包含:" + "start_url,extract_strategy,block_keywords(list),note。" + "只返回 JSON,不要 markdown。"); + llm_timeout_sec_ = EnvInt("CSP_CRAWLER_LLM_TIMEOUT_SEC", 30, 5, 180); + + running_ = false; + processed_count_ = 0; + success_count_ = 0; + failed_count_ = 0; + last_started_at_ = 0; + last_finished_at_ = 0; + last_success_at_ = 0; + last_failure_at_ = 0; + last_error_.clear(); + current_target_id_ = 0; + wake_requested_ = false; +} + +void CrawlerRunner::StartIfEnabled() { + { + std::lock_guard lock(mu_); + if (started_ || !enabled_ || db_path_.empty()) return; + started_ = true; + } + std::thread([this]() { WorkerLoop(); }).detach(); + LOG_INFO << "crawler runner started"; +} + +CrawlerRunner::Status CrawlerRunner::GetStatus() { + std::lock_guard lock(mu_); + Status s; + s.enabled = enabled_; + s.started = started_; + s.interval_sec = interval_sec_; + s.active_requeue_interval_sec = active_requeue_interval_sec_; + s.running = running_; + s.processed_count = processed_count_; + s.success_count = success_count_; + s.failed_count = failed_count_; + s.last_started_at = last_started_at_; + s.last_finished_at = last_finished_at_; + s.last_success_at = last_success_at_; + s.last_failure_at = last_failure_at_; + s.last_error = last_error_; + s.current_target_id = current_target_id_; + return s; +} + +void CrawlerRunner::WakeUp() { + { + std::lock_guard lock(mu_); + wake_requested_ = true; + } + cv_.notify_all(); +} + +void CrawlerRunner::WorkerLoop() { + bool immediate = false; + while (true) { + std::string db_path; + std::string llm_api_url; + std::string llm_api_key; + std::string llm_model; + std::string llm_system_prompt; + std::string script_dir; + int interval = 15; + int requeue_interval = 43200; + int fetch_timeout = 20; + int llm_timeout = 30; + bool enabled = true; + + { + std::unique_lock lock(mu_); + if (!immediate) { + cv_.wait_for(lock, + std::chrono::seconds(interval_sec_), + [this]() { return wake_requested_; }); + } + wake_requested_ = false; + immediate = false; + + enabled = enabled_; + db_path = db_path_; + llm_api_url = llm_api_url_; + llm_api_key = llm_api_key_; + llm_model = llm_model_; + llm_system_prompt = llm_system_prompt_; + script_dir = script_dir_; + interval = interval_sec_; + requeue_interval = active_requeue_interval_sec_; + fetch_timeout = fetch_timeout_sec_; + llm_timeout = llm_timeout_sec_; + (void)interval; + } + + if (!enabled || db_path.empty()) continue; + + try { + db::SqliteDb local = db::SqliteDb::OpenFile(db_path); + CrawlerService crawler(local); + + CrawlerTarget target; + if (!crawler.ClaimNextTarget(target)) { + if (requeue_interval > 0) { + CrawlerTarget due; + if (crawler.EnqueueDueActiveTarget(requeue_interval, NowSec(), due)) { + LOG_INFO << "crawler target re-queued by interval id=" << due.id + << ", interval_sec=" << requeue_interval; + immediate = true; + } + } + continue; + } + + { + std::lock_guard lock(mu_); + running_ = true; + current_target_id_ = target.id; + last_started_at_ = NowSec(); + last_error_.clear(); + } + + const auto cleanup_stats = [this]() { + std::lock_guard lock(mu_); + running_ = false; + current_target_id_ = 0; + last_finished_at_ = NowSec(); + processed_count_ += 1; + }; + + std::string rule_json = JsonToString(BuildFallbackRule(target.normalized_url)); + if (!llm_api_url.empty() && !llm_api_key.empty()) { + ParsedUrl endpoint; + if (ParseUrl(llm_api_url, endpoint)) { + Json::Value payload; + payload["model"] = llm_model; + payload["stream"] = false; + Json::Value messages(Json::arrayValue); + Json::Value system_msg; + system_msg["role"] = "system"; + system_msg["content"] = llm_system_prompt; + messages.append(system_msg); + Json::Value user_msg; + user_msg["role"] = "user"; + user_msg["content"] = "URL: " + target.normalized_url; + messages.append(user_msg); + payload["messages"] = messages; + + std::string llm_resp; + std::string llm_err; + if (HttpPostJson(endpoint, + JsonToString(payload), + { + {"Authorization", "Bearer " + llm_api_key}, + {"Content-Type", "application/json"}, + }, + llm_timeout, + llm_resp, + llm_err)) { + Json::Value llm_json; + if (ParseJson(llm_resp, llm_json)) { + const std::string llm_text = Trim(ExtractLlmText(llm_json)); + const std::string json_text = ExtractJsonObject(llm_text); + Json::Value parsed_rule; + if (!json_text.empty() && ParseJson(json_text, parsed_rule) && + parsed_rule.isObject()) { + if (!parsed_rule.isMember("start_url")) { + parsed_rule["start_url"] = target.normalized_url; + } + rule_json = JsonToString(parsed_rule); + } + } + } else { + LOG_WARN << "crawler llm failed: " << llm_err; + } + } + } + + if (script_dir.empty()) script_dir = "data/crawlers"; + std::error_code ec; + std::filesystem::create_directories(script_dir, ec); + const std::string script_path = + script_dir + "/crawler_target_" + std::to_string(target.id) + ".py"; + { + std::ofstream out(script_path); + if (!out.good()) { + const std::string err = "cannot write crawler script"; + crawler.MarkFailed(target.id, err); + crawler.InsertRun(target.id, "failed", 0, "{}", err); + { + std::lock_guard lock(mu_); + failed_count_ += 1; + last_failure_at_ = NowSec(); + last_error_ = err; + } + cleanup_stats(); + continue; + } + out << BuildScriptContent(rule_json); + } + std::filesystem::permissions( + script_path, + std::filesystem::perms::owner_exec | std::filesystem::perms::owner_read | + std::filesystem::perms::owner_write | std::filesystem::perms::group_read, + std::filesystem::perm_options::add, + ec); + + crawler.UpdateGenerated(target.id, rule_json, script_path); + crawler.MarkTesting(target.id); + + const std::string out_file = + script_dir + "/crawler_target_" + std::to_string(target.id) + ".out.json"; + const std::string err_file = + script_dir + "/crawler_target_" + std::to_string(target.id) + ".err.log"; + std::string cmd = "python3 " + ShellQuote(script_path) + " --url " + + ShellQuote(target.normalized_url) + " --timeout " + + std::to_string(fetch_timeout) + " >" + ShellQuote(out_file) + + " 2>" + ShellQuote(err_file); + const int rc = std::system(cmd.c_str()); + const std::string output = ReadFile(out_file); + const std::string error_text = ReadFile(err_file); + + Json::Value output_json; + bool parse_ok = ParseJson(output, output_json); + bool ok = (rc == 0) && parse_ok && output_json.get("ok", false).asBool(); + const int http_status = + parse_ok ? output_json.get("http_status", 0).asInt() : 0; + + if (ok) { + const int64_t now = NowSec(); + crawler.InsertRun(target.id, "success", http_status, output, ""); + crawler.MarkActive(target.id, now); + { + std::lock_guard lock(mu_); + success_count_ += 1; + last_success_at_ = now; + last_error_.clear(); + } + } else { + std::string err = "crawler test failed"; + if (!error_text.empty()) err = ClipUtf8(error_text, 1200); + if (parse_ok && output_json.isObject() && output_json.isMember("error")) { + const std::string json_err = output_json["error"].asString(); + if (!json_err.empty()) err = ClipUtf8(json_err, 1200); + } + crawler.InsertRun(target.id, "failed", http_status, output, err); + crawler.MarkFailed(target.id, err); + { + std::lock_guard lock(mu_); + failed_count_ += 1; + last_failure_at_ = NowSec(); + last_error_ = err; + } + } + + cleanup_stats(); + immediate = true; + } catch (const std::exception& e) { + std::lock_guard lock(mu_); + running_ = false; + current_target_id_ = 0; + last_finished_at_ = NowSec(); + failed_count_ += 1; + last_failure_at_ = last_finished_at_; + last_error_ = e.what(); + } + } +} + +} // namespace csp::services diff --git a/backend/src/services/crawler_service.cc b/backend/src/services/crawler_service.cc new file mode 100644 index 0000000..97cb6f6 --- /dev/null +++ b/backend/src/services/crawler_service.cc @@ -0,0 +1,557 @@ +#include "csp/services/crawler_service.h" + +#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::optional ColNullableInt64(sqlite3_stmt* stmt, int col) { + if (sqlite3_column_type(stmt, col) == SQLITE_NULL) return std::nullopt; + return sqlite3_column_int64(stmt, col); +} + +std::string Trim(const std::string& s) { + const auto begin = s.find_first_not_of(" \t\r\n"); + if (begin == std::string::npos) return {}; + const auto end = s.find_last_not_of(" \t\r\n"); + return s.substr(begin, end - begin + 1); +} + +std::string StripUrlTail(const std::string& s) { + std::string out = s; + while (!out.empty()) { + const char c = out.back(); + if (c == '.' || c == ',' || c == ';' || c == '!' || c == '?' || c == ')' || + c == ']' || c == '}' || c == '"' || c == '\'') { + out.pop_back(); + } else { + break; + } + } + return out; +} + +CrawlerTarget ReadTarget(sqlite3_stmt* stmt) { + CrawlerTarget t; + t.id = sqlite3_column_int64(stmt, 0); + t.url = ColText(stmt, 1); + t.normalized_url = ColText(stmt, 2); + t.status = ColText(stmt, 3); + t.submit_source = ColText(stmt, 4); + t.submitter_id = ColText(stmt, 5); + t.submitter_name = ColText(stmt, 6); + t.rule_json = ColText(stmt, 7); + t.script_path = ColText(stmt, 8); + t.last_error = ColText(stmt, 9); + t.last_test_at = ColNullableInt64(stmt, 10); + t.last_run_at = ColNullableInt64(stmt, 11); + t.created_at = sqlite3_column_int64(stmt, 12); + t.updated_at = sqlite3_column_int64(stmt, 13); + return t; +} + +CrawlerRun ReadRun(sqlite3_stmt* stmt) { + CrawlerRun r; + r.id = sqlite3_column_int64(stmt, 0); + r.target_id = sqlite3_column_int64(stmt, 1); + r.status = ColText(stmt, 2); + r.http_status = sqlite3_column_int(stmt, 3); + r.output_json = ColText(stmt, 4); + r.error_text = ColText(stmt, 5); + r.created_at = sqlite3_column_int64(stmt, 6); + return r; +} + +} // namespace + +std::string CrawlerService::NormalizeUrl(const std::string& raw_url) { + std::string url = Trim(raw_url); + if (url.empty()) return {}; + url = StripUrlTail(url); + if (url.empty()) return {}; + + std::string lower = url; + std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + std::string prefixed = url; + if (!(lower.rfind("http://", 0) == 0 || lower.rfind("https://", 0) == 0)) { + prefixed = "https://" + url; + } + + const auto scheme_pos = prefixed.find("://"); + if (scheme_pos == std::string::npos) return {}; + std::string scheme = prefixed.substr(0, scheme_pos); + std::transform(scheme.begin(), scheme.end(), scheme.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + if (scheme != "http" && scheme != "https") return {}; + + const size_t host_start = scheme_pos + 3; + if (host_start >= prefixed.size()) return {}; + size_t host_end = prefixed.find_first_of("/?#", host_start); + if (host_end == std::string::npos) host_end = prefixed.size(); + + std::string host = prefixed.substr(host_start, host_end - host_start); + host = Trim(host); + if (host.empty()) return {}; + std::transform(host.begin(), host.end(), host.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + + std::string path = "/"; + if (host_end < prefixed.size() && prefixed[host_end] == '/') { + size_t path_end = prefixed.find_first_of("?#", host_end); + if (path_end == std::string::npos) path_end = prefixed.size(); + path = prefixed.substr(host_end, path_end - host_end); + if (path.empty()) path = "/"; + } + while (path.size() > 1 && path.back() == '/') path.pop_back(); + + return scheme + "://" + host + path; +} + +std::vector CrawlerService::ExtractUrls(const std::string& text) { + static const std::regex kUrlRegex( + R"((https?://[^\s<>'"\]\)]+)|(www\.[^\s<>'"\]\)]+))", + std::regex::icase); + + std::set seen; + std::vector out; + for (std::sregex_iterator it(text.begin(), text.end(), kUrlRegex), end; it != end; + ++it) { + std::string candidate = (*it)[1].matched ? (*it)[1].str() : (*it)[2].str(); + const std::string normalized = NormalizeUrl(candidate); + if (normalized.empty()) continue; + if (seen.insert(normalized).second) { + out.push_back(normalized); + } + } + return out; +} + +UpsertCrawlerTargetResult CrawlerService::UpsertTarget(const std::string& raw_url, + const std::string& submit_source, + const std::string& submitter_id, + const std::string& submitter_name) { + sqlite3* db = db_.raw(); + const std::string normalized = NormalizeUrl(raw_url); + if (normalized.empty()) { + throw std::runtime_error("invalid url"); + } + + auto load_by_normalized = [&](const std::string& norm) + -> std::optional { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,url,normalized_url,status,submit_source,submitter_id,submitter_name," + "rule_json,script_path,last_error,last_test_at,last_run_at,created_at,updated_at " + "FROM crawler_targets WHERE normalized_url=? LIMIT 1"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare get crawler target by normalized_url"); + CheckSqlite(sqlite3_bind_text(stmt, 1, norm.c_str(), -1, SQLITE_TRANSIENT), db, + "bind normalized_url"); + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + return std::nullopt; + } + auto row = ReadTarget(stmt); + sqlite3_finalize(stmt); + return row; + }; + + if (auto existed = load_by_normalized(normalized); existed.has_value()) { + return UpsertCrawlerTargetResult{.target = *existed, .inserted = false}; + } + + const int64_t now = NowSec(); + sqlite3_stmt* insert_stmt = nullptr; + const char* insert_sql = + "INSERT INTO crawler_targets(" + "url,normalized_url,status,submit_source,submitter_id,submitter_name," + "rule_json,script_path,last_error,last_test_at,last_run_at,created_at,updated_at" + ") VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)"; + CheckSqlite(sqlite3_prepare_v2(db, insert_sql, -1, &insert_stmt, nullptr), db, + "prepare insert crawler target"); + CheckSqlite(sqlite3_bind_text(insert_stmt, 1, normalized.c_str(), -1, SQLITE_TRANSIENT), db, + "bind url"); + CheckSqlite(sqlite3_bind_text(insert_stmt, 2, normalized.c_str(), -1, SQLITE_TRANSIENT), db, + "bind normalized_url"); + CheckSqlite(sqlite3_bind_text(insert_stmt, 3, "queued", -1, SQLITE_TRANSIENT), db, + "bind status"); + CheckSqlite( + sqlite3_bind_text(insert_stmt, 4, submit_source.c_str(), -1, SQLITE_TRANSIENT), + db, "bind submit_source"); + CheckSqlite(sqlite3_bind_text(insert_stmt, + 5, + submitter_id.c_str(), + -1, + SQLITE_TRANSIENT), + db, + "bind submitter_id"); + CheckSqlite(sqlite3_bind_text(insert_stmt, + 6, + submitter_name.c_str(), + -1, + SQLITE_TRANSIENT), + db, + "bind submitter_name"); + CheckSqlite(sqlite3_bind_text(insert_stmt, 7, "{}", -1, SQLITE_TRANSIENT), db, + "bind rule_json"); + CheckSqlite(sqlite3_bind_text(insert_stmt, 8, "", -1, SQLITE_TRANSIENT), db, + "bind script_path"); + CheckSqlite(sqlite3_bind_text(insert_stmt, 9, "", -1, SQLITE_TRANSIENT), db, + "bind last_error"); + CheckSqlite(sqlite3_bind_null(insert_stmt, 10), db, "bind last_test_at"); + CheckSqlite(sqlite3_bind_null(insert_stmt, 11), db, "bind last_run_at"); + CheckSqlite(sqlite3_bind_int64(insert_stmt, 12, now), db, "bind created_at"); + CheckSqlite(sqlite3_bind_int64(insert_stmt, 13, now), db, "bind updated_at"); + + const int rc = sqlite3_step(insert_stmt); + sqlite3_finalize(insert_stmt); + if (rc != SQLITE_DONE) { + if (rc == SQLITE_CONSTRAINT || rc == SQLITE_CONSTRAINT_UNIQUE) { + if (auto existed = load_by_normalized(normalized); existed.has_value()) { + return UpsertCrawlerTargetResult{.target = *existed, .inserted = false}; + } + } + CheckSqlite(rc, db, "insert crawler target"); + } + + auto created = load_by_normalized(normalized); + if (!created.has_value()) { + throw std::runtime_error("insert crawler target failed"); + } + return UpsertCrawlerTargetResult{.target = *created, .inserted = true}; +} + +std::optional CrawlerService::GetTargetById(int64_t target_id) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,url,normalized_url,status,submit_source,submitter_id,submitter_name," + "rule_json,script_path,last_error,last_test_at,last_run_at,created_at,updated_at " + "FROM crawler_targets WHERE id=? LIMIT 1"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare get crawler target by id"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, target_id), db, "bind target_id"); + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + return std::nullopt; + } + auto row = ReadTarget(stmt); + sqlite3_finalize(stmt); + return row; +} + +std::vector CrawlerService::ListTargets(const std::string& status, + int limit) { + sqlite3* db = db_.raw(); + const int safe_limit = std::max(1, std::min(limit <= 0 ? 50 : limit, 500)); + sqlite3_stmt* stmt = nullptr; + + std::string sql = + "SELECT id,url,normalized_url,status,submit_source,submitter_id,submitter_name," + "rule_json,script_path,last_error,last_test_at,last_run_at,created_at,updated_at " + "FROM crawler_targets"; + if (!status.empty()) sql += " WHERE status=?"; + sql += " ORDER BY id DESC LIMIT ?"; + + CheckSqlite(sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr), db, + "prepare list crawler targets"); + int bind_index = 1; + if (!status.empty()) { + CheckSqlite( + sqlite3_bind_text(stmt, bind_index++, status.c_str(), -1, SQLITE_TRANSIENT), + db, + "bind status"); + } + CheckSqlite(sqlite3_bind_int(stmt, bind_index++, safe_limit), db, "bind limit"); + + std::vector rows; + while (sqlite3_step(stmt) == SQLITE_ROW) { + rows.push_back(ReadTarget(stmt)); + } + sqlite3_finalize(stmt); + return rows; +} + +std::vector CrawlerService::ListRuns(int64_t target_id, int limit) { + sqlite3* db = db_.raw(); + const int safe_limit = std::max(1, std::min(limit <= 0 ? 20 : limit, 200)); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,target_id,status,http_status,output_json,error_text,created_at " + "FROM crawler_runs WHERE target_id=? ORDER BY id DESC LIMIT ?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare list crawler runs"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, target_id), db, "bind target_id"); + CheckSqlite(sqlite3_bind_int(stmt, 2, safe_limit), db, "bind limit"); + + std::vector rows; + while (sqlite3_step(stmt) == SQLITE_ROW) { + rows.push_back(ReadRun(stmt)); + } + sqlite3_finalize(stmt); + return rows; +} + +bool CrawlerService::EnqueueTarget(int64_t target_id) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const int64_t now = NowSec(); + const char* sql = + "UPDATE crawler_targets SET status='queued',last_error='',updated_at=? WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare enqueue crawler target"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, now), db, "bind updated_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, target_id), db, "bind target_id"); + CheckSqlite(sqlite3_step(stmt), db, "step enqueue crawler target"); + const int changed = sqlite3_changes(db); + sqlite3_finalize(stmt); + return changed > 0; +} + +bool CrawlerService::EnqueueDueActiveTarget(int interval_sec, + int64_t now_sec, + CrawlerTarget& out) { + if (interval_sec <= 0) return false; + + sqlite3* db = db_.raw(); + const int64_t due_before = now_sec - interval_sec; + + db_.Exec("BEGIN IMMEDIATE;"); + try { + sqlite3_stmt* select_stmt = nullptr; + const char* select_sql = + "SELECT id FROM crawler_targets " + "WHERE status='active' AND (last_run_at IS NULL OR last_run_at<=?) " + "ORDER BY COALESCE(last_run_at, 0) ASC, id ASC LIMIT 1"; + CheckSqlite(sqlite3_prepare_v2(db, select_sql, -1, &select_stmt, nullptr), db, + "prepare select due active crawler target"); + CheckSqlite(sqlite3_bind_int64(select_stmt, 1, due_before), db, + "bind due_before"); + + int64_t target_id = 0; + if (sqlite3_step(select_stmt) == SQLITE_ROW) { + target_id = sqlite3_column_int64(select_stmt, 0); + } + sqlite3_finalize(select_stmt); + + if (target_id == 0) { + db_.Exec("COMMIT;"); + return false; + } + + sqlite3_stmt* update_stmt = nullptr; + const char* update_sql = + "UPDATE crawler_targets " + "SET status='queued',last_error='',updated_at=? " + "WHERE id=? AND status='active'"; + CheckSqlite(sqlite3_prepare_v2(db, update_sql, -1, &update_stmt, nullptr), db, + "prepare enqueue due active crawler target"); + CheckSqlite(sqlite3_bind_int64(update_stmt, 1, now_sec), db, "bind updated_at"); + CheckSqlite(sqlite3_bind_int64(update_stmt, 2, target_id), db, + "bind target_id"); + CheckSqlite(sqlite3_step(update_stmt), db, + "step enqueue due active crawler target"); + const int changed = sqlite3_changes(db); + sqlite3_finalize(update_stmt); + + if (changed <= 0) { + db_.Exec("ROLLBACK;"); + return false; + } + + db_.Exec("COMMIT;"); + auto target = GetTargetById(target_id); + if (!target.has_value()) return false; + out = *target; + return true; + } catch (...) { + try { + db_.Exec("ROLLBACK;"); + } catch (...) { + } + throw; + } +} + +bool CrawlerService::ClaimNextTarget(CrawlerTarget& out) { + sqlite3* db = db_.raw(); + db_.Exec("BEGIN IMMEDIATE;"); + try { + sqlite3_stmt* select_stmt = nullptr; + const char* select_sql = + "SELECT id FROM crawler_targets WHERE status='queued' " + "ORDER BY updated_at ASC,id ASC LIMIT 1"; + CheckSqlite(sqlite3_prepare_v2(db, select_sql, -1, &select_stmt, nullptr), db, + "prepare claim select crawler target"); + + int64_t target_id = 0; + if (sqlite3_step(select_stmt) == SQLITE_ROW) { + target_id = sqlite3_column_int64(select_stmt, 0); + } + sqlite3_finalize(select_stmt); + + if (target_id == 0) { + db_.Exec("COMMIT;"); + return false; + } + + sqlite3_stmt* update_stmt = nullptr; + const char* update_sql = + "UPDATE crawler_targets " + "SET status='generating',last_error='',updated_at=? " + "WHERE id=? AND status='queued'"; + CheckSqlite(sqlite3_prepare_v2(db, update_sql, -1, &update_stmt, nullptr), db, + "prepare claim update crawler target"); + CheckSqlite(sqlite3_bind_int64(update_stmt, 1, NowSec()), db, + "bind claim updated_at"); + CheckSqlite(sqlite3_bind_int64(update_stmt, 2, target_id), db, + "bind claim target_id"); + CheckSqlite(sqlite3_step(update_stmt), db, "step claim crawler target"); + const int changed = sqlite3_changes(db); + sqlite3_finalize(update_stmt); + + if (changed <= 0) { + db_.Exec("ROLLBACK;"); + return false; + } + + db_.Exec("COMMIT;"); + auto got = GetTargetById(target_id); + if (!got.has_value()) return false; + out = *got; + return true; + } catch (...) { + try { + db_.Exec("ROLLBACK;"); + } catch (...) { + } + throw; + } +} + +void CrawlerService::UpdateGenerated(int64_t target_id, + const std::string& rule_json, + const std::string& script_path) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "UPDATE crawler_targets " + "SET status='testing',rule_json=?,script_path=?,last_error='',updated_at=? " + "WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare update generated crawler target"); + CheckSqlite(sqlite3_bind_text(stmt, 1, rule_json.c_str(), -1, SQLITE_TRANSIENT), db, + "bind rule_json"); + CheckSqlite(sqlite3_bind_text(stmt, 2, script_path.c_str(), -1, SQLITE_TRANSIENT), db, + "bind script_path"); + CheckSqlite(sqlite3_bind_int64(stmt, 3, NowSec()), db, "bind updated_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 4, target_id), db, "bind target_id"); + CheckSqlite(sqlite3_step(stmt), db, "step update generated crawler target"); + sqlite3_finalize(stmt); +} + +void CrawlerService::MarkTesting(int64_t target_id) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "UPDATE crawler_targets SET status='testing',last_error='',updated_at=? WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare mark testing crawler target"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, NowSec()), db, "bind updated_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, target_id), db, "bind target_id"); + CheckSqlite(sqlite3_step(stmt), db, "step mark testing crawler target"); + sqlite3_finalize(stmt); +} + +void CrawlerService::MarkActive(int64_t target_id, int64_t run_at) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "UPDATE crawler_targets " + "SET status='active',last_error='',last_test_at=?,last_run_at=?,updated_at=? " + "WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare mark active crawler target"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, run_at), db, "bind last_test_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, run_at), db, "bind last_run_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 3, run_at), db, "bind updated_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 4, target_id), db, "bind target_id"); + CheckSqlite(sqlite3_step(stmt), db, "step mark active crawler target"); + sqlite3_finalize(stmt); +} + +void CrawlerService::MarkFailed(int64_t target_id, const std::string& error) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "UPDATE crawler_targets " + "SET status='failed',last_error=?,last_test_at=?,updated_at=? " + "WHERE id=?"; + const int64_t now = NowSec(); + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare mark failed crawler target"); + CheckSqlite(sqlite3_bind_text(stmt, 1, error.c_str(), -1, SQLITE_TRANSIENT), db, + "bind error"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, now), db, "bind last_test_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 3, now), db, "bind updated_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 4, target_id), db, "bind target_id"); + CheckSqlite(sqlite3_step(stmt), db, "step mark failed crawler target"); + sqlite3_finalize(stmt); +} + +void CrawlerService::InsertRun(int64_t target_id, + const std::string& status, + int http_status, + const std::string& output_json, + const std::string& error_text) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "INSERT INTO crawler_runs(target_id,status,http_status,output_json,error_text,created_at)" + "VALUES(?,?,?,?,?,?)"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare insert crawler run"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, target_id), db, "bind target_id"); + CheckSqlite(sqlite3_bind_text(stmt, 2, status.c_str(), -1, SQLITE_TRANSIENT), db, + "bind status"); + CheckSqlite(sqlite3_bind_int(stmt, 3, http_status), db, "bind http_status"); + CheckSqlite(sqlite3_bind_text(stmt, 4, output_json.c_str(), -1, SQLITE_TRANSIENT), db, + "bind output_json"); + CheckSqlite(sqlite3_bind_text(stmt, 5, error_text.c_str(), -1, SQLITE_TRANSIENT), db, + "bind error_text"); + CheckSqlite(sqlite3_bind_int64(stmt, 6, NowSec()), db, "bind created_at"); + CheckSqlite(sqlite3_step(stmt), db, "step insert crawler run"); + sqlite3_finalize(stmt); +} + +} // namespace csp::services diff --git a/backend/src/services/db_lock_guard.cc b/backend/src/services/db_lock_guard.cc new file mode 100644 index 0000000..3d73828 --- /dev/null +++ b/backend/src/services/db_lock_guard.cc @@ -0,0 +1,206 @@ +#include "csp/services/db_lock_guard.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace csp::services { + +namespace { + +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(std::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, int min_value, int max_value) { + const char* raw = std::getenv(key); + if (!raw) return default_value; + try { + const int parsed = std::stoi(raw); + return std::max(min_value, std::min(max_value, parsed)); + } catch (...) { + return default_value; + } +} + +int64_t NowSec() { + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()).count(); +} + +void SafeExec(sqlite3* db, const char* sql, const char* tag) { + char* err = nullptr; + const int rc = sqlite3_exec(db, sql, nullptr, nullptr, &err); + if (rc != SQLITE_OK) { + const std::string msg = err ? err : ""; + sqlite3_free(err); + LOG_WARN << "db lock guard " << tag << " failed: " << rc << " " << msg; + } +} + +} // namespace + +DbLockGuard& DbLockGuard::Instance() { + static DbLockGuard inst; + return inst; +} + +void DbLockGuard::Configure(std::string db_path) { + std::lock_guard lock(mu_); + db_path_ = std::move(db_path); + enabled_ = EnvBool("CSP_DB_LOCK_GUARD_ENABLED", true); + interval_sec_ = EnvInt("CSP_DB_LOCK_GUARD_INTERVAL_SEC", 20, 5, 600); + probe_busy_timeout_ms_ = + EnvInt("CSP_DB_LOCK_GUARD_PROBE_TIMEOUT_MS", 2000, 200, 15000); + busy_streak_trigger_ = EnvInt("CSP_DB_LOCK_GUARD_BUSY_STREAK", 3, 1, 20); + busy_streak_ = 0; + last_probe_at_ = 0; + last_probe_rc_ = 0; + last_probe_error_.clear(); + last_repair_at_ = 0; + repair_count_ = 0; +} + +void DbLockGuard::StartIfEnabled() { + int interval = 0; + { + std::lock_guard lock(mu_); + if (started_ || !enabled_ || db_path_.empty()) return; + started_ = true; + interval = interval_sec_; + } + + std::thread([this]() { WorkerLoop(); }).detach(); + LOG_INFO << "db lock guard started (interval=" << interval << "s)"; +} + +DbLockGuard::Status DbLockGuard::GetStatus() { + std::lock_guard lock(mu_); + Status s; + s.enabled = enabled_; + s.started = started_; + s.interval_sec = interval_sec_; + s.probe_busy_timeout_ms = probe_busy_timeout_ms_; + s.busy_streak_trigger = busy_streak_trigger_; + s.busy_streak = busy_streak_; + s.last_probe_at = last_probe_at_; + s.last_probe_rc = last_probe_rc_; + s.last_probe_error = last_probe_error_; + s.last_repair_at = last_repair_at_; + s.repair_count = repair_count_; + return s; +} + +void DbLockGuard::WorkerLoop() { + while (true) { + std::string db_path; + int timeout_ms = 2000; + int streak_trigger = 3; + int interval_sec = 20; + { + std::lock_guard lock(mu_); + db_path = db_path_; + timeout_ms = probe_busy_timeout_ms_; + streak_trigger = busy_streak_trigger_; + interval_sec = interval_sec_; + } + + if (!db_path.empty()) { + const int64_t now_sec = NowSec(); + sqlite3* db = nullptr; + const int open_rc = + sqlite3_open_v2(db_path.c_str(), &db, SQLITE_OPEN_READWRITE, nullptr); + if (open_rc == SQLITE_OK && db) { + sqlite3_busy_timeout(db, timeout_ms); + + char* err = nullptr; + const int begin_rc = sqlite3_exec(db, "BEGIN IMMEDIATE", nullptr, nullptr, &err); + if (begin_rc == SQLITE_OK) { + sqlite3_exec(db, "ROLLBACK", nullptr, nullptr, nullptr); + { + std::lock_guard lock(mu_); + busy_streak_ = 0; + last_probe_at_ = now_sec; + last_probe_rc_ = begin_rc; + last_probe_error_.clear(); + } + } else { + const bool lock_related = + begin_rc == SQLITE_BUSY || begin_rc == SQLITE_LOCKED; + const std::string err_msg = err ? err : ""; + sqlite3_free(err); + + if (lock_related) { + int streak = 0; + { + std::lock_guard lock(mu_); + busy_streak_ += 1; + streak = busy_streak_; + last_probe_at_ = now_sec; + last_probe_rc_ = begin_rc; + last_probe_error_ = err_msg; + } + + LOG_WARN << "db lock guard probe busy (streak=" << streak + << ", rc=" << begin_rc << ", err=" << err_msg << ")"; + if (streak >= streak_trigger) { + // Best-effort repair: checkpoint + optimize. + int wal_log = 0; + int wal_ckpt = 0; + const int ck_rc = sqlite3_wal_checkpoint_v2( + db, nullptr, SQLITE_CHECKPOINT_RESTART, &wal_log, &wal_ckpt); + if (ck_rc != SQLITE_OK && ck_rc != SQLITE_BUSY && + ck_rc != SQLITE_LOCKED) { + LOG_WARN << "db lock guard checkpoint failed rc=" << ck_rc; + } + SafeExec(db, "PRAGMA optimize;", "optimize"); + + std::lock_guard lock(mu_); + busy_streak_ = 0; + last_repair_at_ = NowSec(); + repair_count_ += 1; + } + } else { + { + std::lock_guard lock(mu_); + busy_streak_ = 0; + last_probe_at_ = now_sec; + last_probe_rc_ = begin_rc; + last_probe_error_ = err_msg; + } + LOG_WARN << "db lock guard probe failed rc=" << begin_rc + << ", err=" << err_msg; + } + } + + sqlite3_close(db); + } else { + if (db) sqlite3_close(db); + { + std::lock_guard lock(mu_); + busy_streak_ = 0; + last_probe_at_ = now_sec; + last_probe_rc_ = open_rc; + last_probe_error_ = "open db failed"; + } + LOG_WARN << "db lock guard open db failed rc=" << open_rc; + } + } + + std::this_thread::sleep_for(std::chrono::seconds(interval_sec)); + } +} + +} // namespace csp::services diff --git a/backend/src/services/experience_service.cc b/backend/src/services/experience_service.cc new file mode 100644 index 0000000..bebd3b1 --- /dev/null +++ b/backend/src/services/experience_service.cc @@ -0,0 +1,116 @@ +#include "csp/services/experience_service.h" + +#include + +#include +#include +#include +#include +#include + +namespace csp::services { + +namespace { + +void CheckSqlite(int rc, sqlite3* db, const char* what) { + if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return; + throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db)); +} + +std::string ColText(sqlite3_stmt* stmt, int col) { + const unsigned char* txt = sqlite3_column_text(stmt, col); + return txt ? reinterpret_cast(txt) : std::string(); +} + +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 EnsureExperienceRow(sqlite3* db, int64_t user_id) { + const int rating = QueryUserRating(db, user_id); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "INSERT OR IGNORE INTO user_experience(user_id,xp,updated_at) " + "VALUES(?,?,strftime('%s','now'))"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare ensure user experience"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_int(stmt, 2, std::max(0, rating)), db, "bind xp"); + CheckSqlite(sqlite3_step(stmt), db, "ensure user experience"); + sqlite3_finalize(stmt); +} + +} // namespace + +ExperienceSummary ExperienceService::GetSummary(int64_t user_id) { + if (user_id <= 0) throw std::runtime_error("invalid user_id"); + sqlite3* db = db_.raw(); + + EnsureExperienceRow(db, user_id); + + sqlite3_stmt* stmt = nullptr; + const char* sql = "SELECT xp,updated_at FROM user_experience WHERE user_id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare get user experience"); + 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 experience not found"); + } + + ExperienceSummary out; + out.user_id = user_id; + out.experience = sqlite3_column_int(stmt, 0); + out.updated_at = sqlite3_column_int64(stmt, 1); + sqlite3_finalize(stmt); + return out; +} + +std::vector ExperienceService::ListHistory(int64_t user_id, + int limit) { + if (user_id <= 0) throw std::runtime_error("invalid user_id"); + sqlite3* db = db_.raw(); + + (void)GetSummary(user_id); // validate user and ensure base row exists + + sqlite3_stmt* stmt = nullptr; + const int safe_limit = std::max(1, std::min(500, limit)); + const char* sql = + "SELECT id,user_id,xp_delta,rating_before,rating_after,source,note,created_at " + "FROM user_experience_logs " + "WHERE user_id=? " + "ORDER BY id DESC LIMIT ?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare list user experience history"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_int(stmt, 2, safe_limit), db, "bind limit"); + + std::vector out; + while (sqlite3_step(stmt) == SQLITE_ROW) { + ExperienceHistoryItem row; + row.id = sqlite3_column_int64(stmt, 0); + row.user_id = sqlite3_column_int64(stmt, 1); + row.xp_delta = sqlite3_column_int(stmt, 2); + row.rating_before = sqlite3_column_int(stmt, 3); + row.rating_after = sqlite3_column_int(stmt, 4); + row.source = ColText(stmt, 5); + row.note = ColText(stmt, 6); + row.created_at = sqlite3_column_int64(stmt, 7); + out.push_back(std::move(row)); + } + sqlite3_finalize(stmt); + return out; +} + +} // namespace csp::services diff --git a/backend/src/services/kb_service.cc b/backend/src/services/kb_service.cc index 9b82c64..26f3b46 100644 --- a/backend/src/services/kb_service.cc +++ b/backend/src/services/kb_service.cc @@ -2,8 +2,15 @@ #include +#include +#include +#include +#include +#include #include #include +#include +#include namespace csp::services { @@ -19,6 +26,139 @@ std::string ColText(sqlite3_stmt* stmt, int col) { return txt ? reinterpret_cast(txt) : std::string(); } +int64_t NowSec() { + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()).count(); +} + +std::string WeekKeyUtc8(int64_t ts_sec) { + const std::time_t shifted = static_cast(ts_sec + 8 * 3600); + std::tm tmv{}; + gmtime_r(&shifted, &tmv); + const int weekday_offset = (tmv.tm_wday + 6) % 7; // Monday=0 + tmv.tm_mday -= weekday_offset; + tmv.tm_hour = 0; + tmv.tm_min = 0; + tmv.tm_sec = 0; + + std::time_t monday_shifted = timegm(&tmv); + monday_shifted -= 8 * 3600; + std::tm monday{}; + gmtime_r(&monday_shifted, &monday); + std::ostringstream out; + out << std::setw(4) << std::setfill('0') << (monday.tm_year + 1900) << "-" + << std::setw(2) << std::setfill('0') << (monday.tm_mon + 1) << "-" + << std::setw(2) << std::setfill('0') << monday.tm_mday; + return out.str(); +} + +std::string JoinCsv(const std::vector& items) { + std::ostringstream out; + for (size_t i = 0; i < items.size(); i += 1) { + if (i > 0) out << ","; + out << items[i]; + } + return out.str(); +} + +std::vector SplitCsv(const std::string& text) { + std::vector out; + if (text.empty()) return out; + std::string cur; + for (char ch : text) { + if (ch == ',') { + if (!cur.empty()) out.push_back(cur); + cur.clear(); + } else { + cur.push_back(ch); + } + } + if (!cur.empty()) out.push_back(cur); + return out; +} + +int DifficultyRank(const std::string& lv) { + if (lv == "bronze") return 0; + if (lv == "silver") return 1; + if (lv == "gold") return 2; + return 9; +} + +const std::unordered_map>& SkillCatalog() { + static const std::unordered_map> kCatalog = { + {"cpp14-skill-tree", + { + {"cpp14-io-01", "输入输出与格式化", "掌握 cin/cout、scanf/printf、%lld、换行与输出格式控制。", "bronze", 1, {}}, + {"cpp14-type-02", "类型系统与溢出边界", "掌握 int/long long/double 边界、类型转换、未定义行为风险。", "bronze", 1, {"cpp14-io-01"}}, + {"cpp14-func-03", "函数与引用传参", "掌握值传递/引用传递/const 引用与函数拆分。", "bronze", 1, {"cpp14-type-02"}}, + {"cpp14-array-04", "数组与字符串处理", "掌握静态数组、vector、string、下标边界与常见错误。", "bronze", 1, {"cpp14-type-02"}}, + {"cpp14-stl-05", "STL 容器与算法", "掌握 vector/map/set/queue/stack 与 sort/lower_bound。", "silver", 2, {"cpp14-array-04"}}, + {"cpp14-greedy-06", "排序与贪心基础", "掌握排序策略、比较器、贪心正确性直觉。", "silver", 2, {"cpp14-stl-05"}}, + {"cpp14-prefix-07", "前缀和与差分", "掌握区间求和、区间更新、二维前缀和。", "silver", 2, {"cpp14-array-04"}}, + {"cpp14-dp-08", "动态规划入门", "掌握状态定义、转移方程、初始化与滚动优化。", "gold", 3, {"cpp14-prefix-07"}}, + {"cpp14-search-09", "DFS/BFS 搜索框架", "掌握递归终止、回溯、队列层序与剪枝策略。", "gold", 3, {"cpp14-array-04"}}, + {"cpp14-debug-10", "调试与复杂度意识", "掌握样例构造、边界测试、O(n log n) 与 O(n^2) 取舍。", "gold", 3, {"cpp14-dp-08", "cpp14-search-09"}}, + }}, + {"github-collaboration-basics", + { + {"git-model-01", "仓库与分支模型", "理解 remote/origin、main/dev/feature 分支职责。", "bronze", 1, {}}, + {"git-commit-02", "高质量提交规范", "掌握原子提交、提交信息模板、变更范围控制。", "bronze", 1, {"git-model-01"}}, + {"git-pr-03", "PR 流程与 Code Review", "掌握 PR 描述、检查项、review comment 处理。", "silver", 2, {"git-commit-02"}}, + {"git-rebase-04", "rebase 与冲突处理", "掌握冲突定位、分段解决、验证后继续 rebase。", "silver", 2, {"git-pr-03"}}, + {"git-tag-05", "版本标签与发布", "掌握 semantic version、release note、回滚策略。", "silver", 2, {"git-commit-02"}}, + {"git-ci-06", "CI 基础与质量门禁", "掌握 lint/test/build 流水线与失败阻断策略。", "gold", 3, {"git-pr-03"}}, + {"git-sec-07", "密钥与凭据安全", "掌握 token 最小权限、泄漏处理、secret 扫描。", "gold", 3, {"git-model-01"}}, + }}, + {"linux-server-basics", + { + {"linux-shell-01", "Shell 基础命令", "掌握 pwd/ls/cd/cat/less/grep/rg/find。", "bronze", 1, {}}, + {"linux-proc-02", "进程与资源监控", "掌握 ps/top/htop/free/df/du 与定位瓶颈。", "bronze", 1, {"linux-shell-01"}}, + {"linux-net-03", "网络与端口排查", "掌握 ss/netstat/curl/wget/ping 与服务可达性检查。", "silver", 2, {"linux-shell-01"}}, + {"linux-systemd-04", "systemd 服务管理", "掌握 enable/start/status/journalctl 与开机自启。", "silver", 2, {"linux-proc-02", "linux-net-03"}}, + {"linux-perm-05", "权限与用户组", "掌握 chmod/chown/sudo/umask 与最小权限原则。", "silver", 2, {"linux-shell-01"}}, + {"linux-log-06", "日志与故障定位", "掌握应用日志结构化、错误栈与时间线追踪。", "gold", 3, {"linux-systemd-04"}}, + {"linux-backup-07", "备份与恢复演练", "掌握数据备份、快照、恢复验证与演练机制。", "gold", 3, {"linux-perm-05"}}, + }}, + {"computer-fundamentals-for-oi", + { + {"cs-binary-01", "二进制与编码", "掌握位运算、补码、ASCII/UTF-8 基础。", "bronze", 1, {}}, + {"cs-memory-02", "内存模型与指针意识", "理解栈/堆/静态区,避免越界与悬垂引用。", "bronze", 1, {"cs-binary-01"}}, + {"cs-algo-03", "复杂度与可扩展性", "掌握时间/空间复杂度与输入规模上限估算。", "silver", 2, {"cs-memory-02"}}, + {"cs-os-04", "操作系统基本机制", "理解进程、线程、上下文切换和文件系统。", "silver", 2, {"cs-memory-02"}}, + {"cs-net-05", "网络基础与协议", "理解 TCP/UDP、HTTP 请求路径、延迟与吞吐。", "silver", 2, {"cs-os-04"}}, + {"cs-database-06", "数据库基础", "理解索引、事务、锁与一致性,避免慢查询。", "gold", 3, {"cs-algo-03"}}, + {"cs-sec-07", "安全基本面", "理解认证授权、输入校验、注入与敏感信息保护。", "gold", 3, {"cs-net-05"}}, + }}, + {"cpp-web-development-basics", + { + {"cpp-web-http-01", "HTTP 与 JSON 基础", "掌握请求/响应模型、状态码与统一 JSON 返回结构。", "bronze", 1, {}}, + {"cpp-web-route-02", "路由与参数校验", "掌握路径参数、query、body 校验与错误码设计。", "bronze", 1, {"cpp-web-http-01"}}, + {"cpp-web-auth-03", "登录鉴权与权限隔离", "掌握 token/session、用户态与管理员态接口隔离。", "silver", 2, {"cpp-web-route-02"}}, + {"cpp-web-db-04", "数据库 CRUD 与事务", "掌握 C++ 服务中事务边界、锁冲突与重试策略。", "silver", 2, {"cpp-web-route-02"}}, + {"cpp-web-log-05", "日志与可观测性", "掌握请求日志、错误追踪、关键字段打点。", "silver", 2, {"cpp-web-auth-03"}}, + {"cpp-web-deploy-06", "部署与反向代理", "掌握 Nginx 反代、HTTPS 证书与服务重启流程。", "gold", 3, {"cpp-web-db-04", "cpp-web-log-05"}}, + {"cpp-web-resilience-07", "稳定性与故障演练", "掌握限流、熔断、回滚、自动恢复守护思路。", "gold", 3, {"cpp-web-deploy-06"}}, + }}, + {"cpp-game-development-basics", + { + {"cpp-game-loop-01", "游戏循环与时间步", "掌握输入-更新-渲染循环与 deltaTime。", "bronze", 1, {}}, + {"cpp-game-math-02", "向量与坐标基础", "掌握位置、速度、方向与基础几何运算。", "bronze", 1, {"cpp-game-loop-01"}}, + {"cpp-game-collision-03", "碰撞检测入门", "掌握 AABB/圆形碰撞与简单碰撞响应。", "silver", 2, {"cpp-game-math-02"}}, + {"cpp-game-resource-04", "资源管理与场景切换", "掌握贴图音频加载、释放与场景状态管理。", "silver", 2, {"cpp-game-loop-01"}}, + {"cpp-game-architecture-05", "模块化架构", "掌握渲染/逻辑/输入分层与组件化组织。", "silver", 2, {"cpp-game-resource-04"}}, + {"cpp-game-opt-06", "性能优化与调试", "掌握帧时间分析、对象池、热路径优化。", "gold", 3, {"cpp-game-collision-03", "cpp-game-architecture-05"}}, + {"cpp-game-release-07", "发布与版本迭代", "掌握打包发布、崩溃定位与版本回归验证。", "gold", 3, {"cpp-game-opt-06"}}, + }}, + {"learning-roadmap-csp", + { + {"roadmap-week-plan", "阶段计划拆分", "能把年度目标拆分为月/周/日训练节奏。", "bronze", 1, {}}, + {"roadmap-review-loop", "复盘闭环", "建立错题复盘、代码重构、二刷计划闭环。", "silver", 2, {"roadmap-week-plan"}}, + {"roadmap-contest-rules", "考场规范意识", "熟悉 C++14 规则与赛场提交规范。", "silver", 2, {"roadmap-week-plan"}}, + }}, + }; + return kCatalog; +} + } // namespace std::vector KbService::ListArticles() { @@ -43,6 +183,46 @@ std::vector KbService::ListArticles() { return out; } +std::string KbService::CurrentWeekKey() const { return WeekKeyUtc8(NowSec()); } + +std::vector +KbService::SkillPointsBySlug(const std::string& slug) { + const auto& catalog = SkillCatalog(); + const auto it = catalog.find(slug); + if (it == catalog.end()) return {}; + return it->second; +} + +std::vector KbService::ClaimedKeysByUser(int64_t user_id) { + std::vector out; + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = "SELECT knowledge_key FROM kb_knowledge_claims WHERE user_id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, "prepare claimed keys by user"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + while (sqlite3_step(stmt) == SQLITE_ROW) { + out.push_back(ColText(stmt, 0)); + } + sqlite3_finalize(stmt); + return out; +} + +std::vector KbService::ClaimedKeysByArticle(int64_t user_id, int64_t article_id) { + std::vector out; + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT knowledge_key FROM kb_knowledge_claims WHERE user_id=? AND article_id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, "prepare claimed keys"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, article_id), db, "bind article_id"); + while (sqlite3_step(stmt) == SQLITE_ROW) { + out.push_back(ColText(stmt, 0)); + } + sqlite3_finalize(stmt); + return out; +} + std::optional KbService::GetBySlug(const std::string& slug) { sqlite3* db = db_.raw(); @@ -81,7 +261,397 @@ std::optional KbService::GetBySlug(const std::string& slug) { } sqlite3_finalize(link_stmt); + detail.skill_points = SkillPointsBySlug(detail.article.slug); return detail; } +KbClaimSummary KbService::ListClaims(int64_t user_id, int64_t article_id) { + if (user_id <= 0 || article_id <= 0) { + throw std::runtime_error("invalid claim query"); + } + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT knowledge_key,reward FROM kb_knowledge_claims " + "WHERE user_id=? AND article_id=? ORDER BY created_at ASC"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare kb list claims"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, article_id), db, "bind article_id"); + + KbClaimSummary out; + while (sqlite3_step(stmt) == SQLITE_ROW) { + out.claimed_keys.push_back(ColText(stmt, 0)); + out.total_reward += sqlite3_column_int(stmt, 1); + } + out.total_count = static_cast(out.claimed_keys.size()); + sqlite3_finalize(stmt); + return out; +} + +void KbService::EnsureWeeklyTasksGenerated(int64_t user_id, const std::string& week_key) { + sqlite3* db = db_.raw(); + sqlite3_stmt* check_stmt = nullptr; + const char* check_sql = "SELECT COUNT(1) FROM kb_weekly_tasks WHERE user_id=? AND week_key=?"; + CheckSqlite(sqlite3_prepare_v2(db, check_sql, -1, &check_stmt, nullptr), db, + "prepare check weekly tasks"); + CheckSqlite(sqlite3_bind_int64(check_stmt, 1, user_id), db, "bind weekly user"); + CheckSqlite(sqlite3_bind_text(check_stmt, 2, week_key.c_str(), -1, SQLITE_TRANSIENT), db, + "bind weekly week"); + int existing = 0; + if (sqlite3_step(check_stmt) == SQLITE_ROW) existing = sqlite3_column_int(check_stmt, 0); + sqlite3_finalize(check_stmt); + if (existing > 0) return; + + struct Candidate { + int64_t article_id = 0; + std::string article_slug; + std::string article_title; + KbArticleDetail::SkillPoint point; + }; + std::vector all; + const auto claimed_global = ClaimedKeysByUser(user_id); + std::unordered_set unlocked_keys(claimed_global.begin(), claimed_global.end()); + + sqlite3_stmt* article_stmt = nullptr; + const char* article_sql = "SELECT id,slug,title FROM kb_articles ORDER BY id ASC"; + CheckSqlite(sqlite3_prepare_v2(db, article_sql, -1, &article_stmt, nullptr), db, + "prepare weekly article list"); + while (sqlite3_step(article_stmt) == SQLITE_ROW) { + const int64_t article_id = sqlite3_column_int64(article_stmt, 0); + const std::string slug = ColText(article_stmt, 1); + const std::string title = ColText(article_stmt, 2); + auto skills = SkillPointsBySlug(slug); + if (skills.empty()) continue; + for (const auto& p : skills) { + if (unlocked_keys.count(p.key) > 0) continue; + Candidate one; + one.article_id = article_id; + one.article_slug = slug; + one.article_title = title; + one.point = p; + all.push_back(std::move(one)); + } + } + sqlite3_finalize(article_stmt); + + std::sort(all.begin(), all.end(), [](const Candidate& a, const Candidate& b) { + const int da = DifficultyRank(a.point.difficulty); + const int db = DifficultyRank(b.point.difficulty); + if (da != db) return da < db; + if (a.article_id != b.article_id) return a.article_id < b.article_id; + return a.point.key < b.point.key; + }); + + constexpr int kWeeklyLimit = 8; + std::unordered_set used; + std::vector selected; + bool progressed = true; + while (progressed && static_cast(selected.size()) < kWeeklyLimit) { + progressed = false; + for (size_t i = 0; i < all.size() && static_cast(selected.size()) < kWeeklyLimit; i += 1) { + if (used.count(i) > 0) continue; + bool ok = true; + for (const auto& pre : all[i].point.prerequisites) { + if (unlocked_keys.count(pre) == 0) { + ok = false; + break; + } + } + if (!ok) continue; + used.insert(i); + selected.push_back(all[i]); + unlocked_keys.insert(all[i].point.key); + progressed = true; + } + } + + if (selected.empty()) return; + + sqlite3_stmt* ins_stmt = nullptr; + const char* ins_sql = + "INSERT OR IGNORE INTO kb_weekly_tasks(" + "user_id,week_key,article_id,article_slug,article_title,knowledge_key,knowledge_title," + "knowledge_description,difficulty,reward,prerequisites,order_no,created_at,completed_at" + ") VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,NULL)"; + CheckSqlite(sqlite3_prepare_v2(db, ins_sql, -1, &ins_stmt, nullptr), db, + "prepare insert weekly task"); + const int64_t now = NowSec(); + for (size_t idx = 0; idx < selected.size(); idx += 1) { + const auto& c = selected[idx]; + CheckSqlite(sqlite3_bind_int64(ins_stmt, 1, user_id), db, "bind task user"); + CheckSqlite(sqlite3_bind_text(ins_stmt, 2, week_key.c_str(), -1, SQLITE_TRANSIENT), db, "bind task week"); + CheckSqlite(sqlite3_bind_int64(ins_stmt, 3, c.article_id), db, "bind task article"); + CheckSqlite(sqlite3_bind_text(ins_stmt, 4, c.article_slug.c_str(), -1, SQLITE_TRANSIENT), db, "bind task slug"); + CheckSqlite(sqlite3_bind_text(ins_stmt, 5, c.article_title.c_str(), -1, SQLITE_TRANSIENT), db, "bind task title"); + CheckSqlite(sqlite3_bind_text(ins_stmt, 6, c.point.key.c_str(), -1, SQLITE_TRANSIENT), db, "bind task key"); + CheckSqlite(sqlite3_bind_text(ins_stmt, 7, c.point.title.c_str(), -1, SQLITE_TRANSIENT), db, "bind task key title"); + CheckSqlite(sqlite3_bind_text(ins_stmt, 8, c.point.description.c_str(), -1, SQLITE_TRANSIENT), db, "bind task desc"); + CheckSqlite(sqlite3_bind_text(ins_stmt, 9, c.point.difficulty.c_str(), -1, SQLITE_TRANSIENT), db, "bind task difficulty"); + CheckSqlite(sqlite3_bind_int(ins_stmt, 10, c.point.reward), db, "bind task reward"); + const auto pre_csv = JoinCsv(c.point.prerequisites); + CheckSqlite(sqlite3_bind_text(ins_stmt, 11, pre_csv.c_str(), -1, SQLITE_TRANSIENT), db, "bind task pre"); + CheckSqlite(sqlite3_bind_int(ins_stmt, 12, static_cast(idx + 1)), db, "bind task order"); + CheckSqlite(sqlite3_bind_int64(ins_stmt, 13, now), db, "bind task created"); + CheckSqlite(sqlite3_step(ins_stmt), db, "insert weekly task"); + sqlite3_reset(ins_stmt); + sqlite3_clear_bindings(ins_stmt); + } + sqlite3_finalize(ins_stmt); +} + +KbWeeklyPlan KbService::GetWeeklyPlan(int64_t user_id) { + if (user_id <= 0) throw std::runtime_error("invalid user_id"); + KbWeeklyPlan out; + out.week_key = CurrentWeekKey(); + EnsureWeeklyTasksGenerated(user_id, out.week_key); + + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,article_id,article_slug,article_title,knowledge_key,knowledge_title," + "knowledge_description,difficulty,reward,prerequisites,COALESCE(completed_at,0) " + "FROM kb_weekly_tasks WHERE user_id=? AND week_key=? ORDER BY order_no ASC,id ASC"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare query weekly tasks"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind plan user"); + CheckSqlite(sqlite3_bind_text(stmt, 2, out.week_key.c_str(), -1, SQLITE_TRANSIENT), db, "bind plan week"); + int completed_count = 0; + while (sqlite3_step(stmt) == SQLITE_ROW) { + KbWeeklyTask t; + t.id = sqlite3_column_int64(stmt, 0); + t.week_key = out.week_key; + t.article_id = sqlite3_column_int64(stmt, 1); + t.article_slug = ColText(stmt, 2); + t.article_title = ColText(stmt, 3); + t.knowledge_key = ColText(stmt, 4); + t.knowledge_title = ColText(stmt, 5); + t.knowledge_description = ColText(stmt, 6); + t.difficulty = ColText(stmt, 7); + t.reward = sqlite3_column_int(stmt, 8); + t.prerequisites = SplitCsv(ColText(stmt, 9)); + t.completed_at = sqlite3_column_int64(stmt, 10); + t.completed = t.completed_at > 0; + out.total_reward += t.reward; + if (t.completed) { + out.gained_reward += t.reward; + completed_count += 1; + } + out.tasks.push_back(std::move(t)); + } + sqlite3_finalize(stmt); + + if (!out.tasks.empty()) { + out.completion_percent = + (completed_count * 100) / static_cast(out.tasks.size()); + } + + sqlite3_stmt* bonus_stmt = nullptr; + const char* bonus_sql = "SELECT 1 FROM kb_weekly_bonus_logs WHERE user_id=? AND week_key=? LIMIT 1"; + CheckSqlite(sqlite3_prepare_v2(db, bonus_sql, -1, &bonus_stmt, nullptr), db, + "prepare query weekly bonus"); + CheckSqlite(sqlite3_bind_int64(bonus_stmt, 1, user_id), db, "bind bonus user"); + CheckSqlite(sqlite3_bind_text(bonus_stmt, 2, out.week_key.c_str(), -1, SQLITE_TRANSIENT), + db, "bind bonus week"); + out.bonus_claimed = sqlite3_step(bonus_stmt) == SQLITE_ROW; + sqlite3_finalize(bonus_stmt); + return out; +} + +KbWeeklyBonusResult KbService::ClaimWeeklyBonus(int64_t user_id) { + if (user_id <= 0) throw std::runtime_error("invalid user_id"); + auto plan = GetWeeklyPlan(user_id); + if (plan.tasks.empty()) { + throw std::runtime_error("weekly tasks not available"); + } + if (plan.completion_percent < 100) { + throw std::runtime_error("weekly tasks are not 100% completed"); + } + + sqlite3* db = db_.raw(); + db_.Exec("BEGIN IMMEDIATE"); + bool committed = false; + try { + sqlite3_stmt* ins_stmt = nullptr; + const char* ins_sql = + "INSERT OR IGNORE INTO kb_weekly_bonus_logs(user_id,week_key,reward,created_at) VALUES(?,?,?,?)"; + CheckSqlite(sqlite3_prepare_v2(db, ins_sql, -1, &ins_stmt, nullptr), db, + "prepare insert weekly bonus"); + const int64_t now = NowSec(); + CheckSqlite(sqlite3_bind_int64(ins_stmt, 1, user_id), db, "bind bonus user"); + CheckSqlite(sqlite3_bind_text(ins_stmt, 2, plan.week_key.c_str(), -1, SQLITE_TRANSIENT), db, + "bind bonus week"); + CheckSqlite(sqlite3_bind_int(ins_stmt, 3, plan.bonus_reward), db, "bind bonus reward"); + CheckSqlite(sqlite3_bind_int64(ins_stmt, 4, now), db, "bind bonus created"); + CheckSqlite(sqlite3_step(ins_stmt), db, "exec insert weekly bonus"); + sqlite3_finalize(ins_stmt); + const bool inserted = sqlite3_changes(db) > 0; + + if (inserted && plan.bonus_reward > 0) { + sqlite3_stmt* add_stmt = nullptr; + const char* add_sql = "UPDATE users SET rating=rating+? WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, add_sql, -1, &add_stmt, nullptr), db, + "prepare add weekly bonus"); + CheckSqlite(sqlite3_bind_int(add_stmt, 1, plan.bonus_reward), db, "bind bonus"); + CheckSqlite(sqlite3_bind_int64(add_stmt, 2, user_id), db, "bind user"); + CheckSqlite(sqlite3_step(add_stmt), db, "exec add weekly bonus"); + sqlite3_finalize(add_stmt); + } + + int rating_after = 0; + sqlite3_stmt* user_stmt = nullptr; + const char* user_sql = "SELECT rating FROM users WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, user_sql, -1, &user_stmt, nullptr), db, + "prepare query user rating"); + CheckSqlite(sqlite3_bind_int64(user_stmt, 1, user_id), db, "bind user rating"); + if (sqlite3_step(user_stmt) == SQLITE_ROW) { + rating_after = sqlite3_column_int(user_stmt, 0); + } + sqlite3_finalize(user_stmt); + + db_.Exec("COMMIT"); + committed = true; + + KbWeeklyBonusResult out; + out.claimed = inserted; + out.reward = inserted ? plan.bonus_reward : 0; + out.rating_after = rating_after; + out.completion_percent = plan.completion_percent; + out.week_key = plan.week_key; + return out; + } catch (...) { + if (!committed) { + try { + db_.Exec("ROLLBACK"); + } catch (...) { + } + } + throw; + } +} + +KbClaimResult KbService::ClaimSkillPoint(int64_t user_id, + int64_t article_id, + const std::string& slug, + const std::string& skill_key) { + if (user_id <= 0 || article_id <= 0) { + throw std::runtime_error("invalid claim request"); + } + if (skill_key.empty() || skill_key.size() > 64) { + throw std::runtime_error("invalid skill key"); + } + + const auto skills = SkillPointsBySlug(slug); + const auto it = std::find_if(skills.begin(), skills.end(), + [&](const auto& item) { return item.key == skill_key; }); + if (it == skills.end()) { + throw std::runtime_error("unknown skill point"); + } + const int reward = std::max(0, it->reward); + const auto claimed = ClaimedKeysByUser(user_id); + std::unordered_set claimed_set(claimed.begin(), claimed.end()); + std::vector missing; + for (const auto& pre : it->prerequisites) { + if (claimed_set.count(pre) == 0) missing.push_back(pre); + } + if (!missing.empty()) { + throw std::runtime_error("prerequisite not completed: " + JoinCsv(missing)); + } + + sqlite3* db = db_.raw(); + db_.Exec("BEGIN IMMEDIATE"); + bool committed = false; + try { + sqlite3_stmt* stmt = nullptr; + const char* ins_sql = + "INSERT OR IGNORE INTO kb_knowledge_claims(user_id,article_id,knowledge_key,reward,created_at) " + "VALUES(?,?,?,?,?)"; + CheckSqlite(sqlite3_prepare_v2(db, ins_sql, -1, &stmt, nullptr), db, + "prepare kb claim insert"); + const int64_t now = NowSec(); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind claim user"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, article_id), db, "bind claim article"); + CheckSqlite(sqlite3_bind_text(stmt, 3, skill_key.c_str(), -1, SQLITE_TRANSIENT), db, + "bind claim key"); + CheckSqlite(sqlite3_bind_int(stmt, 4, reward), db, "bind claim reward"); + CheckSqlite(sqlite3_bind_int64(stmt, 5, now), db, "bind claim created_at"); + CheckSqlite(sqlite3_step(stmt), db, "exec kb claim insert"); + sqlite3_finalize(stmt); + const bool inserted = sqlite3_changes(db) > 0; + + if (inserted) { + if (reward > 0) { + sqlite3_stmt* add_stmt = nullptr; + const char* add_sql = "UPDATE users SET rating=rating+? WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, add_sql, -1, &add_stmt, nullptr), db, + "prepare add kb reward"); + CheckSqlite(sqlite3_bind_int(add_stmt, 1, reward), db, "bind reward"); + CheckSqlite(sqlite3_bind_int64(add_stmt, 2, user_id), db, "bind user"); + CheckSqlite(sqlite3_step(add_stmt), db, "exec add kb reward"); + sqlite3_finalize(add_stmt); + } + + sqlite3_stmt* weekly_stmt = nullptr; + const char* weekly_sql = + "UPDATE kb_weekly_tasks SET completed_at=? " + "WHERE user_id=? AND week_key=? AND article_id=? AND knowledge_key=? AND completed_at IS NULL"; + CheckSqlite(sqlite3_prepare_v2(db, weekly_sql, -1, &weekly_stmt, nullptr), db, + "prepare mark weekly task complete"); + const std::string week_key = CurrentWeekKey(); + CheckSqlite(sqlite3_bind_int64(weekly_stmt, 1, now), db, "bind completed_at"); + CheckSqlite(sqlite3_bind_int64(weekly_stmt, 2, user_id), db, "bind weekly user"); + CheckSqlite(sqlite3_bind_text(weekly_stmt, 3, week_key.c_str(), -1, SQLITE_TRANSIENT), db, "bind weekly key"); + CheckSqlite(sqlite3_bind_int64(weekly_stmt, 4, article_id), db, "bind weekly article"); + CheckSqlite(sqlite3_bind_text(weekly_stmt, 5, skill_key.c_str(), -1, SQLITE_TRANSIENT), db, "bind weekly skill"); + CheckSqlite(sqlite3_step(weekly_stmt), db, "exec mark weekly task complete"); + sqlite3_finalize(weekly_stmt); + } + + int rating_after = 0; + { + sqlite3_stmt* user_stmt = nullptr; + const char* user_sql = "SELECT rating FROM users WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, user_sql, -1, &user_stmt, nullptr), db, + "prepare query user rating"); + CheckSqlite(sqlite3_bind_int64(user_stmt, 1, user_id), db, "bind query user"); + if (sqlite3_step(user_stmt) == SQLITE_ROW) { + rating_after = sqlite3_column_int(user_stmt, 0); + } + sqlite3_finalize(user_stmt); + } + + int total_claimed = 0; + { + sqlite3_stmt* count_stmt = nullptr; + const char* count_sql = + "SELECT COUNT(1) FROM kb_knowledge_claims WHERE user_id=? AND article_id=?"; + CheckSqlite(sqlite3_prepare_v2(db, count_sql, -1, &count_stmt, nullptr), db, + "prepare query claim count"); + CheckSqlite(sqlite3_bind_int64(count_stmt, 1, user_id), db, "bind count user"); + CheckSqlite(sqlite3_bind_int64(count_stmt, 2, article_id), db, "bind count article"); + if (sqlite3_step(count_stmt) == SQLITE_ROW) { + total_claimed = sqlite3_column_int(count_stmt, 0); + } + sqlite3_finalize(count_stmt); + } + + db_.Exec("COMMIT"); + committed = true; + KbClaimResult out; + out.claimed = inserted; + out.reward = inserted ? reward : 0; + out.rating_after = rating_after; + out.total_claimed = total_claimed; + return out; + } catch (...) { + if (!committed) { + try { + db_.Exec("ROLLBACK"); + } catch (...) { + } + } + throw; + } +} + } // namespace csp::services diff --git a/backend/src/services/lark_bot_service.cc b/backend/src/services/lark_bot_service.cc new file mode 100644 index 0000000..fb8f2dd --- /dev/null +++ b/backend/src/services/lark_bot_service.cc @@ -0,0 +1,488 @@ +#include "csp/services/lark_bot_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(); +} + +std::string LocalTrim(const std::string& s) { + const auto begin = s.find_first_not_of(" \t\r\n"); + if (begin == std::string::npos) return {}; + const auto end = s.find_last_not_of(" \t\r\n"); + return s.substr(begin, end - begin + 1); +} + +std::string EnvStr(const char* key, const std::string& default_value) { + const char* raw = std::getenv(key); + if (!raw) return default_value; + return std::string(raw); +} + +bool EnvBool(const char* key, bool default_value) { + const std::string raw = LocalTrim(EnvStr(key, "")); + if (raw.empty()) return default_value; + std::string v; + v.reserve(raw.size()); + for (char c : raw) v.push_back(static_cast(::tolower(static_cast(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; +} + +int EnvInt(const char* key, int default_value, int min_value, int max_value) { + const std::string raw = LocalTrim(EnvStr(key, "")); + if (raw.empty()) return default_value; + try { + const int parsed = std::stoi(raw); + if (parsed < min_value) return min_value; + if (parsed > max_value) return max_value; + return parsed; + } catch (...) { + return default_value; + } +} + +std::string JsonToString(const Json::Value& value) { + Json::StreamWriterBuilder wb; + wb["indentation"] = ""; + return Json::writeString(wb, value); +} + +bool ParseJson(const std::string& text, Json::Value& out) { + Json::CharReaderBuilder rb; + std::string errs; + std::unique_ptr reader(rb.newCharReader()); + return reader->parse(text.data(), text.data() + text.size(), &out, &errs); +} + +std::string ExtractLlmText(const Json::Value& root) { + if (root.isMember("choices") && root["choices"].isArray() && + root["choices"].size() > 0) { + const auto& first = root["choices"][0]; + if (first.isMember("message")) { + const auto& content = first["message"]["content"]; + if (content.isString()) return content.asString(); + if (content.isArray()) { + std::string combined; + for (const auto& part : content) { + if (part.isString()) { + combined += part.asString(); + } else if (part.isObject() && part.isMember("text") && + part["text"].isString()) { + combined += part["text"].asString(); + } + } + return combined; + } + } + if (first.isMember("text") && first["text"].isString()) { + return first["text"].asString(); + } + } + + if (root.isMember("output_text") && root["output_text"].isString()) { + return root["output_text"].asString(); + } + return {}; +} + +} // namespace + +LarkBotService& LarkBotService::Instance() { + static LarkBotService svc; + return svc; +} + +void LarkBotService::ConfigureFromEnv() { + std::lock_guard lock(mu_); + enabled_ = EnvBool("CSP_LARK_BOT_ENABLED", false); + verification_token_ = Trim(EnvStr("CSP_LARK_VERIFICATION_TOKEN", "")); + app_id_ = Trim(EnvStr("CSP_LARK_APP_ID", "")); + app_secret_ = Trim(EnvStr("CSP_LARK_APP_SECRET", "")); + open_base_url_ = Trim(EnvStr("CSP_LARK_OPEN_BASE_URL", "https://open.feishu.cn")); + llm_api_url_ = Trim(EnvStr("CSP_LARK_LLM_API_URL", EnvStr("OI_LLM_API_URL", ""))); + llm_api_key_ = Trim(EnvStr("CSP_LARK_LLM_API_KEY", EnvStr("OI_LLM_API_KEY", ""))); + llm_model_ = Trim(EnvStr("CSP_LARK_LLM_MODEL", EnvStr("OI_LLM_MODEL", "qwen3-max"))); + llm_system_prompt_ = EnvStr( + "CSP_LARK_LLM_SYSTEM_PROMPT", + "你是 CSP Quest World 的编程助教。请用简洁中文回答,先给结论,再给步骤。"); + llm_timeout_sec_ = EnvInt("CSP_LARK_LLM_TIMEOUT_SEC", 30, 5, 180); + lark_timeout_sec_ = EnvInt("CSP_LARK_API_TIMEOUT_SEC", 15, 3, 120); + memory_turns_ = EnvInt("CSP_LARK_MEMORY_TURNS", 6, 0, 20); + max_reply_chars_ = static_cast( + EnvInt("CSP_LARK_MAX_REPLY_CHARS", 1200, 200, 6000)); + + tenant_access_token_.clear(); + tenant_access_token_expire_at_ = 0; + conversations_.clear(); + + if (enabled_ && (app_id_.empty() || app_secret_.empty())) { + LOG_WARN << "lark bot reply may fail: missing CSP_LARK_APP_ID/CSP_LARK_APP_SECRET"; + } + if (enabled_) { + LOG_INFO << "lark bot enabled (memory_turns=" << memory_turns_ << ")"; + } +} + +bool LarkBotService::Enabled() const { + std::lock_guard lock(mu_); + return enabled_; +} + +bool LarkBotService::VerifyToken(const std::string& token) const { + std::lock_guard lock(mu_); + if (verification_token_.empty()) return true; + return verification_token_ == token; +} + +void LarkBotService::HandleEventAsync(IncomingTextEvent event) { + if (!Enabled()) return; + if (Trim(event.message_id).empty()) return; + event.text = Trim(event.text); + if (event.text.empty()) return; + + std::thread([this, ev = std::move(event)]() mutable { + const std::string session_key = ev.chat_id + ":" + ev.sender_id; + + std::string llm_err; + std::string reply = BuildReplyWithLlm(session_key, ev.text, llm_err); + if (reply.empty()) { + if (!llm_err.empty()) LOG_WARN << "lark bot llm failed: " << llm_err; + reply = FallbackReply(); + } + reply = ClipUtf8(reply, max_reply_chars_); + + std::string send_err; + if (!SendReplyToLark(ev.message_id, reply, send_err)) { + LOG_WARN << "lark bot send reply failed: " << send_err; + } + }).detach(); +} + +void LarkBotService::ReplyTextAsync(const std::string& message_id, + const std::string& text) { + if (!Enabled()) return; + const std::string mid = Trim(message_id); + const std::string content = Trim(text); + if (mid.empty() || content.empty()) return; + size_t max_chars = 1200; + { + std::lock_guard lock(mu_); + max_chars = max_reply_chars_; + } + + std::thread([this, mid, content, max_chars]() { + std::string send_err; + if (!SendReplyToLark(mid, ClipUtf8(content, max_chars), send_err)) { + LOG_WARN << "lark bot plain reply failed: " << send_err; + } + }).detach(); +} + +std::string LarkBotService::BuildReplyWithLlm(const std::string& session_key, + const std::string& user_text, + std::string& err) { + std::string llm_api_url; + std::string llm_api_key; + std::string llm_model; + std::string system_prompt; + int timeout = 30; + int memory_turns = 0; + std::deque history; + { + std::lock_guard lock(mu_); + llm_api_url = llm_api_url_; + llm_api_key = llm_api_key_; + llm_model = llm_model_; + system_prompt = llm_system_prompt_; + timeout = llm_timeout_sec_; + memory_turns = memory_turns_; + const auto it = conversations_.find(session_key); + if (it != conversations_.end()) history = it->second; + } + + if (llm_api_url.empty() || llm_api_key.empty()) { + err = "missing CSP_LARK_LLM_API_URL/CSP_LARK_LLM_API_KEY"; + return {}; + } + + ParsedUrl endpoint; + if (!ParseUrl(llm_api_url, endpoint)) { + err = "invalid llm api url"; + return {}; + } + + Json::Value payload; + payload["model"] = llm_model; + payload["stream"] = false; + Json::Value messages(Json::arrayValue); + if (!Trim(system_prompt).empty()) { + Json::Value msg; + msg["role"] = "system"; + msg["content"] = system_prompt; + messages.append(msg); + } + for (const auto& turn : history) { + Json::Value msg; + msg["role"] = turn.role; + msg["content"] = turn.content; + messages.append(msg); + } + Json::Value current; + current["role"] = "user"; + current["content"] = ClipUtf8(user_text, 3000); + messages.append(current); + payload["messages"] = messages; + + std::string body = JsonToString(payload); + std::string resp_body; + if (!HttpPostJson(endpoint, + body, + { + {"Authorization", "Bearer " + llm_api_key}, + {"Content-Type", "application/json"}, + }, + timeout, + resp_body, + err)) { + return {}; + } + + Json::Value resp_json; + if (!ParseJson(resp_body, resp_json)) { + err = "invalid llm json response"; + return {}; + } + + std::string answer = Trim(ExtractLlmText(resp_json)); + if (answer.empty()) { + err = "empty llm answer"; + return {}; + } + + { + std::lock_guard lock(mu_); + auto& turns = conversations_[session_key]; + turns.push_back(ChatTurn{.role = "user", .content = ClipUtf8(user_text, 1000)}); + turns.push_back(ChatTurn{.role = "assistant", .content = ClipUtf8(answer, 2000)}); + const size_t max_turn_records = + memory_turns > 0 ? static_cast(memory_turns * 2) : 0; + if (max_turn_records == 0) { + turns.clear(); + } else { + while (turns.size() > max_turn_records) turns.pop_front(); + } + } + return answer; +} + +std::string LarkBotService::FallbackReply() const { + return "收到啦,我在认真思考中~请稍后再试一次。"; +} + +bool LarkBotService::SendReplyToLark(const std::string& message_id, + const std::string& text, + std::string& err) { + std::string token; + if (!ObtainTenantToken(token, err)) return false; + + std::string open_base_url; + int timeout = 15; + { + std::lock_guard lock(mu_); + open_base_url = open_base_url_; + timeout = lark_timeout_sec_; + } + + ParsedUrl endpoint; + if (!ParseUrl(open_base_url + "/open-apis/im/v1/messages/" + message_id + + "/reply", + endpoint)) { + err = "invalid lark open base url"; + return false; + } + + Json::Value content; + content["text"] = ClipUtf8(text, max_reply_chars_); + + Json::Value payload; + payload["msg_type"] = "text"; + payload["content"] = JsonToString(content); + + std::string resp_body; + if (!HttpPostJson(endpoint, + JsonToString(payload), + { + {"Authorization", "Bearer " + token}, + {"Content-Type", "application/json; charset=utf-8"}, + }, + timeout, + resp_body, + err)) { + return false; + } + + Json::Value resp_json; + if (!ParseJson(resp_body, resp_json)) return true; + if (resp_json.isMember("code") && resp_json["code"].isInt() && + resp_json["code"].asInt() != 0) { + err = "lark api code=" + std::to_string(resp_json["code"].asInt()) + " msg=" + + resp_json.get("msg", "").asString(); + return false; + } + return true; +} + +bool LarkBotService::ObtainTenantToken(std::string& token, std::string& err) { + std::string cached; + int64_t expires_at = 0; + { + std::lock_guard lock(mu_); + cached = tenant_access_token_; + expires_at = tenant_access_token_expire_at_; + } + const int64_t now = NowSec(); + if (!cached.empty() && expires_at > now + 60) { + token = cached; + return true; + } + + std::string app_id; + std::string app_secret; + std::string open_base_url; + int timeout = 15; + { + std::lock_guard lock(mu_); + app_id = app_id_; + app_secret = app_secret_; + open_base_url = open_base_url_; + timeout = lark_timeout_sec_; + } + if (app_id.empty() || app_secret.empty()) { + err = "missing lark app credentials"; + return false; + } + + ParsedUrl endpoint; + if (!ParseUrl(open_base_url + "/open-apis/auth/v3/tenant_access_token/internal", + endpoint)) { + err = "invalid lark open base url"; + return false; + } + + Json::Value payload; + payload["app_id"] = app_id; + payload["app_secret"] = app_secret; + + std::string resp_body; + if (!HttpPostJson(endpoint, + JsonToString(payload), + {{"Content-Type", "application/json; charset=utf-8"}}, + timeout, + resp_body, + err)) { + return false; + } + + Json::Value resp_json; + if (!ParseJson(resp_body, resp_json)) { + err = "invalid lark token json response"; + return false; + } + if (!resp_json.isMember("tenant_access_token") || + !resp_json["tenant_access_token"].isString()) { + err = "missing tenant_access_token"; + return false; + } + const std::string fresh = resp_json["tenant_access_token"].asString(); + const int expire = resp_json.get("expire", 7200).asInt(); + + { + std::lock_guard lock(mu_); + tenant_access_token_ = fresh; + tenant_access_token_expire_at_ = NowSec() + expire; + } + token = fresh; + return true; +} + +bool LarkBotService::HttpPostJson( + const ParsedUrl& endpoint, + const std::string& body, + const std::unordered_map& headers, + double timeout_sec, + std::string& response_body, + std::string& err) const { + auto client = drogon::HttpClient::newHttpClient(endpoint.origin); + if (!client) { + err = "http client init failed"; + return false; + } + + auto req = drogon::HttpRequest::newHttpRequest(); + req->setMethod(drogon::Post); + req->setPath(endpoint.path); + req->setBody(body); + for (const auto& kv : headers) req->addHeader(kv.first, kv.second); + + const auto result = client->sendRequest(req, timeout_sec); + if (result.first != drogon::ReqResult::Ok || !result.second) { + err = "http request failed"; + return false; + } + const auto resp = result.second; + response_body = resp->body(); + if (resp->statusCode() < 200 || resp->statusCode() >= 300) { + err = "http status " + std::to_string(static_cast(resp->statusCode())) + + " body=" + ClipUtf8(response_body, 800); + return false; + } + return true; +} + +std::string LarkBotService::Trim(const std::string& s) { + const auto begin = s.find_first_not_of(" \t\r\n"); + if (begin == std::string::npos) return {}; + const auto end = s.find_last_not_of(" \t\r\n"); + return s.substr(begin, end - begin + 1); +} + +std::string LarkBotService::ClipUtf8(const std::string& s, size_t max_bytes) { + if (s.size() <= max_bytes) return s; + size_t cut = max_bytes; + while (cut > 0 && + (static_cast(s[cut]) & 0xC0) == 0x80) { + --cut; + } + return s.substr(0, cut); +} + +bool LarkBotService::ParseUrl(const std::string& url, ParsedUrl& out) { + const std::string u = Trim(url); + const auto scheme_pos = u.find("://"); + if (scheme_pos == std::string::npos) return false; + const auto path_pos = u.find('/', scheme_pos + 3); + if (path_pos == std::string::npos) { + out.origin = u; + out.path = "/"; + return true; + } + out.origin = u.substr(0, path_pos); + out.path = u.substr(path_pos); + return !out.origin.empty() && !out.path.empty(); +} + +} // namespace csp::services diff --git a/backend/src/services/learning_note_scoring_service.cc b/backend/src/services/learning_note_scoring_service.cc index 322422d..9fcc9d8 100644 --- a/backend/src/services/learning_note_scoring_service.cc +++ b/backend/src/services/learning_note_scoring_service.cc @@ -4,6 +4,9 @@ #include +#include +#include +#include #include #include #include @@ -37,6 +40,69 @@ std::string JsonToString(const Json::Value& value) { return Json::writeString(builder, value); } +bool ContainsAny(const std::string& text, const std::vector& needles) { + for (const auto& needle : needles) { + if (!needle.empty() && text.find(needle) != std::string::npos) return true; + } + return false; +} + +LearningNoteScoreResult BuildFallbackScoreResult(const std::string& note, + const std::string& reason) { + const std::string trimmed = note; + const bool has_content = std::any_of(trimmed.begin(), trimmed.end(), [](unsigned char c) { + return !std::isspace(c); + }); + if (!has_content) { + LearningNoteScoreResult empty; + empty.score = 0; + empty.rating = 0; + empty.model_name = "fallback-rules"; + empty.feedback_md = + "### ⛏️ 空白卷轴\n" + "- 鉴定服务暂时波动,且当前笔记为空,请先填写内容再鉴定。\n" + "- 原因:" + reason + "\n"; + return empty; + } + + int score = 20; + if (trimmed.size() >= 200) score += 10; + if (trimmed.size() >= 500) score += 5; + if (trimmed.find("```") != std::string::npos) score += 10; + if (ContainsAny(trimmed, {"踩坑", "错误"})) score += 8; + + std::string lower = trimmed; + std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + if (lower.find("debug") != std::string::npos) score += 8; + if (ContainsAny(trimmed, {"总结", "注意"})) score += 7; + + if (score < 0) score = 0; + if (score > 60) score = 60; + int rating = static_cast(std::lround(static_cast(score) / 10.0)); + if (rating < 0) rating = 0; + if (rating > 6) rating = 6; + + LearningNoteScoreResult r; + r.score = score; + r.rating = rating; + r.model_name = "fallback-rules"; + r.feedback_md = + "### ⛏️ 矿石鉴定报告(规则兜底)\n" + "- 鉴定服务暂时波动,已自动使用本地规则继续评分。\n" + "- 原因:" + reason + + "\n" + "- 品质:**" + std::to_string(score) + "/60** ⚡ 经验值:**+" + + std::to_string(rating) + + "**\n" + "\n### 📜 升级指南\n" + "- 写清本次**探索目标**、**核心知识点**、**代码片段**。\n" + "- 记录至少 1 个踩坑点和修复过程。\n" + "- 最后用 3~5 行总结本题收获。\n"; + return r; +} + int ExitCodeFromSystem(int rc) { if (rc == -1) return -1; if (WIFEXITED(rc)) return WEXITSTATUS(rc); @@ -74,10 +140,14 @@ LearningNoteScoreResult LearningNoteScoringService::Score( namespace fs = std::filesystem; const fs::path temp_file = fs::path("/tmp") / ("csp_note_scoring_" + crypto::RandomHex(8) + ".json"); - { + try { 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"); + if (!out) { + return BuildFallbackScoreResult(note, "failed to create temp input file"); + } out << JsonToString(input); + } catch (const std::exception& e) { + return BuildFallbackScoreResult(note, e.what()); } const std::string script = ResolveScriptPath(); @@ -91,7 +161,7 @@ LearningNoteScoreResult LearningNoteScoringService::Score( FILE* pipe = popen(cmd.c_str(), "r"); if (!pipe) { fs::remove(temp_file); - throw std::runtime_error("failed to start note scoring script"); + return BuildFallbackScoreResult(note, "failed to start note scoring script"); } char buffer[4096]; while (fgets(buffer, sizeof(buffer), pipe) != nullptr) { @@ -102,7 +172,8 @@ LearningNoteScoreResult LearningNoteScoringService::Score( fs::remove(temp_file); if (exit_code != 0) { - throw std::runtime_error("note scoring script failed: " + output); + return BuildFallbackScoreResult( + note, "note scoring script failed (exit=" + std::to_string(exit_code) + ")"); } Json::CharReaderBuilder builder; @@ -111,7 +182,7 @@ LearningNoteScoreResult LearningNoteScoringService::Score( std::unique_ptr reader(builder.newCharReader()); if (!reader->parse(output.data(), output.data() + output.size(), &parsed, &errs) || !parsed.isObject()) { - throw std::runtime_error("note scoring script returned invalid json"); + return BuildFallbackScoreResult(note, "note scoring script returned invalid json"); } LearningNoteScoreResult r; diff --git a/backend/src/services/redeem_service.cc b/backend/src/services/redeem_service.cc index 6878c84..efa6c35 100644 --- a/backend/src/services/redeem_service.cc +++ b/backend/src/services/redeem_service.cc @@ -1,13 +1,21 @@ #include "csp/services/redeem_service.h" +#include #include #include #include #include +#include +#include +#include +#include #include #include +#include +#include #include +#include namespace csp::services { @@ -61,14 +69,110 @@ RedeemRecord ReadRecord(sqlite3_stmt* stmt) { return row; } -std::string NormalizeDayType(std::string day_type) { - for (auto& c : day_type) { - c = static_cast(std::tolower(static_cast(c))); +std::string ShellQuote(const std::string& text) { + std::string out = "'"; + for (char c : text) { + if (c == '\'') { + out += "'\"'\"'"; + } else { + out.push_back(c); + } } - if (day_type == "holiday" || day_type == "vacation" || day_type == "weekend") { - return "holiday"; + out.push_back('\''); + return out; +} + +int ExitCodeFromSystem(int rc) { + if (rc == -1) return -1; + if (WIFEXITED(rc)) return WEXITSTATUS(rc); + if (WIFSIGNALED(rc)) return 128 + WTERMSIG(rc); + return -1; +} + +std::string ResolveHolidayScriptPath() { + const char* env_path = std::getenv("CSP_HOLIDAY_RESOLVER_SCRIPT_PATH"); + if (env_path && std::filesystem::exists(env_path)) return env_path; + const std::vector candidates = { + "/app/scripts/check_china_holiday.py", + "scripts/check_china_holiday.py", + "../scripts/check_china_holiday.py", + "../../scripts/check_china_holiday.py", + }; + for (const auto& path : candidates) { + if (std::filesystem::exists(path)) return path; } - return "studyday"; + return "/app/scripts/check_china_holiday.py"; +} + +struct ShanghaiDay { + std::string date_ymd; + int weekday = 1; // 0=Sun,1=Mon,...,6=Sat + bool weekend = false; +}; + +ShanghaiDay ResolveShanghaiDay(int64_t now_sec) { + const int64_t local_sec = now_sec + 8 * 3600; // UTC+8 + const std::time_t tt = static_cast(local_sec); + std::tm tm {}; +#ifdef _WIN32 + gmtime_s(&tm, &tt); +#else + gmtime_r(&tt, &tm); +#endif + char buf[32]; + if (std::strftime(buf, sizeof(buf), "%Y-%m-%d", &tm) == 0) { + throw std::runtime_error("failed to format date"); + } + ShanghaiDay out; + out.date_ymd = buf; + out.weekday = tm.tm_wday; + out.weekend = (tm.tm_wday == 0 || tm.tm_wday == 6); + return out; +} + +struct HolidayLlmResult { + bool is_holiday = false; + std::string reason; + std::string source; +}; + +std::optional QueryHolidayByLlm(const std::string& date_ymd) { + const std::string script = ResolveHolidayScriptPath(); + if (!std::filesystem::exists(script)) return std::nullopt; + + const std::string cmd = + "/usr/bin/timeout 25s python3 " + ShellQuote(script) + " --date " + + ShellQuote(date_ymd) + " 2>&1"; + + std::string output; + int exit_code = -1; + FILE* pipe = popen(cmd.c_str(), "r"); + if (!pipe) return std::nullopt; + char buffer[2048]; + while (fgets(buffer, sizeof(buffer), pipe) != nullptr) { + output += buffer; + } + exit_code = ExitCodeFromSystem(pclose(pipe)); + if (exit_code != 0) return std::nullopt; + + Json::CharReaderBuilder b; + Json::Value parsed; + std::string errs; + std::unique_ptr reader(b.newCharReader()); + if (!reader->parse(output.data(), output.data() + output.size(), &parsed, + &errs) || + !parsed.isObject()) { + return std::nullopt; + } + + HolidayLlmResult out; + out.is_holiday = parsed.get("is_holiday", false).asBool(); + out.reason = parsed.get("reason", "").asString(); + out.source = parsed.get("model_name", "llm").asString(); + if (out.reason.empty()) { + out.reason = out.is_holiday ? "LLM: 法定节假日" : "LLM: 非法定节假日"; + } + return out; } void ValidateItemWrite(const RedeemItemWrite& input) { @@ -272,6 +376,37 @@ std::vector RedeemService::ListRecordsAll(std::optional u return out; } +RedeemDayTypeDecision RedeemService::ResolveCurrentDayType() { + const int64_t now = NowSec(); + const auto day = ResolveShanghaiDay(now); + + RedeemDayTypeDecision out; + out.checked_at = now; + out.date_ymd = day.date_ymd; + if (day.weekend) { + out.day_type = "holiday"; + out.is_holiday = true; + out.reason = "周末自动判定为假期"; + out.source = "calendar-weekend"; + return out; + } + + const auto llm = QueryHolidayByLlm(day.date_ymd); + if (!llm.has_value()) { + out.day_type = "studyday"; + out.is_holiday = false; + out.reason = "工作日默认学习日(LLM不可用)"; + out.source = "fallback-weekday"; + return out; + } + + out.day_type = llm->is_holiday ? "holiday" : "studyday"; + out.is_holiday = llm->is_holiday; + out.reason = llm->reason; + out.source = llm->source.empty() ? "llm" : llm->source; + 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"); @@ -295,7 +430,8 @@ RedeemRecord RedeemService::Redeem(const RedeemRequest& request) { throw std::runtime_error("redeem item is inactive"); } - const std::string day_type = NormalizeDayType(request.day_type); + const auto day_decision = ResolveCurrentDayType(); + const std::string day_type = day_decision.day_type; const int unit_cost = day_type == "holiday" ? item->holiday_cost : item->studyday_cost; const int total_cost = unit_cost * request.quantity; diff --git a/backend/src/services/season_service.cc b/backend/src/services/season_service.cc new file mode 100644 index 0000000..1e3d87f --- /dev/null +++ b/backend/src/services/season_service.cc @@ -0,0 +1,923 @@ +#include "csp/services/season_service.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace csp::services { + +namespace { + +void CheckSqlite(int rc, sqlite3* db, const char* what) { + if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return; + throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db)); +} + +int64_t NowSec() { + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()).count(); +} + +std::string ColText(sqlite3_stmt* stmt, int col) { + const unsigned char* txt = sqlite3_column_text(stmt, col); + return txt ? reinterpret_cast(txt) : std::string(); +} + +std::string Lower(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return s; +} + +std::string NormalizeJsonText(const std::string& text) { + if (text.empty()) return "{}"; + Json::Value parsed; + Json::CharReaderBuilder builder; + std::string errs; + std::unique_ptr reader(builder.newCharReader()); + const bool ok = reader->parse(text.data(), text.data() + text.size(), &parsed, &errs); + if (!ok) throw std::runtime_error("invalid json payload"); + return text; +} + +std::string JsonFieldString(const std::string& json_text, + const std::string& key, + const std::string& fallback) { + if (json_text.empty()) return fallback; + Json::Value parsed; + Json::CharReaderBuilder builder; + std::string errs; + std::unique_ptr reader(builder.newCharReader()); + if (!reader->parse(json_text.data(), json_text.data() + json_text.size(), &parsed, + &errs)) { + return fallback; + } + if (!parsed.isObject() || !parsed.isMember(key) || !parsed[key].isString()) { + return fallback; + } + return parsed[key].asString(); +} + +void ValidateSeasonStatus(const std::string& status) { + const std::string v = Lower(status); + if (v == "draft" || v == "active" || v == "archived") return; + throw std::runtime_error("invalid season status"); +} + +void ValidateSeasonWrite(const SeasonWrite& in) { + if (in.key.empty() || in.key.size() > 80) throw std::runtime_error("invalid season key"); + if (in.title.empty() || in.title.size() > 120) throw std::runtime_error("invalid season title"); + if (in.starts_at <= 0 || in.ends_at <= 0 || in.ends_at <= in.starts_at) { + throw std::runtime_error("invalid season time range"); + } + ValidateSeasonStatus(in.status); + (void)NormalizeJsonText(in.pass_json); +} + +void ValidateRewardTrack(const SeasonRewardTrackWrite& in) { + if (in.tier_no <= 0 || in.tier_no > 999) throw std::runtime_error("invalid tier_no"); + if (in.required_xp < 0) throw std::runtime_error("required_xp must be >= 0"); + if (in.reward_type.empty() || in.reward_type.size() > 40) { + throw std::runtime_error("invalid reward_type"); + } + if (in.reward_value < 0) throw std::runtime_error("reward_value must be >= 0"); + (void)NormalizeJsonText(in.reward_meta_json); +} + +void ValidateRewardTracks(const std::vector& tracks) { + if (tracks.empty()) return; + std::unordered_set seen; + for (const auto& t : tracks) { + ValidateRewardTrack(t); + const std::string key = std::to_string(t.tier_no) + ":" + Lower(t.reward_type); + if (!seen.insert(key).second) { + throw std::runtime_error("duplicate season reward track"); + } + } +} + +void ValidateModifierWrite(const ContestModifierWrite& in) { + if (in.code.empty() || in.code.size() > 64) throw std::runtime_error("invalid modifier code"); + if (in.title.empty() || in.title.size() > 120) throw std::runtime_error("invalid modifier title"); + if (in.description.size() > 2000) throw std::runtime_error("modifier description too long"); + (void)NormalizeJsonText(in.rule_json); +} + +bool ContestExists(sqlite3* db, int64_t contest_id) { + sqlite3_stmt* stmt = nullptr; + const char* sql = "SELECT 1 FROM contests WHERE id=? LIMIT 1"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare contest exists"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, contest_id), db, "bind contest_id"); + const bool exists = sqlite3_step(stmt) == SQLITE_ROW; + sqlite3_finalize(stmt); + return exists; +} + +domain::Season ReadSeason(sqlite3_stmt* stmt) { + domain::Season out; + out.id = sqlite3_column_int64(stmt, 0); + out.key = ColText(stmt, 1); + out.title = ColText(stmt, 2); + out.starts_at = sqlite3_column_int64(stmt, 3); + out.ends_at = sqlite3_column_int64(stmt, 4); + out.status = ColText(stmt, 5); + out.pass_json = ColText(stmt, 6); + out.created_at = sqlite3_column_int64(stmt, 7); + out.updated_at = sqlite3_column_int64(stmt, 8); + return out; +} + +domain::SeasonRewardTrack ReadSeasonTrack(sqlite3_stmt* stmt) { + domain::SeasonRewardTrack out; + out.id = sqlite3_column_int64(stmt, 0); + out.season_id = sqlite3_column_int64(stmt, 1); + out.tier_no = sqlite3_column_int(stmt, 2); + out.required_xp = sqlite3_column_int(stmt, 3); + out.reward_type = ColText(stmt, 4); + out.reward_value = sqlite3_column_int(stmt, 5); + out.reward_meta_json = ColText(stmt, 6); + return out; +} + +domain::SeasonRewardClaim ReadSeasonClaim(sqlite3_stmt* stmt) { + domain::SeasonRewardClaim out; + out.id = sqlite3_column_int64(stmt, 0); + out.season_id = sqlite3_column_int64(stmt, 1); + out.user_id = sqlite3_column_int64(stmt, 2); + out.tier_no = sqlite3_column_int(stmt, 3); + out.reward_type = ColText(stmt, 4); + out.claimed_at = sqlite3_column_int64(stmt, 5); + return out; +} + +domain::ContestModifier ReadContestModifier(sqlite3_stmt* stmt) { + domain::ContestModifier out; + out.id = sqlite3_column_int64(stmt, 0); + out.contest_id = sqlite3_column_int64(stmt, 1); + out.code = ColText(stmt, 2); + out.title = ColText(stmt, 3); + out.description = ColText(stmt, 4); + out.rule_json = ColText(stmt, 5); + out.is_active = sqlite3_column_int(stmt, 6) != 0; + out.created_at = sqlite3_column_int64(stmt, 7); + out.updated_at = sqlite3_column_int64(stmt, 8); + return out; +} + +domain::LootDropLog ReadLootDrop(sqlite3_stmt* stmt) { + domain::LootDropLog out; + out.id = sqlite3_column_int64(stmt, 0); + out.user_id = sqlite3_column_int64(stmt, 1); + out.source_type = ColText(stmt, 2); + out.source_id = sqlite3_column_int64(stmt, 3); + out.item_code = ColText(stmt, 4); + out.item_name = ColText(stmt, 5); + out.rarity = ColText(stmt, 6); + out.amount = sqlite3_column_int(stmt, 7); + out.meta_json = ColText(stmt, 8); + out.created_at = sqlite3_column_int64(stmt, 9); + return out; +} + +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; +} + +std::vector QuerySeasonTracks(sqlite3* db, + int64_t season_id) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,season_id,tier_no,required_xp,reward_type,reward_value,reward_meta_json " + "FROM season_reward_tracks WHERE season_id=? ORDER BY tier_no ASC,id ASC"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare list season tracks"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, season_id), db, "bind season_id"); + std::vector tracks; + while (sqlite3_step(stmt) == SQLITE_ROW) { + tracks.push_back(ReadSeasonTrack(stmt)); + } + sqlite3_finalize(stmt); + return tracks; +} + +int ComputeLevelByXp(const std::vector& tracks, int xp) { + int level = 0; + for (const auto& t : tracks) { + if (xp >= t.required_xp && t.tier_no > level) level = t.tier_no; + } + return level; +} + +std::optional FindTrack( + const std::vector& tracks, + int tier_no, + const std::string& reward_type) { + const std::string target_type = Lower(reward_type); + for (const auto& t : tracks) { + if (t.tier_no == tier_no && Lower(t.reward_type) == target_type) { + return t; + } + } + return std::nullopt; +} + +domain::SeasonUserProgress EnsureSeasonProgress(sqlite3* db, + int64_t season_id, + int64_t user_id, + const std::vector& tracks) { + sqlite3_stmt* stmt = nullptr; + const char* query_sql = + "SELECT xp,level,updated_at FROM season_user_progress WHERE season_id=? AND user_id=?"; + CheckSqlite(sqlite3_prepare_v2(db, query_sql, -1, &stmt, nullptr), db, + "prepare query season progress"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, season_id), db, "bind season_id"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id"); + + const int user_rating = QueryUserRating(db, user_id); + const int64_t now = NowSec(); + const int current_level = ComputeLevelByXp(tracks, user_rating); + + domain::SeasonUserProgress progress; + progress.season_id = season_id; + progress.user_id = user_id; + + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + sqlite3_stmt* insert_stmt = nullptr; + const char* insert_sql = + "INSERT INTO season_user_progress(season_id,user_id,xp,level,updated_at) " + "VALUES(?,?,?,?,?)"; + CheckSqlite(sqlite3_prepare_v2(db, insert_sql, -1, &insert_stmt, nullptr), db, + "prepare insert season progress"); + CheckSqlite(sqlite3_bind_int64(insert_stmt, 1, season_id), db, + "bind season_id"); + CheckSqlite(sqlite3_bind_int64(insert_stmt, 2, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_int(insert_stmt, 3, user_rating), db, "bind xp"); + CheckSqlite(sqlite3_bind_int(insert_stmt, 4, current_level), db, "bind level"); + CheckSqlite(sqlite3_bind_int64(insert_stmt, 5, now), db, "bind updated_at"); + CheckSqlite(sqlite3_step(insert_stmt), db, "insert season progress"); + sqlite3_finalize(insert_stmt); + + progress.xp = user_rating; + progress.level = current_level; + progress.updated_at = now; + return progress; + } + + const int old_xp = sqlite3_column_int(stmt, 0); + const int old_level = sqlite3_column_int(stmt, 1); + const int64_t old_updated_at = sqlite3_column_int64(stmt, 2); + sqlite3_finalize(stmt); + + // V1: season xp follows user's maximum observed rating. + const int next_xp = std::max(old_xp, user_rating); + const int next_level = ComputeLevelByXp(tracks, next_xp); + if (next_xp != old_xp || next_level != old_level) { + sqlite3_stmt* update_stmt = nullptr; + const char* update_sql = + "UPDATE season_user_progress SET xp=?,level=?,updated_at=? " + "WHERE season_id=? AND user_id=?"; + CheckSqlite(sqlite3_prepare_v2(db, update_sql, -1, &update_stmt, nullptr), db, + "prepare update season progress"); + CheckSqlite(sqlite3_bind_int(update_stmt, 1, next_xp), db, "bind xp"); + CheckSqlite(sqlite3_bind_int(update_stmt, 2, next_level), db, "bind level"); + CheckSqlite(sqlite3_bind_int64(update_stmt, 3, now), db, "bind updated_at"); + CheckSqlite(sqlite3_bind_int64(update_stmt, 4, season_id), db, + "bind season_id"); + CheckSqlite(sqlite3_bind_int64(update_stmt, 5, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_step(update_stmt), db, "update season progress"); + sqlite3_finalize(update_stmt); + progress.xp = next_xp; + progress.level = next_level; + progress.updated_at = now; + return progress; + } + + progress.xp = old_xp; + progress.level = old_level; + progress.updated_at = old_updated_at; + return progress; +} + +std::optional QueryClaim(sqlite3* db, + int64_t season_id, + int64_t user_id, + int tier_no, + const std::string& reward_type) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,season_id,user_id,tier_no,reward_type,claimed_at " + "FROM season_reward_claims " + "WHERE season_id=? AND user_id=? AND tier_no=? AND reward_type=? " + "LIMIT 1"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare query season claim"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, season_id), db, "bind season_id"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_int(stmt, 3, tier_no), db, "bind tier_no"); + CheckSqlite(sqlite3_bind_text(stmt, 4, reward_type.c_str(), -1, SQLITE_TRANSIENT), + db, "bind reward_type"); + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + return std::nullopt; + } + auto out = ReadSeasonClaim(stmt); + sqlite3_finalize(stmt); + return out; +} + +std::optional QueryContestModifier(sqlite3* db, + int64_t contest_id, + int64_t modifier_id) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,contest_id,code,title,description,rule_json,is_active,created_at,updated_at " + "FROM contest_modifiers WHERE contest_id=? AND id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare query contest modifier"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, contest_id), db, "bind contest_id"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, modifier_id), db, "bind modifier_id"); + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + return std::nullopt; + } + auto out = ReadContestModifier(stmt); + sqlite3_finalize(stmt); + return out; +} + +void ReplaceSeasonTracks(sqlite3* db, + int64_t season_id, + const std::vector& tracks) { + sqlite3_stmt* del_stmt = nullptr; + const char* del_sql = "DELETE FROM season_reward_tracks WHERE season_id=?"; + CheckSqlite(sqlite3_prepare_v2(db, del_sql, -1, &del_stmt, nullptr), db, + "prepare delete season tracks"); + CheckSqlite(sqlite3_bind_int64(del_stmt, 1, season_id), db, "bind season_id"); + CheckSqlite(sqlite3_step(del_stmt), db, "delete season tracks"); + sqlite3_finalize(del_stmt); + + if (tracks.empty()) return; + const char* ins_sql = + "INSERT INTO season_reward_tracks(" + "season_id,tier_no,required_xp,reward_type,reward_value,reward_meta_json" + ") VALUES(?,?,?,?,?,?)"; + for (const auto& track : tracks) { + sqlite3_stmt* ins_stmt = nullptr; + CheckSqlite(sqlite3_prepare_v2(db, ins_sql, -1, &ins_stmt, nullptr), db, + "prepare insert season track"); + CheckSqlite(sqlite3_bind_int64(ins_stmt, 1, season_id), db, "bind season_id"); + CheckSqlite(sqlite3_bind_int(ins_stmt, 2, track.tier_no), db, "bind tier_no"); + CheckSqlite(sqlite3_bind_int(ins_stmt, 3, track.required_xp), db, + "bind required_xp"); + CheckSqlite(sqlite3_bind_text(ins_stmt, 4, track.reward_type.c_str(), -1, + SQLITE_TRANSIENT), + db, "bind reward_type"); + CheckSqlite(sqlite3_bind_int(ins_stmt, 5, track.reward_value), db, + "bind reward_value"); + CheckSqlite(sqlite3_bind_text(ins_stmt, 6, track.reward_meta_json.c_str(), -1, + SQLITE_TRANSIENT), + db, "bind reward_meta_json"); + CheckSqlite(sqlite3_step(ins_stmt), db, "insert season track"); + sqlite3_finalize(ins_stmt); + } +} + +} // namespace + +std::optional SeasonService::GetCurrentSeason() { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const int64_t now = NowSec(); + const char* sql_current = + "SELECT id,key,title,starts_at,ends_at,status,pass_json,created_at,updated_at " + "FROM seasons WHERE status='active' AND starts_at<=? AND ends_at>=? " + "ORDER BY starts_at DESC,id DESC LIMIT 1"; + CheckSqlite(sqlite3_prepare_v2(db, sql_current, -1, &stmt, nullptr), db, + "prepare get current season"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, now), db, "bind now"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, now), db, "bind now"); + if (sqlite3_step(stmt) == SQLITE_ROW) { + auto out = ReadSeason(stmt); + sqlite3_finalize(stmt); + return out; + } + sqlite3_finalize(stmt); + + const char* sql_active = + "SELECT id,key,title,starts_at,ends_at,status,pass_json,created_at,updated_at " + "FROM seasons WHERE status='active' ORDER BY starts_at DESC,id DESC LIMIT 1"; + CheckSqlite(sqlite3_prepare_v2(db, sql_active, -1, &stmt, nullptr), db, + "prepare fallback active season"); + if (sqlite3_step(stmt) == SQLITE_ROW) { + auto out = ReadSeason(stmt); + sqlite3_finalize(stmt); + return out; + } + sqlite3_finalize(stmt); + + const char* sql_latest = + "SELECT id,key,title,starts_at,ends_at,status,pass_json,created_at,updated_at " + "FROM seasons ORDER BY id DESC LIMIT 1"; + CheckSqlite(sqlite3_prepare_v2(db, sql_latest, -1, &stmt, nullptr), db, + "prepare fallback latest season"); + if (sqlite3_step(stmt) == SQLITE_ROW) { + auto out = ReadSeason(stmt); + sqlite3_finalize(stmt); + return out; + } + sqlite3_finalize(stmt); + return std::nullopt; +} + +std::optional SeasonService::GetSeasonById(int64_t season_id) { + if (season_id <= 0) return std::nullopt; + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,key,title,starts_at,ends_at,status,pass_json,created_at,updated_at " + "FROM seasons WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare get season by id"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, season_id), db, "bind season_id"); + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + return std::nullopt; + } + auto out = ReadSeason(stmt); + sqlite3_finalize(stmt); + return out; +} + +std::vector SeasonService::ListRewardTracks( + int64_t season_id) { + if (season_id <= 0) return {}; + return QuerySeasonTracks(db_.raw(), season_id); +} + +std::vector SeasonService::ListUserClaims( + int64_t season_id, + int64_t user_id) { + if (season_id <= 0 || user_id <= 0) return {}; + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,season_id,user_id,tier_no,reward_type,claimed_at " + "FROM season_reward_claims WHERE season_id=? AND user_id=? " + "ORDER BY tier_no ASC,id ASC"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare list season claims"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, season_id), db, "bind season_id"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id"); + std::vector out; + while (sqlite3_step(stmt) == SQLITE_ROW) { + out.push_back(ReadSeasonClaim(stmt)); + } + sqlite3_finalize(stmt); + return out; +} + +domain::SeasonUserProgress SeasonService::GetOrSyncUserProgress(int64_t season_id, + int64_t user_id) { + if (season_id <= 0 || user_id <= 0) throw std::runtime_error("invalid season/user"); + const auto season = GetSeasonById(season_id); + if (!season.has_value()) throw std::runtime_error("season not found"); + const auto tracks = ListRewardTracks(season_id); + return EnsureSeasonProgress(db_.raw(), season_id, user_id, tracks); +} + +SeasonClaimResult SeasonService::ClaimReward(int64_t season_id, + int64_t user_id, + int tier_no, + const std::string& reward_type) { + if (season_id <= 0 || user_id <= 0 || tier_no <= 0) { + throw std::runtime_error("invalid claim arguments"); + } + const auto season = GetSeasonById(season_id); + if (!season.has_value()) throw std::runtime_error("season not found"); + + const std::string normalized_reward_type = + reward_type.empty() ? "free" : Lower(reward_type); + auto tracks = ListRewardTracks(season_id); + const auto track = + FindTrack(tracks, tier_no, normalized_reward_type); + if (!track.has_value()) throw std::runtime_error("season reward track not found"); + + sqlite3* db = db_.raw(); + db_.Exec("BEGIN IMMEDIATE"); + bool committed = false; + try { + auto progress = EnsureSeasonProgress(db, season_id, user_id, tracks); + if (progress.xp < track->required_xp) { + throw std::runtime_error("xp not enough for this tier"); + } + + sqlite3_stmt* claim_stmt = nullptr; + const int64_t now = NowSec(); + const char* claim_sql = + "INSERT OR IGNORE INTO season_reward_claims(" + "season_id,user_id,tier_no,reward_type,claimed_at" + ") VALUES(?,?,?,?,?)"; + CheckSqlite(sqlite3_prepare_v2(db, claim_sql, -1, &claim_stmt, nullptr), db, + "prepare insert season claim"); + CheckSqlite(sqlite3_bind_int64(claim_stmt, 1, season_id), db, "bind season_id"); + CheckSqlite(sqlite3_bind_int64(claim_stmt, 2, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_int(claim_stmt, 3, tier_no), db, "bind tier_no"); + CheckSqlite(sqlite3_bind_text(claim_stmt, 4, normalized_reward_type.c_str(), -1, + SQLITE_TRANSIENT), + db, "bind reward_type"); + CheckSqlite(sqlite3_bind_int64(claim_stmt, 5, now), db, "bind claimed_at"); + CheckSqlite(sqlite3_step(claim_stmt), db, "insert season claim"); + sqlite3_finalize(claim_stmt); + const bool claimed_now = sqlite3_changes(db) > 0; + + if (claimed_now && track->reward_value > 0) { + sqlite3_stmt* rating_stmt = nullptr; + const char* rating_sql = "UPDATE users SET rating=rating+? WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, rating_sql, -1, &rating_stmt, nullptr), db, + "prepare update user rating by season claim"); + CheckSqlite(sqlite3_bind_int(rating_stmt, 1, track->reward_value), db, + "bind reward_value"); + CheckSqlite(sqlite3_bind_int64(rating_stmt, 2, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_step(rating_stmt), db, "update user rating by season claim"); + sqlite3_finalize(rating_stmt); + if (sqlite3_changes(db) <= 0) throw std::runtime_error("user not found"); + + const std::string item_name = JsonFieldString( + track->reward_meta_json, + "item_name", + season->title + " T" + std::to_string(track->tier_no) + " 奖励"); + const std::string rarity = + JsonFieldString(track->reward_meta_json, "rarity", "common"); + const std::string item_code = season->key + ":tier-" + + std::to_string(track->tier_no) + ":" + + normalized_reward_type; + sqlite3_stmt* loot_stmt = nullptr; + const char* loot_sql = + "INSERT INTO loot_drop_logs(" + "user_id,source_type,source_id,item_code,item_name,rarity,amount,meta_json,created_at" + ") VALUES(?,?,?,?,?,?,?,?,?)"; + CheckSqlite(sqlite3_prepare_v2(db, loot_sql, -1, &loot_stmt, nullptr), db, + "prepare insert loot drop log"); + CheckSqlite(sqlite3_bind_int64(loot_stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_text(loot_stmt, 2, "season", -1, SQLITE_STATIC), db, + "bind source_type"); + CheckSqlite(sqlite3_bind_int64(loot_stmt, 3, season_id), db, "bind source_id"); + CheckSqlite(sqlite3_bind_text(loot_stmt, 4, item_code.c_str(), -1, + SQLITE_TRANSIENT), + db, "bind item_code"); + CheckSqlite(sqlite3_bind_text(loot_stmt, 5, item_name.c_str(), -1, + SQLITE_TRANSIENT), + db, "bind item_name"); + CheckSqlite(sqlite3_bind_text(loot_stmt, 6, rarity.c_str(), -1, + SQLITE_TRANSIENT), + db, "bind rarity"); + CheckSqlite(sqlite3_bind_int(loot_stmt, 7, track->reward_value), db, + "bind amount"); + CheckSqlite(sqlite3_bind_text(loot_stmt, 8, track->reward_meta_json.c_str(), -1, + SQLITE_TRANSIENT), + db, "bind meta_json"); + CheckSqlite(sqlite3_bind_int64(loot_stmt, 9, now), db, "bind created_at"); + CheckSqlite(sqlite3_step(loot_stmt), db, "insert loot drop log"); + sqlite3_finalize(loot_stmt); + } + + db_.Exec("COMMIT"); + committed = true; + + SeasonClaimResult out; + out.claimed = claimed_now; + out.track = *track; + out.claim = QueryClaim(db, season_id, user_id, tier_no, normalized_reward_type); + out.progress = GetOrSyncUserProgress(season_id, user_id); + out.rating_after = QueryUserRating(db, user_id); + return out; + } catch (...) { + if (!committed) { + try { + db_.Exec("ROLLBACK"); + } catch (...) { + } + } + throw; + } +} + +std::vector SeasonService::ListLootDropsByUser(int64_t user_id, + int limit) { + if (user_id <= 0) return {}; + const int safe_limit = std::max(1, std::min(500, limit)); + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,user_id,source_type,source_id,item_code,item_name,rarity,amount,meta_json,created_at " + "FROM loot_drop_logs WHERE user_id=? ORDER BY id DESC LIMIT ?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare list loot drops"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_int(stmt, 2, safe_limit), db, "bind limit"); + std::vector out; + while (sqlite3_step(stmt) == SQLITE_ROW) { + out.push_back(ReadLootDrop(stmt)); + } + sqlite3_finalize(stmt); + return out; +} + +domain::Season SeasonService::CreateSeason( + const SeasonWrite& input, + const std::vector& tracks) { + ValidateSeasonWrite(input); + ValidateRewardTracks(tracks); + + sqlite3* db = db_.raw(); + db_.Exec("BEGIN IMMEDIATE"); + bool committed = false; + try { + sqlite3_stmt* stmt = nullptr; + const int64_t now = NowSec(); + const char* sql = + "INSERT INTO seasons(key,title,starts_at,ends_at,status,pass_json,created_at,updated_at) " + "VALUES(?,?,?,?,?,?,?,?)"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare create season"); + CheckSqlite(sqlite3_bind_text(stmt, 1, input.key.c_str(), -1, SQLITE_TRANSIENT), + db, "bind key"); + CheckSqlite(sqlite3_bind_text(stmt, 2, input.title.c_str(), -1, SQLITE_TRANSIENT), + db, "bind title"); + CheckSqlite(sqlite3_bind_int64(stmt, 3, input.starts_at), db, + "bind starts_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 4, input.ends_at), db, "bind ends_at"); + CheckSqlite(sqlite3_bind_text(stmt, 5, input.status.c_str(), -1, SQLITE_TRANSIENT), + db, "bind status"); + CheckSqlite(sqlite3_bind_text(stmt, 6, input.pass_json.c_str(), -1, + SQLITE_TRANSIENT), + db, "bind pass_json"); + CheckSqlite(sqlite3_bind_int64(stmt, 7, now), db, "bind created_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 8, now), db, "bind updated_at"); + CheckSqlite(sqlite3_step(stmt), db, "create season"); + sqlite3_finalize(stmt); + + const int64_t season_id = sqlite3_last_insert_rowid(db); + ReplaceSeasonTracks(db, season_id, tracks); + + db_.Exec("COMMIT"); + committed = true; + const auto created = GetSeasonById(season_id); + if (!created.has_value()) throw std::runtime_error("reload season failed"); + return *created; + } catch (...) { + if (!committed) { + try { + db_.Exec("ROLLBACK"); + } catch (...) { + } + } + throw; + } +} + +domain::Season SeasonService::UpdateSeason( + int64_t season_id, + const SeasonPatch& patch, + const std::optional>& replace_tracks) { + auto season = GetSeasonById(season_id); + if (!season.has_value()) throw std::runtime_error("season not found"); + + SeasonWrite merged; + merged.key = patch.key.value_or(season->key); + merged.title = patch.title.value_or(season->title); + merged.starts_at = patch.starts_at.value_or(season->starts_at); + merged.ends_at = patch.ends_at.value_or(season->ends_at); + merged.status = patch.status.value_or(season->status); + merged.pass_json = patch.pass_json.value_or(season->pass_json); + ValidateSeasonWrite(merged); + if (replace_tracks.has_value()) { + ValidateRewardTracks(*replace_tracks); + } + + sqlite3* db = db_.raw(); + db_.Exec("BEGIN IMMEDIATE"); + bool committed = false; + try { + sqlite3_stmt* stmt = nullptr; + const int64_t now = NowSec(); + const char* sql = + "UPDATE seasons SET key=?,title=?,starts_at=?,ends_at=?,status=?,pass_json=?,updated_at=? " + "WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare update season"); + CheckSqlite(sqlite3_bind_text(stmt, 1, merged.key.c_str(), -1, SQLITE_TRANSIENT), + db, "bind key"); + CheckSqlite(sqlite3_bind_text(stmt, 2, merged.title.c_str(), -1, SQLITE_TRANSIENT), + db, "bind title"); + CheckSqlite(sqlite3_bind_int64(stmt, 3, merged.starts_at), db, + "bind starts_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 4, merged.ends_at), db, "bind ends_at"); + CheckSqlite(sqlite3_bind_text(stmt, 5, merged.status.c_str(), -1, + SQLITE_TRANSIENT), + db, "bind status"); + CheckSqlite(sqlite3_bind_text(stmt, 6, merged.pass_json.c_str(), -1, + SQLITE_TRANSIENT), + db, "bind pass_json"); + CheckSqlite(sqlite3_bind_int64(stmt, 7, now), db, "bind updated_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 8, season_id), db, "bind season_id"); + CheckSqlite(sqlite3_step(stmt), db, "update season"); + sqlite3_finalize(stmt); + if (sqlite3_changes(db) <= 0) throw std::runtime_error("season not found"); + + if (replace_tracks.has_value()) { + ReplaceSeasonTracks(db, season_id, *replace_tracks); + + // Recompute season level snapshots after track replacements. + const auto tracks = QuerySeasonTracks(db, season_id); + sqlite3_stmt* prog_stmt = nullptr; + const char* prog_sql = + "SELECT user_id,xp FROM season_user_progress WHERE season_id=?"; + CheckSqlite(sqlite3_prepare_v2(db, prog_sql, -1, &prog_stmt, nullptr), db, + "prepare list season progress for level refresh"); + CheckSqlite(sqlite3_bind_int64(prog_stmt, 1, season_id), db, + "bind season_id"); + while (sqlite3_step(prog_stmt) == SQLITE_ROW) { + const int64_t user_id = sqlite3_column_int64(prog_stmt, 0); + const int xp = sqlite3_column_int(prog_stmt, 1); + const int level = ComputeLevelByXp(tracks, xp); + + sqlite3_stmt* update_level_stmt = nullptr; + const char* update_level_sql = + "UPDATE season_user_progress SET level=?,updated_at=? " + "WHERE season_id=? AND user_id=?"; + CheckSqlite(sqlite3_prepare_v2(db, update_level_sql, -1, &update_level_stmt, + nullptr), + db, "prepare update season progress level"); + CheckSqlite(sqlite3_bind_int(update_level_stmt, 1, level), db, + "bind level"); + CheckSqlite(sqlite3_bind_int64(update_level_stmt, 2, now), db, + "bind updated_at"); + CheckSqlite(sqlite3_bind_int64(update_level_stmt, 3, season_id), db, + "bind season_id"); + CheckSqlite(sqlite3_bind_int64(update_level_stmt, 4, user_id), db, + "bind user_id"); + CheckSqlite(sqlite3_step(update_level_stmt), db, + "update season progress level"); + sqlite3_finalize(update_level_stmt); + } + sqlite3_finalize(prog_stmt); + } + + db_.Exec("COMMIT"); + committed = true; + season = GetSeasonById(season_id); + if (!season.has_value()) throw std::runtime_error("reload season failed"); + return *season; + } catch (...) { + if (!committed) { + try { + db_.Exec("ROLLBACK"); + } catch (...) { + } + } + throw; + } +} + +std::vector SeasonService::ListContestModifiers( + int64_t contest_id, + bool include_inactive) { + if (contest_id <= 0) return {}; + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql_all = + "SELECT id,contest_id,code,title,description,rule_json,is_active,created_at,updated_at " + "FROM contest_modifiers WHERE contest_id=? ORDER BY id ASC"; + const char* sql_active = + "SELECT id,contest_id,code,title,description,rule_json,is_active,created_at,updated_at " + "FROM contest_modifiers WHERE contest_id=? AND is_active=1 ORDER BY id ASC"; + CheckSqlite(sqlite3_prepare_v2(db, include_inactive ? sql_all : sql_active, -1, + &stmt, nullptr), + db, "prepare list contest modifiers"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, contest_id), db, "bind contest_id"); + + std::vector out; + while (sqlite3_step(stmt) == SQLITE_ROW) { + out.push_back(ReadContestModifier(stmt)); + } + sqlite3_finalize(stmt); + return out; +} + +domain::ContestModifier SeasonService::CreateContestModifier( + int64_t contest_id, + const ContestModifierWrite& input) { + if (contest_id <= 0) throw std::runtime_error("invalid contest_id"); + ValidateModifierWrite(input); + + sqlite3* db = db_.raw(); + if (!ContestExists(db, contest_id)) throw std::runtime_error("contest not found"); + + sqlite3_stmt* stmt = nullptr; + const int64_t now = NowSec(); + const char* sql = + "INSERT INTO contest_modifiers(" + "contest_id,code,title,description,rule_json,is_active,created_at,updated_at" + ") VALUES(?,?,?,?,?,?,?,?)"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare create contest modifier"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, contest_id), db, "bind contest_id"); + CheckSqlite(sqlite3_bind_text(stmt, 2, input.code.c_str(), -1, SQLITE_TRANSIENT), + db, "bind code"); + CheckSqlite(sqlite3_bind_text(stmt, 3, input.title.c_str(), -1, SQLITE_TRANSIENT), + db, "bind title"); + CheckSqlite(sqlite3_bind_text(stmt, 4, input.description.c_str(), -1, + SQLITE_TRANSIENT), + db, "bind description"); + CheckSqlite(sqlite3_bind_text(stmt, 5, input.rule_json.c_str(), -1, + SQLITE_TRANSIENT), + db, "bind rule_json"); + CheckSqlite(sqlite3_bind_int(stmt, 6, input.is_active ? 1 : 0), db, + "bind is_active"); + CheckSqlite(sqlite3_bind_int64(stmt, 7, now), db, "bind created_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 8, now), db, "bind updated_at"); + CheckSqlite(sqlite3_step(stmt), db, "create contest modifier"); + sqlite3_finalize(stmt); + + const int64_t modifier_id = sqlite3_last_insert_rowid(db); + const auto created = QueryContestModifier(db, contest_id, modifier_id); + if (!created.has_value()) throw std::runtime_error("reload contest modifier failed"); + return *created; +} + +domain::ContestModifier SeasonService::UpdateContestModifier( + int64_t contest_id, + int64_t modifier_id, + const ContestModifierPatch& patch) { + if (contest_id <= 0 || modifier_id <= 0) { + throw std::runtime_error("invalid contest/modifier id"); + } + sqlite3* db = db_.raw(); + if (!ContestExists(db, contest_id)) throw std::runtime_error("contest not found"); + auto existing = QueryContestModifier(db, contest_id, modifier_id); + if (!existing.has_value()) throw std::runtime_error("contest modifier not found"); + + ContestModifierWrite merged; + merged.code = patch.code.value_or(existing->code); + merged.title = patch.title.value_or(existing->title); + merged.description = patch.description.value_or(existing->description); + merged.rule_json = patch.rule_json.value_or(existing->rule_json); + merged.is_active = patch.is_active.value_or(existing->is_active); + ValidateModifierWrite(merged); + + sqlite3_stmt* stmt = nullptr; + const char* sql = + "UPDATE contest_modifiers " + "SET code=?,title=?,description=?,rule_json=?,is_active=?,updated_at=? " + "WHERE contest_id=? AND id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare update contest modifier"); + CheckSqlite(sqlite3_bind_text(stmt, 1, merged.code.c_str(), -1, SQLITE_TRANSIENT), + db, "bind code"); + CheckSqlite(sqlite3_bind_text(stmt, 2, merged.title.c_str(), -1, SQLITE_TRANSIENT), + db, "bind title"); + CheckSqlite(sqlite3_bind_text(stmt, 3, merged.description.c_str(), -1, + SQLITE_TRANSIENT), + db, "bind description"); + CheckSqlite(sqlite3_bind_text(stmt, 4, merged.rule_json.c_str(), -1, + SQLITE_TRANSIENT), + db, "bind rule_json"); + CheckSqlite(sqlite3_bind_int(stmt, 5, merged.is_active ? 1 : 0), db, + "bind is_active"); + CheckSqlite(sqlite3_bind_int64(stmt, 6, NowSec()), db, "bind updated_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 7, contest_id), db, "bind contest_id"); + CheckSqlite(sqlite3_bind_int64(stmt, 8, modifier_id), db, "bind modifier_id"); + CheckSqlite(sqlite3_step(stmt), db, "update contest modifier"); + sqlite3_finalize(stmt); + if (sqlite3_changes(db) <= 0) throw std::runtime_error("contest modifier not found"); + + existing = QueryContestModifier(db, contest_id, modifier_id); + if (!existing.has_value()) throw std::runtime_error("reload contest modifier failed"); + return *existing; +} + +} // namespace csp::services diff --git a/backend/src/services/solution_access_service.cc b/backend/src/services/solution_access_service.cc index 8988a29..8d14968 100644 --- a/backend/src/services/solution_access_service.cc +++ b/backend/src/services/solution_access_service.cc @@ -253,6 +253,12 @@ SolutionAccessService::ListRatingHistory(int64_t user_id, int limit) { "note " "FROM daily_task_logs WHERE user_id=? " "UNION ALL " + "SELECT 'kb_skill' as type, k.created_at, k.reward as change, " + "('KB ' || COALESCE(a.slug,'') || ':' || k.knowledge_key) as note " + "FROM kb_knowledge_claims k " + "LEFT JOIN kb_articles a ON a.id=k.article_id " + "WHERE k.user_id=? " + "UNION ALL " "SELECT 'redeem' as type, created_at, -total_cost as change, item_name " "as note " "FROM redeem_records WHERE user_id=? " @@ -263,7 +269,8 @@ SolutionAccessService::ListRatingHistory(int64_t user_id, int limit) { CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id 1"); CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id 2"); CheckSqlite(sqlite3_bind_int64(stmt, 3, user_id), db, "bind user_id 3"); - CheckSqlite(sqlite3_bind_int(stmt, 4, limit), db, "bind limit"); + CheckSqlite(sqlite3_bind_int64(stmt, 4, user_id), db, "bind user_id 4"); + CheckSqlite(sqlite3_bind_int(stmt, 5, limit), db, "bind limit"); while (sqlite3_step(stmt) == SQLITE_ROW) { RatingHistoryItem item; diff --git a/backend/src/services/source_crystal_service.cc b/backend/src/services/source_crystal_service.cc new file mode 100644 index 0000000..9b89de5 --- /dev/null +++ b/backend/src/services/source_crystal_service.cc @@ -0,0 +1,512 @@ +#include "csp/services/source_crystal_service.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace csp::services { + +namespace { + +constexpr int64_t kShanghaiOffsetSeconds = 8LL * 3600LL; + +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)); +} + +double Round4(double v) { + return std::round(v * 10000.0) / 10000.0; +} + +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::tm GmTimeFromEpoch(int64_t epoch_sec) { + const std::time_t tt = static_cast(epoch_sec); + std::tm out {}; +#ifdef _WIN32 + gmtime_s(&out, &tt); +#else + gmtime_r(&tt, &out); +#endif + return out; +} + +int64_t UtcEpochFromTm(std::tm tm) { +#ifdef _WIN32 + return static_cast(_mkgmtime(&tm)); +#else + return static_cast(timegm(&tm)); +#endif +} + +int64_t CurrentMonthStartUtc(int64_t utc_sec) { + std::tm local_tm = GmTimeFromEpoch(utc_sec + kShanghaiOffsetSeconds); + local_tm.tm_mday = 1; + local_tm.tm_hour = 0; + local_tm.tm_min = 0; + local_tm.tm_sec = 0; + return UtcEpochFromTm(local_tm) - kShanghaiOffsetSeconds; +} + +int64_t NextMonthStartUtc(int64_t utc_sec) { + std::tm local_tm = GmTimeFromEpoch(utc_sec + kShanghaiOffsetSeconds); + local_tm.tm_mday = 1; + local_tm.tm_mon += 1; + local_tm.tm_hour = 0; + local_tm.tm_min = 0; + local_tm.tm_sec = 0; + return UtcEpochFromTm(local_tm) - kShanghaiOffsetSeconds; +} + +std::string BuildInterestNote(int64_t period_start_utc, bool prorated) { + const std::tm local_tm = GmTimeFromEpoch(period_start_utc + kShanghaiOffsetSeconds); + const int year = local_tm.tm_year + 1900; + const int month = local_tm.tm_mon + 1; + const std::string month_text = month < 10 ? "0" + std::to_string(month) + : std::to_string(month); + if (prorated) { + return "natural month interest prorated " + std::to_string(year) + "-" + + month_text; + } + return "natural month interest " + std::to_string(year) + "-" + month_text; +} + +struct AccountDeltaTx { + int64_t created_at = 0; + double amount = 0.0; +}; + +std::vector ListAccountDeltasLocked(sqlite3* db, + int64_t user_id, + int64_t from_inclusive, + int64_t to_inclusive) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT created_at,amount " + "FROM source_crystal_transactions " + "WHERE user_id=? AND created_at>=? AND created_at<=? " + "ORDER BY created_at ASC,id ASC"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare source crystal list account deltas"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, from_inclusive), db, "bind from"); + CheckSqlite(sqlite3_bind_int64(stmt, 3, to_inclusive), db, "bind to"); + + std::vector out; + while (sqlite3_step(stmt) == SQLITE_ROW) { + AccountDeltaTx tx; + tx.created_at = sqlite3_column_int64(stmt, 0); + tx.amount = sqlite3_column_double(stmt, 1); + out.push_back(tx); + } + sqlite3_finalize(stmt); + return out; +} + +void ValidateAmount(double amount) { + if (!std::isfinite(amount) || amount <= 0.0) { + throw std::runtime_error("amount must be > 0"); + } + if (amount > 1e12) { + throw std::runtime_error("amount too large"); + } +} + +void ValidateNote(const std::string& note) { + if (note.size() > 1000) { + throw std::runtime_error("note too long"); + } +} + +bool UserExists(sqlite3* db, int64_t user_id) { + sqlite3_stmt* stmt = nullptr; + const char* sql = "SELECT 1 FROM users WHERE id=? LIMIT 1"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare source crystal user exists"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + const bool ok = sqlite3_step(stmt) == SQLITE_ROW; + sqlite3_finalize(stmt); + return ok; +} + +void EnsureSettingsRow(sqlite3* db, int64_t now) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "INSERT OR IGNORE INTO source_crystal_settings(id,monthly_interest_rate,updated_at) " + "VALUES(1,0.02,?)"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare ensure source crystal settings"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, now), db, "bind updated_at"); + CheckSqlite(sqlite3_step(stmt), db, "ensure source crystal settings"); + sqlite3_finalize(stmt); +} + +SourceCrystalSettings GetSettingsLocked(sqlite3* db) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT monthly_interest_rate,updated_at FROM source_crystal_settings WHERE id=1"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare source crystal get settings"); + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + throw std::runtime_error("source crystal settings missing"); + } + SourceCrystalSettings s; + s.monthly_interest_rate = sqlite3_column_double(stmt, 0); + s.updated_at = sqlite3_column_int64(stmt, 1); + sqlite3_finalize(stmt); + return s; +} + +SourceCrystalAccountSummary LoadOrCreateAccountLocked(sqlite3* db, + int64_t user_id, + int64_t now) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT balance,last_interest_at,updated_at " + "FROM source_crystal_accounts WHERE user_id=? LIMIT 1"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare source crystal get account"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + if (sqlite3_step(stmt) == SQLITE_ROW) { + SourceCrystalAccountSummary out; + out.user_id = user_id; + out.balance = sqlite3_column_double(stmt, 0); + out.last_interest_at = sqlite3_column_int64(stmt, 1); + out.updated_at = sqlite3_column_int64(stmt, 2); + sqlite3_finalize(stmt); + return out; + } + sqlite3_finalize(stmt); + + const char* ins_sql = + "INSERT INTO source_crystal_accounts(user_id,balance,last_interest_at,updated_at) " + "VALUES(?,?,?,?)"; + CheckSqlite(sqlite3_prepare_v2(db, ins_sql, -1, &stmt, nullptr), db, + "prepare source crystal create account"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_double(stmt, 2, 0.0), db, "bind balance"); + CheckSqlite(sqlite3_bind_int64(stmt, 3, now), db, "bind last_interest_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 4, now), db, "bind updated_at"); + CheckSqlite(sqlite3_step(stmt), db, "create source crystal account"); + sqlite3_finalize(stmt); + + SourceCrystalAccountSummary out; + out.user_id = user_id; + out.balance = 0.0; + out.last_interest_at = now; + out.updated_at = now; + return out; +} + +void UpdateAccountLocked(sqlite3* db, + int64_t user_id, + double balance, + int64_t last_interest_at, + int64_t updated_at) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "UPDATE source_crystal_accounts " + "SET balance=?,last_interest_at=?,updated_at=? WHERE user_id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare source crystal update account"); + CheckSqlite(sqlite3_bind_double(stmt, 1, balance), db, "bind balance"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, last_interest_at), db, + "bind last_interest_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 3, updated_at), db, "bind updated_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 4, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_step(stmt), db, "update source crystal account"); + sqlite3_finalize(stmt); +} + +SourceCrystalTransaction InsertTransactionLocked(sqlite3* db, + int64_t user_id, + const std::string& tx_type, + double amount, + double balance_after, + const std::string& note, + int64_t now) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "INSERT INTO source_crystal_transactions(user_id,tx_type,amount,balance_after,note,created_at) " + "VALUES(?,?,?,?,?,?)"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare source crystal insert transaction"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_text(stmt, 2, tx_type.c_str(), -1, SQLITE_TRANSIENT), + db, "bind tx_type"); + CheckSqlite(sqlite3_bind_double(stmt, 3, amount), db, "bind amount"); + CheckSqlite(sqlite3_bind_double(stmt, 4, balance_after), db, + "bind balance_after"); + CheckSqlite(sqlite3_bind_text(stmt, 5, note.c_str(), -1, SQLITE_TRANSIENT), db, + "bind note"); + CheckSqlite(sqlite3_bind_int64(stmt, 6, now), db, "bind created_at"); + CheckSqlite(sqlite3_step(stmt), db, "insert source crystal transaction"); + sqlite3_finalize(stmt); + + SourceCrystalTransaction tx; + tx.id = sqlite3_last_insert_rowid(db); + tx.user_id = user_id; + tx.tx_type = tx_type; + tx.amount = amount; + tx.balance_after = balance_after; + tx.note = note; + tx.created_at = now; + return tx; +} + +SourceCrystalAccountSummary ApplyInterestLocked(sqlite3* db, + SourceCrystalAccountSummary acc, + const SourceCrystalSettings& settings, + int64_t now) { + if (now <= acc.last_interest_at) { + acc.monthly_interest_rate = settings.monthly_interest_rate; + return acc; + } + + const int64_t original_last_interest_at = acc.last_interest_at; + const auto deltas = + ListAccountDeltasLocked(db, acc.user_id, acc.last_interest_at, now); + + double net_delta = 0.0; + for (const auto& tx : deltas) { + net_delta += tx.amount; + } + double settled_balance = Round4(acc.balance - net_delta); + if (settled_balance < 0.0 && settled_balance > -1e-6) { + settled_balance = 0.0; + } + + std::size_t tx_index = 0; + int64_t period_start = acc.last_interest_at; + double total_interest_delta = 0.0; + while (period_start < now) { + const int64_t period_end = NextMonthStartUtc(period_start); + if (period_end <= period_start || period_end > now) break; + + const int64_t month_start = CurrentMonthStartUtc(period_start); + const int64_t month_span = period_end - month_start; + const int64_t period_span = period_end - period_start; + if (month_span <= 0 || period_span <= 0) break; + + // Weighted average balance by time for this settlement window. + double weighted_balance_seconds = 0.0; + int64_t cursor = period_start; + while (tx_index < deltas.size() && deltas[tx_index].created_at < period_end) { + const int64_t tx_time = std::max(period_start, deltas[tx_index].created_at); + if (tx_time > cursor) { + weighted_balance_seconds += + settled_balance * static_cast(tx_time - cursor); + } + settled_balance = Round4(settled_balance + deltas[tx_index].amount); + cursor = tx_time; + ++tx_index; + } + if (period_end > cursor) { + weighted_balance_seconds += + settled_balance * static_cast(period_end - cursor); + } + + const double interest_delta = Round4( + settings.monthly_interest_rate * + (weighted_balance_seconds / static_cast(month_span))); + if (std::fabs(interest_delta) > 1e-9) { + settled_balance = Round4(settled_balance + interest_delta); + total_interest_delta = Round4(total_interest_delta + interest_delta); + const bool prorated = period_span < month_span; + InsertTransactionLocked(db, acc.user_id, "interest", interest_delta, + settled_balance, + BuildInterestNote(period_start, prorated), + period_end); + } + + period_start = period_end; + acc.last_interest_at = period_end; + } + + if (std::fabs(total_interest_delta) > 1e-9 || + acc.last_interest_at != original_last_interest_at) { + acc.balance = Round4(acc.balance + total_interest_delta); + acc.updated_at = now; + UpdateAccountLocked(db, acc.user_id, acc.balance, acc.last_interest_at, + acc.updated_at); + } + acc.monthly_interest_rate = settings.monthly_interest_rate; + return acc; +} + +SourceCrystalTransaction ExecuteTransfer(db::SqliteDb& db_obj, + int64_t user_id, + double amount, + const std::string& note, + bool is_withdraw) { + ValidateAmount(amount); + ValidateNote(note); + sqlite3* db = db_obj.raw(); + db_obj.Exec("BEGIN IMMEDIATE"); + bool committed = false; + try { + if (!UserExists(db, user_id)) { + throw std::runtime_error("user not found"); + } + const int64_t now = NowSec(); + EnsureSettingsRow(db, now); + const auto settings = GetSettingsLocked(db); + auto account = LoadOrCreateAccountLocked(db, user_id, now); + account = ApplyInterestLocked(db, account, settings, now); + + const double delta = is_withdraw ? -amount : amount; + const double next_balance = Round4(account.balance + delta); + if (is_withdraw && next_balance < -1e-9) { + throw std::runtime_error("source crystal balance not enough"); + } + + account.balance = std::max(0.0, next_balance); + account.updated_at = now; + UpdateAccountLocked(db, user_id, account.balance, account.last_interest_at, + account.updated_at); + + const auto tx = InsertTransactionLocked( + db, user_id, is_withdraw ? "withdraw" : "deposit", delta, + account.balance, note, now); + + db_obj.Exec("COMMIT"); + committed = true; + return tx; + } catch (...) { + if (!committed) { + try { + db_obj.Exec("ROLLBACK"); + } catch (...) { + } + } + throw; + } +} + +} // namespace + +SourceCrystalSettings SourceCrystalService::GetSettings() { + sqlite3* db = db_.raw(); + const int64_t now = NowSec(); + EnsureSettingsRow(db, now); + return GetSettingsLocked(db); +} + +SourceCrystalSettings SourceCrystalService::UpdateMonthlyInterestRate( + double monthly_interest_rate) { + if (!std::isfinite(monthly_interest_rate) || monthly_interest_rate < 0.0 || + monthly_interest_rate > 1.0) { + throw std::runtime_error("monthly_interest_rate must be in [0,1]"); + } + sqlite3* db = db_.raw(); + const int64_t now = NowSec(); + EnsureSettingsRow(db, now); + + sqlite3_stmt* stmt = nullptr; + const char* sql = + "UPDATE source_crystal_settings SET monthly_interest_rate=?,updated_at=? WHERE id=1"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare update source crystal settings"); + CheckSqlite(sqlite3_bind_double(stmt, 1, monthly_interest_rate), db, + "bind monthly_interest_rate"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, now), db, "bind updated_at"); + CheckSqlite(sqlite3_step(stmt), db, "update source crystal settings"); + sqlite3_finalize(stmt); + + return GetSettingsLocked(db); +} + +SourceCrystalAccountSummary SourceCrystalService::GetSummary(int64_t user_id) { + if (user_id <= 0) throw std::runtime_error("invalid user_id"); + sqlite3* db = db_.raw(); + db_.Exec("BEGIN IMMEDIATE"); + bool committed = false; + try { + if (!UserExists(db, user_id)) { + throw std::runtime_error("user not found"); + } + const int64_t now = NowSec(); + EnsureSettingsRow(db, now); + const auto settings = GetSettingsLocked(db); + auto account = LoadOrCreateAccountLocked(db, user_id, now); + account = ApplyInterestLocked(db, account, settings, now); + account.monthly_interest_rate = settings.monthly_interest_rate; + + db_.Exec("COMMIT"); + committed = true; + return account; + } catch (...) { + if (!committed) { + try { + db_.Exec("ROLLBACK"); + } catch (...) { + } + } + throw; + } +} + +std::vector SourceCrystalService::ListTransactions( + int64_t user_id, + int limit) { + if (user_id <= 0) throw std::runtime_error("invalid user_id"); + (void)GetSummary(user_id); // ensure interest is up to date + + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const int safe_limit = std::max(1, std::min(500, limit)); + const char* sql = + "SELECT id,user_id,tx_type,amount,balance_after,note,created_at " + "FROM source_crystal_transactions WHERE user_id=? " + "ORDER BY id DESC LIMIT ?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare source crystal list transactions"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_int(stmt, 2, safe_limit), db, "bind limit"); + + std::vector out; + while (sqlite3_step(stmt) == SQLITE_ROW) { + SourceCrystalTransaction row; + row.id = sqlite3_column_int64(stmt, 0); + row.user_id = sqlite3_column_int64(stmt, 1); + row.tx_type = ColText(stmt, 2); + row.amount = sqlite3_column_double(stmt, 3); + row.balance_after = sqlite3_column_double(stmt, 4); + row.note = ColText(stmt, 5); + row.created_at = sqlite3_column_int64(stmt, 6); + out.push_back(std::move(row)); + } + sqlite3_finalize(stmt); + return out; +} + +SourceCrystalTransaction SourceCrystalService::Deposit(int64_t user_id, + double amount, + const std::string& note) { + return ExecuteTransfer(db_, user_id, amount, note, false); +} + +SourceCrystalTransaction SourceCrystalService::Withdraw(int64_t user_id, + double amount, + const std::string& note) { + return ExecuteTransfer(db_, user_id, amount, note, true); +} + +} // namespace csp::services diff --git a/backend/src/services/submission_service.cc b/backend/src/services/submission_service.cc index b78873e..203ab69 100644 --- a/backend/src/services/submission_service.cc +++ b/backend/src/services/submission_service.cc @@ -199,6 +199,15 @@ bool HasSolvedBefore(sqlite3* db, return solved; } +int FirstAcRewardByDifficulty(int difficulty) { + // Requested reward policy: + // tier 2 -> 15, tier 3 -> 20, other tiers use +3 per tier progression. + if (difficulty == 2) return 15; + if (difficulty == 3) return 20; + if (difficulty <= 1) return 12; + return 20 + (difficulty - 3) * 3; +} + void AddRating(sqlite3* db, int64_t user_id, int delta) { sqlite3_stmt* stmt = nullptr; const char* sql = "UPDATE users SET rating = rating + ? WHERE id=?"; @@ -318,7 +327,7 @@ domain::Submission SubmissionService::CreateAndJudge(const SubmissionCreateReque wb.Remove(req.user_id, req.problem_id); if (!HasSolvedBefore(db, req.user_id, req.problem_id, submission_id)) { - AddRating(db, req.user_id, 2); + AddRating(db, req.user_id, FirstAcRewardByDifficulty(problem->difficulty)); } } else { wb.UpsertBySubmission(req.user_id, req.problem_id, submission_id, @@ -335,6 +344,8 @@ domain::Submission SubmissionService::CreateAndJudge(const SubmissionCreateReque std::vector SubmissionService::List(std::optional user_id, std::optional problem_id, std::optional contest_id, + std::optional created_from, + std::optional created_to, int page, int page_size) { sqlite3* db = db_.raw(); @@ -345,11 +356,22 @@ std::vector SubmissionService::List(std::optional u "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 CheckSqlite(sqlite3_bind_int64(stmt, idx++, *problem_id), db, "bind problem_id"); if (contest_id.has_value()) CheckSqlite(sqlite3_bind_int64(stmt, idx++, *contest_id), db, "bind contest_id"); + if (created_from.has_value()) + CheckSqlite(sqlite3_bind_int64(stmt, idx++, *created_from), db, "bind created_from"); + if (created_to.has_value()) + CheckSqlite(sqlite3_bind_int64(stmt, idx++, *created_to), db, "bind created_to"); if (page <= 0) page = 1; if (page_size <= 0) page_size = 20; @@ -404,8 +430,17 @@ std::optional SubmissionService::GetById(int64_t id) { "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) { return s; } +SubmissionSiblingIds SubmissionService::GetSiblingIds(int64_t user_id, + int64_t submission_id) { + SubmissionSiblingIds out; + sqlite3* db = db_.raw(); + + sqlite3_stmt* prev_stmt = nullptr; + const char* prev_sql = + "SELECT id FROM submissions WHERE user_id=? AND id? ORDER BY id ASC LIMIT 1"; + CheckSqlite(sqlite3_prepare_v2(db, next_sql, -1, &next_stmt, nullptr), db, + "prepare get next submission"); + CheckSqlite(sqlite3_bind_int64(next_stmt, 1, user_id), db, "bind next user_id"); + CheckSqlite(sqlite3_bind_int64(next_stmt, 2, submission_id), db, + "bind next submission_id"); + if (sqlite3_step(next_stmt) == SQLITE_ROW) { + out.next_id = sqlite3_column_int64(next_stmt, 0); + } + sqlite3_finalize(next_stmt); + + return out; +} + RunOnlyResult SubmissionService::RunOnlyCpp(const std::string& code, const std::string& input) { if (code.empty()) throw std::runtime_error("code is empty"); diff --git a/backend/src/services/user_service.cc b/backend/src/services/user_service.cc index 05d0fdc..0686d19 100644 --- a/backend/src/services/user_service.cc +++ b/backend/src/services/user_service.cc @@ -3,9 +3,13 @@ #include #include +#include +#include +#include #include #include #include +#include namespace csp::services { @@ -21,6 +25,62 @@ std::string ColText(sqlite3_stmt* stmt, int col) { return txt ? reinterpret_cast(txt) : std::string(); } +bool TableExists(sqlite3* db, const char* table) { + sqlite3_stmt* stmt = nullptr; + const char* sql = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, "prepare table exists"); + CheckSqlite(sqlite3_bind_text(stmt, 1, table, -1, SQLITE_STATIC), db, "bind table name"); + const bool exists = sqlite3_step(stmt) == SQLITE_ROW; + sqlite3_finalize(stmt); + return exists; +} + +bool ColumnExists(sqlite3* db, const char* table, const char* col) { + if (!TableExists(db, table)) return false; + sqlite3_stmt* stmt = nullptr; + const std::string sql = std::string("PRAGMA table_info(") + table + ")"; + CheckSqlite(sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr), db, "prepare table info"); + bool found = false; + while (sqlite3_step(stmt) == SQLITE_ROW) { + const unsigned char* name = sqlite3_column_text(stmt, 1); + if (name && std::string(reinterpret_cast(name)) == col) { + found = true; + break; + } + } + sqlite3_finalize(stmt); + return found; +} + +int64_t NowSec() { + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()).count(); +} + +int64_t StartOfDayUtc8(int64_t ts) { + const std::time_t shifted = static_cast(ts + 8 * 3600); + std::tm tmv{}; + gmtime_r(&shifted, &tmv); + tmv.tm_hour = 0; + tmv.tm_min = 0; + tmv.tm_sec = 0; + const auto start_shifted = static_cast(timegm(&tmv)); + return start_shifted - 8 * 3600; +} + +int64_t StartOfWeekUtc8(int64_t ts) { + const std::time_t shifted = static_cast(ts + 8 * 3600); + std::tm tmv{}; + gmtime_r(&shifted, &tmv); + const int weekday_offset = (tmv.tm_wday + 6) % 7; // Monday=0 + tmv.tm_mday -= weekday_offset; + tmv.tm_hour = 0; + tmv.tm_min = 0; + tmv.tm_sec = 0; + const auto start_shifted = static_cast(timegm(&tmv)); + return start_shifted - 8 * 3600; +} + } // namespace std::optional UserService::GetById(int64_t id) { @@ -48,24 +108,85 @@ std::optional UserService::GetById(int64_t id) { return u; } -std::vector UserService::GlobalLeaderboard(int limit) { +std::vector +UserService::GlobalLeaderboard(int limit, const std::string& scope) { if (limit <= 0) limit = 100; sqlite3* db = db_.raw(); - sqlite3_stmt* stmt = nullptr; - const char* sql = - "SELECT id,username,rating,created_at FROM users ORDER BY rating DESC,id ASC LIMIT ?"; - CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, - "prepare leaderboard"); - CheckSqlite(sqlite3_bind_int(stmt, 1, limit), db, "bind limit"); - std::vector out; + + if (scope == "all") { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,username,rating,created_at FROM users ORDER BY rating DESC,id ASC LIMIT ?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare leaderboard all"); + CheckSqlite(sqlite3_bind_int(stmt, 1, limit), db, "bind limit"); + 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.period_score = e.rating; + e.created_at = sqlite3_column_int64(stmt, 3); + out.push_back(std::move(e)); + } + sqlite3_finalize(stmt); + return out; + } + + const int64_t now = NowSec(); + const int64_t since = scope == "today" ? StartOfDayUtc8(now) : StartOfWeekUtc8(now); + std::vector change_parts; + if (TableExists(db, "submissions")) { + if (ColumnExists(db, "submissions", "rating_delta")) { + change_parts.emplace_back("SELECT user_id, rating_delta AS change, created_at FROM submissions"); + } else { + // Old databases may not have rating_delta; keep submissions neutral. + change_parts.emplace_back("SELECT user_id, 0 AS change, created_at FROM submissions"); + } + } + if (TableExists(db, "daily_task_logs")) { + change_parts.emplace_back("SELECT user_id, reward AS change, created_at FROM daily_task_logs"); + } + if (TableExists(db, "redeem_records")) { + change_parts.emplace_back("SELECT user_id, -total_cost AS change, created_at FROM redeem_records"); + } + if (TableExists(db, "problem_solution_view_logs")) { + change_parts.emplace_back("SELECT user_id, -cost AS change, created_at FROM problem_solution_view_logs"); + } + + sqlite3_stmt* stmt = nullptr; + std::ostringstream sql_builder; + sql_builder << "SELECT u.id,u.username,u.rating,u.created_at,COALESCE(p.delta,0) AS period_score " + << "FROM users u "; + if (!change_parts.empty()) { + sql_builder << "LEFT JOIN (SELECT user_id,SUM(change) AS delta FROM ("; + for (size_t i = 0; i < change_parts.size(); ++i) { + if (i > 0) sql_builder << " UNION ALL "; + sql_builder << change_parts[i]; + } + sql_builder << ") changes WHERE created_at >= ? GROUP BY user_id) p ON p.user_id=u.id "; + } else { + sql_builder << "LEFT JOIN (SELECT 0 AS user_id, 0 AS delta) p ON p.user_id=u.id "; + } + sql_builder << "ORDER BY COALESCE(p.delta,0) DESC, u.rating DESC, u.id ASC LIMIT ?"; + const std::string sql = sql_builder.str(); + CheckSqlite(sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr), db, + "prepare leaderboard period"); + int bind_idx = 1; + if (!change_parts.empty()) { + CheckSqlite(sqlite3_bind_int64(stmt, bind_idx, since), db, "bind since"); + bind_idx += 1; + } + CheckSqlite(sqlite3_bind_int(stmt, bind_idx, limit), db, "bind limit"); 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); + e.period_score = sqlite3_column_int(stmt, 4); out.push_back(std::move(e)); } sqlite3_finalize(stmt); @@ -109,6 +230,7 @@ UserListResult UserService::ListUsers(int page, int page_size) { e.user_id = sqlite3_column_int64(stmt, 0); e.username = ColText(stmt, 1); e.rating = sqlite3_column_int(stmt, 2); + e.period_score = e.rating; e.created_at = sqlite3_column_int64(stmt, 3); e.total_submissions = sqlite3_column_int(stmt, 4); e.total_ac = sqlite3_column_int(stmt, 5); diff --git a/backend/src/services/wrong_book_service.cc b/backend/src/services/wrong_book_service.cc index 80ffda5..848c338 100644 --- a/backend/src/services/wrong_book_service.cc +++ b/backend/src/services/wrong_book_service.cc @@ -86,6 +86,24 @@ void WrongBookService::UpsertNote(int64_t user_id, sqlite3_finalize(stmt); } +std::string WrongBookService::GetNote(int64_t user_id, int64_t problem_id) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT note FROM wrong_book WHERE user_id=? AND problem_id=? LIMIT 1"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare wrong_book get note"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id"); + + std::string note; + if (sqlite3_step(stmt) == SQLITE_ROW) { + note = ColText(stmt, 0); + } + sqlite3_finalize(stmt); + return note; +} + void WrongBookService::UpsertNoteScore(int64_t user_id, int64_t problem_id, int32_t note_score, diff --git a/backend/tests/auth_service_test.cc b/backend/tests/auth_service_test.cc index 3d5a1b4..e07ad77 100644 --- a/backend/tests/auth_service_test.cc +++ b/backend/tests/auth_service_test.cc @@ -20,4 +20,14 @@ TEST_CASE("auth register/login/verify") { const auto r2 = auth.Login("alice", "password123"); REQUIRE(r2.user_id == r.user_id); REQUIRE(r2.token != r.token); + + const auto verified = auth.VerifyCredentials("alice", "password123"); + REQUIRE(verified.has_value()); + REQUIRE(verified.value() == r.user_id); + + const auto wrong_password = auth.VerifyCredentials("alice", "wrongpass"); + REQUIRE_FALSE(wrong_password.has_value()); + + const auto wrong_user = auth.VerifyCredentials("missing_user", "password123"); + REQUIRE_FALSE(wrong_user.has_value()); } diff --git a/backend/tests/crawler_service_test.cc b/backend/tests/crawler_service_test.cc new file mode 100644 index 0000000..8f4c9bb --- /dev/null +++ b/backend/tests/crawler_service_test.cc @@ -0,0 +1,55 @@ +#include + +#include "csp/app_state.h" +#include "csp/services/crawler_service.h" + +TEST_CASE("crawler target upsert and queue lifecycle") { + csp::AppState::Instance().Init(":memory:"); + csp::services::CrawlerService svc(csp::AppState::Instance().db()); + + const auto first = + svc.UpsertTarget("https://Example.com/news/?a=1", "test", "u1", "tester"); + REQUIRE(first.inserted); + REQUIRE(first.target.id > 0); + REQUIRE(first.target.normalized_url == "https://example.com/news"); + + const auto second = + svc.UpsertTarget("https://example.com/news", "test", "u1", "tester"); + REQUIRE_FALSE(second.inserted); + REQUIRE(second.target.id == first.target.id); + + auto listed = svc.ListTargets("", 50); + REQUIRE(listed.size() == 1); + + csp::services::CrawlerTarget claimed; + REQUIRE(svc.ClaimNextTarget(claimed)); + REQUIRE(claimed.id == first.target.id); + REQUIRE(claimed.status == "generating"); + + svc.UpdateGenerated(claimed.id, "{}", "/tmp/demo.py"); + svc.MarkTesting(claimed.id); + svc.InsertRun(claimed.id, "success", 200, "{}", ""); + svc.MarkActive(claimed.id, 1700000000); + + const auto got = svc.GetTargetById(claimed.id); + REQUIRE(got.has_value()); + REQUIRE(got->status == "active"); + + csp::services::CrawlerTarget due; + REQUIRE_FALSE(svc.EnqueueDueActiveTarget(3600, 1700002000, due)); + REQUIRE(svc.EnqueueDueActiveTarget(3600, 1700004000, due)); + REQUIRE(due.id == claimed.id); + REQUIRE(due.status == "queued"); + + const auto runs = svc.ListRuns(claimed.id, 20); + REQUIRE(runs.size() == 1); + REQUIRE(runs[0].status == "success"); +} + +TEST_CASE("crawler extract urls from mixed text") { + const auto urls = csp::services::CrawlerService::ExtractUrls( + "请收录 https://one.hao.work/path/?a=1 和 www.Example.com/docs, 谢谢"); + REQUIRE(urls.size() == 2); + REQUIRE(urls[0] == "https://one.hao.work/path"); + REQUIRE(urls[1] == "https://www.example.com/docs"); +} diff --git a/backend/tests/experience_service_test.cc b/backend/tests/experience_service_test.cc new file mode 100644 index 0000000..13a705c --- /dev/null +++ b/backend/tests/experience_service_test.cc @@ -0,0 +1,41 @@ +#include + +#include "csp/db/sqlite_db.h" +#include "csp/services/auth_service.h" +#include "csp/services/experience_service.h" + +TEST_CASE("experience only increases on rating gains") { + auto db = csp::db::SqliteDb::OpenMemory(); + csp::db::ApplyMigrations(db); + csp::db::SeedDemoData(db); + + csp::services::AuthService auth(db); + const auto user = auth.Register("xp_user", "password123"); + + csp::services::ExperienceService xp(db); + + const auto s0 = xp.GetSummary(user.user_id); + REQUIRE(s0.user_id == user.user_id); + REQUIRE(s0.experience >= 0); + const int base_exp = s0.experience; + + db.Exec("UPDATE users SET rating=rating+10 WHERE id=" + + std::to_string(user.user_id)); + const auto s1 = xp.GetSummary(user.user_id); + REQUIRE(s1.experience == base_exp + 10); + + db.Exec("UPDATE users SET rating=rating-4 WHERE id=" + + std::to_string(user.user_id)); + const auto s2 = xp.GetSummary(user.user_id); + REQUIRE(s2.experience == base_exp + 10); + + db.Exec("UPDATE users SET rating=rating+3 WHERE id=" + + std::to_string(user.user_id)); + const auto s3 = xp.GetSummary(user.user_id); + REQUIRE(s3.experience == base_exp + 13); + + const auto rows = xp.ListHistory(user.user_id, 20); + REQUIRE(rows.size() >= 2); + REQUIRE(rows[0].xp_delta == 3); + REQUIRE(rows[1].xp_delta == 10); +} diff --git a/backend/tests/import_http_test.cc b/backend/tests/import_http_test.cc index b6678a8..974f149 100644 --- a/backend/tests/import_http_test.cc +++ b/backend/tests/import_http_test.cc @@ -2,6 +2,7 @@ #include "csp/app_state.h" #include "csp/controllers/import_controller.h" +#include "csp/services/auth_service.h" #include @@ -9,17 +10,22 @@ namespace { -drogon::HttpResponsePtr CallLatest(csp::controllers::ImportController& ctl) { +drogon::HttpResponsePtr CallLatest(csp::controllers::ImportController& ctl, + const std::string& token) { auto req = drogon::HttpRequest::newHttpRequest(); req->setMethod(drogon::Get); + req->addHeader("Authorization", "Bearer " + token); std::promise p; ctl.latestJob(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); }); return p.get_future().get(); } -drogon::HttpResponsePtr CallItems(csp::controllers::ImportController& ctl, int64_t job_id) { +drogon::HttpResponsePtr CallItems(csp::controllers::ImportController& ctl, + int64_t job_id, + const std::string& token) { auto req = drogon::HttpRequest::newHttpRequest(); req->setMethod(drogon::Get); + req->addHeader("Authorization", "Bearer " + token); req->setParameter("page", "1"); req->setParameter("page_size", "20"); std::promise p; @@ -31,6 +37,8 @@ drogon::HttpResponsePtr CallItems(csp::controllers::ImportController& ctl, int64 TEST_CASE("import controller latest and items") { csp::AppState::Instance().Init(":memory:"); + csp::services::AuthService auth(csp::AppState::Instance().db()); + const auto login = auth.Register("admin", "password123"); auto& db = csp::AppState::Instance().db(); db.Exec( "INSERT INTO import_jobs(status,trigger,total_count,processed_count,success_count," @@ -43,14 +51,14 @@ TEST_CASE("import controller latest and items") { csp::controllers::ImportController ctl; - auto latest = CallLatest(ctl); + auto latest = CallLatest(ctl, login.token); REQUIRE(latest->statusCode() == drogon::k200OK); auto latest_json = latest->jsonObject(); REQUIRE(latest_json != nullptr); REQUIRE((*latest_json)["ok"].asBool()); REQUIRE((*latest_json)["data"]["job"]["id"].asInt64() == 1); - auto items = CallItems(ctl, 1); + auto items = CallItems(ctl, 1, login.token); REQUIRE(items->statusCode() == drogon::k200OK); auto items_json = items->jsonObject(); REQUIRE(items_json != nullptr); diff --git a/backend/tests/kb_service_test.cc b/backend/tests/kb_service_test.cc index 447c978..46654a5 100644 --- a/backend/tests/kb_service_test.cc +++ b/backend/tests/kb_service_test.cc @@ -1,7 +1,12 @@ #include #include "csp/db/sqlite_db.h" +#include "csp/services/auth_service.h" #include "csp/services/kb_service.h" +#include "csp/services/user_service.h" + +#include +#include TEST_CASE("kb service list/detail") { auto db = csp::db::SqliteDb::OpenMemory(); @@ -16,3 +21,107 @@ TEST_CASE("kb service list/detail") { REQUIRE(detail.has_value()); REQUIRE(detail->article.slug == rows.front().slug); } + +TEST_CASE("kb skill claim requires prerequisites") { + auto db = csp::db::SqliteDb::OpenMemory(); + csp::db::ApplyMigrations(db); + csp::db::SeedDemoData(db); + + csp::services::AuthService auth(db); + const auto login = auth.Register("kb_pre_user", "password123"); + + csp::services::KbService svc(db); + const auto detail = svc.GetBySlug("cpp14-skill-tree"); + REQUIRE(detail.has_value()); + REQUIRE(detail->article.id > 0); + + bool prerequisite_throw = false; + try { + (void)svc.ClaimSkillPoint(login.user_id, detail->article.id, detail->article.slug, + "cpp14-type-02"); + } catch (const std::runtime_error& e) { + prerequisite_throw = true; + REQUIRE(std::string(e.what()).find("prerequisite not completed") != std::string::npos); + } + REQUIRE(prerequisite_throw); + + const auto first = + svc.ClaimSkillPoint(login.user_id, detail->article.id, detail->article.slug, + "cpp14-io-01"); + REQUIRE(first.claimed); + REQUIRE(first.reward == 1); + + const auto second = + svc.ClaimSkillPoint(login.user_id, detail->article.id, detail->article.slug, + "cpp14-type-02"); + REQUIRE(second.claimed); + REQUIRE(second.reward == 1); + + const auto second_again = + svc.ClaimSkillPoint(login.user_id, detail->article.id, detail->article.slug, + "cpp14-type-02"); + REQUIRE_FALSE(second_again.claimed); + REQUIRE(second_again.reward == 0); +} + +TEST_CASE("kb weekly tasks auto-generate and bonus awarded at 100 percent") { + auto db = csp::db::SqliteDb::OpenMemory(); + csp::db::ApplyMigrations(db); + csp::db::SeedDemoData(db); + + csp::services::AuthService auth(db); + const auto login = auth.Register("kb_weekly_user", "password123"); + + csp::services::KbService svc(db); + csp::services::UserService users(db); + + auto plan = svc.GetWeeklyPlan(login.user_id); + REQUIRE_FALSE(plan.tasks.empty()); + REQUIRE(plan.tasks.size() <= 8); + REQUIRE(plan.completion_percent == 0); + + std::unordered_set unlocked; + for (const auto& task : plan.tasks) { + for (const auto& pre : task.prerequisites) { + REQUIRE(unlocked.count(pre) > 0); + } + unlocked.insert(task.knowledge_key); + } + + bool bonus_throw = false; + try { + (void)svc.ClaimWeeklyBonus(login.user_id); + } catch (const std::runtime_error& e) { + bonus_throw = true; + REQUIRE(std::string(e.what()).find("100% completed") != std::string::npos); + } + REQUIRE(bonus_throw); + + int weekly_reward_sum = 0; + for (const auto& task : plan.tasks) { + const auto claim = svc.ClaimSkillPoint(login.user_id, task.article_id, + task.article_slug, task.knowledge_key); + weekly_reward_sum += claim.reward; + REQUIRE(claim.claimed); + } + + plan = svc.GetWeeklyPlan(login.user_id); + REQUIRE(plan.completion_percent == 100); + REQUIRE(plan.gained_reward == plan.total_reward); + + const auto bonus = svc.ClaimWeeklyBonus(login.user_id); + REQUIRE(bonus.claimed); + REQUIRE(bonus.reward == plan.bonus_reward); + REQUIRE(bonus.completion_percent == 100); + REQUIRE(bonus.week_key == plan.week_key); + + const auto user = users.GetById(login.user_id); + REQUIRE(user.has_value()); + // Register auto-login grants +1 via login check-in task. + REQUIRE(user->rating == 1 + weekly_reward_sum + plan.bonus_reward); + + const auto bonus_again = svc.ClaimWeeklyBonus(login.user_id); + REQUIRE_FALSE(bonus_again.claimed); + REQUIRE(bonus_again.reward == 0); + REQUIRE(bonus_again.rating_after == user->rating); +} diff --git a/backend/tests/lark_http_test.cc b/backend/tests/lark_http_test.cc new file mode 100644 index 0000000..29193c1 --- /dev/null +++ b/backend/tests/lark_http_test.cc @@ -0,0 +1,149 @@ +#include + +#include "csp/app_state.h" +#include "csp/controllers/lark_controller.h" +#include "csp/services/crawler_service.h" +#include "csp/services/lark_bot_service.h" + +#include + +#include +#include +#include +#include + +namespace { + +class ScopedEnv { + public: + ScopedEnv(std::string key, std::optional value) + : key_(std::move(key)) { + const char* old = std::getenv(key_.c_str()); + if (old) old_ = std::string(old); + if (value.has_value()) { + ::setenv(key_.c_str(), value->c_str(), 1); + } else { + ::unsetenv(key_.c_str()); + } + } + + ~ScopedEnv() { + if (old_.has_value()) { + ::setenv(key_.c_str(), old_->c_str(), 1); + } else { + ::unsetenv(key_.c_str()); + } + } + + private: + std::string key_; + std::optional old_; +}; + +drogon::HttpResponsePtr CallEvents(csp::controllers::LarkController& ctl, + const Json::Value& body) { + auto req = drogon::HttpRequest::newHttpJsonRequest(body); + req->setMethod(drogon::Post); + std::promise p; + ctl.events(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); }); + return p.get_future().get(); +} + +Json::Value MakeTextEventBody() { + Json::Value body; + body["header"]["event_type"] = "im.message.receive_v1"; + body["header"]["event_id"] = "evt-1"; + body["event"]["sender"]["sender_id"]["open_id"] = "ou_xxx"; + body["event"]["message"]["message_type"] = "text"; + body["event"]["message"]["message_id"] = "om_xxx"; + body["event"]["message"]["chat_id"] = "oc_xxx"; + Json::Value content; + content["text"] = "你好"; + Json::StreamWriterBuilder wb; + wb["indentation"] = ""; + body["event"]["message"]["content"] = Json::writeString(wb, content); + return body; +} + +} // namespace + +TEST_CASE("lark url verification challenge pass") { + ScopedEnv enabled("CSP_LARK_BOT_ENABLED", "1"); + ScopedEnv token("CSP_LARK_VERIFICATION_TOKEN", "verify_token"); + ScopedEnv app_id("CSP_LARK_APP_ID", "cli_test"); + ScopedEnv app_secret("CSP_LARK_APP_SECRET", "secret_test"); + csp::services::LarkBotService::Instance().ConfigureFromEnv(); + + csp::controllers::LarkController ctl; + Json::Value body; + body["challenge"] = "challenge-abc"; + body["token"] = "verify_token"; + auto resp = CallEvents(ctl, body); + + REQUIRE(resp->statusCode() == drogon::k200OK); + const auto json = resp->jsonObject(); + REQUIRE(json != nullptr); + REQUIRE((*json)["challenge"].asString() == "challenge-abc"); +} + +TEST_CASE("lark url verification token mismatch") { + ScopedEnv enabled("CSP_LARK_BOT_ENABLED", "1"); + ScopedEnv token("CSP_LARK_VERIFICATION_TOKEN", "verify_token"); + ScopedEnv app_id("CSP_LARK_APP_ID", "cli_test"); + ScopedEnv app_secret("CSP_LARK_APP_SECRET", "secret_test"); + csp::services::LarkBotService::Instance().ConfigureFromEnv(); + + csp::controllers::LarkController ctl; + Json::Value body; + body["challenge"] = "challenge-abc"; + body["token"] = "bad_token"; + auto resp = CallEvents(ctl, body); + + REQUIRE(resp->statusCode() == drogon::k401Unauthorized); +} + +TEST_CASE("lark events ignored when bot disabled") { + ScopedEnv enabled("CSP_LARK_BOT_ENABLED", "0"); + ScopedEnv token("CSP_LARK_VERIFICATION_TOKEN", std::nullopt); + ScopedEnv app_id("CSP_LARK_APP_ID", std::nullopt); + ScopedEnv app_secret("CSP_LARK_APP_SECRET", std::nullopt); + csp::services::LarkBotService::Instance().ConfigureFromEnv(); + + csp::controllers::LarkController ctl; + auto resp = CallEvents(ctl, MakeTextEventBody()); + + REQUIRE(resp->statusCode() == drogon::k200OK); + const auto json = resp->jsonObject(); + REQUIRE(json != nullptr); + REQUIRE((*json)["code"].asInt() == 0); +} + +TEST_CASE("lark text url queued into crawler targets") { + csp::AppState::Instance().Init(":memory:"); + ScopedEnv enabled("CSP_LARK_BOT_ENABLED", "1"); + ScopedEnv token("CSP_LARK_VERIFICATION_TOKEN", std::nullopt); + ScopedEnv app_id("CSP_LARK_APP_ID", "cli_test"); + ScopedEnv app_secret("CSP_LARK_APP_SECRET", "secret_test"); + ScopedEnv open_base("CSP_LARK_OPEN_BASE_URL", "invalid-url"); + csp::services::LarkBotService::Instance().ConfigureFromEnv(); + + csp::controllers::LarkController ctl; + auto body = MakeTextEventBody(); + Json::Value content; + content["text"] = "请收录 https://one.hao.work/news/?a=1"; + Json::StreamWriterBuilder wb; + wb["indentation"] = ""; + body["event"]["message"]["content"] = Json::writeString(wb, content); + + auto resp = CallEvents(ctl, body); + REQUIRE(resp->statusCode() == drogon::k200OK); + const auto json = resp->jsonObject(); + REQUIRE(json != nullptr); + REQUIRE((*json)["code"].asInt() == 0); + REQUIRE((*json)["msg"].asString() == "crawler targets queued"); + + csp::services::CrawlerService crawler(csp::AppState::Instance().db()); + const auto targets = crawler.ListTargets("", 10); + REQUIRE(targets.size() == 1); + REQUIRE(targets[0].normalized_url == "https://one.hao.work/news"); +} diff --git a/backend/tests/me_http_test.cc b/backend/tests/me_http_test.cc index d260c3b..f85615f 100644 --- a/backend/tests/me_http_test.cc +++ b/backend/tests/me_http_test.cc @@ -3,9 +3,12 @@ #include "csp/app_state.h" #include "csp/controllers/me_controller.h" #include "csp/services/auth_service.h" +#include "csp/services/daily_task_service.h" #include "csp/services/problem_service.h" +#include "csp/services/user_service.h" #include +#include #include @@ -22,6 +25,18 @@ drogon::HttpResponsePtr CallProfile(csp::controllers::MeController& ctl, return p.get_future().get(); } +drogon::HttpResponsePtr CallProfileWithAuthHeader( + csp::controllers::MeController& ctl, + const std::string& auth_header) { + auto req = drogon::HttpRequest::newHttpRequest(); + req->setMethod(drogon::Get); + req->addHeader("Authorization", auth_header); + + std::promise p; + ctl.profile(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); }); + return p.get_future().get(); +} + drogon::HttpResponsePtr CallListWrongBook(csp::controllers::MeController& ctl, const std::string& token) { auto req = drogon::HttpRequest::newHttpRequest(); @@ -69,6 +84,44 @@ drogon::HttpResponsePtr CallDeleteWrongBook(csp::controllers::MeController& ctl, return p.get_future().get(); } +drogon::HttpResponsePtr CallScoreWrongBook(csp::controllers::MeController& ctl, + const std::string& token, + int64_t problem_id, + const std::string& note) { + Json::Value body; + body["note"] = note; + auto req = drogon::HttpRequest::newHttpJsonRequest(body); + req->setMethod(drogon::Post); + req->addHeader("Authorization", "Bearer " + token); + + std::promise p; + ctl.scoreWrongBookNote(req, + [&p](const drogon::HttpResponsePtr& resp) { + p.set_value(resp); + }, + problem_id); + return p.get_future().get(); +} + +int QueryDailyTaskCount(csp::db::SqliteDb& db, + int64_t user_id, + const std::string& task_code, + const std::string& day_key) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT COUNT(1) FROM daily_task_logs WHERE user_id=? AND task_code=? AND day_key=?"; + REQUIRE(sqlite3_prepare_v2(db.raw(), sql, -1, &stmt, nullptr) == SQLITE_OK); + REQUIRE(sqlite3_bind_int64(stmt, 1, user_id) == SQLITE_OK); + REQUIRE(sqlite3_bind_text(stmt, 2, task_code.c_str(), -1, SQLITE_TRANSIENT) == + SQLITE_OK); + REQUIRE(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT) == + SQLITE_OK); + REQUIRE(sqlite3_step(stmt) == SQLITE_ROW); + const int count = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + return count; +} + } // namespace TEST_CASE("me controller profile and wrong-book") { @@ -90,9 +143,24 @@ TEST_CASE("me controller profile and wrong-book") { REQUIRE(profile_json != nullptr); REQUIRE((*profile_json)["ok"].asBool()); + auto profile_basic = + CallProfileWithAuthHeader(ctl, "Basic bWVfaHR0cF91c2VyOnBhc3N3b3JkMTIz"); + REQUIRE(profile_basic->statusCode() == drogon::k200OK); + auto patch = CallPatchWrongBook(ctl, login.token, problem_id, "复盘记录"); REQUIRE(patch->statusCode() == drogon::k200OK); + auto score = CallScoreWrongBook(ctl, login.token, problem_id, "题意+思路+踩坑总结"); + REQUIRE(score->statusCode() == drogon::k200OK); + const auto score_json = score->jsonObject(); + REQUIRE(score_json != nullptr); + REQUIRE((*score_json)["ok"].asBool()); + REQUIRE((*score_json)["data"].isObject()); + REQUIRE((*score_json)["data"]["note_score"].asInt() >= 0); + REQUIRE((*score_json)["data"]["note_score"].asInt() <= 60); + REQUIRE((*score_json)["data"]["note_rating"].asInt() >= 0); + REQUIRE((*score_json)["data"]["note_rating"].asInt() <= 6); + auto list_resp = CallListWrongBook(ctl, login.token); REQUIRE(list_resp->statusCode() == drogon::k200OK); auto list_json = list_resp->jsonObject(); @@ -103,3 +171,50 @@ TEST_CASE("me controller profile and wrong-book") { auto del = CallDeleteWrongBook(ctl, login.token, problem_id); REQUIRE(del->statusCode() == drogon::k200OK); } + +TEST_CASE("me profile auto recovers daily login checkin for stale session") { + csp::AppState::Instance().Init(":memory:"); + + csp::services::AuthService auth(csp::AppState::Instance().db()); + const auto login = auth.Register("me_daily_user", "password123"); + csp::controllers::MeController ctl; + + csp::services::DailyTaskService daily(csp::AppState::Instance().db()); + csp::services::UserService users(csp::AppState::Instance().db()); + const auto day_key = daily.CurrentDayKey(); + + // Simulate a stale session user whose today's login_checkin wasn't recorded. + { + sqlite3* db = csp::AppState::Instance().db().raw(); + sqlite3_stmt* del = nullptr; + const char* del_sql = + "DELETE FROM daily_task_logs WHERE user_id=? AND task_code=? AND day_key=?"; + REQUIRE(sqlite3_prepare_v2(db, del_sql, -1, &del, nullptr) == SQLITE_OK); + REQUIRE(sqlite3_bind_int64(del, 1, login.user_id) == SQLITE_OK); + REQUIRE(sqlite3_bind_text( + del, 2, csp::services::DailyTaskService::kTaskLoginCheckin, -1, + SQLITE_STATIC) == SQLITE_OK); + REQUIRE(sqlite3_bind_text(del, 3, day_key.c_str(), -1, SQLITE_TRANSIENT) == + SQLITE_OK); + REQUIRE(sqlite3_step(del) == SQLITE_DONE); + sqlite3_finalize(del); + + // Keep rating consistent with removed daily task log. + csp::AppState::Instance().db().Exec("UPDATE users SET rating=rating-1 WHERE id=" + + std::to_string(login.user_id)); + } + + REQUIRE(QueryDailyTaskCount(csp::AppState::Instance().db(), login.user_id, + csp::services::DailyTaskService::kTaskLoginCheckin, + day_key) == 0); + + auto profile = CallProfile(ctl, login.token); + REQUIRE(profile->statusCode() == drogon::k200OK); + + const auto user = users.GetById(login.user_id); + REQUIRE(user.has_value()); + REQUIRE(user->rating == 1); + REQUIRE(QueryDailyTaskCount(csp::AppState::Instance().db(), login.user_id, + csp::services::DailyTaskService::kTaskLoginCheckin, + day_key) == 1); +} diff --git a/backend/tests/problem_workspace_service_test.cc b/backend/tests/problem_workspace_service_test.cc index 5926004..f0bf18a 100644 --- a/backend/tests/problem_workspace_service_test.cc +++ b/backend/tests/problem_workspace_service_test.cc @@ -3,6 +3,7 @@ #include "csp/db/sqlite_db.h" #include "csp/services/problem_workspace_service.h" +#include #include TEST_CASE("problem workspace service drafts and solution jobs") { @@ -54,9 +55,12 @@ TEST_CASE("problem workspace service drafts and solution jobs") { REQUIRE(solutions.empty()); REQUIRE(svc.CountProblemsWithoutSolutions() >= 1); - const auto missing_all = svc.ListProblemIdsWithoutSolutions(10, false); + const auto missing_all = svc.ListProblemIdsWithoutSolutions(200, false); REQUIRE(!missing_all.empty()); - const auto missing_skip_busy = svc.ListProblemIdsWithoutSolutions(10, true); + const auto missing_skip_busy = svc.ListProblemIdsWithoutSolutions(200, true); REQUIRE(!missing_skip_busy.empty()); - REQUIRE(missing_skip_busy.size() < missing_all.size()); + REQUIRE(std::find(missing_all.begin(), missing_all.end(), pid) != missing_all.end()); + REQUIRE(std::find(missing_skip_busy.begin(), missing_skip_busy.end(), pid) == + missing_skip_busy.end()); + REQUIRE(missing_skip_busy.size() <= missing_all.size()); } diff --git a/backend/tests/season_http_test.cc b/backend/tests/season_http_test.cc new file mode 100644 index 0000000..f88b368 --- /dev/null +++ b/backend/tests/season_http_test.cc @@ -0,0 +1,274 @@ +#include + +#include "csp/app_state.h" +#include "csp/controllers/admin_controller.h" +#include "csp/controllers/contest_controller.h" +#include "csp/controllers/me_controller.h" +#include "csp/controllers/season_controller.h" +#include "csp/services/auth_service.h" + +#include + +#include + +namespace { + +drogon::HttpResponsePtr CallSeasonCurrent(csp::controllers::SeasonController& ctl) { + auto req = drogon::HttpRequest::newHttpRequest(); + req->setMethod(drogon::Get); + std::promise p; + ctl.currentSeason(req, [&p](const drogon::HttpResponsePtr& resp) { + p.set_value(resp); + }); + return p.get_future().get(); +} + +drogon::HttpResponsePtr CallSeasonMe(csp::controllers::SeasonController& ctl, + int64_t season_id, + const std::string& token) { + auto req = drogon::HttpRequest::newHttpRequest(); + req->setMethod(drogon::Get); + req->addHeader("Authorization", "Bearer " + token); + std::promise p; + ctl.mySeasonProgress(req, [&p](const drogon::HttpResponsePtr& resp) { + p.set_value(resp); + }, season_id); + return p.get_future().get(); +} + +drogon::HttpResponsePtr CallSeasonClaim(csp::controllers::SeasonController& ctl, + int64_t season_id, + const std::string& token, + int tier_no, + const std::string& reward_type) { + Json::Value body; + body["tier_no"] = tier_no; + body["reward_type"] = reward_type; + auto req = drogon::HttpRequest::newHttpJsonRequest(body); + req->setMethod(drogon::Post); + req->addHeader("Authorization", "Bearer " + token); + std::promise p; + ctl.claimSeasonReward(req, [&p](const drogon::HttpResponsePtr& resp) { + p.set_value(resp); + }, season_id); + return p.get_future().get(); +} + +drogon::HttpResponsePtr CallMeLoot(csp::controllers::MeController& ctl, + const std::string& token) { + auto req = drogon::HttpRequest::newHttpRequest(); + req->setMethod(drogon::Get); + req->addHeader("Authorization", "Bearer " + token); + req->setParameter("limit", "20"); + std::promise p; + ctl.listLootDrops(req, [&p](const drogon::HttpResponsePtr& resp) { + p.set_value(resp); + }); + return p.get_future().get(); +} + +drogon::HttpResponsePtr CallContestList(csp::controllers::ContestController& ctl) { + auto req = drogon::HttpRequest::newHttpRequest(); + req->setMethod(drogon::Get); + std::promise p; + ctl.list(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); }); + return p.get_future().get(); +} + +drogon::HttpResponsePtr CallContestModifiers(csp::controllers::ContestController& ctl, + int64_t contest_id, + bool include_inactive) { + auto req = drogon::HttpRequest::newHttpRequest(); + req->setMethod(drogon::Get); + if (include_inactive) req->setParameter("include_inactive", "true"); + std::promise p; + ctl.modifiers(req, [&p](const drogon::HttpResponsePtr& resp) { + p.set_value(resp); + }, contest_id); + return p.get_future().get(); +} + +drogon::HttpResponsePtr CallAdminCreateSeason(csp::controllers::AdminController& ctl, + const std::string& token, + const Json::Value& body) { + auto req = drogon::HttpRequest::newHttpJsonRequest(body); + req->setMethod(drogon::Post); + req->addHeader("Authorization", "Bearer " + token); + std::promise p; + ctl.createSeason(req, [&p](const drogon::HttpResponsePtr& resp) { + p.set_value(resp); + }); + return p.get_future().get(); +} + +drogon::HttpResponsePtr CallAdminUpdateSeason(csp::controllers::AdminController& ctl, + const std::string& token, + int64_t season_id, + const Json::Value& body) { + auto req = drogon::HttpRequest::newHttpJsonRequest(body); + req->setMethod(drogon::Patch); + req->addHeader("Authorization", "Bearer " + token); + std::promise p; + ctl.updateSeason(req, [&p](const drogon::HttpResponsePtr& resp) { + p.set_value(resp); + }, season_id); + return p.get_future().get(); +} + +drogon::HttpResponsePtr CallAdminCreateModifier(csp::controllers::AdminController& ctl, + const std::string& token, + int64_t contest_id, + const Json::Value& body) { + auto req = drogon::HttpRequest::newHttpJsonRequest(body); + req->setMethod(drogon::Post); + req->addHeader("Authorization", "Bearer " + token); + std::promise p; + ctl.createContestModifier(req, [&p](const drogon::HttpResponsePtr& resp) { + p.set_value(resp); + }, contest_id); + return p.get_future().get(); +} + +drogon::HttpResponsePtr CallAdminUpdateModifier(csp::controllers::AdminController& ctl, + const std::string& token, + int64_t contest_id, + int64_t modifier_id, + const Json::Value& body) { + auto req = drogon::HttpRequest::newHttpJsonRequest(body); + req->setMethod(drogon::Patch); + req->addHeader("Authorization", "Bearer " + token); + std::promise p; + ctl.updateContestModifier(req, [&p](const drogon::HttpResponsePtr& resp) { + p.set_value(resp); + }, contest_id, modifier_id); + return p.get_future().get(); +} + +} // namespace + +TEST_CASE("season controller current/me/claim and loot endpoint") { + csp::AppState::Instance().Init(":memory:"); + csp::services::AuthService auth(csp::AppState::Instance().db()); + const auto user = auth.Register("season_http_user", "password123"); + + csp::controllers::SeasonController season_ctl; + csp::controllers::MeController me_ctl; + + auto current_resp = CallSeasonCurrent(season_ctl); + REQUIRE(current_resp->statusCode() == drogon::k200OK); + auto current_json = current_resp->jsonObject(); + REQUIRE(current_json != nullptr); + const int64_t season_id = (*current_json)["data"]["season"]["id"].asInt64(); + REQUIRE(season_id > 0); + REQUIRE((*current_json)["data"]["reward_tracks"].isArray()); + REQUIRE((*current_json)["data"]["reward_tracks"].size() >= 1); + + auto me_resp = CallSeasonMe(season_ctl, season_id, user.token); + REQUIRE(me_resp->statusCode() == drogon::k200OK); + auto me_json = me_resp->jsonObject(); + REQUIRE(me_json != nullptr); + REQUIRE((*me_json)["data"]["progress"].isObject()); + REQUIRE((*me_json)["data"]["reward_tracks"].isArray()); + + const int tier_no = + (*me_json)["data"]["reward_tracks"][0]["tier_no"].asInt(); + const std::string reward_type = + (*me_json)["data"]["reward_tracks"][0]["reward_type"].asString(); + + auto claim_resp = CallSeasonClaim( + season_ctl, season_id, user.token, tier_no, reward_type); + REQUIRE(claim_resp->statusCode() == drogon::k200OK); + auto claim_json = claim_resp->jsonObject(); + REQUIRE(claim_json != nullptr); + REQUIRE((*claim_json)["data"]["track"]["tier_no"].asInt() == tier_no); + + auto loot_resp = CallMeLoot(me_ctl, user.token); + REQUIRE(loot_resp->statusCode() == drogon::k200OK); + auto loot_json = loot_resp->jsonObject(); + REQUIRE(loot_json != nullptr); + REQUIRE((*loot_json)["data"].isArray()); + REQUIRE((*loot_json)["data"].size() >= 1); +} + +TEST_CASE("admin season/modifier endpoints and contest modifier read endpoint") { + csp::AppState::Instance().Init(":memory:"); + csp::services::AuthService auth(csp::AppState::Instance().db()); + const auto admin = auth.Register("admin", "password123"); + + csp::controllers::AdminController admin_ctl; + csp::controllers::ContestController contest_ctl; + + auto contests_resp = CallContestList(contest_ctl); + REQUIRE(contests_resp->statusCode() == drogon::k200OK); + auto contests_json = contests_resp->jsonObject(); + REQUIRE(contests_json != nullptr); + REQUIRE((*contests_json)["data"].isArray()); + REQUIRE((*contests_json)["data"].size() >= 1); + const int64_t contest_id = (*contests_json)["data"][0]["id"].asInt64(); + + Json::Value create_season_body; + create_season_body["key"] = "season-http-admin"; + create_season_body["title"] = "HTTP 管理赛季"; + create_season_body["starts_at"] = Json::Int64(1700000000); + create_season_body["ends_at"] = Json::Int64(1900000000); + create_season_body["status"] = "active"; + Json::Value tracks(Json::arrayValue); + Json::Value t1; + t1["tier_no"] = 1; + t1["required_xp"] = 0; + t1["reward_type"] = "free"; + t1["reward_value"] = 3; + tracks.append(t1); + create_season_body["reward_tracks"] = tracks; + + auto create_season_resp = + CallAdminCreateSeason(admin_ctl, admin.token, create_season_body); + REQUIRE(create_season_resp->statusCode() == drogon::k200OK); + auto create_season_json = create_season_resp->jsonObject(); + REQUIRE(create_season_json != nullptr); + const int64_t new_season_id = + (*create_season_json)["data"]["season"]["id"].asInt64(); + REQUIRE(new_season_id > 0); + + Json::Value update_season_body; + update_season_body["title"] = "HTTP 管理赛季(更新)"; + auto update_season_resp = + CallAdminUpdateSeason(admin_ctl, admin.token, new_season_id, update_season_body); + REQUIRE(update_season_resp->statusCode() == drogon::k200OK); + auto update_season_json = update_season_resp->jsonObject(); + REQUIRE(update_season_json != nullptr); + REQUIRE((*update_season_json)["data"]["season"]["title"].asString() == + "HTTP 管理赛季(更新)"); + + Json::Value create_modifier_body; + create_modifier_body["code"] = "limit10"; + create_modifier_body["title"] = "限时十分钟"; + create_modifier_body["description"] = "每道题建议 10 分钟内完成。"; + create_modifier_body["is_active"] = true; + create_modifier_body["rule_json"] = R"({"time_limit_min":10})"; + auto create_modifier_resp = CallAdminCreateModifier( + admin_ctl, admin.token, contest_id, create_modifier_body); + REQUIRE(create_modifier_resp->statusCode() == drogon::k200OK); + auto create_modifier_json = create_modifier_resp->jsonObject(); + REQUIRE(create_modifier_json != nullptr); + const int64_t modifier_id = (*create_modifier_json)["data"]["id"].asInt64(); + REQUIRE(modifier_id > 0); + + Json::Value update_modifier_body; + update_modifier_body["is_active"] = false; + update_modifier_body["title"] = "限时十分钟(更新)"; + auto update_modifier_resp = CallAdminUpdateModifier( + admin_ctl, admin.token, contest_id, modifier_id, update_modifier_body); + REQUIRE(update_modifier_resp->statusCode() == drogon::k200OK); + auto update_modifier_json = update_modifier_resp->jsonObject(); + REQUIRE(update_modifier_json != nullptr); + REQUIRE((*update_modifier_json)["data"]["is_active"].asBool() == false); + REQUIRE((*update_modifier_json)["data"]["title"].asString() == "限时十分钟(更新)"); + + auto modifiers_resp = CallContestModifiers(contest_ctl, contest_id, true); + REQUIRE(modifiers_resp->statusCode() == drogon::k200OK); + auto modifiers_json = modifiers_resp->jsonObject(); + REQUIRE(modifiers_json != nullptr); + REQUIRE((*modifiers_json)["data"].isArray()); + REQUIRE((*modifiers_json)["data"].size() >= 1); +} diff --git a/backend/tests/season_service_test.cc b/backend/tests/season_service_test.cc new file mode 100644 index 0000000..b49fc72 --- /dev/null +++ b/backend/tests/season_service_test.cc @@ -0,0 +1,115 @@ +#include + +#include "csp/db/sqlite_db.h" +#include "csp/services/auth_service.h" +#include "csp/services/contest_service.h" +#include "csp/services/season_service.h" +#include "csp/services/user_service.h" + +#include + +TEST_CASE("season reward claim is idempotent and writes loot log") { + auto db = csp::db::SqliteDb::OpenMemory(); + csp::db::ApplyMigrations(db); + csp::db::SeedDemoData(db); + + csp::services::AuthService auth(db); + const auto login = auth.Register("season_user_1", "password123"); + + csp::services::SeasonService seasons(db); + const auto season = seasons.GetCurrentSeason(); + REQUIRE(season.has_value()); + const auto tracks = seasons.ListRewardTracks(season->id); + REQUIRE_FALSE(tracks.empty()); + + const auto target_track = tracks.back(); + db.Exec("UPDATE users SET rating=200 WHERE id=" + std::to_string(login.user_id)); + + const auto before_progress = + seasons.GetOrSyncUserProgress(season->id, login.user_id); + REQUIRE(before_progress.xp >= target_track.required_xp); + + const auto first_claim = seasons.ClaimReward( + season->id, login.user_id, target_track.tier_no, target_track.reward_type); + REQUIRE(first_claim.claimed); + REQUIRE(first_claim.claim.has_value()); + REQUIRE(first_claim.rating_after >= 200 + target_track.reward_value); + + const auto second_claim = seasons.ClaimReward( + season->id, login.user_id, target_track.tier_no, target_track.reward_type); + REQUIRE_FALSE(second_claim.claimed); + REQUIRE(second_claim.claim.has_value()); + REQUIRE(second_claim.rating_after == first_claim.rating_after); + + const auto loot = seasons.ListLootDropsByUser(login.user_id, 20); + REQUIRE_FALSE(loot.empty()); + REQUIRE(loot.front().source_type == "season"); + REQUIRE(loot.front().source_id == season->id); + + csp::services::UserService users(db); + const auto user = users.GetById(login.user_id); + REQUIRE(user.has_value()); + REQUIRE(user->rating == first_claim.rating_after); +} + +TEST_CASE("contest modifiers create update and filtered list") { + auto db = csp::db::SqliteDb::OpenMemory(); + csp::db::ApplyMigrations(db); + csp::db::SeedDemoData(db); + + csp::services::ContestService contests(db); + const auto contest_list = contests.ListContests(); + REQUIRE_FALSE(contest_list.empty()); + const int64_t contest_id = contest_list.front().id; + + csp::services::SeasonService seasons(db); + csp::services::ContestModifierWrite create; + create.code = "no_recursion"; + create.title = "禁用递归"; + create.description = "仅允许循环写法。"; + create.rule_json = R"({"forbid":["recursion"]})"; + create.is_active = true; + const auto created = seasons.CreateContestModifier(contest_id, create); + REQUIRE(created.id > 0); + REQUIRE(created.contest_id == contest_id); + REQUIRE(created.is_active); + + const auto active_list = seasons.ListContestModifiers(contest_id, false); + bool found_created = false; + for (const auto& one : active_list) { + if (one.id == created.id) { + found_created = true; + break; + } + } + REQUIRE(found_created); + + csp::services::ContestModifierPatch patch; + patch.title = "禁用递归(更新)"; + patch.is_active = false; + const auto updated = + seasons.UpdateContestModifier(contest_id, created.id, patch); + REQUIRE(updated.title == "禁用递归(更新)"); + REQUIRE_FALSE(updated.is_active); + + const auto active_after = seasons.ListContestModifiers(contest_id, false); + bool still_active = false; + for (const auto& one : active_after) { + if (one.id == created.id) { + still_active = true; + break; + } + } + REQUIRE_FALSE(still_active); + + const auto all_after = seasons.ListContestModifiers(contest_id, true); + bool found_updated = false; + for (const auto& one : all_after) { + if (one.id == created.id && one.title == "禁用递归(更新)" && + !one.is_active) { + found_updated = true; + break; + } + } + REQUIRE(found_updated); +} diff --git a/backend/tests/source_crystal_service_test.cc b/backend/tests/source_crystal_service_test.cc new file mode 100644 index 0000000..7354544 --- /dev/null +++ b/backend/tests/source_crystal_service_test.cc @@ -0,0 +1,61 @@ +#include + +#include "csp/db/sqlite_db.h" +#include "csp/services/auth_service.h" +#include "csp/services/source_crystal_service.h" + +namespace { + +double AbsDiff(double a, double b) { + const double d = a - b; + return d < 0 ? -d : d; +} + +} // namespace + +TEST_CASE("source crystal deposit withdraw interest and settings") { + auto db = csp::db::SqliteDb::OpenMemory(); + csp::db::ApplyMigrations(db); + csp::db::SeedDemoData(db); + + csp::services::AuthService auth(db); + const auto user = auth.Register("crystal_user", "password123"); + + csp::services::SourceCrystalService crystal(db); + + const auto s0 = crystal.GetSummary(user.user_id); + REQUIRE(s0.user_id == user.user_id); + REQUIRE(AbsDiff(s0.balance, 0.0) < 1e-9); + REQUIRE(s0.monthly_interest_rate >= 0.0); + + const auto d1 = crystal.Deposit(user.user_id, 100.0, "initial deposit"); + REQUIRE(d1.tx_type == "deposit"); + REQUIRE(AbsDiff(d1.amount, 100.0) < 1e-9); + REQUIRE(d1.balance_after > 99.99); + + const auto w1 = crystal.Withdraw(user.user_id, 30.0, "buy resources"); + REQUIRE(w1.tx_type == "withdraw"); + REQUIRE(AbsDiff(w1.amount, -30.0) < 1e-9); + REQUIRE(w1.balance_after > 69.99); + REQUIRE(w1.balance_after < 70.01); + + db.Exec("UPDATE source_crystal_accounts " + "SET last_interest_at = last_interest_at - 7776000 " + "WHERE user_id=" + std::to_string(user.user_id)); + db.Exec("UPDATE source_crystal_transactions " + "SET created_at = created_at - 7776000 " + "WHERE user_id=" + std::to_string(user.user_id)); + + const auto s1 = crystal.GetSummary(user.user_id); + REQUIRE(s1.balance > 72.0); + REQUIRE(s1.balance < 75.0); + + const auto records = crystal.ListTransactions(user.user_id, 50); + REQUIRE(records.size() >= 3); + + const auto cfg = crystal.UpdateMonthlyInterestRate(0.05); + REQUIRE(AbsDiff(cfg.monthly_interest_rate, 0.05) < 1e-9); + + const auto cfg2 = crystal.GetSettings(); + REQUIRE(AbsDiff(cfg2.monthly_interest_rate, 0.05) < 1e-9); +} diff --git a/backend/tests/sqlite_db_test.cc b/backend/tests/sqlite_db_test.cc index efafb5f..8390b20 100644 --- a/backend/tests/sqlite_db_test.cc +++ b/backend/tests/sqlite_db_test.cc @@ -27,6 +27,8 @@ TEST_CASE("migrations create core tables") { REQUIRE(CountTable(db.raw(), "users") == 1); REQUIRE(CountTable(db.raw(), "sessions") == 1); + REQUIRE(CountTable(db.raw(), "user_experience") == 1); + REQUIRE(CountTable(db.raw(), "user_experience_logs") == 1); REQUIRE(CountTable(db.raw(), "problems") == 1); REQUIRE(CountTable(db.raw(), "problem_tags") == 1); REQUIRE(CountTable(db.raw(), "submissions") == 1); @@ -34,11 +36,23 @@ TEST_CASE("migrations create core tables") { REQUIRE(CountTable(db.raw(), "contests") == 1); REQUIRE(CountTable(db.raw(), "contest_problems") == 1); REQUIRE(CountTable(db.raw(), "contest_registrations") == 1); + REQUIRE(CountTable(db.raw(), "contest_modifiers") == 1); + REQUIRE(CountTable(db.raw(), "seasons") == 1); + REQUIRE(CountTable(db.raw(), "season_reward_tracks") == 1); + REQUIRE(CountTable(db.raw(), "season_user_progress") == 1); + REQUIRE(CountTable(db.raw(), "season_reward_claims") == 1); + REQUIRE(CountTable(db.raw(), "loot_drop_logs") == 1); REQUIRE(CountTable(db.raw(), "kb_articles") == 1); REQUIRE(CountTable(db.raw(), "kb_article_links") == 1); + REQUIRE(CountTable(db.raw(), "kb_knowledge_claims") == 1); + REQUIRE(CountTable(db.raw(), "kb_weekly_tasks") == 1); + REQUIRE(CountTable(db.raw(), "kb_weekly_bonus_logs") == 1); REQUIRE(CountTable(db.raw(), "import_jobs") == 1); REQUIRE(CountTable(db.raw(), "import_job_items") == 1); REQUIRE(CountTable(db.raw(), "problem_drafts") == 1); REQUIRE(CountTable(db.raw(), "problem_solution_jobs") == 1); REQUIRE(CountTable(db.raw(), "problem_solutions") == 1); + REQUIRE(CountTable(db.raw(), "source_crystal_settings") == 1); + REQUIRE(CountTable(db.raw(), "source_crystal_accounts") == 1); + REQUIRE(CountTable(db.raw(), "source_crystal_transactions") == 1); } diff --git a/docs/API参考.md b/docs/API参考.md index ee3aed1..1595328 100644 --- a/docs/API参考.md +++ b/docs/API参考.md @@ -6,10 +6,35 @@ ## 通用约定 -- 鉴权头:`Authorization: Bearer ` +- 鉴权头(二选一): + - `Authorization: Bearer `(推荐) + - `Authorization: Basic `(第三方直连更方便) - 成功响应:`{ "ok": true, "data": ... }`(Auth 接口除外) - 失败响应:`{ "ok": false, "error": "..." }` +### 第三方接入快速示例 + +1) 登录拿 token(Bearer 方式) +```bash +curl -X POST 'https://csp.hao.work/admin139/api/v1/auth/login' \ + -H 'Content-Type: application/json' \ + -d '{"username":"alice","password":"password123"}' +``` + +2) Bearer 调用 +```bash +curl 'https://csp.hao.work/admin139/api/v1/me' \ + -H 'Authorization: Bearer ' +``` + +3) Basic 直接调用 +```bash +curl 'https://csp.hao.work/admin139/api/v1/me' \ + -H 'Authorization: Basic YWxpY2U6cGFzc3dvcmQxMjM=' +``` + +> `YWxpY2U6cGFzc3dvcmQxMjM=` 是 `alice:password123` 的 Base64。 + --- ## 1) Auth @@ -65,7 +90,7 @@ ``` - `contest_id` 可选;若提交到比赛,需已报名且比赛进行中。 -### `GET /submissions?user_id=&problem_id=&contest_id=&page=&page_size=` +### `GET /submissions?user_id=&problem_id=&contest_id=&created_from=&created_to=&page=&page_size=` 返回提交列表。 ### `GET /submissions/:id` @@ -120,6 +145,24 @@ ### `GET /kb/articles/:slug` 返回文章详情与关联题目。 +### `GET /kb/articles/:slug/claims`(需鉴权) +返回当前用户在该文章下已领取知识点和累计积分。 + +### `POST /kb/articles/:slug/claim`(需鉴权) +请求: +```json +{ "knowledge_key": "cpp14-io-01" } +``` +说明: +- 有前置依赖的知识点,若前置未完成会返回 400。 +- 同一知识点不可重复领取积分。 + +### `GET /kb/weekly-plan`(需鉴权) +返回本周自动生成的学习任务(含完成度、奖励汇总)。 + +### `POST /kb/weekly-bonus/claim`(需鉴权) +领取本周任务 100% 完成奖励;未达 100% 返回 400。 + --- ## 8) 题库导入任务(PDF + LLM) @@ -157,3 +200,53 @@ ### `GET /backend/logs?limit=100` 返回最近题解生成任务日志(按任务 ID 倒序),并包含当前排队任务数 `pending_jobs`。 + +### `GET /backend/db-lock-guard/status`(需管理员鉴权) +返回 SQLite 锁守护运行状态,字段包含: +- `enabled` / `started`:守护是否启用、是否已启动 +- `interval_sec` / `probe_busy_timeout_ms`:探测间隔与超时 +- `busy_streak` / `busy_streak_trigger`:当前连续锁冲突计数与触发阈值 +- `last_probe_at` / `last_probe_rc` / `last_probe_error`:最近探测结果 +- `last_repair_at` / `repair_count`:最近一次自愈时间与累计自愈次数 + +--- + +## 10) 网站爬虫列表(管理员) + +### `GET /admin/crawlers?status=&limit=` +返回爬虫目标列表(按最新倒序)。 + +### `POST /admin/crawlers` +手动新增爬虫目标。请求体示例: +```json +{ "url": "https://example.com" } +``` + +### `POST /admin/crawlers/:id/queue` +将目标重新入队,触发“规则生成 -> 自动测试 -> 自动运行”流程。 + +### `GET /admin/crawlers/:id/runs?limit=20` +查看指定目标的运行记录。 + +### `GET /backend/crawler-guard/status`(需管理员鉴权) +返回爬虫守护进程状态(是否启用、是否运行、处理统计、最近错误等)。 +- `active_requeue_interval_sec` 表示活跃目标的周期重跑间隔(秒,默认 43200 即 12 小时)。 + +> 飞书群内 `@机器人` 文本如果包含 URL,也会自动写入爬虫目标列表并触发守护流程。 + +--- + +## 11) Lark 机器人事件回调 + +### `POST /lark/events` +用于飞书(Lark)事件订阅回调,支持: +- URL 验证:请求体带 `challenge` 时回传 `{ "challenge": "..." }` +- 消息事件:处理 `im.message.receive_v1` 文本消息并异步回复 + +环境变量(后端): +- `CSP_LARK_BOT_ENABLED=true`:是否启用机器人 +- `CSP_LARK_VERIFICATION_TOKEN=...`:事件回调 token(可选,建议配置) +- `CSP_LARK_APP_ID=...` / `CSP_LARK_APP_SECRET=...`:飞书应用凭据 +- `CSP_LARK_OPEN_BASE_URL=https://open.feishu.cn`:飞书 OpenAPI 域名 +- `CSP_LARK_LLM_API_URL=...` / `CSP_LARK_LLM_API_KEY=...` / `CSP_LARK_LLM_MODEL=...`:对话模型配置 +- 可选:`CSP_LARK_LLM_SYSTEM_PROMPT`、`CSP_LARK_MEMORY_TURNS`、`CSP_LARK_MAX_REPLY_CHARS` diff --git a/docs/Docker部署.md b/docs/Docker部署.md index 49d933d..d12a296 100644 --- a/docs/Docker部署.md +++ b/docs/Docker部署.md @@ -76,8 +76,55 @@ OI_IMPORT_WORKERS=3 OI_IMPORT_CLEAR_EXISTING=true OI_IMPORT_CLEAR_SOURCE_PREFIX=winterant/oi OI_IMPORT_CLEAR_ALL_PROBLEMS=false + +# SQLite 锁守护(定时探测 + 自愈) +CSP_SQLITE_BUSY_TIMEOUT_MS=15000 +CSP_DB_LOCK_GUARD_ENABLED=true +CSP_DB_LOCK_GUARD_INTERVAL_SEC=20 +CSP_DB_LOCK_GUARD_PROBE_TIMEOUT_MS=2000 +CSP_DB_LOCK_GUARD_BUSY_STREAK=3 + +# Lark 机器人(事件回调 + 对话) +CSP_LARK_BOT_ENABLED=false +CSP_LARK_VERIFICATION_TOKEN= +CSP_LARK_APP_ID= +CSP_LARK_APP_SECRET= +CSP_LARK_OPEN_BASE_URL=https://open.feishu.cn +CSP_LARK_LLM_API_URL= +CSP_LARK_LLM_API_KEY= +CSP_LARK_LLM_MODEL=qwen3-max +CSP_LARK_LLM_SYSTEM_PROMPT=你是 CSP Quest World 的编程助教。请用简洁中文回答,先给结论,再给步骤。 +CSP_LARK_LLM_TIMEOUT_SEC=30 +CSP_LARK_API_TIMEOUT_SEC=15 +CSP_LARK_MEMORY_TURNS=6 +CSP_LARK_MAX_REPLY_CHARS=1200 + +# 网站爬虫守护(URL 入库后自动:生成规则 -> 测试 -> 运行) +CSP_CRAWLER_ENABLED=true +CSP_CRAWLER_INTERVAL_SEC=15 +CSP_CRAWLER_REQUEUE_INTERVAL_SEC=43200 +CSP_CRAWLER_FETCH_TIMEOUT_SEC=20 +CSP_CRAWLER_SCRIPT_DIR=/data/crawlers +CSP_CRAWLER_LLM_API_URL=https://one.hao.work/v1/chat/completions +CSP_CRAWLER_LLM_API_KEY=替换为你的Key +CSP_CRAWLER_LLM_MODEL=qwen3-max +CSP_CRAWLER_LLM_TIMEOUT_SEC=30 ``` +说明: + +- `CSP_SQLITE_BUSY_TIMEOUT_MS`:主连接遇到锁时的等待时间(毫秒)。 +- `CSP_DB_LOCK_GUARD_ENABLED`:是否启用定时守护。 +- `CSP_DB_LOCK_GUARD_INTERVAL_SEC`:守护探测间隔(秒)。 +- `CSP_DB_LOCK_GUARD_PROBE_TIMEOUT_MS`:守护探测连接的 busy timeout(毫秒)。 +- `CSP_DB_LOCK_GUARD_BUSY_STREAK`:连续探测到 busy/locked 达到阈值后触发一次自愈(WAL checkpoint + optimize)。 +- `CSP_LARK_BOT_ENABLED`:是否启用 Lark 对话机器人回调处理。 +- `CSP_LARK_VERIFICATION_TOKEN`:Lark 事件回调 token(建议配置)。 +- `CSP_LARK_APP_ID/CSP_LARK_APP_SECRET`:Lark 应用凭据。 +- `CSP_LARK_LLM_*`:机器人对话调用的模型配置。 +- `CSP_CRAWLER_*`:网站爬虫守护配置(URL 入库后自动生成规则/测试/运行)。 +- `CSP_CRAWLER_REQUEUE_INTERVAL_SEC`:已激活目标再次入队执行的周期(秒,`43200`=12小时,`0`=关闭周期重跑)。 + ## 5. 故障排查 ### 5.1 无法访问 7888 diff --git a/docs/知识库重塑与游戏化规划.md b/docs/知识库重塑与游戏化规划.md new file mode 100644 index 0000000..b881d80 --- /dev/null +++ b/docs/知识库重塑与游戏化规划.md @@ -0,0 +1,97 @@ +# 知识库重塑与游戏化规划(CSP 平台) + +## 1. 重塑目标 +- 建立“可学习、可打卡、可成长”的知识库系统,不再只是文章列表。 +- 形成四条核心技能线: + - C++14 技能树(竞赛编码主线) + - GitHub 协作技能树(团队工程主线) + - Linux 服务器技能树(部署运维主线) + - 计算机基础技能树(底层认知主线) +- 将“知识点学习”直接转化为积分成长,形成闭环:学习 -> 打卡 -> 得分 -> 排行与成长反馈。 + +## 2. 内容架构 +### 2.1 主线知识文章(已落地) +- `cpp14-skill-tree` +- `github-collaboration-basics` +- `linux-server-basics` +- `computer-fundamentals-for-oi` + +### 2.2 每篇文章的知识点模型 +- 每篇文章附带一组 `skill_points`: + - `key`: 唯一知识点 ID + - `title`: 知识点标题 + - `description`: 可执行的学习目标 + - `difficulty`: bronze/silver/gold + - `reward`: 领取奖励分值 + +### 2.3 文章与题目联动 +- 保留 `kb_article_links`,把知识点和题目联起来。 +- 在 KB 列表中提供“做相关任务”跳转到题库搜索。 + +## 3. 游戏化机制 +### 3.1 积分获取 +- 用户可对每个知识点打卡领取奖励。 +- 领取规则:`user + article + knowledge_key` 唯一,不可重复刷分。 +- 奖励实时写入用户 `rating`。 + +### 3.2 数据表设计 +- 新增表:`kb_knowledge_claims` + - `user_id` + - `article_id` + - `knowledge_key` + - `reward` + - `created_at` + - 唯一约束:`UNIQUE(user_id, article_id, knowledge_key)` + +### 3.3 成长展示 +- 文章详情页展示: + - 技能点清单 + - 已领取数量 + - 已领取总积分 + - 单点“领取奖励”按钮与状态 +- 积分流水新增 `kb_skill` 类型,进入个人成长历史。 + +## 4. 交互设计 +### 4.1 KB 列表页 +- 支持关键词搜索。 +- 增加快捷筛选按钮:`C++14`、`GitHub`、`Linux`、`计算机基础`。 +- 分类分组:路线图、C++、CSP-J、CSP-S、GitHub、Linux、计算机基础、其他。 + +### 4.2 KB 详情页 +- 文章内容区 +- 技能打卡区(奖励领取) +- 关联题目区(学练结合) + +## 5. 版本规划(建议) +### Phase A(当前已实现) +- 重构四条核心文章 +- 技能点打卡与积分领取 +- 积分流水接入 +- KB 列表筛选与分类增强 + +### Phase B(下一步) +- 技能点前置依赖(先修解锁) +- 每周学习任务(自动生成) +- 学习路径推荐(根据已领取点与错题) +- 章节徽章(铜/银/金) + +### Phase C(进阶) +- 班级/战队学习排行 +- 学习挑战赛(限时技能闯关) +- 知识点掌握度雷达图 +- AI 学习教练(按薄弱点推送下一步) + +## 6. 运营指标(建议) +- 日活学习人数 +- 人均领取知识点数 +- 文章完成率(领取数 / 可领取数) +- 学完知识后去做题转化率 +- 学习后 AC 提升率 + +## 7. 风险与控制 +- 风险:刷接口刷分。 + - 控制:唯一约束 + 服务端验证技能点 key。 +- 风险:内容过长难消化。 + - 控制:拆分为技能点任务和周计划。 +- 风险:学习与做题割裂。 + - 控制:每篇文章都提供相关题目入口。 diff --git a/docs/第三方REST接入指南.md b/docs/第三方REST接入指南.md new file mode 100644 index 0000000..70e07ad --- /dev/null +++ b/docs/第三方REST接入指南.md @@ -0,0 +1,101 @@ +# CSP Quest World 第三方 REST 接入指南 + +本文档用于第三方系统(脚本、自动化平台、企业内部系统)快速接入 CSP Quest World 的 REST API。 + +## 1. 基础信息 + +- 线上基础地址:`https://csp.hao.work/admin139/api/v1` +- 文档入口(Swagger):`https://csp.hao.work/api-docs` +- OpenAPI JSON:`https://csp.hao.work/admin139/api/openapi.json` +- 数据格式:`application/json` + +## 2. 鉴权方式 + +受保护接口支持两种方式,任选其一: + +1. Bearer Token(推荐) +- 先调用 `/auth/login` 获取 `token` +- 请求头带上:`Authorization: Bearer ` + +2. Basic 账号密码(适合第三方直连) +- 请求头带上:`Authorization: Basic ` +- 示例:`alice:password123` -> `YWxpY2U6cGFzc3dvcmQxMjM=` + +说明: +- 两种方式都可访问全部 REST 接口。 +- 管理员接口仍需管理员账号(如 `admin`)才能通过权限校验。 + +## 3. 快速调用示例 + +### 3.1 登录获取 Token + +```bash +curl -X POST 'https://csp.hao.work/admin139/api/v1/auth/login' \ + -H 'Content-Type: application/json' \ + -d '{"username":"alice","password":"password123"}' +``` + +### 3.2 使用 Bearer 调用 + +```bash +curl 'https://csp.hao.work/admin139/api/v1/me' \ + -H 'Authorization: Bearer ' +``` + +### 3.3 使用 Basic 调用 + +```bash +curl 'https://csp.hao.work/admin139/api/v1/me' \ + -H 'Authorization: Basic YWxpY2U6cGFzc3dvcmQxMjM=' +``` + +## 4. Python 示例(requests) + +```python +import base64 +import requests + +BASE = "https://csp.hao.work/admin139/api/v1" +username = "alice" +password = "password123" + +basic = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("utf-8") +headers = {"Authorization": f"Basic {basic}"} + +resp = requests.get(f"{BASE}/me", headers=headers, timeout=15) +resp.raise_for_status() +print(resp.json()) +``` + +## 5. 响应约定 + +- 成功:`{ "ok": true, "data": ... }`(Auth 接口除外) +- 失败:`{ "ok": false, "error": "..." }` + +常见状态码: +- `200`:成功 +- `400`:请求参数错误 +- `401`:未登录或鉴权失败 +- `403`:有登录身份但权限不足(如非管理员访问管理员接口) +- `404`:资源不存在 +- `409`:状态冲突(例如任务已在运行) + +## 6. 安全建议 + +1. 生产环境优先用 HTTPS。 +2. 若可控,优先使用 Bearer Token,减少明文密码在网关/代理日志出现的风险。 +3. 使用 Basic 时建议为第三方集成准备专用账号,并定期更换密码。 +4. 管理员账号只用于管理员接口,不建议用于普通业务拉取。 + +## 7. 爬虫自动化接口(管理员) + +- 爬虫列表:`GET /admin/crawlers?status=&limit=` +- 新增目标:`POST /admin/crawlers`,Body:`{ "url": "https://example.com" }` +- 重新入队:`POST /admin/crawlers/{id}/queue` +- 运行记录:`GET /admin/crawlers/{id}/runs?limit=20` +- 守护状态:`GET /backend/crawler-guard/status` + +说明: +- 新地址入库后,后端守护会自动执行:规则生成(LLM)→ 自动测试 → 自动运行。 +- `crawler-guard/status` 返回 `active_requeue_interval_sec`,用于确认周期重跑间隔(默认 43200 秒,即 12 小时)。 +- 飞书群内 `@机器人` 的文本消息若含 URL,也会自动入库到爬虫列表。 diff --git a/frontend/package.json b/frontend/package.json index 756df74..274d1c8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build", + "build": "next build --webpack", "start": "next start", "lint": "eslint" }, diff --git a/frontend/src/app/admin-redeem/page.tsx b/frontend/src/app/admin-redeem/page.tsx index 93c8ffe..f5a699f 100644 --- a/frontend/src/app/admin-redeem/page.tsx +++ b/frontend/src/app/admin-redeem/page.tsx @@ -4,8 +4,10 @@ import { useEffect, useState } from "react"; import { apiFetch } from "@/lib/api"; import { readToken } from "@/lib/auth"; +import { HintTip } from "@/components/hint-tip"; import { useI18nText } from "@/lib/i18n"; -import { Edit, Gift, Plus, RefreshCw, ScrollText, Search, Trash2, Coins } from "lucide-react"; +import { formatUnixDateTime } from "@/lib/time"; +import { Coins, Edit, Gift, Plus, RefreshCw, ScrollText, Trash2 } from "lucide-react"; type RedeemItem = { id: number; @@ -58,8 +60,7 @@ const DEFAULT_FORM: ItemForm = { }; function fmtTs(v: number | null | undefined): string { - if (!v) return "-"; - return new Date(v * 1000).toLocaleString(); + return formatUnixDateTime(v); } function formatDuration(mins: number): string { @@ -179,6 +180,10 @@ export default function AdminRedeemPage() { setMsg(""); try { if (!token) throw new Error(tx("请先登录管理员账号", "Please sign in with admin account first")); + const ok = window.confirm( + tx(`确认下架兑换物品 #${id}?`, `Disable redeem item #${id}?`) + ); + if (!ok) return; await apiFetch(`/api/v1/admin/redeem-items/${id}`, { method: "DELETE" }, token); setMsg(tx(`已下架兑换物品 #${id}`, `Disabled redeem item #${id}`)); await load(); @@ -193,12 +198,31 @@ export default function AdminRedeemPage() { {tx("管理员:积分兑换管理", "Admin: Redeem Management")} -

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

+
+

{tx("管理兑换物品与全站兑换记录。", "Manage redeem items and global redeem records.")}

+ +
    +
  • + {tx( + "物品支持新增、编辑、下架;下架后不会影响历史记录。", + "Items support create/update/disable; disabling does not affect historical records." + )} +
  • +
  • + {tx( + "可同时配置假期/学习日单价与持续时长(永久或分钟)。", + "You can configure holiday/study-day cost and duration (permanent or minutes)." + )} +
  • +
  • + {tx( + "兑换记录支持按 user_id 筛选,便于核对扣分与备注。", + "Redeem records can be filtered by user_id for auditing costs and notes." + )} +
  • +
+
+
{error &&

{error}

} {msg &&

{msg}

} diff --git a/frontend/src/app/admin-users/page.tsx b/frontend/src/app/admin-users/page.tsx index 03dda67..63c6e68 100644 --- a/frontend/src/app/admin-users/page.tsx +++ b/frontend/src/app/admin-users/page.tsx @@ -1,10 +1,13 @@ "use client"; +import Link from "next/link"; import React, { useEffect, useState } from "react"; +import { HintTip } from "@/components/hint-tip"; import { apiFetch, type RatingHistoryItem } from "@/lib/api"; import { readToken } from "@/lib/auth"; import { useI18nText } from "@/lib/i18n"; +import { formatUnixDateTime } from "@/lib/time"; import { ChevronDown, ChevronUp, @@ -30,12 +33,38 @@ type ListResp = { page_size: number; }; +type SourceCrystalSettings = { + monthly_interest_rate: number; + updated_at: number; +}; + +type UserSourceCrystalSummary = { + user_id: number; + balance: number; + monthly_interest_rate: number; + last_interest_at: number; + updated_at: number; +}; + +type UserSourceCrystalRecord = { + id: number; + user_id: number; + tx_type: string; + amount: number; + balance_after: number; + note: string; + created_at: number; +}; + type SubmissionRow = { id: number; problem_id: number; status: string; score: number; language: string; + rating_delta: number; + time_ms: number; + memory_kb: number; created_at: number; }; @@ -49,51 +78,171 @@ type RedeemRow = { }; function fmtTs(v: number): string { - if (!v) return "-"; - return new Date(v * 1000).toLocaleString(); + return formatUnixDateTime(v); } function DetailPanel({ userId, tx }: { userId: number; tx: (zh: string, en: string) => string }) { - const [tab, setTab] = useState<"subs" | "rating" | "redeem">("subs"); + const [tab, setTab] = useState<"subs" | "rating" | "redeem" | "crystal">("subs"); + const [subRange, setSubRange] = useState<"7d" | "30d" | "all">("all"); const [subs, setSubs] = useState([]); + const [subsPage, setSubsPage] = useState(1); + const [subsHasMore, setSubsHasMore] = useState(false); + const [subsLoadingMore, setSubsLoadingMore] = useState(false); const [ratingH, setRatingH] = useState([]); const [redeems, setRedeems] = useState([]); + const [crystalSummary, setCrystalSummary] = useState(null); + const [crystalRecords, setCrystalRecords] = useState([]); + const [crystalAmount, setCrystalAmount] = useState("10"); + const [crystalNote, setCrystalNote] = useState(""); + const [crystalSaving, setCrystalSaving] = useState(false); + const [crystalMsg, setCrystalMsg] = useState(""); const [loading, setLoading] = useState(false); const token = readToken() ?? ""; + const subRangeDays = subRange === "7d" ? 7 : subRange === "30d" ? 30 : 0; + const subRangeFromTs = + subRangeDays > 0 + ? Math.floor(Date.now() / 1000) - subRangeDays * 24 * 60 * 60 + : 0; + const subRangeLabel = + subRange === "7d" + ? tx("近7天", "Last 7 days") + : subRange === "30d" + ? tx("近30天", "Last 30 days") + : tx("全部", "All"); useEffect(() => { - setLoading(true); const loadTab = async () => { + const subPageSize = 200; + const loadSubs = async (page: number, append: boolean) => { + const params = new URLSearchParams(); + params.set("user_id", String(userId)); + params.set("page", String(page)); + params.set("page_size", String(subPageSize)); + if (subRangeFromTs > 0) params.set("created_from", String(subRangeFromTs)); + const d = await apiFetch<{ items: SubmissionRow[] }>( + `/api/v1/submissions?${params.toString()}`, + undefined, + token + ); + const rows = d.items ?? []; + setSubs((prev) => (append ? [...prev, ...rows] : rows)); + setSubsPage(page); + setSubsHasMore(rows.length >= subPageSize); + }; + + setLoading(true); try { if (tab === "subs") { - const d = await apiFetch<{ items: SubmissionRow[] }>( - `/api/v1/submissions?user_id=${userId}&page=1&page_size=50`, - undefined, token - ); - setSubs(d.items ?? []); + await loadSubs(1, false); } else if (tab === "rating") { const d = await apiFetch( `/api/v1/admin/users/${userId}/rating-history?limit=100`, undefined, token ); setRatingH(Array.isArray(d) ? d : []); - } else { + } else if (tab === "redeem") { const d = await apiFetch( `/api/v1/admin/redeem-records?user_id=${userId}&limit=100`, undefined, token ); setRedeems(Array.isArray(d) ? d : []); + } else { + const [summary, records] = await Promise.all([ + apiFetch( + `/api/v1/admin/users/${userId}/source-crystal`, + undefined, + token + ), + apiFetch( + `/api/v1/admin/users/${userId}/source-crystal/records?limit=100`, + undefined, + token + ), + ]); + setCrystalSummary(summary ?? null); + setCrystalRecords(Array.isArray(records) ? records : []); } } catch { /* ignore */ } setLoading(false); }; void loadTab(); - }, [tab, userId, token]); + }, [tab, userId, token, subRangeFromTs]); + + const loadMoreSubs = async () => { + if (tab !== "subs" || !subsHasMore || subsLoadingMore) return; + setSubsLoadingMore(true); + const nextPage = subsPage + 1; + const subPageSize = 200; + try { + const params = new URLSearchParams(); + params.set("user_id", String(userId)); + params.set("page", String(nextPage)); + params.set("page_size", String(subPageSize)); + if (subRangeFromTs > 0) params.set("created_from", String(subRangeFromTs)); + const d = await apiFetch<{ items: SubmissionRow[] }>( + `/api/v1/submissions?${params.toString()}`, + undefined, + token + ); + const rows = d.items ?? []; + setSubs((prev) => [...prev, ...rows]); + setSubsPage(nextPage); + setSubsHasMore(rows.length >= subPageSize); + } catch { + // ignore + } finally { + setSubsLoadingMore(false); + } + }; const tabCls = (t: string) => `px-3 py-1 text-xs border ${tab === t ? "bg-zinc-900 text-white" : "bg-white text-zinc-700 hover:bg-zinc-100"}`; + const depositSourceCrystal = async () => { + setCrystalMsg(""); + const amount = Number(crystalAmount); + if (!Number.isFinite(amount) || amount <= 0) { + setCrystalMsg(tx("请输入大于 0 的存入数量", "Please enter a deposit amount > 0")); + return; + } + setCrystalSaving(true); + try { + await apiFetch( + `/api/v1/admin/users/${userId}/source-crystal/deposit`, + { + method: "POST", + body: JSON.stringify({ amount, note: crystalNote }), + }, + token + ); + setCrystalNote(""); + setCrystalMsg(tx("源晶存入成功", "Source crystal deposited")); + const [summary, records] = await Promise.all([ + apiFetch( + `/api/v1/admin/users/${userId}/source-crystal`, + undefined, + token + ), + apiFetch( + `/api/v1/admin/users/${userId}/source-crystal/records?limit=100`, + undefined, + token + ), + ]); + setCrystalSummary(summary ?? null); + setCrystalRecords(Array.isArray(records) ? records : []); + } catch (e: unknown) { + setCrystalMsg(String(e)); + } finally { + setCrystalSaving(false); + } + }; + + const crystalBalance = crystalSummary?.balance ?? 0; + const crystalMonthlyRate = crystalSummary?.monthly_interest_rate ?? 0; + const estimatedMonthlyInterest = Math.max(0, crystalBalance * crystalMonthlyRate); + return (
@@ -106,15 +255,50 @@ function DetailPanel({ userId, tx }: { userId: number; tx: (zh: string, en: stri +
{loading &&

{tx("加载中...", "Loading...")}

} {!loading && tab === "subs" && (
+
+ + + +
+
+ {tx(`${subRangeLabel} 已加载 ${subs.length} 条`, `${subRangeLabel}: ${subs.length} loaded`)} + 0 ? `&created_from=${subRangeFromTs}` : ""}`} + className="underline text-blue-600 hover:text-blue-700" + > + {tx("打开完整提交页", "Open full submissions page")} + +
+ + + {subs.map((s) => ( @@ -123,12 +307,35 @@ function DetailPanel({ userId, tx }: { userId: number; tx: (zh: string, en: stri + + + ))} - {subs.length === 0 && } + {subs.length === 0 && }
ID{tx("题目", "Problem")} {tx("状态", "Status")}{tx("分数", "Score")}ΔR{tx("耗时", "Time")} {tx("时间", "Time")}{tx("详情", "Detail")}
P{s.problem_id} {s.status} {s.score} 0 ? "text-emerald-600" : s.rating_delta < 0 ? "text-red-600" : "text-zinc-500"}`}> + {s.rating_delta > 0 ? `+${s.rating_delta}` : s.rating_delta} + {s.time_ms}ms {fmtTs(s.created_at)} + + {tx("查看", "View")} + +
{tx("无记录", "No records")}
{tx("无记录", "No records")}
+ {subsHasMore && ( +
+ +
+ )}
)} {!loading && tab === "rating" && ( @@ -168,6 +375,70 @@ function DetailPanel({ userId, tx }: { userId: number; tx: (zh: string, en: stri
)} + {!loading && tab === "crystal" && ( +
+
+

+ {tx("当前余额", "Current Balance")}:{" "} + + {crystalBalance.toFixed(2)} + + + {tx("预计月息", "Est. monthly interest")}: +{estimatedMonthlyInterest.toFixed(2)} + +

+

+ {tx("月利率", "Monthly rate")}: {(crystalMonthlyRate * 100).toFixed(2)}% ·{" "} + {tx("上次计息", "Last interest")}: {crystalSummary ? fmtTs(crystalSummary.last_interest_at) : "-"} +

+
+
+ setCrystalAmount(e.target.value)} + placeholder={tx("数量", "Amount")} + /> + setCrystalNote(e.target.value)} + placeholder={tx("备注(可选)", "Note (optional)")} + /> + +
+ {crystalMsg &&

{crystalMsg}

} +
+ {crystalRecords.map((r) => ( +
+ + = 0 ? "font-bold text-emerald-700" : "font-bold text-red-600"}> + {r.amount >= 0 ? "+" : ""} + {r.amount.toFixed(2)} + + {r.tx_type} + {r.note ? · {r.note} : null} + + + {tx("余额", "Bal")}: {r.balance_after.toFixed(2)} · {fmtTs(r.created_at)} + +
+ ))} + {crystalRecords.length === 0 && ( +

{tx("暂无源晶流水", "No source crystal records")}

+ )} +
+
+ )} ); } @@ -179,6 +450,9 @@ export default function AdminUsersPage() { const [error, setError] = useState(""); const [msg, setMsg] = useState(""); const [expandedId, setExpandedId] = useState(null); + const [monthlyInterestRate, setMonthlyInterestRate] = useState(0.02); + const [rateUpdatedAt, setRateUpdatedAt] = useState(null); + const [savingRate, setSavingRate] = useState(false); const load = async () => { setLoading(true); @@ -186,8 +460,15 @@ export default function AdminUsersPage() { try { const token = readToken(); if (!token) throw new Error(tx("请先登录管理员账号", "Please sign in with admin account first")); - const data = await apiFetch("/api/v1/admin/users?page=1&page_size=200", undefined, token); + const [data, settings] = await Promise.all([ + apiFetch("/api/v1/admin/users?page=1&page_size=200", undefined, token), + apiFetch("/api/v1/admin/source-crystal/settings", undefined, token), + ]); setItems(data.items ?? []); + setMonthlyInterestRate( + Number.isFinite(settings?.monthly_interest_rate) ? settings.monthly_interest_rate : 0.02 + ); + setRateUpdatedAt(Number.isFinite(settings?.updated_at) ? settings.updated_at : null); } catch (e: unknown) { setError(String(e)); } finally { @@ -221,16 +502,67 @@ export default function AdminUsersPage() { } }; + const saveMonthlyInterestRate = async () => { + setMsg(""); + setError(""); + setSavingRate(true); + try { + const token = readToken(); + if (!token) throw new Error(tx("请先登录管理员账号", "Please sign in with admin account first")); + const nextRate = Math.max(0, Math.min(1, Number(monthlyInterestRate) || 0)); + const resp = await apiFetch( + "/api/v1/admin/source-crystal/settings", + { + method: "PATCH", + body: JSON.stringify({ monthly_interest_rate: nextRate }), + }, + token + ); + setMonthlyInterestRate(resp.monthly_interest_rate); + setRateUpdatedAt(resp.updated_at); + setMsg( + tx( + `已更新源晶月利率为 ${(resp.monthly_interest_rate * 100).toFixed(2)}%`, + `Updated source crystal monthly rate to ${(resp.monthly_interest_rate * 100).toFixed(2)}%` + ) + ); + } catch (e: unknown) { + setError(String(e)); + } finally { + setSavingRate(false); + } + }; + return (

{tx("管理员用户与积分", "Admin Users & Rating")}

-

- {tx("默认管理员账号:", "Default admin account: ")} - admin / whoami139 -

+
+

+ {tx( + "管理员入口(账号与密码请通过安全渠道配置)", + "Admin entry (account/password should be managed via secure channels)" + )} +

+ +
    +
  • + {tx( + "可直接修改并保存用户 Rating,实时刷新统计。", + "You can update and save user rating directly with live refresh." + )} +
  • +
  • + {tx( + "展开详情可查看该用户提交、积分历史、兑换记录。", + "Expand a row to inspect submissions, rating history, and redeem records." + )} +
  • +
+
+
+
+

{tx("源晶月利率设置", "Source Crystal Monthly Rate")}

+

+ {tx( + "默认 0.02(2%/月)。修改后将用于后续计息。", + "Default is 0.02 (2%/month). New value applies to future interest accrual." + )} +

+
+ setMonthlyInterestRate(Number(e.target.value))} + /> + + {tx("当前显示", "Preview")}: {(Number(monthlyInterestRate) * 100).toFixed(2)}% + + + + {tx("更新时间", "Updated")}: {rateUpdatedAt ? fmtTs(rateUpdatedAt) : "-"} + +
+
+ {msg &&

{msg}

} {error &&

{error}

} diff --git a/frontend/src/app/admin139/page.tsx b/frontend/src/app/admin139/page.tsx index 9a3908b..d1523d2 100644 --- a/frontend/src/app/admin139/page.tsx +++ b/frontend/src/app/admin139/page.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/navigation"; import { useEffect } from "react"; +import { HintTip } from "@/components/hint-tip"; import { useUiPreferences } from "@/components/ui-preference-provider"; export default function AdminEntryPage() { @@ -16,10 +17,12 @@ export default function AdminEntryPage() { return (

{t("admin.entry.title")}

-

{t("admin.entry.desc")}

-

- {t("admin.entry.moved_to_platform")} -

+
+

{t("admin.entry.desc")}

+ +

{t("admin.entry.moved_to_platform")}

+
+
); } diff --git a/frontend/src/app/api-docs/page.tsx b/frontend/src/app/api-docs/page.tsx index 1d30eb3..859f878 100644 --- a/frontend/src/app/api-docs/page.tsx +++ b/frontend/src/app/api-docs/page.tsx @@ -1,12 +1,12 @@ "use client"; import dynamic from "next/dynamic"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo } from "react"; import Link from "next/link"; import { FileCode, ArrowLeft } from "lucide-react"; -import { API_BASE, apiFetch } from "@/lib/api"; -import { readToken } from "@/lib/auth"; +import { API_BASE } from "@/lib/api"; +import { HintTip } from "@/components/hint-tip"; import { useI18nText } from "@/lib/i18n"; const SwaggerUI = dynamic(() => import("swagger-ui-react"), { ssr: false }); @@ -14,62 +14,6 @@ const SwaggerUI = dynamic(() => import("swagger-ui-react"), { ssr: false }); export default function ApiDocsPage() { const { tx } = useI18nText(); const specUrl = useMemo(() => `${API_BASE}/api/openapi.json`, []); - const [checkingAdmin, setCheckingAdmin] = useState(true); - const [isAdmin, setIsAdmin] = useState(false); - const [error, setError] = useState(""); - - useEffect(() => { - let canceled = false; - const checkAdmin = async () => { - const token = readToken(); - if (!token) { - if (!canceled) { - setIsAdmin(false); - setError(tx("请先登录管理员账号", "Please sign in with admin account first")); - setCheckingAdmin(false); - } - return; - } - try { - const me = await apiFetch<{ username?: string }>("/api/v1/me", {}, token); - if (!canceled) { - const allowed = (me?.username ?? "") === "admin"; - setIsAdmin(allowed); - setError(allowed ? "" : tx("仅管理员可查看 API 文档", "API docs are visible to admin only")); - } - } catch (e: unknown) { - if (!canceled) { - setIsAdmin(false); - setError(String(e)); - } - } finally { - if (!canceled) setCheckingAdmin(false); - } - }; - void checkAdmin(); - return () => { - canceled = true; - }; - }, [tx]); - - if (checkingAdmin) { - return ( -
- {tx("正在校验管理员权限...", "Checking admin access...")} -
- ); - } - - if (!isAdmin) { - return ( -
-

{tx("API 文档(Swagger)", "API Docs (Swagger)")}

-

- {error || tx("仅管理员可查看此页面", "This page is available for admin only")} -

-
- ); - } return (
@@ -83,6 +27,25 @@ export default function ApiDocsPage() { {tx("返回", "Back")} +
+

{tx("查看与调试平台 API。", "Inspect and debug platform APIs.")}

+ +
    +
  • + {tx( + "文档来源于后端 OpenAPI:/api/openapi.json。", + "Docs are generated from backend OpenAPI: /api/openapi.json." + )} +
  • +
  • + {tx( + "鉴权支持 Bearer Token 与 Basic(账号:密码);管理员接口仍需管理员账号。", + "Protected APIs support Bearer token and Basic (username:password); admin endpoints still require admin account." + )} +
  • +
+
+
diff --git a/frontend/src/app/auth/page.tsx b/frontend/src/app/auth/page.tsx index 637c8bf..0d35741 100644 --- a/frontend/src/app/auth/page.tsx +++ b/frontend/src/app/auth/page.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { Eye, EyeOff, Key, LogIn, User, UserPlus } from "lucide-react"; +import { HintTip } from "@/components/hint-tip"; import { API_BASE, apiFetch } from "@/lib/api"; import { readToken, saveToken } from "@/lib/auth"; import { useI18nText } from "@/lib/i18n"; @@ -26,8 +27,8 @@ export default function AuthPage() { const [checkingAuth, setCheckingAuth] = useState(true); const [mode, setMode] = useState<"register" | "login">("login"); - const [username, setUsername] = useState(process.env.NEXT_PUBLIC_TEST_USERNAME ?? ""); - const [password, setPassword] = useState(process.env.NEXT_PUBLIC_TEST_PASSWORD ?? ""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [loading, setLoading] = useState(false); @@ -47,6 +48,7 @@ export default function AuthPage() { mode === "register" && password !== confirmPassword ? tx("两次密码不一致", "Passwords do not match") : ""; const canSubmit = !loading && !usernameErr && !passwordErr && !confirmErr; + const disabledReason = usernameErr || passwordErr || confirmErr; async function submit() { if (!canSubmit) return; @@ -88,13 +90,14 @@ export default function AuthPage() {

{tx("欢迎回来,冒险者!", "Welcome Back, Adventurer!")}

-

- {tx("登录服务器以访问任务布告栏、保存冒险进度、查看错题卷轴和个人成就。", "Login to access Quest Board, save Game Progress, review Grimoire, and track Achievements.")} -

-
-

{tx("• 任务按 CSP-J / CSP-S / NOIP 难度分级", "• Quests organized by CSP-J / CSP-S / NOIP Tiers")}

-

{tx("• 任务卷轴支持本地草稿与试炼运行", "• Quest Scrolls support local drafting and trial runs")}

-

{tx("• 先知题解异步生成,包含多种解法", "• Oracles provide asynchronous wisdom with multiple paths")}

+
+ {tx("登录后可同步你的学习进度。", "Sign in to sync your learning progress.")} + + {tx( + "登录后可访问任务布告栏、保存草稿、查看错题卷轴和个人成长。题目按 CSP-J/CSP-S/NOIP 分级,题解支持异步生成。", + "After sign-in you can access quests, save drafts, review wrong-book notes, and track growth. Problems are tiered by CSP-J/CSP-S/NOIP, and solutions are generated asynchronously." + )} +

Server API: {apiBase} @@ -116,7 +119,7 @@ export default function AuthPage() { disabled={loading} > - {tx("登录服务器", "Login")} + {tx("已有账号", "Sign In")}

@@ -197,12 +200,28 @@ export default function AuthPage() { + {!canSubmit && !loading && disabledReason && ( +

+ {tx("当前不可提交:", "Cannot submit yet: ")} + {disabledReason} +

+ )} +
+ {tx("只想先看看内容?", "Just want to browse first?")} + + {tx("游客模式", "Guest Mode")} + +
{resp && ( diff --git a/frontend/src/app/backend-logs/page.tsx b/frontend/src/app/backend-logs/page.tsx index 87d86f9..678850a 100644 --- a/frontend/src/app/backend-logs/page.tsx +++ b/frontend/src/app/backend-logs/page.tsx @@ -3,10 +3,12 @@ import Link from "next/link"; import { useEffect, useState } from "react"; +import { HintTip } from "@/components/hint-tip"; import { apiFetch } from "@/lib/api"; import { readToken } from "@/lib/auth"; import { useI18nText } from "@/lib/i18n"; -import { Activity, AlertCircle, List, Play, RefreshCw, Server, Trash2, Zap } from "lucide-react"; +import { formatUnixDateTime } from "@/lib/time"; +import { Activity, AlertCircle, List, RefreshCw, Server, Zap } from "lucide-react"; type BackendLogItem = { id: number; @@ -75,8 +77,72 @@ type AdminUsersResp = { }; function fmtTs(v: number | null | undefined): string { - if (!v) return "-"; - return new Date(v * 1000).toLocaleString(); + return formatUnixDateTime(v); +} + +function renderStatusLabel(raw: string, tx: (zhText: string, enText: string) => string): string { + const value = raw.toLowerCase(); + if (value.includes("queue") || value === "queued" || value === "pending") { + return tx("排队中", "Queued"); + } + if (value.includes("run") || value === "running" || value === "processing") { + return tx("施法中", "Running"); + } + if (value.includes("success") || value === "done" || value === "completed") { + return tx("已完成", "Completed"); + } + if (value.includes("fail") || value === "error") { + return tx("失败", "Failed"); + } + return raw; +} + +function statusToneClass(raw: string): string { + const value = raw.toLowerCase(); + if (value.includes("queue") || value === "queued" || value === "pending") { + return "mc-status-warning text-amber-700"; + } + if (value.includes("run") || value === "running" || value === "processing") { + return "mc-status-running text-blue-700"; + } + if (value.includes("success") || value === "done" || value === "completed") { + return "mc-status-success text-emerald-700"; + } + if (value.includes("fail") || value === "error") { + return "mc-status-danger text-red-700"; + } + return "mc-status-muted text-zinc-700"; +} + +function resolveAccessIssue( + hasToken: boolean, + rawError: string, + tx: (zhText: string, enText: string) => string +): { kind: "forbidden" | "expired" | "signin"; title: string; detail: string } { + if (!hasToken) { + return { + kind: "signin", + title: tx("登录状态缺失", "Not Signed In"), + detail: tx("请先登录,再尝试访问后台日志。", "Please sign in first, then try again."), + }; + } + const normalized = rawError.toLowerCase(); + if ( + normalized.includes("expired") || + normalized.includes("invalid or expired token") || + normalized.includes("token") + ) { + return { + kind: "expired", + title: tx("登录已过期", "Session Expired"), + detail: tx("当前登录令牌已失效,请重新登录后再访问。", "Your token is no longer valid. Please sign in again."), + }; + } + return { + kind: "forbidden", + title: tx("权限不足", "No Permission"), + detail: tx("该页面仅管理员可见。", "This page is for administrators only."), + }; } export default function BackendLogsPage() { @@ -265,12 +331,35 @@ export default function BackendLogsPage() { } if (!isAdmin) { + const issue = resolveAccessIssue(Boolean(token), error, tx); return (

{tx("后台日志(题解异步队列)", "Backend Logs (Async Solution Queue)")}

-

- {error || tx("仅管理员可查看此页面", "This page is available for admin only")} -

+
+
+ +

{issue.title}

+
+

{issue.detail}

+ {!!error && ( +
+ {tx("查看原始错误", "Show raw error")} +
+                {error}
+              
+
+ )} +
+ + {tx("返回任务板", "Back to Quest Board")} + + + {issue.kind === "expired" || issue.kind === "signin" + ? tx("重新登录", "Sign In Again") + : tx("切换管理员账号", "Switch Admin Account")} + +
+
); } @@ -316,12 +405,31 @@ export default function BackendLogsPage() { {error &&

{error}

} {triggerMsg &&

{triggerMsg}

} {userMsg &&

{userMsg}

} -

- {tx( - "系统已自动单线程异步处理待队列任务,无需手工点击;上方按钮仅用于立即手动补全。", - "System auto-processes queued jobs in single-thread async mode; the button above is only for manual trigger." - )} -

+
+

{tx("队列会自动异步处理。", "Queue is processed automatically in async mode.")}

+ +
    +
  • + {tx( + "页面每 5 秒自动刷新一次运行态与排队态。", + "This page auto-refreshes running and queued states every 5 seconds." + )} +
  • +
  • + {tx( + "“手动补全”只是在当前时刻额外触发一次补题,不影响后台常驻处理。", + "\"Manual fill\" triggers one extra generation batch and does not replace background processing." + )} +
  • +
  • + {tx( + "删除用户会级联删除其提交、错题本、草稿与积分记录。", + "Deleting a user cascades submissions, wrong-book, drafts, and rating records." + )} +
  • +
+
+
@@ -420,7 +528,12 @@ export default function BackendLogsPage() { {tx("任务", "Job")} #{job.id} · {tx("题目", "Problem")} #{job.problem_id} {job.problem_title || tx("(未命名题目)", "(Untitled)")}

- {tx("状态", "Status")} {job.status} · {tx("进度", "Progress")} {job.progress}% · {tx("开始", "Start")} {fmtTs(job.started_at ?? null)} + {tx("状态", "Status")}{" "} + + {renderStatusLabel(job.status, tx)} ({job.status}) + {" "} + · {tx("进度", "Progress")} {job.progress}% · {tx("开始", "Start")}{" "} + {fmtTs(job.started_at ?? null)}

{job.message || "-"}

@@ -445,7 +558,12 @@ export default function BackendLogsPage() { {tx("任务", "Job")} #{job.id} · {tx("题目", "Problem")} #{job.problem_id} {job.problem_title || tx("(未命名题目)", "(Untitled)")}

- {tx("状态", "Status")} {job.status} · {tx("进度", "Progress")} {job.progress}% · {tx("更新", "Updated")} {fmtTs(job.updated_at)} + {tx("状态", "Status")}{" "} + + {renderStatusLabel(job.status, tx)} ({job.status}) + {" "} + · {tx("进度", "Progress")} {job.progress}% · {tx("更新", "Updated")}{" "} + {fmtTs(job.updated_at)}

{job.message || "-"}

@@ -464,7 +582,10 @@ export default function BackendLogsPage() { {tx("任务", "Job")} #{item.id}

- {item.status} · {item.progress}% + + {renderStatusLabel(item.status, tx)} ({item.status}) + {" "} + · {item.progress}%
@@ -504,7 +625,9 @@ export default function BackendLogsPage() { - {item.status} + + {renderStatusLabel(item.status, tx)} ({item.status}) + {item.progress}% diff --git a/frontend/src/app/contests/[id]/page.tsx b/frontend/src/app/contests/[id]/page.tsx index f7ece54..eb449ac 100644 --- a/frontend/src/app/contests/[id]/page.tsx +++ b/frontend/src/app/contests/[id]/page.tsx @@ -4,9 +4,12 @@ import Link from "next/link"; import { useParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; +import { HintTip } from "@/components/hint-tip"; +import { PageCrumbs } from "@/components/page-crumbs"; import { apiFetch } from "@/lib/api"; import { readToken } from "@/lib/auth"; import { useI18nText } from "@/lib/i18n"; +import { formatUnixDateTime } from "@/lib/time"; type Contest = { id: number; @@ -81,9 +84,35 @@ export default function ContestDetailPage() { return (
+

{tx("比赛详情", "Contest Detail")} #{contestId}

+
+

{tx("查看赛程、题目与当前榜单。", "View schedule, problems, and current leaderboard.")}

+ +
    +
  • + {tx( + "可报名后再开始提交;重复点击报名用于刷新状态。", + "Register first before submitting; clicking register again refreshes status." + )} +
  • +
  • + {tx( + "Penalty 为罚时秒数,Solved 为已解题数。", + "Penalty is time penalty in seconds, Solved is number of solved problems." + )} +
  • +
+
+
{loading &&

{tx("加载中...", "Loading...")}

} {error &&

{error}

} @@ -92,12 +121,17 @@ export default function ContestDetailPage() {

{detail.contest.title}

- {new Date(detail.contest.starts_at * 1000).toLocaleString()} - {" "} - {new Date(detail.contest.ends_at * 1000).toLocaleString()} + {formatUnixDateTime(detail.contest.starts_at)} - {" "} + {formatUnixDateTime(detail.contest.ends_at)}

-
-              {detail.contest.rule_json}
-            
+
+ + {tx("查看完整规则 JSON", "View full rule JSON")} + +
+                {detail.contest.rule_json}
+              
+
+ + {weeklyHint && ( +

+ {weeklyHint} +

+ )} + + {relatedWeeklyTasks.length > 0 && ( +
+

+ {tx("本文章相关周任务", "Weekly Tasks Related to This Article")} +

+
    + {relatedWeeklyTasks.map((task) => ( +
  • + {task.completed ? "✅" : "⬜"} {task.knowledge_title} (+{task.reward}) +
  • + ))} +
+
+ )} + + )} +
+

{data.article.title}

{tx("更新时间:", "Updated: ")} - {new Date(data.article.created_at * 1000).toLocaleString()} + {formatUnixDateTime(data.article.created_at)}

+ {data.skill_points.length > 0 && ( +
+
+

+ {tx("技能打卡与积分奖励", "Skill Checkpoints & Rewards")} + + {tx( + "每个知识点仅可领取一次。存在前置依赖时,必须先完成前置点才可领取后续点。", + "Each skill point can be claimed once. If prerequisites exist, complete them first before claiming follow-up points." + )} + +

+ + {tx("已领取积分", "Claimed Points")}: {claimTotalReward} + +
+ {claimMsg && ( +

{claimMsg}

+ )} +
+ {data.skill_points.map((point) => { + const done = claimedKeys.has(point.key); + return ( +
+
+

+ {done ? "✅" : "⬜"} {point.title} +

+
+ + {point.difficulty} + + +{point.reward} + +
+
+

{point.description}

+ {(point.prerequisites?.length ?? 0) > 0 && ( +

+ {tx("前置:", "Prerequisites: ")} + {point.prerequisites?.join(", ")} +

+ )} +

ID: {point.key}

+
+ ); + })} +
+
+ )} +

{tx("关联题目", "Related Problems")}

{data.related_problems.length ? ( diff --git a/frontend/src/app/kb/page.tsx b/frontend/src/app/kb/page.tsx index d652d7c..cb77e3a 100644 --- a/frontend/src/app/kb/page.tsx +++ b/frontend/src/app/kb/page.tsx @@ -2,12 +2,16 @@ import Link from "next/link"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { HintTip } from "@/components/hint-tip"; +import { useUiPreferences } from "@/components/ui-preference-provider"; import { apiFetch } from "@/lib/api"; import { readToken } from "@/lib/auth"; import { useI18nText } from "@/lib/i18n"; +import { formatUnixDateTime } from "@/lib/time"; import { - Book, + CheckCircle2, Code2, FileQuestion, Library, @@ -15,6 +19,8 @@ import { RefreshCw, Shield, Sword, + Target, + Trophy, } from "lucide-react"; type Article = { @@ -38,14 +44,51 @@ type TriggerKbRefreshResp = KbRefreshStatus & { message: string; }; +type WeeklyTask = { + id: number; + week_key: string; + article_id: number; + article_slug: string; + article_title: string; + knowledge_key: string; + knowledge_title: string; + knowledge_description: string; + difficulty: string; + reward: number; + prerequisites: string[]; + completed: boolean; + completed_at: number | null; +}; + +type WeeklyPlanResp = { + week_key: string; + tasks: WeeklyTask[]; + total_reward: number; + gained_reward: number; + bonus_reward: number; + bonus_claimed: boolean; + completion_percent: number; +}; + +type WeeklyBonusResp = { + claimed: boolean; + reward: number; + rating_after: number; + completion_percent: number; + week_key: string; +}; + function fmtTs(v: number | null | undefined): string { - if (!v) return "-"; - return new Date(v * 1000).toLocaleString(); + return formatUnixDateTime(v); } export default function KbListPage() { const { tx } = useI18nText(); + const { theme } = useUiPreferences(); + const isMc = theme === "minecraft"; + const router = useRouter(); const [refreshToken, setRefreshToken] = useState(""); + const [userToken, setUserToken] = useState(""); const [canManageRefresh, setCanManageRefresh] = useState(false); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); @@ -55,31 +98,59 @@ export default function KbListPage() { const [hint, setHint] = useState(""); const [refreshStatus, setRefreshStatus] = useState(null); const [lastSyncedFinishedAt, setLastSyncedFinishedAt] = useState(0); + const [queryInput, setQueryInput] = useState(""); + const [query, setQuery] = useState(""); + const [weeklyPlan, setWeeklyPlan] = useState(null); + const [weeklyLoading, setWeeklyLoading] = useState(false); + const [weeklyError, setWeeklyError] = useState(""); + const [weeklyHint, setWeeklyHint] = useState(""); + const [claimWeeklyLoading, setClaimWeeklyLoading] = useState(false); const grouped = useMemo(() => { + const source = query.trim() + ? items.filter((article) => { + const needle = query.trim().toLowerCase(); + return article.title.toLowerCase().includes(needle) || article.slug.toLowerCase().includes(needle); + }) + : items; const buckets: Record = { roadmap: [], cpp: [], + web: [], + game: [], cspj: [], csps: [], + github: [], + linux: [], + csfund: [], other: [], }; - for (const article of items) { + for (const article of source) { const slug = article.slug.toLowerCase(); if (slug.includes("roadmap")) { buckets.roadmap.push(article); + } else if (slug.includes("web")) { + buckets.web.push(article); + } else if (slug.includes("game")) { + buckets.game.push(article); } else if (slug.includes("cpp")) { buckets.cpp.push(article); } else if (slug.includes("csp-j") || slug.includes("cspj")) { buckets.cspj.push(article); } else if (slug.includes("csp-s") || slug.includes("csps")) { buckets.csps.push(article); + } else if (slug.includes("github") || slug.includes("git-")) { + buckets.github.push(article); + } else if (slug.includes("linux")) { + buckets.linux.push(article); + } else if (slug.includes("computer") || slug.includes("cs-") || slug.includes("fundamental")) { + buckets.csfund.push(article); } else { buckets.other.push(article); } } return buckets; - }, [items]); + }, [items, query]); const loadArticles = useCallback(async () => { setLoading(true); @@ -110,12 +181,32 @@ export default function KbListPage() { } }, [canManageRefresh, refreshToken]); + const loadWeeklyPlan = useCallback(async () => { + if (!userToken) { + setWeeklyPlan(null); + setWeeklyError(""); + return; + } + setWeeklyLoading(true); + setWeeklyError(""); + try { + const data = await apiFetch("/api/v1/kb/weekly-plan", {}, userToken); + setWeeklyPlan(data); + } catch (e: unknown) { + setWeeklyPlan(null); + setWeeklyError(String(e)); + } finally { + setWeeklyLoading(false); + } + }, [userToken]); + useEffect(() => { let canceled = false; const refreshAdminState = async () => { const tk = readToken(); if (!tk) { if (!canceled) { + setUserToken(""); setRefreshToken(""); setCanManageRefresh(false); } @@ -124,12 +215,14 @@ export default function KbListPage() { try { const me = await apiFetch<{ username?: string }>("/api/v1/me", {}, tk); if (!canceled) { + setUserToken(tk); const isAdmin = (me?.username ?? "") === "admin"; setCanManageRefresh(isAdmin); setRefreshToken(isAdmin ? tk : ""); } } catch { if (!canceled) { + setUserToken(""); setRefreshToken(""); setCanManageRefresh(false); } @@ -151,6 +244,17 @@ export default function KbListPage() { void loadStatus(); }, [loadArticles, loadStatus]); + useEffect(() => { + void loadWeeklyPlan(); + }, [loadWeeklyPlan]); + + useEffect(() => { + const q = new URLSearchParams(window.location.search).get("q")?.trim() ?? ""; + if (!q) return; + setQueryInput(q); + setQuery(q); + }, []); + useEffect(() => { const timer = setInterval(() => { void loadStatus(); @@ -205,6 +309,47 @@ export default function KbListPage() { } }; + const applyQuery = () => { + const normalized = queryInput.trim(); + setQuery(normalized); + router.replace(normalized ? `/kb?q=${encodeURIComponent(normalized)}` : "/kb"); + }; + + const applyQuickQuery = (value: string) => { + setQueryInput(value); + setQuery(value); + router.replace(`/kb?q=${encodeURIComponent(value)}`); + }; + + const claimWeeklyBonus = async () => { + if (!userToken || !weeklyPlan) return; + setClaimWeeklyLoading(true); + setWeeklyError(""); + setWeeklyHint(""); + try { + const result = await apiFetch( + "/api/v1/kb/weekly-bonus/claim", + { method: "POST", body: JSON.stringify({}) }, + userToken + ); + if (result.claimed) { + setWeeklyHint( + tx( + `领取成功:+${result.reward} 周奖励积分(当前积分 ${result.rating_after})`, + `Claimed: +${result.reward} weekly bonus (rating ${result.rating_after})` + ) + ); + } else { + setWeeklyHint(tx("本周奖励已领取过。", "Weekly bonus already claimed.")); + } + await loadWeeklyPlan(); + } catch (e: unknown) { + setWeeklyError(String(e)); + } finally { + setClaimWeeklyLoading(false); + } + }; + return (
@@ -232,11 +377,38 @@ export default function KbListPage() { )}

- {tx( - "已整理 C++ 基础、CSP-J、CSP-S 学习资料,可按阶段逐步学习。", - "Curated learning materials for C++ fundamentals, CSP-J, and CSP-S." - )} + + {tx("按路线学习并完成每周任务。", "Follow the roadmap and finish weekly tasks.")} + + {tx( + "这里整合了 C++ 基础、CSP-J、CSP-S 与工程协作资料。建议先从路线图开始,再按每周任务推进,学习后去做对应题目巩固。", + "This hub covers C++ basics, CSP-J/S, and engineering collaboration. Start with the roadmap, follow weekly tasks, then solve related problems for reinforcement." + )} + +

+
+ setQueryInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") applyQuery(); + }} + /> + +
+
+ + + + + + +

{canManageRefresh ? tx("更新状态:", "Refresh status:") @@ -253,13 +425,227 @@ export default function KbListPage() { {loading &&

{tx("加载中...", "Loading...")}

} {error &&

{error}

} {hint &&

{hint}

} + {query && ( +

+ {tx("当前筛选:", "Current filter: ")} {query} +

+ )} + +
+
+
+ +

+ {tx("本周学习任务", "Weekly Learning Plan")} +

+ + {tx( + "周任务会自动生成。完成全部任务后可领取 100% 奖励。若任务有前置依赖,需先完成前置知识点。", + "Weekly tasks are generated automatically. Complete all tasks to claim the 100% bonus. If a task has prerequisites, finish those first." + )} + +
+ {weeklyPlan && ( + + {tx("周起始", "Week")} {weeklyPlan.week_key} + + )} +
+ + {!userToken && ( +
+ {tx("登录后可查看每周任务与 100% 完成奖励。", "Sign in to see weekly tasks and 100% completion bonus.")} + + {tx("去登录", "Sign in")} + +
+ )} + + {userToken && weeklyLoading && ( +

+ {tx("周任务加载中...", "Loading weekly tasks...")} +

+ )} + + {userToken && !weeklyLoading && weeklyError && ( +

{weeklyError}

+ )} + + {userToken && !weeklyLoading && !weeklyError && weeklyPlan && ( +
+
+
+ + {tx("完成度", "Progress")}:{" "} + + {weeklyPlan.completion_percent}% + + + + {tx("任务积分", "Task Reward")}:{" "} + + {weeklyPlan.gained_reward}/{weeklyPlan.total_reward} + + + + {tx("周奖励", "Bonus")}:{" "} + + +{weeklyPlan.bonus_reward} + + +
+
+
+
+
+
+ {weeklyPlan.bonus_claimed + ? tx("本周 100% 奖励已领取", "100% weekly bonus already claimed") + : tx("完成所有任务后可领取 100% 奖励", "Complete all tasks to claim 100% bonus")} +
+ +
+ {weeklyHint && ( +

+ {weeklyHint} +

+ )} +
+ +
+ {weeklyPlan.tasks.map((task) => ( +
+
+
+

+ {task.completed && ( + + )} + {task.knowledge_title} +

+

+ {task.article_title} +

+
+
+ + {task.difficulty} + + + +{task.reward} + +
+
+

+ {task.knowledge_description} +

+ {task.prerequisites?.length > 0 && ( +

+ {tx("前置:", "Prerequisites: ")} + {task.prerequisites.join(", ")} +

+ )} +
+ + {task.completed + ? tx("状态:已完成", "Status: Completed") + : tx("状态:待完成", "Status: Pending")} + + + {tx("去学习", "Study")} + +
+
+ ))} +
+
+ )} +
{[ ["roadmap", tx("学习总路线", "Learning Roadmap"), MapIcon], ["cpp", tx("C++ 基础", "C++ Fundamentals"), Code2], + ["web", tx("Web 开发(C++)", "Web Dev (C++)"), Library], + ["game", tx("游戏开发(C++)", "Game Dev (C++)"), Library], ["cspj", "CSP-J", Sword], ["csps", "CSP-S", Shield], + ["github", tx("GitHub 协作", "GitHub Collaboration"), Library], + ["linux", tx("Linux 服务器", "Linux Server"), Library], + ["csfund", tx("计算机基础", "Computer Fundamentals"), Library], ["other", tx("其他资料", "Other Resources"), FileQuestion], ].map(([key, label, Icon]) => { const group = grouped[key as string] ?? []; @@ -271,16 +657,22 @@ export default function KbListPage() { {label as string} {group.map((a) => ( - -

{a.title}

-

- slug: {a.slug} · {new Date(a.created_at * 1000).toLocaleString()} -

- +
+ +

{a.title}

+

+ slug: {a.slug} · {formatUnixDateTime(a.created_at)} +

+ +
+ + {tx("阅读知识", "Read")} + + + {tx("做相关任务", "Related Problems")} + +
+
))} ); diff --git a/frontend/src/app/leaderboard/page.tsx b/frontend/src/app/leaderboard/page.tsx index 726a4ca..d317e56 100644 --- a/frontend/src/app/leaderboard/page.tsx +++ b/frontend/src/app/leaderboard/page.tsx @@ -1,9 +1,12 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { HintTip } from "@/components/hint-tip"; import { apiFetch } from "@/lib/api"; +import { readToken } from "@/lib/auth"; import { useI18nText } from "@/lib/i18n"; +import { formatUnixDateTime } from "@/lib/time"; import { useUiPreferences } from "@/components/ui-preference-provider"; import { Crown, Medal, Trophy, User, Calendar } from "lucide-react"; @@ -11,9 +14,12 @@ type Row = { user_id: number; username: string; rating: number; + period_score: number; created_at: number; }; +type Scope = "all" | "week" | "today"; + export default function LeaderboardPage() { const { tx } = useI18nText(); const { theme } = useUiPreferences(); @@ -21,13 +27,23 @@ export default function LeaderboardPage() { const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const [scope, setScope] = useState("all"); + const [meId, setMeId] = useState(null); + + useEffect(() => { + const token = readToken(); + if (!token) return; + void apiFetch<{ id?: number }>("/api/v1/me", {}, token) + .then((me) => setMeId(typeof me?.id === "number" ? me.id : null)) + .catch(() => setMeId(null)); + }, []); useEffect(() => { const load = async () => { setLoading(true); setError(""); try { - const data = await apiFetch("/api/v1/leaderboard/global?limit=200"); + const data = await apiFetch(`/api/v1/leaderboard/global?limit=200&scope=${scope}`); setItems(data); } catch (e: unknown) { setError(String(e)); @@ -36,14 +52,14 @@ export default function LeaderboardPage() { } }; void load(); - }, []); + }, [scope]); const getRankColor = (index: number) => { if (!isMc) return ""; switch (index) { - case 0: return "text-[color:var(--mc-gold)] drop-shadow-sm"; // Gold - case 1: return "text-zinc-300"; // Iron - case 2: return "text-orange-700"; // Copper + case 0: return "text-[color:var(--mc-gold)] drop-shadow-sm"; + case 1: return "text-zinc-300"; + case 2: return "text-orange-700"; default: return "text-zinc-400"; } }; @@ -58,6 +74,32 @@ export default function LeaderboardPage() { } }; + const scoreLabel = useMemo( + () => (scope === "all" ? tx("总 XP", "Total XP") : scope === "week" ? tx("本周 XP", "Weekly XP") : tx("今日 XP", "Today XP")), + [scope, tx] + ); + + const currentItems = useMemo( + () => + items.map((item) => ({ + ...item, + boardScore: scope === "all" ? item.rating : item.period_score, + })), + [items, scope] + ); + + const meGap = useMemo(() => { + if (meId == null) return null; + const idx = currentItems.findIndex((item) => item.user_id === meId); + if (idx <= 0) return null; + const me = currentItems[idx]; + const prev = currentItems[idx - 1]; + return { + rank: idx + 1, + gap: Math.max(1, prev.boardScore - me.boardScore), + }; + }, [currentItems, meId]); + return (

@@ -70,27 +112,56 @@ export default function LeaderboardPage() { tx("全站排行榜", "Global Leaderboard") )}

+
+

{tx("切换时间范围查看排行。", "Switch scope to compare rankings.")}

+ +
    +
  • {tx("总榜按总 Rating 排序。", "All-time board is ranked by total rating.")}
  • +
  • {tx("本周/今日按周期增量 XP 排序。", "Week/today boards use period XP gain.")}
  • +
  • {tx("提示会显示你与上一名的差距。", "Gap hint shows distance to the player above you.")}
  • +
+
+
+ +
+ + + +
+ + {meGap && ( +

+ {tx(`你当前第 ${meGap.rank} 名,距离上一名还差 ${meGap.gap} ${scoreLabel}。`, `You are #${meGap.rank}, ${meGap.gap} ${scoreLabel} behind the next player.`)} +

+ )} + {loading &&

{tx("正在读取卷轴...", "Reading scrolls...")}

} {error &&

{error}

}
- {items.map((row, i) => ( + {currentItems.map((row, i) => (

{getRankIcon(i)} {row.username}

- {row.rating} + {row.boardScore}

{tx("注册时间:", "Registered: ")} - {new Date(row.created_at * 1000).toLocaleString()} + {formatUnixDateTime(row.created_at)}

))} - {!loading && items.length === 0 && ( + {!loading && currentItems.length === 0 && (

{tx("暂无数据", "No legends yet")}

@@ -108,6 +179,7 @@ export default function LeaderboardPage() { {tx("用户", "User")}
+ {scoreLabel} Rating
@@ -118,19 +190,20 @@ export default function LeaderboardPage() { - {items.map((row, i) => ( + {currentItems.map((row, i) => ( - {getRankIcon(i)} + {getRankIcon(i)} {row.username} - {row.rating} + {row.boardScore} + {row.rating} - {new Date(row.created_at * 1000).toLocaleString()} + {formatUnixDateTime(row.created_at)} ))} - {!loading && items.length === 0 && ( + {!loading && currentItems.length === 0 && ( - + {tx("暂无数据", "No legends yet")} diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..f4c7b8d --- /dev/null +++ b/frontend/src/app/login/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function LoginPage() { + redirect("/auth"); +} diff --git a/frontend/src/app/me/page.tsx b/frontend/src/app/me/page.tsx index c62a19f..c283b24 100644 --- a/frontend/src/app/me/page.tsx +++ b/frontend/src/app/me/page.tsx @@ -1,23 +1,29 @@ "use client"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; import { ArrowRightLeft, Calendar, CheckCircle2, + CircleMinus, History, IdCard, RefreshCw, ShoppingBag, + LogOut, TrendingUp, TrendingDown, Zap, } from "lucide-react"; +import { HintTip } from "@/components/hint-tip"; import { PixelAvatar } from "@/components/pixel-avatar"; +import { SourceCrystalIcon } from "@/components/source-crystal-icon"; import { apiFetch, listRatingHistory, type RatingHistoryItem } from "@/lib/api"; -import { readToken } from "@/lib/auth"; +import { clearToken, readToken } from "@/lib/auth"; import { useI18nText } from "@/lib/i18n"; +import { dayKeyInShanghai, dayKeySerial, formatUnixDate, formatUnixDateTime, serialToDayKey } from "@/lib/time"; type Me = { id: number; @@ -26,10 +32,6 @@ type Me = { created_at: number; }; -// Use a distinct style for inputs/selects -const inputClass = "w-full bg-[color:var(--mc-surface)] text-[color:var(--mc-plank-light)] border-2 border-[color:var(--mc-stone-dark)] px-3 py-2 text-base focus:border-[color:var(--mc-gold)] focus:outline-none transition-colors font-minecraft"; -const labelClass = "text-sm text-[color:var(--mc-stone)] mb-1 block"; - type RedeemItem = { id: number; name: string; @@ -57,6 +59,53 @@ type RedeemCreateResp = RedeemRecord & { rating_after?: number; }; +type RedeemDayTypeInfo = { + day_type: "holiday" | "studyday"; + is_holiday: boolean; + reason: string; + source: string; + date_ymd: string; + checked_at: number; +}; + +type SourceCrystalSummary = { + user_id: number; + balance: number; + monthly_interest_rate: number; + last_interest_at: number; + updated_at: number; +}; + +type SourceCrystalRecord = { + id: number; + user_id: number; + tx_type: "deposit" | "withdraw" | "interest" | string; + amount: number; + balance_after: number; + note: string; + created_at: number; +}; + +type ExperienceSummary = { + user_id: number; + experience: number; + level: number; + current_level_base: number; + next_level_experience: number; + updated_at: number; +}; + +type ExperienceHistoryItem = { + id: number; + user_id: number; + xp_delta: number; + rating_before: number; + rating_after: number; + source: string; + note: string; + created_at: number; +}; + type DailyTaskItem = { code: string; title: string; @@ -74,8 +123,12 @@ type DailyTaskPayload = { }; function fmtTs(v: number | null | undefined): string { - if (!v) return "-"; - return new Date(v * 1000).toLocaleString(); + return formatUnixDateTime(v); +} + +function fmtCrystal(v: number | null | undefined): string { + if (typeof v !== "number" || !Number.isFinite(v)) return "0.00"; + return v.toFixed(2); } function resolveRank(rating: number): { label: string; color: string; icon: string } { @@ -86,30 +139,76 @@ function resolveRank(rating: number): { label: string; color: string; icon: stri return { label: "Wood", color: "text-[color:var(--mc-wood)]", icon: "🪵" }; } +function calcLearningStreak(items: RatingHistoryItem[]): number { + const daySet = new Set( + items + .filter((item) => item.type === "daily_task" && item.change > 0) + .map((item) => dayKeyInShanghai(item.created_at)) + .filter((key) => key.length > 0) + ); + if (daySet.size === 0) return 0; + + const nowSec = Math.floor(Date.now() / 1000); + const todaySerial = dayKeySerial(dayKeyInShanghai(nowSec)); + if (todaySerial == null) return 0; + + let cursor = todaySerial; + const todayKey = serialToDayKey(cursor); + if (!daySet.has(todayKey)) { + const yesterday = cursor - 1; + const yesterdayKey = serialToDayKey(yesterday); + if (!daySet.has(yesterdayKey)) return 0; + cursor = yesterday; + } + + let streak = 0; + while (true) { + const key = serialToDayKey(cursor); + if (!daySet.has(key)) break; + streak += 1; + cursor -= 1; + } + return streak; +} + export default function MePage() { const { isZh, tx } = useI18nText(); + const router = useRouter(); const [token, setToken] = useState(""); const [profile, setProfile] = useState(null); const [items, setItems] = useState([]); const [records, setRecords] = useState([]); const [historyItems, setHistoryItems] = useState([]); + const [ratingHistoryTypeFilter, setRatingHistoryTypeFilter] = useState("all"); + const [tradeTypeFilter, setTradeTypeFilter] = useState("all"); const [dailyTasks, setDailyTasks] = useState([]); const [dailyDayKey, setDailyDayKey] = useState(""); const [dailyTotalReward, setDailyTotalReward] = useState(0); const [dailyGainedReward, setDailyGainedReward] = useState(0); + const [learningStreak, setLearningStreak] = useState(0); + const [redeemDayType, setRedeemDayType] = useState(null); + const [sourceCrystal, setSourceCrystal] = useState(null); + const [sourceCrystalRecords, setSourceCrystalRecords] = useState([]); + const [experience, setExperience] = useState(null); + const [experienceHistory, setExperienceHistory] = useState([]); + const [experienceHistoryOpen, setExperienceHistoryOpen] = useState(false); + const [experienceHistoryLoading, setExperienceHistoryLoading] = useState(false); const [selectedItemId, setSelectedItemId] = useState(0); const [quantity, setQuantity] = useState(1); - const [dayType, setDayType] = useState<"holiday" | "studyday">("holiday"); const [note, setNote] = useState(""); + const [crystalAmount, setCrystalAmount] = useState("10"); + const [crystalNote, setCrystalNote] = useState(""); const [loading, setLoading] = useState(false); const [redeemLoading, setRedeemLoading] = useState(false); + const [crystalLoading, setCrystalLoading] = useState(false); // Toast notification system const [toast, setToast] = useState<{ type: "success" | "error"; text: string } | null>(null); const [toastVisible, setToastVisible] = useState(false); const toastTimer = useRef>(undefined); + const lastCompletedTaskCountRef = useRef(-1); const showToast = useCallback((type: "success" | "error", text: string) => { if (toastTimer.current) clearTimeout(toastTimer.current); @@ -134,22 +233,6 @@ export default function MePage() { [items, selectedItemId] ); - const unitCost = useMemo(() => { - if (!selectedItem) return 0; - return dayType === "holiday" ? selectedItem.holiday_cost : selectedItem.studyday_cost; - }, [dayType, selectedItem]); - - const totalCost = useMemo(() => Math.max(0, unitCost * Math.max(1, quantity)), [quantity, unitCost]); - - const taskTitle = (task: DailyTaskItem): string => { - if (isZh) return task.title; - if (task.code === "login_checkin") return "Daily Sign-in"; - if (task.code === "daily_submit") return "Daily Submission"; - if (task.code === "first_ac") return "First Blood"; - if (task.code === "code_quality") return "Craftsman"; - return task.title; - }; - const itemName = (name: string): string => { if (isZh) return name; if (name === "私人玩游戏时间") return "Game Time pass"; @@ -201,21 +284,30 @@ export default function MePage() { setToken(tk); if (!tk) throw new Error(tx("请先登录", "Please sign in first")); - const [me, redeemItems, redeemRecords, daily, history] = await Promise.all([ + const [me, redeemItems, redeemRecords, daily, history, dayTypeInfo, crystalSummary, crystalRows, expSummary] = await Promise.all([ apiFetch("/api/v1/me", {}, tk), apiFetch("/api/v1/me/redeem/items", {}, tk), apiFetch("/api/v1/me/redeem/records?limit=200", {}, tk), apiFetch("/api/v1/me/daily-tasks", {}, tk), listRatingHistory(50, tk), + apiFetch("/api/v1/me/redeem/day-type", {}, tk), + apiFetch("/api/v1/me/source-crystal", {}, tk), + apiFetch("/api/v1/me/source-crystal/records?limit=200", {}, tk), + apiFetch("/api/v1/me/experience", {}, tk), ]); setProfile(me); setItems(redeemItems ?? []); setRecords(redeemRecords ?? []); setHistoryItems(history ?? []); + setLearningStreak(calcLearningStreak(history ?? [])); setDailyTasks(daily?.tasks ?? []); setDailyDayKey(daily?.day_key ?? ""); setDailyTotalReward(daily?.total_reward ?? 0); setDailyGainedReward(daily?.gained_reward ?? 0); + setRedeemDayType(dayTypeInfo ?? null); + setSourceCrystal(crystalSummary ?? null); + setSourceCrystalRecords(crystalRows ?? []); + setExperience(expSummary ?? null); if ((redeemItems ?? []).length > 0) { setSelectedItemId((prev) => prev || redeemItems[0].id); @@ -227,11 +319,54 @@ export default function MePage() { } }; + const toggleExperienceHistory = async () => { + const nextOpen = !experienceHistoryOpen; + setExperienceHistoryOpen(nextOpen); + if (!nextOpen || experienceHistory.length > 0) return; + try { + const tk = token || readToken(); + if (!tk) throw new Error(tx("请先登录", "Please sign in first")); + setExperienceHistoryLoading(true); + const rows = await apiFetch( + "/api/v1/me/experience/history?limit=200", + {}, + tk + ); + setExperienceHistory(rows ?? []); + } catch (e: unknown) { + showToast("error", String(e)); + } finally { + setExperienceHistoryLoading(false); + } + }; + useEffect(() => { void loadAll(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + setLearningStreak(calcLearningStreak(historyItems)); + }, [historyItems]); + + useEffect(() => { + const completedCount = dailyTasks.filter((task) => task.completed).length; + if (lastCompletedTaskCountRef.current < 0) { + lastCompletedTaskCountRef.current = completedCount; + return; + } + if (completedCount > lastCompletedTaskCountRef.current) { + const gained = Math.max(0, dailyGainedReward); + showToast( + "success", + isZh + ? `🎉 每日任务推进!当前奖励 ${gained}/${dailyTotalReward} XP` + : `🎉 Daily quest progressed! Reward ${gained}/${dailyTotalReward} XP` + ); + } + lastCompletedTaskCountRef.current = completedCount; + }, [dailyGainedReward, dailyTasks, dailyTotalReward, isZh, showToast]); + const redeem = async () => { setRedeemLoading(true); try { @@ -248,7 +383,6 @@ export default function MePage() { body: JSON.stringify({ item_id: selectedItemId, quantity, - day_type: dayType, note, }), }, @@ -269,13 +403,190 @@ export default function MePage() { } }; + const withdrawSourceCrystal = async () => { + setCrystalLoading(true); + try { + if (!token) throw new Error(tx("请先登录", "Please sign in first")); + const amount = Number(crystalAmount); + if (!Number.isFinite(amount) || amount <= 0) { + throw new Error(tx("源晶数量必须大于 0", "Source crystal amount must be greater than 0")); + } + + const payload = { amount, note: crystalNote }; + await apiFetch( + "/api/v1/me/source-crystal/withdraw", + { + method: "POST", + body: JSON.stringify(payload), + }, + token + ); + + showToast( + "success", + tx(`已支出 ${fmtCrystal(amount)} 源晶`, `Spent ${fmtCrystal(amount)} Source Crystals`) + ); + setCrystalNote(""); + await loadAll(); + } catch (e: unknown) { + showToast("error", String(e)); + } finally { + setCrystalLoading(false); + } + }; + + const logout = useCallback(() => { + const confirmed = window.confirm( + tx( + "确认断开连接并退出当前账号?", + "Disconnect and sign out from current account?" + ) + ); + if (!confirmed) return; + + clearToken(); + setToken(""); + setProfile(null); + setItems([]); + setRecords([]); + setHistoryItems([]); + setDailyTasks([]); + setDailyDayKey(""); + setDailyTotalReward(0); + setDailyGainedReward(0); + setRedeemDayType(null); + setSourceCrystal(null); + setSourceCrystalRecords([]); + setExperience(null); + setExperienceHistory([]); + setExperienceHistoryOpen(false); + showToast( + "success", + tx("已断开连接并退出登录。", "Disconnected and signed out.") + ); + router.replace("/auth"); + }, [router, showToast, tx]); + const rank = resolveRank(profile?.rating ?? 0); + const expValue = experience?.experience ?? 0; + const expLevel = experience?.level ?? 1; + const expCurrentBase = experience?.current_level_base ?? 0; + const expNext = experience?.next_level_experience ?? 100; + const expProgress = Math.max( + 0, + Math.min(1, (expValue - expCurrentBase) / Math.max(1, expNext - expCurrentBase)) + ); + const expToNext = Math.max(0, expNext - expValue); + const currentRedeemDayType = redeemDayType?.day_type === "holiday" ? "holiday" : "studyday"; + const sourceCrystalBalance = sourceCrystal?.balance ?? 0; + const sourceCrystalMonthlyRate = sourceCrystal?.monthly_interest_rate ?? 0; + const sourceCrystalEstimatedMonthlyInterest = Math.max(0, sourceCrystalBalance * sourceCrystalMonthlyRate); + const sectionTitleClass = + "text-lg font-extrabold text-[color:var(--mc-gold)] drop-shadow-sm tracking-wide"; + const sectionIconClass = "text-[color:var(--mc-diamond)]"; + const crystalTxLabel = (txType: string): string => { + if (txType === "deposit") return tx("存入", "Deposit"); + if (txType === "withdraw") return tx("取出", "Withdraw"); + if (txType === "interest") return tx("月息", "Interest"); + return txType; + }; + const ratingTypeLabel = (type: string): string => { + if (type === "daily_task") return tx("每日任务", "Daily Task"); + if (type === "redeem") return tx("兑换消费", "Redeem"); + if (type === "solution_view") return tx("题解查看", "Solution View"); + if (type === "kb_skill") return tx("知识库奖励", "KB Reward"); + return type; + }; + const tradeTypeLabel = (type: string): string => { + if (type === "studyday") return tx("学习日", "Study Day"); + if (type === "holiday") return tx("假期", "Holiday"); + if (type === "unknown") return tx("未标注", "Unknown"); + return type; + }; + const ratingHistoryTypes = useMemo( + () => Array.from(new Set(historyItems.map((item) => item.type).filter((v) => v.length > 0))), + [historyItems] + ); + const filteredHistoryItems = useMemo( + () => + historyItems.filter( + (item) => ratingHistoryTypeFilter === "all" || item.type === ratingHistoryTypeFilter + ), + [historyItems, ratingHistoryTypeFilter] + ); + const tradeTypes = useMemo( + () => + Array.from( + new Set(records.map((row) => (row.day_type && row.day_type.length > 0 ? row.day_type : "unknown"))) + ), + [records] + ); + const filteredTradeRecords = useMemo( + () => + records.filter((row) => { + const rowType = row.day_type && row.day_type.length > 0 ? row.day_type : "unknown"; + return tradeTypeFilter === "all" || rowType === tradeTypeFilter; + }), + [records, tradeTypeFilter] + ); + const achievementItems = useMemo(() => { + const hasFirstAc = historyItems.some((item) => item.type === "daily_task" && item.note === "first_ac"); + const hasSubmit = historyItems.some((item) => item.type === "daily_task" && item.note === "daily_submit"); + return [ + { + key: "workbench", + icon: "🧰", + label: tx("工作台", "Workbench"), + unlock: hasSubmit || hasFirstAc, + hint: tx("完成一次提交", "Complete one submission"), + }, + { + key: "torch", + icon: "🕯️", + label: tx("火把", "Torch"), + unlock: learningStreak >= 3, + hint: tx("连续学习 3 天", "Study 3 days in a row"), + }, + { + key: "compass", + icon: "🧭", + label: tx("指南针", "Compass"), + unlock: historyItems.length >= 10, + hint: tx("累计 10 次成长记录", "Collect 10 progress logs"), + }, + { + key: "iron-pickaxe", + icon: "⛏️", + label: tx("铁镐", "Iron Pickaxe"), + unlock: hasFirstAc, + hint: tx("首次通过题目", "Get first AC"), + }, + ]; + }, [historyItems, learningStreak, tx]); return (
-

- {tx("冒险者档案 & 交易站", "Character Sheet & Trading Post")} -

+
+

+ {tx("冒险者档案 & 交易站", "Character Sheet & Trading Post")} +

+ + {tx( + "这里汇总每日任务、成长记录与交易记录。建议优先完成每日任务,再按需求在交易站兑换物品。", + "This page combines daily tasks, growth history, and trade records. Complete daily tasks first, then redeem items in the trading post when needed." + )} + + +
{loading &&

{tx("读取存档中...", "Loading Save...")}

} {/* Toast notification */} @@ -349,6 +660,38 @@ export default function MePage() { {tx("下一等级", "Next Lv")}
{100 - (profile.rating % 100)} XP + +
+ 🔥 + {tx("连学", "Streak")} +
+ + {learningStreak} {tx("天", "days")} + + +
+ + {tx("经验值", "Experience")} +
+ {expValue} + +
+ + {tx("经验等级", "XP Level")} +
+ Lv.{expLevel} +
+ +
+
+
+
+

+ {tx("下一级经验", "XP to next")}: {expToNext} +

@@ -366,7 +709,32 @@ export default function MePage() { {tx("加入时间", "Joined")} - {new Date(profile.created_at * 1000).toLocaleDateString()} + {formatUnixDate(profile.created_at)} +
+ + +
+
+

+ + {tx("源晶账户", "Source Crystal Account")} +

+

+ {fmtCrystal(sourceCrystalBalance)} {tx("源晶", "SC")} +

+

+ {tx("月利率", "Monthly Interest")}: {(sourceCrystalMonthlyRate * 100).toFixed(2)}% ·{" "} + {tx("预计月息", "Est. monthly interest")}: +{fmtCrystal(sourceCrystalEstimatedMonthlyInterest)} +

+

+ {tx("上次计息", "Last interest update")}: {fmtTs(sourceCrystal?.last_interest_at)} +

+

+ {tx( + "仅管理员可在管理页为你存入源晶;你可在此自行支出并填写备注。", + "Only admin can deposit Source Crystals for you; you can spend here with notes." + )} +

@@ -375,9 +743,9 @@ export default function MePage() {
{/* Daily Tasks */}
-

+

每日悬赏任务 - 进度: {dailyGainedReward} / {dailyTotalReward} XP + 进度: {dailyGainedReward} / {dailyTotalReward} XP · 🔥 {learningStreak}d · {dailyDayKey || "--"}

@@ -407,8 +775,33 @@ export default function MePage() { ))}
+ +
+

+ {tx("物品图鉴", "Item Collection")} +

+
+ {achievementItems.map((item) => ( +
+

+ {item.icon} + {item.label} + {item.unlock ? : null} +

+

{item.hint}

+
+ ))} +
+
-

+

💎 村民交易站 消耗: RATING @@ -445,20 +838,27 @@ export default function MePage() {

{selectedItem.description}

- 单价: {dayType === 'holiday' ? selectedItem.holiday_cost : selectedItem.studyday_cost} Rating / {selectedItem.unit_label} + {tx("单价", "Unit cost")}:{" "} + {currentRedeemDayType === "holiday" + ? selectedItem.holiday_cost + : selectedItem.studyday_cost}{" "} + Rating / {selectedItem.unit_label} +

+

+ {currentRedeemDayType === "holiday" + ? tx("今日判定:假期", "Today: Holiday") + : tx("今日判定:学习日", "Today: Study Day")} + {redeemDayType?.reason ? ` · ${redeemDayType.reason}` : ""}

)}
- +
+ {currentRedeemDayType === "holiday" + ? tx("自动使用假期价格", "Auto using holiday price") + : tx("自动使用学习日价格", "Auto using study-day price")} +
)} +
+
+

+ + {tx("源晶支出与流水", "Source Crystal Spend & History")} +

+
+ +
+ setCrystalAmount(e.target.value)} + placeholder={tx("数量", "Amount")} + /> + setCrystalNote(e.target.value)} + placeholder={tx("备注(可选)", "Note (optional)")} + /> + +
+ +
+ {sourceCrystalRecords.map((row) => ( +
+ + = 0 ? "text-emerald-700" : "text-red-600"}`}> + {row.amount >= 0 ? "+" : ""} + {fmtCrystal(row.amount)} {tx("源晶", "SC")} + + {crystalTxLabel(row.tx_type)} + {row.note ? · {row.note} : null} + + + {tx("余额", "Bal")}: {fmtCrystal(row.balance_after)} · {fmtTs(row.created_at)} + +
+ ))} + {!loading && sourceCrystalRecords.length === 0 && ( +

{tx("暂无源晶流水。", "No source crystal records yet.")}

+ )} +
+
+ +
+
+

+ + {tx("经验值系统", "Experience")} +

+ +
+

+ {tx( + "规则:1 经验值 = 1 Rating 增量;消费 Rating 不会减少经验值。", + "Rule: 1 XP = 1 rating gain; spending rating never decreases XP." + )} +

+
+

+ {tx("当前经验", "Current XP")}: {expValue} +

+

+ {tx("当前等级", "Level")}: Lv.{expLevel} +

+
+
+
+

+ {expValue} / {expNext} {tx("(下一级)", "(next level)")} +

+
+ + {experienceHistoryOpen && ( +
+ {experienceHistoryLoading && ( +

{tx("加载经验历史中...", "Loading XP history...")}

+ )} + {!experienceHistoryLoading && experienceHistory.map((row) => ( +
+ + +{row.xp_delta} XP + + {tx("Rating", "Rating")} {row.rating_before} → {row.rating_after} + + {row.note ? · {row.note} : null} + + {fmtTs(row.created_at)} +
+ ))} + {!experienceHistoryLoading && experienceHistory.length === 0 && ( +

{tx("暂无经验历史。", "No XP history yet.")}

+ )} +
+ )} +
+ {/* Rating History Section */}
-

- - {tx("积分变动记录", "Rating History")} -

-
- {historyItems.map((item, idx) => ( -
+
+

+ + {tx("积分变动记录", "Rating History")} +

+ +
+
+ {filteredHistoryItems.map((item, idx) => ( +
0 ? 'text-[color:var(--mc-green)]' : 'text-[color:var(--mc-red)]'}`}> {item.change > 0 ? : } {item.change > 0 ? `+${item.change}` : item.change} - {formatRatingNote(item.note, item.type)} + {formatRatingNote(item.note, item.type)} + + {ratingTypeLabel(item.type)} + - - {new Date(item.created_at * 1000).toLocaleString()} + + {formatUnixDateTime(item.created_at)}
))} - {!loading && historyItems.length === 0 && ( + {!loading && filteredHistoryItems.length === 0 && (

{tx("暂无记录。", "No history.")}

)}
@@ -504,29 +1044,52 @@ export default function MePage() { {/* Trades Section */}
-

{tx("交易记录", "Trade History")}

- +

+ + {tx("交易记录", "Trade History")} +

+
+ + +
-
- {records.map((row) => ( -
- +
+ {filteredTradeRecords.map((row) => ( +
+ {itemName(row.item_name)} × {row.quantity} + + {tradeTypeLabel(row.day_type && row.day_type.length > 0 ? row.day_type : "unknown")} + - - -{row.total_cost} Gems · {new Date(row.created_at * 1000).toLocaleDateString()} + + -{row.total_cost} Gems · {formatUnixDate(row.created_at)}
))} - {!loading && records.length === 0 && ( + {!loading && filteredTradeRecords.length === 0 && (

{tx("暂无交易。", "No trades.")}

)}
diff --git a/frontend/src/app/problems/[id]/page.tsx b/frontend/src/app/problems/[id]/page.tsx index 170efc8..1fc7529 100644 --- a/frontend/src/app/problems/[id]/page.tsx +++ b/frontend/src/app/problems/[id]/page.tsx @@ -1,11 +1,14 @@ "use client"; +import NextImage from "next/image"; import Link from "next/link"; import { useParams } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; import { CodeEditor } from "@/components/code-editor"; +import { HintTip } from "@/components/hint-tip"; import { MarkdownRenderer } from "@/components/markdown-renderer"; +import { PageCrumbs } from "@/components/page-crumbs"; import { apiFetch } from "@/lib/api"; import { readToken } from "@/lib/auth"; import { type Cpp14PolicyIssue } from "@/lib/cpp14-policy"; @@ -77,29 +80,12 @@ function normalizeCodeText(raw: string): string { return `${merged}\n`; } -function codeLineCount(raw: string): number { - const text = normalizeCodeText(raw); - if (!text) return 0; - return text.split("\n").length - 1; -} - -function countCompileWarnings(log: string): number { - if (!log) return 0; - const lines = log.split("\n"); - return lines.filter((line) => line.toLowerCase().includes("warning:")).length; -} - function countCompileErrors(log: string): number { if (!log) return 0; const lines = log.split("\n"); return lines.filter((line) => line.toLowerCase().includes("error:")).length; } -function scoreRatio(score: number): number { - if (!Number.isFinite(score)) return 0; - return Math.max(0, Math.min(100, score)); -} - function buildDraftSignature(code: string, stdinText: string): string { const normCode = normalizeCodeText(code); const normStdin = (stdinText ?? "").replace(/\r\n?/g, "\n"); @@ -280,6 +266,10 @@ int main() { const defaultRunInput = ``; +function sleep(ms: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + function difficultyIcon(diff: number): string { if (diff <= 2) return "🪵"; if (diff <= 4) return "🪨"; @@ -296,6 +286,8 @@ export default function ProblemDetailPage() { const [problem, setProblem] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const [reloadNonce, setReloadNonce] = useState(0); + const [mobilePanel, setMobilePanel] = useState<"statement" | "code">("statement"); const [code, setCode] = useState(starterCode); const [runInput, setRunInput] = useState(defaultRunInput); @@ -306,7 +298,6 @@ export default function ProblemDetailPage() { const [submitResp, setSubmitResp] = useState(null); const [runResp, setRunResp] = useState(null); const [draftMsg, setDraftMsg] = useState(""); - const [showPolicyTips, setShowPolicyTips] = useState(false); const [policyIssues, setPolicyIssues] = useState([]); const [policyMsg, setPolicyMsg] = useState(""); @@ -328,6 +319,7 @@ export default function ProblemDetailPage() { const [solutionData, setSolutionData] = useState(null); const [solutionMsg, setSolutionMsg] = useState(""); const [printAnswerMarkdown, setPrintAnswerMarkdown] = useState(""); + const outputAnchorRef = useRef(null); const draftLatestRef = useRef<{ code: string; stdin: string }>({ code: starterCode, stdin: defaultRunInput, @@ -438,7 +430,7 @@ export default function ProblemDetailPage() { method: "DELETE", body: JSON.stringify({ filename }), }, token); - if (Array.isArray((resp as any).note_images)) setNoteImages((resp as any).note_images); + if (Array.isArray(resp.note_images)) setNoteImages(resp.note_images); setNoteMsg(tx("图片已删除。", "Image deleted.")); } catch (e: unknown) { setNoteMsg(String(e)); @@ -457,7 +449,7 @@ export default function ProblemDetailPage() { await apiFetch<{ note: string }>(`/api/v1/me/wrong-book/${problemId}`, { method: "PATCH", body: JSON.stringify({ note: noteText }), - }, token); + }, token, { retryOnStatus: [500], retryCount: 10, retryDelayMs: 500 }); setNoteMsg(tx("笔记已保存。", "Notes saved.")); } catch (e: unknown) { setNoteMsg(String(e)); @@ -473,6 +465,15 @@ export default function ProblemDetailPage() { return; } if (!problemId) return; + if (!noteText.trim()) { + setNoteMsg( + tx( + "请先填写或保存学习笔记后再进行鉴定。", + "Write or save your learning note before appraising." + ) + ); + return; + } setNoteScoring(true); setNoteMsg(""); try { @@ -483,7 +484,7 @@ export default function ProblemDetailPage() { }>(`/api/v1/me/wrong-book/${problemId}/note-score`, { method: "POST", body: JSON.stringify({ note: noteText }), - }, token); + }, token, { retryOnStatus: [500], retryCount: 10, retryDelayMs: 500 }); setNoteScore(resp.note_score); setNoteRating(resp.note_rating); setNoteFeedback(resp.note_feedback_md || ""); @@ -533,11 +534,6 @@ export default function ProblemDetailPage() { () => policyIssues.filter((item) => item.severity === "warning").length, [policyIssues] ); - const policyHintCount = useMemo( - () => policyIssues.filter((item) => item.severity === "hint").length, - [policyIssues] - ); - const visiblePolicyIssues = useMemo(() => policyIssues.slice(0, 6), [policyIssues]); const hasSolutionAnswer = useMemo( () => Boolean(solutionData?.has_solutions), [solutionData] @@ -564,18 +560,6 @@ export default function ProblemDetailPage() { ], [tx] ); - const submitWarningCount = useMemo( - () => countCompileWarnings(submitResp?.compile_log ?? ""), - [submitResp?.compile_log] - ); - const submitErrorCount = useMemo( - () => countCompileErrors(submitResp?.compile_log ?? ""), - [submitResp?.compile_log] - ); - const runWarningCount = useMemo( - () => countCompileWarnings(runResp?.compile_log ?? ""), - [runResp?.compile_log] - ); const runErrorCount = useMemo( () => countCompileErrors(runResp?.compile_log ?? ""), [runResp?.compile_log] @@ -598,12 +582,29 @@ export default function ProblemDetailPage() { if (solutionStatusLoading) return "text-[color:var(--mc-stone)]"; return "text-[color:var(--mc-gold)]"; }, [hasSolutionAnswer, solutionStatusLoading]); + const isProblemNotFound = /problem not found|404/i.test(error); + const missionProgress = useMemo( + () => [ + { label: tx("运行", "Run"), done: Boolean(runResp) }, + { label: tx("提交", "Submit"), done: Boolean(submitResp) }, + { label: tx("笔记", "Notes"), done: noteText.trim().length >= 20 }, + ], + [noteText, runResp, submitResp, tx] + ); + const relatedKnowledge = useMemo(() => { + const raw = [...(llmProfile?.knowledge_points ?? []), ...(llmProfile?.tags ?? [])] + .map((item) => item.trim()) + .filter((item) => item.length > 0); + const uniq = Array.from(new Set(raw)); + return uniq.slice(0, 8); + }, [llmProfile?.knowledge_points, llmProfile?.tags]); useEffect(() => { setRunInput(defaultRunInput); setShowSolutions(false); setSolutionData(null); setSolutionMsg(""); + setMobilePanel("statement"); draftLatestRef.current = { code: starterCode, stdin: defaultRunInput }; draftLastSavedSigRef.current = buildDraftSignature(starterCode, defaultRunInput); }, [id]); @@ -646,16 +647,35 @@ export default function ProblemDetailPage() { setLoading(true); setError(""); try { - const data = await apiFetch(`/api/v1/problems/${id}`); - setProblem(data); + let loaded: Problem | null = null; + let lastErr: unknown = null; + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + loaded = await apiFetch(`/api/v1/problems/${id}`); + break; + } catch (e: unknown) { + lastErr = e; + if (attempt < 2) { + await sleep(300 * (2 ** attempt)); + } + } + } + if (!loaded) throw lastErr ?? new Error("load failed"); + setProblem(loaded); } catch (e: unknown) { - setError(String(e)); + const msg = String(e); + console.error("[problem-detail] load failed", { + problemId: id, + error: msg, + }); + setProblem(null); + setError(msg); } finally { setLoading(false); } }; void load(); - }, [id]); + }, [id, reloadNonce]); useEffect(() => { let cancelled = false; @@ -778,6 +798,7 @@ export default function ProblemDetailPage() { token ); setSubmitResp(resp); + setMobilePanel("code"); } catch (e: unknown) { setError(String(e)); } finally { @@ -796,6 +817,7 @@ export default function ProblemDetailPage() { body: JSON.stringify({ code: sourceCode, input: runInput }), }); setRunResp(resp); + setMobilePanel("code"); } catch (e: unknown) { setError(String(e)); } finally { @@ -970,18 +992,106 @@ export default function ProblemDetailPage() { window.print(); }; + const focusOutput = () => { + setMobilePanel("code"); + window.setTimeout(() => { + outputAnchorRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, 80); + }; + return (
-

- {tx("任务详情与试炼", "Mission Details")} -

+ +
+

+ {tx("任务详情与试炼", "Mission Details")} +

+ + {tx("返回任务板", "Back to Quest Board")} + +
+
+

{tx("题面、代码、评测输出都在本页完成。", "Solve, run, and review outputs on this page.")}

+ +
    +
  • {tx("支持自动保存草稿,默认每分钟检查并保存。", "Draft auto-save checks and persists changes every minute.")}
  • +
  • {tx("可提交学习笔记评分,60 分对应 +6 XP。", "Learning notes can be scored; 60 points yields +6 XP.")}
  • +
  • {tx("先知题解支持预览状态、生成、解锁与一键写入代码。", "Oracle solutions support preview, generation, unlock, and one-click code insert.")}
  • +
+
+
{loading &&

{tx("加载地图中...", "Loading Map...")}

} - {error &&

{error}

} + {!loading && error && !problem && ( +
+

+ {isProblemNotFound + ? tx("该任务不存在或已下线。", "This problem does not exist or was removed.") + : tx("任务加载失败。", "Failed to load this problem.")} +

+

{error}

+
+ + + {tx("返回任务板", "Back to Quest Board")} + +
+
+ )} + {!!error && !!problem &&

{error}

} {problem && ( -
-
+
+
+ {tx("任务简报", "Mission Brief")} +
+
+

{tx("本任务进度", "Mission Progress")}

+
+ {missionProgress.map((item) => ( + + {item.done ? "✅" : "⬜"} {item.label} + + ))} +
+
+ +
+ + +
+ +
+
+
+ {tx("阅读区", "Reading Zone")} +

{problem.title}

6 ? "text-[color:var(--mc-diamond)]" : @@ -994,6 +1104,22 @@ export default function ProblemDetailPage() { · {tx("来源", "Origin")}: {problem.source}
+ {relatedKnowledge.length > 0 && ( +
+

{tx("相关知识", "Related Knowledge")}

+
+ {relatedKnowledge.map((item) => ( + + {item} + + ))} +
+
+ )}
-
- - {showPolicyTips && ( -
-

{tx("考场生存指南:", "Survival Guide:")}

-
    + +

    {tx("考场生存指南", "Contest Survival Guide")}

    +
      {policyTips.map((tip, idx) => (
    • {tip}
    • ))}
    -
- )} + +
@@ -1037,17 +1154,20 @@ export default function ProblemDetailPage() { )}
-
+
-

📜 {tx("探索笔记(看完视频后记录)", "Explorer Notes (record after watching)")}

+

📜 {tx("探索笔记", "Explorer Notes")}

⚡ {tx("满分60 = 经验值+6", "Max 60 = +6 XP")} + +

{tx("建议记录:题意、思路、关键代码、踩坑与修复、复盘结论。", "Suggested structure: understanding, approach, key code, pitfalls/fixes, recap.")}

+