feat: 完成源晶权限与经验系统并优化 me/admin 交互

这个提交包含在:
cryptocommuniums-afk
2026-02-23 20:02:46 +08:00
父节点 2b6def2560
当前提交 43cbd38bac
修改 104 个文件,包含 13348 行新增776 行删除

查看文件

@@ -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
)

查看文件

@@ -13,6 +13,15 @@ class AdminController : public drogon::HttpController<AdminController> {
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<AdminController> {
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<AdminController> {
void updateUserRating(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t user_id);
void getUserSourceCrystalSummary(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t user_id);
void listUserSourceCrystalRecords(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t user_id);
void depositUserSourceCrystal(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t user_id);
void deleteUser(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t user_id);
@@ -59,6 +96,30 @@ class AdminController : public drogon::HttpController<AdminController> {
void listRedeemRecords(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void getSourceCrystalSettings(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void updateSourceCrystalSettings(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void createSeason(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void updateSeason(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t season_id);
void createContestModifier(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t contest_id);
void updateContestModifier(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t contest_id,
int64_t modifier_id);
void userRatingHistory(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t user_id);

查看文件

@@ -13,6 +13,7 @@ class ContestController : public drogon::HttpController<ContestController> {
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<ContestController> {
void leaderboard(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t contest_id);
void modifiers(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t contest_id);
};
} // namespace csp::controllers

查看文件

@@ -0,0 +1,41 @@
#pragma once
#include <drogon/HttpController.h>
namespace csp::controllers {
class CrawlerController : public drogon::HttpController<CrawlerController> {
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<void(const drogon::HttpResponsePtr&)>&& cb);
void createTarget(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void queueTarget(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t target_id);
void listRuns(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t target_id);
void status(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
};
} // namespace csp::controllers

查看文件

@@ -11,6 +11,10 @@ class KbController : public drogon::HttpController<KbController> {
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<KbController> {
void getArticle(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
std::string slug);
void listClaims(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
std::string slug);
void claimSkillPoint(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
std::string slug);
void weeklyPlan(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void claimWeeklyBonus(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
};
} // namespace csp::controllers

查看文件

@@ -0,0 +1,18 @@
#pragma once
#include <drogon/HttpController.h>
namespace csp::controllers {
class LarkController : public drogon::HttpController<LarkController> {
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<void(const drogon::HttpResponsePtr&)>&& cb);
};
} // namespace csp::controllers

查看文件

@@ -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<void(const drogon::HttpResponsePtr &)> &&cb);
void
getRedeemDayType(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
void
listRedeemRecords(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
@@ -49,6 +72,34 @@ public:
createRedeemRecord(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
void sourceCrystalSummary(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
void listSourceCrystalRecords(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
void sourceCrystalDeposit(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
void sourceCrystalWithdraw(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
void experienceSummary(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
void experienceHistory(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
void
listLootDrops(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
void
listDailyTasks(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb);

查看文件

@@ -9,6 +9,8 @@ class MetaController : public drogon::HttpController<MetaController> {
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<MetaController> {
void backendLogs(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void dbLockGuardStatus(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void kbRefreshStatus(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);

查看文件

@@ -0,0 +1,38 @@
#pragma once
#include <drogon/HttpController.h>
#include <cstdint>
namespace csp::controllers {
class SeasonController : public drogon::HttpController<SeasonController> {
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<void(const drogon::HttpResponsePtr&)>&& cb);
void mySeasonProgress(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t season_id);
void claimSeasonReward(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t season_id);
};
} // namespace csp::controllers

查看文件

@@ -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;
};

查看文件

@@ -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);

查看文件

@@ -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<int> VerifyCredentials(const std::string& username,
const std::string& password);
std::optional<int> VerifyToken(const std::string& token);
private:

查看文件

@@ -0,0 +1,70 @@
#pragma once
#include <condition_variable>
#include <cstdint>
#include <mutex>
#include <string>
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

查看文件

@@ -0,0 +1,79 @@
#pragma once
#include "csp/db/sqlite_db.h"
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
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<int64_t> last_test_at;
std::optional<int64_t> 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<CrawlerTarget> GetTargetById(int64_t target_id);
std::vector<CrawlerTarget> ListTargets(const std::string& status, int limit);
std::vector<CrawlerRun> 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<std::string> ExtractUrls(const std::string& text);
private:
db::SqliteDb& db_;
};
} // namespace csp::services

查看文件

@@ -0,0 +1,54 @@
#pragma once
#include <mutex>
#include <cstdint>
#include <string>
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

查看文件

@@ -0,0 +1,39 @@
#pragma once
#include "csp/db/sqlite_db.h"
#include <cstdint>
#include <string>
#include <vector>
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<ExperienceHistoryItem> ListHistory(int64_t user_id, int limit);
private:
db::SqliteDb& db_;
};
} // namespace csp::services

查看文件

@@ -13,6 +13,62 @@ namespace csp::services {
struct KbArticleDetail {
domain::KbArticle article;
std::vector<std::pair<int64_t, std::string>> related_problems;
struct SkillPoint {
std::string key;
std::string title;
std::string description;
std::string difficulty;
int reward = 1;
std::vector<std::string> prerequisites;
};
std::vector<SkillPoint> skill_points;
};
struct KbClaimSummary {
std::vector<std::string> 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<std::string> prerequisites;
bool completed = false;
int64_t completed_at = 0;
};
struct KbWeeklyPlan {
std::string week_key;
std::vector<KbWeeklyTask> 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<domain::KbArticle> ListArticles();
std::optional<KbArticleDetail> 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<KbArticleDetail::SkillPoint> SkillPointsBySlug(const std::string& slug);
std::vector<std::string> ClaimedKeysByUser(int64_t user_id);
std::vector<std::string> 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_;
};

查看文件

@@ -0,0 +1,85 @@
#pragma once
#include <cstdint>
#include <deque>
#include <mutex>
#include <string>
#include <unordered_map>
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<std::string, std::string>& 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<std::string, std::deque<ChatTurn>> conversations_;
};
} // namespace csp::services

查看文件

@@ -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<RedeemRecord> ListRecordsByUser(int64_t user_id, int limit);
std::vector<RedeemRecord> ListRecordsAll(std::optional<int64_t> user_id, int limit);
RedeemDayTypeDecision ResolveCurrentDayType();
RedeemRecord Redeem(const RedeemRequest& request);
private:

查看文件

@@ -0,0 +1,102 @@
#pragma once
#include "csp/db/sqlite_db.h"
#include "csp/domain/entities.h"
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
namespace csp::services {
struct 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<std::string> key;
std::optional<std::string> title;
std::optional<int64_t> starts_at;
std::optional<int64_t> ends_at;
std::optional<std::string> status;
std::optional<std::string> 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<domain::SeasonRewardClaim> 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<std::string> code;
std::optional<std::string> title;
std::optional<std::string> description;
std::optional<std::string> rule_json;
std::optional<bool> is_active;
};
class SeasonService {
public:
explicit SeasonService(db::SqliteDb& db) : db_(db) {}
std::optional<domain::Season> GetCurrentSeason();
std::optional<domain::Season> GetSeasonById(int64_t season_id);
std::vector<domain::SeasonRewardTrack> ListRewardTracks(int64_t season_id);
std::vector<domain::SeasonRewardClaim> 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<domain::LootDropLog> ListLootDropsByUser(int64_t user_id, int limit);
domain::Season CreateSeason(const SeasonWrite& input,
const std::vector<SeasonRewardTrackWrite>& tracks);
domain::Season UpdateSeason(
int64_t season_id,
const SeasonPatch& patch,
const std::optional<std::vector<SeasonRewardTrackWrite>>& replace_tracks);
std::vector<domain::ContestModifier> 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

查看文件

@@ -0,0 +1,56 @@
#pragma once
#include "csp/db/sqlite_db.h"
#include <cstdint>
#include <string>
#include <vector>
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<SourceCrystalTransaction> 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

查看文件

@@ -26,6 +26,11 @@ struct RunOnlyResult {
std::string compile_log;
};
struct SubmissionSiblingIds {
std::optional<int64_t> prev_id;
std::optional<int64_t> next_id;
};
class SubmissionService {
public:
explicit SubmissionService(db::SqliteDb& db) : db_(db) {}
@@ -35,9 +40,12 @@ class SubmissionService {
std::vector<domain::Submission> List(std::optional<int64_t> user_id,
std::optional<int64_t> problem_id,
std::optional<int64_t> contest_id,
std::optional<int64_t> created_from,
std::optional<int64_t> created_to,
int page,
int page_size);
std::optional<domain::Submission> GetById(int64_t id);
SubmissionSiblingIds GetSiblingIds(int64_t user_id, int64_t submission_id);
RunOnlyResult RunOnlyCpp(const std::string& code, const std::string& input);

查看文件

@@ -5,6 +5,7 @@
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
namespace csp::services {
@@ -19,7 +20,8 @@ class UserService {
explicit UserService(db::SqliteDb& db) : db_(db) {}
std::optional<domain::User> GetById(int64_t id);
std::vector<domain::GlobalLeaderboardEntry> GlobalLeaderboard(int limit = 100);
std::vector<domain::GlobalLeaderboardEntry> 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);

查看文件

@@ -20,6 +20,7 @@ class WrongBookService {
std::vector<WrongBookEntry> 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,

查看文件

@@ -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);

查看文件

@@ -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 <optional>
#include <stdexcept>
#include <string>
#include <vector>
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<bool> ParseOptionalBool(const Json::Value& json,
const char* key) {
if (!json.isMember(key)) return std::nullopt;
return json[key].asBool();
}
std::optional<std::string> ParseOptionalString(const Json::Value& json,
const char* key) {
if (!json.isMember(key)) return std::nullopt;
return json[key].asString();
}
std::optional<int64_t> 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<services::SeasonRewardTrackWrite> ParseSeasonTracks(
const Json::Value& json) {
std::vector<services::SeasonRewardTrackWrite> 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<void(const drogon::HttpResponsePtr&)>&& 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<void(const drogon::HttpResponsePtr&)>&& 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<void(const drogon::HttpResponsePtr&)>&& 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<void(const drogon::HttpResponsePtr&)>&& cb,
@@ -358,6 +551,192 @@ void AdminController::listRedeemRecords(
}
}
void AdminController::getSourceCrystalSettings(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& 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<void(const drogon::HttpResponsePtr&)>&& 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<void(const drogon::HttpResponsePtr&)>&& 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<services::SeasonRewardTrackWrite> 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<void(const drogon::HttpResponsePtr&)>&& 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<std::vector<services::SeasonRewardTrackWrite>> 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<void(const drogon::HttpResponsePtr&)>&& 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<void(const drogon::HttpResponsePtr&)>&& 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<void(const drogon::HttpResponsePtr&)>&& cb,

查看文件

@@ -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 <exception>
@@ -130,4 +131,28 @@ void ContestController::leaderboard(
}
}
void ContestController::modifiers(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& 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

查看文件

@@ -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 <algorithm>
#include <optional>
#include <string>
namespace csp::controllers {
namespace {
drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code,
const std::string& msg) {
Json::Value j;
j["ok"] = false;
j["error"] = msg;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(code);
return resp;
}
drogon::HttpResponsePtr JsonOk(const Json::Value& data) {
Json::Value j;
j["ok"] = true;
j["data"] = data;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(drogon::k200OK);
return resp;
}
int 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<int64_t> RequireAdminUserId(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>& cb) {
std::string auth_error;
const auto user_id = GetAuthedUserId(req, auth_error);
if (!user_id.has_value()) {
cb(JsonError(drogon::k401Unauthorized, auth_error));
return std::nullopt;
}
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<void(const drogon::HttpResponsePtr&)>&& 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<int>(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<void(const drogon::HttpResponsePtr&)>&& 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<void(const drogon::HttpResponsePtr&)>&& 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<void(const drogon::HttpResponsePtr&)>&& 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<int>(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<void(const drogon::HttpResponsePtr&)>&& 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

查看文件

@@ -5,33 +5,112 @@
#include <drogon/HttpRequest.h>
#include <cctype>
#include <cstdint>
#include <optional>
#include <string>
namespace csp::controllers {
inline std::optional<std::string> 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<unsigned char>(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<uint32_t>(a) << 18) |
(static_cast<uint32_t>(b) << 12) |
(static_cast<uint32_t>(c > 0 ? c : 0) << 6) |
static_cast<uint32_t>(d > 0 ? d : 0);
out.push_back(static_cast<char>((bits >> 16) & 0xFF));
if (c != -2) out.push_back(static_cast<char>((bits >> 8) & 0xFF));
if (d != -2) out.push_back(static_cast<char>(bits & 0xFF));
}
return out;
}
inline std::optional<int64_t> GetAuthedUserId(const drogon::HttpRequestPtr& req,
std::string& error) {
const std::string authz = req->getHeader("Authorization");
const std::string prefix = "Bearer ";
if (authz.rfind(prefix, 0) != 0) {
error = "missing Authorization: Bearer <token>";
return std::nullopt;
}
const std::string token = authz.substr(prefix.size());
if (token.empty()) {
error = "empty bearer token";
if (authz.empty()) {
error =
"missing Authorization header; use Bearer <token> or Basic <base64(username:password)>";
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<int64_t>(*user_id);
}
return static_cast<int64_t>(*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<int64_t>(*user_id);
}
error =
"unsupported Authorization scheme; use Bearer <token> or Basic <base64(username:password)>";
return std::nullopt;
}
} // namespace csp::controllers

查看文件

@@ -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 <exception>
#include <optional>
#include <string>
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<void(const drogon::HttpResponsePtr&)>&& 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<void(const drogon::HttpResponsePtr&)>&& 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<void(const drogon::HttpResponsePtr&)>&& 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<void(const drogon::HttpResponsePtr&)>&& 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

查看文件

@@ -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 <json/json.h>
#include <memory>
#include <string>
#include <vector>
namespace csp::controllers {
namespace {
drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code,
const std::string& msg) {
Json::Value j;
j["ok"] = false;
j["error"] = msg;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(code);
return resp;
}
drogon::HttpResponsePtr JsonOk(const Json::Value& data) {
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<void(const drogon::HttpResponsePtr&)>&& 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<Json::CharReader> 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<int>(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

查看文件

@@ -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));

查看文件

@@ -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 <drogon/MultiPart.h>
@@ -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<void(const drogon::HttpResponsePtr &)> &&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<void(const drogon::HttpResponsePtr &)> &&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<void(const drogon::HttpResponsePtr &)> &&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<void(const drogon::HttpResponsePtr &)> &&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<void(const drogon::HttpResponsePtr &)> &&cb) {
try {
const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value())
return;
services::UserService users(csp::AppState::Instance().db());
const auto user = users.GetById(*user_id);
if (!user.has_value() || 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<void(const drogon::HttpResponsePtr &)> &&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<void(const drogon::HttpResponsePtr &)> &&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<void(const drogon::HttpResponsePtr &)> &&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<void(const drogon::HttpResponsePtr &)> &&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<void(const drogon::HttpResponsePtr &)> &&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);

查看文件

@@ -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>。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<void(const drogon::HttpResponsePtr&)>&& 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<void(const drogon::HttpResponsePtr&)>&& cb) {

查看文件

@@ -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 <algorithm>
#include <cctype>
#include <exception>
#include <optional>
#include <string>
#include <unordered_set>
namespace csp::controllers {
namespace {
drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code,
const std::string& msg) {
Json::Value j;
j["ok"] = false;
j["error"] = msg;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(code);
return resp;
}
drogon::HttpResponsePtr JsonOk(const Json::Value& data) {
Json::Value j;
j["ok"] = true;
j["data"] = data;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(drogon::k200OK);
return resp;
}
std::optional<int64_t> RequireAuth(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>& cb) {
std::string auth_error;
const auto user_id = GetAuthedUserId(req, auth_error);
if (!user_id.has_value()) {
cb(JsonError(drogon::k401Unauthorized, auth_error));
return std::nullopt;
}
return user_id;
}
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<char>(std::tolower(c));
});
return std::to_string(tier_no) + ":" + lower;
}
} // namespace
void SeasonController::currentSeason(
const drogon::HttpRequestPtr& /*req*/,
std::function<void(const drogon::HttpResponsePtr&)>&& 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<void(const drogon::HttpResponsePtr&)>&& 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<std::string> 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<int>(tracks.size());
data["claimed_count"] = claimed_count;
data["claimable_count"] = claimable_count;
if (!tracks.empty()) {
data["completion_percent"] = claimed_count * 100 / static_cast<int>(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<void(const drogon::HttpResponsePtr&)>&& 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

查看文件

@@ -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.
{

查看文件

@@ -1,6 +1,7 @@
#include "csp/db/sqlite_db.h"
#include <chrono>
#include <cstdlib>
#include <optional>
#include <stdexcept>
#include <string>
@@ -21,6 +22,40 @@ int64_t NowSec() {
return duration_cast<seconds>(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<int64_t> 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 <service>` 排查启动失败。
- 开机自启与配置变更后的重载策略。
### 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,

查看文件

@@ -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;
}

查看文件

@@ -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,

查看文件

@@ -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<int> 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) {

查看文件

@@ -0,0 +1,589 @@
#include "csp/services/crawler_runner.h"
#include "csp/db/sqlite_db.h"
#include "csp/services/crawler_service.h"
#include <drogon/HttpClient.h>
#include <drogon/drogon.h>
#include <json/json.h>
#include <chrono>
#include <cctype>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <optional>
#include <sstream>
#include <string>
#include <thread>
#include <utility>
namespace csp::services {
namespace {
int64_t NowSec() {
using namespace std::chrono;
return duration_cast<seconds>(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<char>(::tolower(static_cast<unsigned char>(c))));
}
if (val == "1" || val == "true" || val == "yes" || val == "on") return true;
if (val == "0" || val == "false" || val == "no" || val == "off") return false;
return default_value;
}
int EnvInt(const char* key, int default_value, 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<Json::CharReader> 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<unsigned char>(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<std::pair<std::string, std::string>>& 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<int>(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)<script.*?>.*?</script>', ' ', html)\n";
ss << " html = re.sub(r'(?is)<style.*?>.*?</style>', ' ', 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)<title[^>]*>(.*?)</title>', 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> lock(mu_);
running_ = true;
current_target_id_ = target.id;
last_started_at_ = NowSec();
last_error_.clear();
}
const auto cleanup_stats = [this]() {
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> lock(mu_);
failed_count_ += 1;
last_failure_at_ = NowSec();
last_error_ = err;
}
}
cleanup_stats();
immediate = true;
} catch (const std::exception& e) {
std::lock_guard<std::mutex> 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

查看文件

@@ -0,0 +1,557 @@
#include "csp/services/crawler_service.h"
#include <sqlite3.h>
#include <algorithm>
#include <chrono>
#include <cctype>
#include <optional>
#include <regex>
#include <set>
#include <stdexcept>
#include <string>
#include <vector>
namespace csp::services {
namespace {
int64_t NowSec() {
using namespace std::chrono;
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
}
void CheckSqlite(int rc, sqlite3* db, const char* what) {
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return;
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
}
std::string ColText(sqlite3_stmt* stmt, int col) {
const unsigned char* txt = sqlite3_column_text(stmt, col);
return txt ? reinterpret_cast<const char*>(txt) : std::string();
}
std::optional<int64_t> ColNullableInt64(sqlite3_stmt* stmt, int col) {
if (sqlite3_column_type(stmt, col) == SQLITE_NULL) return std::nullopt;
return sqlite3_column_int64(stmt, col);
}
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<char>(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<char>(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<char>(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<std::string> CrawlerService::ExtractUrls(const std::string& text) {
static const std::regex kUrlRegex(
R"((https?://[^\s<>'"\]\)]+)|(www\.[^\s<>'"\]\)]+))",
std::regex::icase);
std::set<std::string> seen;
std::vector<std::string> 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<CrawlerTarget> {
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<CrawlerTarget> 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<CrawlerTarget> 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<CrawlerTarget> rows;
while (sqlite3_step(stmt) == SQLITE_ROW) {
rows.push_back(ReadTarget(stmt));
}
sqlite3_finalize(stmt);
return rows;
}
std::vector<CrawlerRun> 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<CrawlerRun> 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

查看文件

@@ -0,0 +1,206 @@
#include "csp/services/db_lock_guard.h"
#include <drogon/drogon.h>
#include <sqlite3.h>
#include <algorithm>
#include <chrono>
#include <cctype>
#include <cstdlib>
#include <string>
#include <thread>
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<char>(std::tolower(static_cast<unsigned char>(c)));
if (val == "1" || val == "true" || val == "yes" || val == "on") return true;
if (val == "0" || val == "false" || val == "no" || val == "off") return false;
return default_value;
}
int EnvInt(const char* key, int default_value, 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<seconds>(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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> lock(mu_);
busy_streak_ = 0;
last_repair_at_ = NowSec();
repair_count_ += 1;
}
} else {
{
std::lock_guard<std::mutex> 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<std::mutex> 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

查看文件

@@ -0,0 +1,116 @@
#include "csp/services/experience_service.h"
#include <sqlite3.h>
#include <algorithm>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace csp::services {
namespace {
void CheckSqlite(int rc, sqlite3* db, const char* what) {
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return;
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
}
std::string ColText(sqlite3_stmt* stmt, int col) {
const unsigned char* txt = sqlite3_column_text(stmt, col);
return txt ? reinterpret_cast<const char*>(txt) : std::string();
}
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<ExperienceHistoryItem> 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<ExperienceHistoryItem> 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

查看文件

@@ -2,8 +2,15 @@
#include <sqlite3.h>
#include <algorithm>
#include <chrono>
#include <ctime>
#include <iomanip>
#include <sstream>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <unordered_set>
namespace csp::services {
@@ -19,6 +26,139 @@ std::string ColText(sqlite3_stmt* stmt, int col) {
return txt ? reinterpret_cast<const char*>(txt) : std::string();
}
int64_t NowSec() {
using namespace std::chrono;
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
}
std::string WeekKeyUtc8(int64_t ts_sec) {
const std::time_t shifted = static_cast<std::time_t>(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<std::string>& 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<std::string> SplitCsv(const std::string& text) {
std::vector<std::string> 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<std::string, std::vector<KbArticleDetail::SkillPoint>>& SkillCatalog() {
static const std::unordered_map<std::string, std::vector<KbArticleDetail::SkillPoint>> 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<domain::KbArticle> KbService::ListArticles() {
@@ -43,6 +183,46 @@ std::vector<domain::KbArticle> KbService::ListArticles() {
return out;
}
std::string KbService::CurrentWeekKey() const { return WeekKeyUtc8(NowSec()); }
std::vector<KbArticleDetail::SkillPoint>
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<std::string> KbService::ClaimedKeysByUser(int64_t user_id) {
std::vector<std::string> 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<std::string> KbService::ClaimedKeysByArticle(int64_t user_id, int64_t article_id) {
std::vector<std::string> 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<KbArticleDetail> KbService::GetBySlug(const std::string& slug) {
sqlite3* db = db_.raw();
@@ -81,7 +261,397 @@ std::optional<KbArticleDetail> 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<int>(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<Candidate> all;
const auto claimed_global = ClaimedKeysByUser(user_id);
std::unordered_set<std::string> 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<size_t> used;
std::vector<Candidate> selected;
bool progressed = true;
while (progressed && static_cast<int>(selected.size()) < kWeeklyLimit) {
progressed = false;
for (size_t i = 0; i < all.size() && static_cast<int>(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<int>(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<int>(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<std::string> claimed_set(claimed.begin(), claimed.end());
std::vector<std::string> 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

查看文件

@@ -0,0 +1,488 @@
#include "csp/services/lark_bot_service.h"
#include <drogon/HttpClient.h>
#include <drogon/drogon.h>
#include <json/json.h>
#include <chrono>
#include <cctype>
#include <cstdlib>
#include <thread>
namespace csp::services {
namespace {
int64_t NowSec() {
using namespace std::chrono;
return duration_cast<seconds>(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<char>(::tolower(static_cast<unsigned char>(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<Json::CharReader> 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<std::mutex> 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<size_t>(
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<std::mutex> lock(mu_);
return enabled_;
}
bool LarkBotService::VerifyToken(const std::string& token) const {
std::lock_guard<std::mutex> 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<std::mutex> 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<ChatTurn> history;
{
std::lock_guard<std::mutex> 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<std::mutex> 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<size_t>(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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::string, std::string>& 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<int>(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<unsigned char>(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

查看文件

@@ -4,6 +4,9 @@
#include <json/json.h>
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
@@ -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<std::string>& 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<char>(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<int>(std::lround(static_cast<double>(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<Json::CharReader> 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;

查看文件

@@ -1,13 +1,21 @@
#include "csp/services/redeem_service.h"
#include <json/json.h>
#include <sqlite3.h>
#include <algorithm>
#include <chrono>
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <memory>
#include <stdexcept>
#include <string>
#include <sys/wait.h>
#include <time.h>
#include <utility>
#include <vector>
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<char>(std::tolower(static_cast<unsigned char>(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<std::string> 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<std::time_t>(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<HolidayLlmResult> 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<Json::CharReader> 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<RedeemRecord> RedeemService::ListRecordsAll(std::optional<int64_t> 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;

查看文件

@@ -0,0 +1,923 @@
#include "csp/services/season_service.h"
#include <json/json.h>
#include <sqlite3.h>
#include <algorithm>
#include <chrono>
#include <cctype>
#include <memory>
#include <stdexcept>
#include <string>
#include <unordered_set>
#include <utility>
#include <vector>
namespace csp::services {
namespace {
void CheckSqlite(int rc, sqlite3* db, const char* what) {
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return;
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
}
int64_t NowSec() {
using namespace std::chrono;
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
}
std::string ColText(sqlite3_stmt* stmt, int col) {
const unsigned char* txt = sqlite3_column_text(stmt, col);
return txt ? reinterpret_cast<const char*>(txt) : std::string();
}
std::string Lower(std::string s) {
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) {
return static_cast<char>(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<Json::CharReader> 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<Json::CharReader> 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<SeasonRewardTrackWrite>& tracks) {
if (tracks.empty()) return;
std::unordered_set<std::string> 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<domain::SeasonRewardTrack> 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<domain::SeasonRewardTrack> tracks;
while (sqlite3_step(stmt) == SQLITE_ROW) {
tracks.push_back(ReadSeasonTrack(stmt));
}
sqlite3_finalize(stmt);
return tracks;
}
int ComputeLevelByXp(const std::vector<domain::SeasonRewardTrack>& 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<domain::SeasonRewardTrack> FindTrack(
const std::vector<domain::SeasonRewardTrack>& 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<domain::SeasonRewardTrack>& 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<domain::SeasonRewardClaim> 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<domain::ContestModifier> 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<SeasonRewardTrackWrite>& 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<domain::Season> 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<domain::Season> 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<domain::SeasonRewardTrack> SeasonService::ListRewardTracks(
int64_t season_id) {
if (season_id <= 0) return {};
return QuerySeasonTracks(db_.raw(), season_id);
}
std::vector<domain::SeasonRewardClaim> 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<domain::SeasonRewardClaim> 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<domain::LootDropLog> 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<domain::LootDropLog> 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<SeasonRewardTrackWrite>& 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<std::vector<SeasonRewardTrackWrite>>& 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<domain::ContestModifier> 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<domain::ContestModifier> 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

查看文件

@@ -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;

查看文件

@@ -0,0 +1,512 @@
#include "csp/services/source_crystal_service.h"
#include <sqlite3.h>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <ctime>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace csp::services {
namespace {
constexpr int64_t kShanghaiOffsetSeconds = 8LL * 3600LL;
int64_t NowSec() {
using namespace std::chrono;
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
}
void CheckSqlite(int rc, sqlite3* db, const char* what) {
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return;
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
}
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<const char*>(txt) : std::string();
}
std::tm GmTimeFromEpoch(int64_t epoch_sec) {
const std::time_t tt = static_cast<std::time_t>(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<int64_t>(_mkgmtime(&tm));
#else
return static_cast<int64_t>(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<AccountDeltaTx> 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<AccountDeltaTx> 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<double>(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<double>(period_end - cursor);
}
const double interest_delta = Round4(
settings.monthly_interest_rate *
(weighted_balance_seconds / static_cast<double>(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<SourceCrystalTransaction> 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<SourceCrystalTransaction> 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

查看文件

@@ -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<domain::Submission> SubmissionService::List(std::optional<int64_t> user_id,
std::optional<int64_t> problem_id,
std::optional<int64_t> contest_id,
std::optional<int64_t> created_from,
std::optional<int64_t> created_to,
int page,
int page_size) {
sqlite3* db = db_.raw();
@@ -345,11 +356,22 @@ std::vector<domain::Submission> SubmissionService::List(std::optional<int64_t> 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<s.id"
") THEN 2 ELSE 0 END AS rating_delta "
"FROM submissions s WHERE 1=1 ";
") THEN "
" CASE "
" WHEN COALESCE(p.difficulty,0)=2 THEN 15 "
" WHEN COALESCE(p.difficulty,0)=3 THEN 20 "
" WHEN COALESCE(p.difficulty,0)<=1 THEN 12 "
" ELSE 20 + (COALESCE(p.difficulty,0)-3)*3 "
" END "
"ELSE 0 END AS rating_delta "
"FROM submissions s "
"LEFT JOIN problems p ON p.id=s.problem_id "
"WHERE 1=1 ";
if (user_id.has_value()) sql += "AND s.user_id=? ";
if (problem_id.has_value()) sql += "AND s.problem_id=? ";
if (contest_id.has_value()) sql += "AND s.contest_id=? ";
if (created_from.has_value()) sql += "AND s.created_at>=? ";
if (created_to.has_value()) sql += "AND s.created_at<=? ";
sql += "ORDER BY s.id DESC LIMIT ? OFFSET ?";
CheckSqlite(sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr), db,
@@ -362,6 +384,10 @@ std::vector<domain::Submission> SubmissionService::List(std::optional<int64_t> 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<domain::Submission> 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<s.id"
") THEN 2 ELSE 0 END AS rating_delta "
"FROM submissions s WHERE s.id=?";
") THEN "
" CASE "
" WHEN COALESCE(p.difficulty,0)=2 THEN 15 "
" WHEN COALESCE(p.difficulty,0)=3 THEN 20 "
" WHEN COALESCE(p.difficulty,0)<=1 THEN 12 "
" ELSE 20 + (COALESCE(p.difficulty,0)-3)*3 "
" END "
"ELSE 0 END AS rating_delta "
"FROM submissions s "
"LEFT JOIN problems p ON p.id=s.problem_id "
"WHERE s.id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare get submission");
CheckSqlite(sqlite3_bind_int64(stmt, 1, id), db, "bind submission_id");
@@ -438,6 +473,40 @@ std::optional<domain::Submission> 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 DESC LIMIT 1";
CheckSqlite(sqlite3_prepare_v2(db, prev_sql, -1, &prev_stmt, nullptr), db,
"prepare get previous submission");
CheckSqlite(sqlite3_bind_int64(prev_stmt, 1, user_id), db, "bind prev user_id");
CheckSqlite(sqlite3_bind_int64(prev_stmt, 2, submission_id), db,
"bind prev submission_id");
if (sqlite3_step(prev_stmt) == SQLITE_ROW) {
out.prev_id = sqlite3_column_int64(prev_stmt, 0);
}
sqlite3_finalize(prev_stmt);
sqlite3_stmt* next_stmt = nullptr;
const char* next_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");

查看文件

@@ -3,9 +3,13 @@
#include <sqlite3.h>
#include <algorithm>
#include <chrono>
#include <ctime>
#include <sstream>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace csp::services {
@@ -21,6 +25,62 @@ std::string ColText(sqlite3_stmt* stmt, int col) {
return txt ? reinterpret_cast<const char*>(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<const char*>(name)) == col) {
found = true;
break;
}
}
sqlite3_finalize(stmt);
return found;
}
int64_t NowSec() {
using namespace std::chrono;
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
}
int64_t StartOfDayUtc8(int64_t ts) {
const std::time_t shifted = static_cast<std::time_t>(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<int64_t>(timegm(&tmv));
return start_shifted - 8 * 3600;
}
int64_t StartOfWeekUtc8(int64_t ts) {
const std::time_t shifted = static_cast<std::time_t>(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<int64_t>(timegm(&tmv));
return start_shifted - 8 * 3600;
}
} // namespace
std::optional<domain::User> UserService::GetById(int64_t id) {
@@ -48,24 +108,85 @@ std::optional<domain::User> UserService::GetById(int64_t id) {
return u;
}
std::vector<domain::GlobalLeaderboardEntry> UserService::GlobalLeaderboard(int limit) {
std::vector<domain::GlobalLeaderboardEntry>
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<domain::GlobalLeaderboardEntry> 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<std::string> 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);

查看文件

@@ -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,

查看文件

@@ -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());
}

查看文件

@@ -0,0 +1,55 @@
#include <catch2/catch_test_macros.hpp>
#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");
}

查看文件

@@ -0,0 +1,41 @@
#include <catch2/catch_test_macros.hpp>
#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);
}

查看文件

@@ -2,6 +2,7 @@
#include "csp/app_state.h"
#include "csp/controllers/import_controller.h"
#include "csp/services/auth_service.h"
#include <drogon/HttpRequest.h>
@@ -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<drogon::HttpResponsePtr> p;
ctl.latestJob(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); });
return p.get_future().get();
}
drogon::HttpResponsePtr CallItems(csp::controllers::ImportController& ctl, int64_t job_id) {
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<drogon::HttpResponsePtr> 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);

查看文件

@@ -1,7 +1,12 @@
#include <catch2/catch_test_macros.hpp>
#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 <string>
#include <unordered_set>
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<std::string> 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);
}

查看文件

@@ -0,0 +1,149 @@
#include <catch2/catch_test_macros.hpp>
#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 <drogon/HttpRequest.h>
#include <cstdlib>
#include <future>
#include <optional>
#include <string>
namespace {
class ScopedEnv {
public:
ScopedEnv(std::string key, std::optional<std::string> 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<std::string> old_;
};
drogon::HttpResponsePtr CallEvents(csp::controllers::LarkController& ctl,
const Json::Value& body) {
auto req = drogon::HttpRequest::newHttpJsonRequest(body);
req->setMethod(drogon::Post);
std::promise<drogon::HttpResponsePtr> 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");
}

查看文件

@@ -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 <drogon/HttpRequest.h>
#include <sqlite3.h>
#include <future>
@@ -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<drogon::HttpResponsePtr> p;
ctl.profile(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); });
return p.get_future().get();
}
drogon::HttpResponsePtr CallListWrongBook(csp::controllers::MeController& ctl,
const std::string& token) {
auto req = drogon::HttpRequest::newHttpRequest();
@@ -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<drogon::HttpResponsePtr> 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);
}

查看文件

@@ -3,6 +3,7 @@
#include "csp/db/sqlite_db.h"
#include "csp/services/problem_workspace_service.h"
#include <algorithm>
#include <sqlite3.h>
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());
}

查看文件

@@ -0,0 +1,274 @@
#include <catch2/catch_test_macros.hpp>
#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 <drogon/HttpRequest.h>
#include <future>
namespace {
drogon::HttpResponsePtr CallSeasonCurrent(csp::controllers::SeasonController& ctl) {
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
std::promise<drogon::HttpResponsePtr> 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<drogon::HttpResponsePtr> 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<drogon::HttpResponsePtr> 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<drogon::HttpResponsePtr> 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<drogon::HttpResponsePtr> 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<drogon::HttpResponsePtr> 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<drogon::HttpResponsePtr> 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<drogon::HttpResponsePtr> 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<drogon::HttpResponsePtr> 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<drogon::HttpResponsePtr> 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);
}

查看文件

@@ -0,0 +1,115 @@
#include <catch2/catch_test_macros.hpp>
#include "csp/db/sqlite_db.h"
#include "csp/services/auth_service.h"
#include "csp/services/contest_service.h"
#include "csp/services/season_service.h"
#include "csp/services/user_service.h"
#include <string>
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);
}

查看文件

@@ -0,0 +1,61 @@
#include <catch2/catch_test_macros.hpp>
#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);
}

查看文件

@@ -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);
}