feat: 完成源晶权限与经验系统并优化 me/admin 交互
这个提交包含在:
@@ -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>";
|
||||
if (authz.empty()) {
|
||||
error =
|
||||
"missing Authorization header; use Bearer <token> or Basic <base64(username:password)>";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const std::string token = authz.substr(prefix.size());
|
||||
services::AuthService auth(csp::AppState::Instance().db());
|
||||
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;
|
||||
}
|
||||
|
||||
services::AuthService auth(csp::AppState::Instance().db());
|
||||
const auto user_id = auth.VerifyToken(token);
|
||||
if (!user_id.has_value()) {
|
||||
error = "invalid or expired token";
|
||||
return std::nullopt;
|
||||
}
|
||||
return static_cast<int64_t>(*user_id);
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
return "studyday";
|
||||
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 "/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();
|
||||
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");
|
||||
"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;
|
||||
}
|
||||
|
||||
std::vector<domain::GlobalLeaderboardEntry> 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);
|
||||
}
|
||||
|
||||
149
backend/tests/lark_http_test.cc
普通文件
149
backend/tests/lark_http_test.cc
普通文件
@@ -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());
|
||||
}
|
||||
|
||||
274
backend/tests/season_http_test.cc
普通文件
274
backend/tests/season_http_test.cc
普通文件
@@ -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);
|
||||
}
|
||||
115
backend/tests/season_service_test.cc
普通文件
115
backend/tests/season_service_test.cc
普通文件
@@ -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);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,35 @@
|
||||
|
||||
## 通用约定
|
||||
|
||||
- 鉴权头:`Authorization: Bearer <token>`
|
||||
- 鉴权头(二选一):
|
||||
- `Authorization: Bearer <token>`(推荐)
|
||||
- `Authorization: Basic <base64(username:password)>`(第三方直连更方便)
|
||||
- 成功响应:`{ "ok": true, "data": ... }`(Auth 接口除外)
|
||||
- 失败响应:`{ "ok": false, "error": "..." }`
|
||||
|
||||
### 第三方接入快速示例
|
||||
|
||||
1) 登录拿 token(Bearer 方式)
|
||||
```bash
|
||||
curl -X POST 'https://csp.hao.work/admin139/api/v1/auth/login' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"alice","password":"password123"}'
|
||||
```
|
||||
|
||||
2) Bearer 调用
|
||||
```bash
|
||||
curl 'https://csp.hao.work/admin139/api/v1/me' \
|
||||
-H 'Authorization: Bearer <token>'
|
||||
```
|
||||
|
||||
3) Basic 直接调用
|
||||
```bash
|
||||
curl 'https://csp.hao.work/admin139/api/v1/me' \
|
||||
-H 'Authorization: Basic YWxpY2U6cGFzc3dvcmQxMjM='
|
||||
```
|
||||
|
||||
> `YWxpY2U6cGFzc3dvcmQxMjM=` 是 `alice:password123` 的 Base64。
|
||||
|
||||
---
|
||||
|
||||
## 1) Auth
|
||||
@@ -65,7 +90,7 @@
|
||||
```
|
||||
- `contest_id` 可选;若提交到比赛,需已报名且比赛进行中。
|
||||
|
||||
### `GET /submissions?user_id=&problem_id=&contest_id=&page=&page_size=`
|
||||
### `GET /submissions?user_id=&problem_id=&contest_id=&created_from=&created_to=&page=&page_size=`
|
||||
返回提交列表。
|
||||
|
||||
### `GET /submissions/:id`
|
||||
@@ -120,6 +145,24 @@
|
||||
### `GET /kb/articles/:slug`
|
||||
返回文章详情与关联题目。
|
||||
|
||||
### `GET /kb/articles/:slug/claims`(需鉴权)
|
||||
返回当前用户在该文章下已领取知识点和累计积分。
|
||||
|
||||
### `POST /kb/articles/:slug/claim`(需鉴权)
|
||||
请求:
|
||||
```json
|
||||
{ "knowledge_key": "cpp14-io-01" }
|
||||
```
|
||||
说明:
|
||||
- 有前置依赖的知识点,若前置未完成会返回 400。
|
||||
- 同一知识点不可重复领取积分。
|
||||
|
||||
### `GET /kb/weekly-plan`(需鉴权)
|
||||
返回本周自动生成的学习任务(含完成度、奖励汇总)。
|
||||
|
||||
### `POST /kb/weekly-bonus/claim`(需鉴权)
|
||||
领取本周任务 100% 完成奖励;未达 100% 返回 400。
|
||||
|
||||
---
|
||||
|
||||
## 8) 题库导入任务(PDF + LLM)
|
||||
@@ -157,3 +200,53 @@
|
||||
|
||||
### `GET /backend/logs?limit=100`
|
||||
返回最近题解生成任务日志(按任务 ID 倒序),并包含当前排队任务数 `pending_jobs`。
|
||||
|
||||
### `GET /backend/db-lock-guard/status`(需管理员鉴权)
|
||||
返回 SQLite 锁守护运行状态,字段包含:
|
||||
- `enabled` / `started`:守护是否启用、是否已启动
|
||||
- `interval_sec` / `probe_busy_timeout_ms`:探测间隔与超时
|
||||
- `busy_streak` / `busy_streak_trigger`:当前连续锁冲突计数与触发阈值
|
||||
- `last_probe_at` / `last_probe_rc` / `last_probe_error`:最近探测结果
|
||||
- `last_repair_at` / `repair_count`:最近一次自愈时间与累计自愈次数
|
||||
|
||||
---
|
||||
|
||||
## 10) 网站爬虫列表(管理员)
|
||||
|
||||
### `GET /admin/crawlers?status=&limit=`
|
||||
返回爬虫目标列表(按最新倒序)。
|
||||
|
||||
### `POST /admin/crawlers`
|
||||
手动新增爬虫目标。请求体示例:
|
||||
```json
|
||||
{ "url": "https://example.com" }
|
||||
```
|
||||
|
||||
### `POST /admin/crawlers/:id/queue`
|
||||
将目标重新入队,触发“规则生成 -> 自动测试 -> 自动运行”流程。
|
||||
|
||||
### `GET /admin/crawlers/:id/runs?limit=20`
|
||||
查看指定目标的运行记录。
|
||||
|
||||
### `GET /backend/crawler-guard/status`(需管理员鉴权)
|
||||
返回爬虫守护进程状态(是否启用、是否运行、处理统计、最近错误等)。
|
||||
- `active_requeue_interval_sec` 表示活跃目标的周期重跑间隔(秒,默认 43200 即 12 小时)。
|
||||
|
||||
> 飞书群内 `@机器人` 文本如果包含 URL,也会自动写入爬虫目标列表并触发守护流程。
|
||||
|
||||
---
|
||||
|
||||
## 11) Lark 机器人事件回调
|
||||
|
||||
### `POST /lark/events`
|
||||
用于飞书(Lark)事件订阅回调,支持:
|
||||
- URL 验证:请求体带 `challenge` 时回传 `{ "challenge": "..." }`
|
||||
- 消息事件:处理 `im.message.receive_v1` 文本消息并异步回复
|
||||
|
||||
环境变量(后端):
|
||||
- `CSP_LARK_BOT_ENABLED=true`:是否启用机器人
|
||||
- `CSP_LARK_VERIFICATION_TOKEN=...`:事件回调 token(可选,建议配置)
|
||||
- `CSP_LARK_APP_ID=...` / `CSP_LARK_APP_SECRET=...`:飞书应用凭据
|
||||
- `CSP_LARK_OPEN_BASE_URL=https://open.feishu.cn`:飞书 OpenAPI 域名
|
||||
- `CSP_LARK_LLM_API_URL=...` / `CSP_LARK_LLM_API_KEY=...` / `CSP_LARK_LLM_MODEL=...`:对话模型配置
|
||||
- 可选:`CSP_LARK_LLM_SYSTEM_PROMPT`、`CSP_LARK_MEMORY_TURNS`、`CSP_LARK_MAX_REPLY_CHARS`
|
||||
|
||||
@@ -76,8 +76,55 @@ OI_IMPORT_WORKERS=3
|
||||
OI_IMPORT_CLEAR_EXISTING=true
|
||||
OI_IMPORT_CLEAR_SOURCE_PREFIX=winterant/oi
|
||||
OI_IMPORT_CLEAR_ALL_PROBLEMS=false
|
||||
|
||||
# SQLite 锁守护(定时探测 + 自愈)
|
||||
CSP_SQLITE_BUSY_TIMEOUT_MS=15000
|
||||
CSP_DB_LOCK_GUARD_ENABLED=true
|
||||
CSP_DB_LOCK_GUARD_INTERVAL_SEC=20
|
||||
CSP_DB_LOCK_GUARD_PROBE_TIMEOUT_MS=2000
|
||||
CSP_DB_LOCK_GUARD_BUSY_STREAK=3
|
||||
|
||||
# Lark 机器人(事件回调 + 对话)
|
||||
CSP_LARK_BOT_ENABLED=false
|
||||
CSP_LARK_VERIFICATION_TOKEN=
|
||||
CSP_LARK_APP_ID=
|
||||
CSP_LARK_APP_SECRET=
|
||||
CSP_LARK_OPEN_BASE_URL=https://open.feishu.cn
|
||||
CSP_LARK_LLM_API_URL=
|
||||
CSP_LARK_LLM_API_KEY=
|
||||
CSP_LARK_LLM_MODEL=qwen3-max
|
||||
CSP_LARK_LLM_SYSTEM_PROMPT=你是 CSP Quest World 的编程助教。请用简洁中文回答,先给结论,再给步骤。
|
||||
CSP_LARK_LLM_TIMEOUT_SEC=30
|
||||
CSP_LARK_API_TIMEOUT_SEC=15
|
||||
CSP_LARK_MEMORY_TURNS=6
|
||||
CSP_LARK_MAX_REPLY_CHARS=1200
|
||||
|
||||
# 网站爬虫守护(URL 入库后自动:生成规则 -> 测试 -> 运行)
|
||||
CSP_CRAWLER_ENABLED=true
|
||||
CSP_CRAWLER_INTERVAL_SEC=15
|
||||
CSP_CRAWLER_REQUEUE_INTERVAL_SEC=43200
|
||||
CSP_CRAWLER_FETCH_TIMEOUT_SEC=20
|
||||
CSP_CRAWLER_SCRIPT_DIR=/data/crawlers
|
||||
CSP_CRAWLER_LLM_API_URL=https://one.hao.work/v1/chat/completions
|
||||
CSP_CRAWLER_LLM_API_KEY=替换为你的Key
|
||||
CSP_CRAWLER_LLM_MODEL=qwen3-max
|
||||
CSP_CRAWLER_LLM_TIMEOUT_SEC=30
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `CSP_SQLITE_BUSY_TIMEOUT_MS`:主连接遇到锁时的等待时间(毫秒)。
|
||||
- `CSP_DB_LOCK_GUARD_ENABLED`:是否启用定时守护。
|
||||
- `CSP_DB_LOCK_GUARD_INTERVAL_SEC`:守护探测间隔(秒)。
|
||||
- `CSP_DB_LOCK_GUARD_PROBE_TIMEOUT_MS`:守护探测连接的 busy timeout(毫秒)。
|
||||
- `CSP_DB_LOCK_GUARD_BUSY_STREAK`:连续探测到 busy/locked 达到阈值后触发一次自愈(WAL checkpoint + optimize)。
|
||||
- `CSP_LARK_BOT_ENABLED`:是否启用 Lark 对话机器人回调处理。
|
||||
- `CSP_LARK_VERIFICATION_TOKEN`:Lark 事件回调 token(建议配置)。
|
||||
- `CSP_LARK_APP_ID/CSP_LARK_APP_SECRET`:Lark 应用凭据。
|
||||
- `CSP_LARK_LLM_*`:机器人对话调用的模型配置。
|
||||
- `CSP_CRAWLER_*`:网站爬虫守护配置(URL 入库后自动生成规则/测试/运行)。
|
||||
- `CSP_CRAWLER_REQUEUE_INTERVAL_SEC`:已激活目标再次入队执行的周期(秒,`43200`=12小时,`0`=关闭周期重跑)。
|
||||
|
||||
## 5. 故障排查
|
||||
|
||||
### 5.1 无法访问 7888
|
||||
|
||||
97
docs/知识库重塑与游戏化规划.md
普通文件
97
docs/知识库重塑与游戏化规划.md
普通文件
@@ -0,0 +1,97 @@
|
||||
# 知识库重塑与游戏化规划(CSP 平台)
|
||||
|
||||
## 1. 重塑目标
|
||||
- 建立“可学习、可打卡、可成长”的知识库系统,不再只是文章列表。
|
||||
- 形成四条核心技能线:
|
||||
- C++14 技能树(竞赛编码主线)
|
||||
- GitHub 协作技能树(团队工程主线)
|
||||
- Linux 服务器技能树(部署运维主线)
|
||||
- 计算机基础技能树(底层认知主线)
|
||||
- 将“知识点学习”直接转化为积分成长,形成闭环:学习 -> 打卡 -> 得分 -> 排行与成长反馈。
|
||||
|
||||
## 2. 内容架构
|
||||
### 2.1 主线知识文章(已落地)
|
||||
- `cpp14-skill-tree`
|
||||
- `github-collaboration-basics`
|
||||
- `linux-server-basics`
|
||||
- `computer-fundamentals-for-oi`
|
||||
|
||||
### 2.2 每篇文章的知识点模型
|
||||
- 每篇文章附带一组 `skill_points`:
|
||||
- `key`: 唯一知识点 ID
|
||||
- `title`: 知识点标题
|
||||
- `description`: 可执行的学习目标
|
||||
- `difficulty`: bronze/silver/gold
|
||||
- `reward`: 领取奖励分值
|
||||
|
||||
### 2.3 文章与题目联动
|
||||
- 保留 `kb_article_links`,把知识点和题目联起来。
|
||||
- 在 KB 列表中提供“做相关任务”跳转到题库搜索。
|
||||
|
||||
## 3. 游戏化机制
|
||||
### 3.1 积分获取
|
||||
- 用户可对每个知识点打卡领取奖励。
|
||||
- 领取规则:`user + article + knowledge_key` 唯一,不可重复刷分。
|
||||
- 奖励实时写入用户 `rating`。
|
||||
|
||||
### 3.2 数据表设计
|
||||
- 新增表:`kb_knowledge_claims`
|
||||
- `user_id`
|
||||
- `article_id`
|
||||
- `knowledge_key`
|
||||
- `reward`
|
||||
- `created_at`
|
||||
- 唯一约束:`UNIQUE(user_id, article_id, knowledge_key)`
|
||||
|
||||
### 3.3 成长展示
|
||||
- 文章详情页展示:
|
||||
- 技能点清单
|
||||
- 已领取数量
|
||||
- 已领取总积分
|
||||
- 单点“领取奖励”按钮与状态
|
||||
- 积分流水新增 `kb_skill` 类型,进入个人成长历史。
|
||||
|
||||
## 4. 交互设计
|
||||
### 4.1 KB 列表页
|
||||
- 支持关键词搜索。
|
||||
- 增加快捷筛选按钮:`C++14`、`GitHub`、`Linux`、`计算机基础`。
|
||||
- 分类分组:路线图、C++、CSP-J、CSP-S、GitHub、Linux、计算机基础、其他。
|
||||
|
||||
### 4.2 KB 详情页
|
||||
- 文章内容区
|
||||
- 技能打卡区(奖励领取)
|
||||
- 关联题目区(学练结合)
|
||||
|
||||
## 5. 版本规划(建议)
|
||||
### Phase A(当前已实现)
|
||||
- 重构四条核心文章
|
||||
- 技能点打卡与积分领取
|
||||
- 积分流水接入
|
||||
- KB 列表筛选与分类增强
|
||||
|
||||
### Phase B(下一步)
|
||||
- 技能点前置依赖(先修解锁)
|
||||
- 每周学习任务(自动生成)
|
||||
- 学习路径推荐(根据已领取点与错题)
|
||||
- 章节徽章(铜/银/金)
|
||||
|
||||
### Phase C(进阶)
|
||||
- 班级/战队学习排行
|
||||
- 学习挑战赛(限时技能闯关)
|
||||
- 知识点掌握度雷达图
|
||||
- AI 学习教练(按薄弱点推送下一步)
|
||||
|
||||
## 6. 运营指标(建议)
|
||||
- 日活学习人数
|
||||
- 人均领取知识点数
|
||||
- 文章完成率(领取数 / 可领取数)
|
||||
- 学完知识后去做题转化率
|
||||
- 学习后 AC 提升率
|
||||
|
||||
## 7. 风险与控制
|
||||
- 风险:刷接口刷分。
|
||||
- 控制:唯一约束 + 服务端验证技能点 key。
|
||||
- 风险:内容过长难消化。
|
||||
- 控制:拆分为技能点任务和周计划。
|
||||
- 风险:学习与做题割裂。
|
||||
- 控制:每篇文章都提供相关题目入口。
|
||||
101
docs/第三方REST接入指南.md
普通文件
101
docs/第三方REST接入指南.md
普通文件
@@ -0,0 +1,101 @@
|
||||
# CSP Quest World 第三方 REST 接入指南
|
||||
|
||||
本文档用于第三方系统(脚本、自动化平台、企业内部系统)快速接入 CSP Quest World 的 REST API。
|
||||
|
||||
## 1. 基础信息
|
||||
|
||||
- 线上基础地址:`https://csp.hao.work/admin139/api/v1`
|
||||
- 文档入口(Swagger):`https://csp.hao.work/api-docs`
|
||||
- OpenAPI JSON:`https://csp.hao.work/admin139/api/openapi.json`
|
||||
- 数据格式:`application/json`
|
||||
|
||||
## 2. 鉴权方式
|
||||
|
||||
受保护接口支持两种方式,任选其一:
|
||||
|
||||
1. Bearer Token(推荐)
|
||||
- 先调用 `/auth/login` 获取 `token`
|
||||
- 请求头带上:`Authorization: Bearer <token>`
|
||||
|
||||
2. Basic 账号密码(适合第三方直连)
|
||||
- 请求头带上:`Authorization: Basic <base64(username:password)>`
|
||||
- 示例:`alice:password123` -> `YWxpY2U6cGFzc3dvcmQxMjM=`
|
||||
|
||||
说明:
|
||||
- 两种方式都可访问全部 REST 接口。
|
||||
- 管理员接口仍需管理员账号(如 `admin`)才能通过权限校验。
|
||||
|
||||
## 3. 快速调用示例
|
||||
|
||||
### 3.1 登录获取 Token
|
||||
|
||||
```bash
|
||||
curl -X POST 'https://csp.hao.work/admin139/api/v1/auth/login' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"alice","password":"password123"}'
|
||||
```
|
||||
|
||||
### 3.2 使用 Bearer 调用
|
||||
|
||||
```bash
|
||||
curl 'https://csp.hao.work/admin139/api/v1/me' \
|
||||
-H 'Authorization: Bearer <token>'
|
||||
```
|
||||
|
||||
### 3.3 使用 Basic 调用
|
||||
|
||||
```bash
|
||||
curl 'https://csp.hao.work/admin139/api/v1/me' \
|
||||
-H 'Authorization: Basic YWxpY2U6cGFzc3dvcmQxMjM='
|
||||
```
|
||||
|
||||
## 4. Python 示例(requests)
|
||||
|
||||
```python
|
||||
import base64
|
||||
import requests
|
||||
|
||||
BASE = "https://csp.hao.work/admin139/api/v1"
|
||||
username = "alice"
|
||||
password = "password123"
|
||||
|
||||
basic = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("utf-8")
|
||||
headers = {"Authorization": f"Basic {basic}"}
|
||||
|
||||
resp = requests.get(f"{BASE}/me", headers=headers, timeout=15)
|
||||
resp.raise_for_status()
|
||||
print(resp.json())
|
||||
```
|
||||
|
||||
## 5. 响应约定
|
||||
|
||||
- 成功:`{ "ok": true, "data": ... }`(Auth 接口除外)
|
||||
- 失败:`{ "ok": false, "error": "..." }`
|
||||
|
||||
常见状态码:
|
||||
- `200`:成功
|
||||
- `400`:请求参数错误
|
||||
- `401`:未登录或鉴权失败
|
||||
- `403`:有登录身份但权限不足(如非管理员访问管理员接口)
|
||||
- `404`:资源不存在
|
||||
- `409`:状态冲突(例如任务已在运行)
|
||||
|
||||
## 6. 安全建议
|
||||
|
||||
1. 生产环境优先用 HTTPS。
|
||||
2. 若可控,优先使用 Bearer Token,减少明文密码在网关/代理日志出现的风险。
|
||||
3. 使用 Basic 时建议为第三方集成准备专用账号,并定期更换密码。
|
||||
4. 管理员账号只用于管理员接口,不建议用于普通业务拉取。
|
||||
|
||||
## 7. 爬虫自动化接口(管理员)
|
||||
|
||||
- 爬虫列表:`GET /admin/crawlers?status=&limit=`
|
||||
- 新增目标:`POST /admin/crawlers`,Body:`{ "url": "https://example.com" }`
|
||||
- 重新入队:`POST /admin/crawlers/{id}/queue`
|
||||
- 运行记录:`GET /admin/crawlers/{id}/runs?limit=20`
|
||||
- 守护状态:`GET /backend/crawler-guard/status`
|
||||
|
||||
说明:
|
||||
- 新地址入库后,后端守护会自动执行:规则生成(LLM)→ 自动测试 → 自动运行。
|
||||
- `crawler-guard/status` 返回 `active_requeue_interval_sec`,用于确认周期重跑间隔(默认 43200 秒,即 12 小时)。
|
||||
- 飞书群内 `@机器人` 的文本消息若含 URL,也会自动入库到爬虫列表。
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
|
||||
@@ -4,8 +4,10 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { Edit, Gift, Plus, RefreshCw, ScrollText, Search, Trash2, Coins } from "lucide-react";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
import { Coins, Edit, Gift, Plus, RefreshCw, ScrollText, Trash2 } from "lucide-react";
|
||||
|
||||
type RedeemItem = {
|
||||
id: number;
|
||||
@@ -58,8 +60,7 @@ const DEFAULT_FORM: ItemForm = {
|
||||
};
|
||||
|
||||
function fmtTs(v: number | null | undefined): string {
|
||||
if (!v) return "-";
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
return formatUnixDateTime(v);
|
||||
}
|
||||
|
||||
function formatDuration(mins: number): string {
|
||||
@@ -179,6 +180,10 @@ export default function AdminRedeemPage() {
|
||||
setMsg("");
|
||||
try {
|
||||
if (!token) throw new Error(tx("请先登录管理员账号", "Please sign in with admin account first"));
|
||||
const ok = window.confirm(
|
||||
tx(`确认下架兑换物品 #${id}?`, `Disable redeem item #${id}?`)
|
||||
);
|
||||
if (!ok) return;
|
||||
await apiFetch(`/api/v1/admin/redeem-items/${id}`, { method: "DELETE" }, token);
|
||||
setMsg(tx(`已下架兑换物品 #${id}`, `Disabled redeem item #${id}`));
|
||||
await load();
|
||||
@@ -193,12 +198,31 @@ export default function AdminRedeemPage() {
|
||||
<Gift size={24} />
|
||||
{tx("管理员:积分兑换管理", "Admin: Redeem Management")}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600">
|
||||
<div className="mt-1 flex items-center gap-2 text-sm text-zinc-600">
|
||||
<p>{tx("管理兑换物品与全站兑换记录。", "Manage redeem items and global redeem records.")}</p>
|
||||
<HintTip title={tx("管理说明", "Management Notes")} align="left">
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
<li>
|
||||
{tx(
|
||||
"可在此添加/修改/下架全局兑换物品,并查看全站兑换记录。",
|
||||
"Add/update/disable global redeem items and view all redeem records here."
|
||||
"物品支持新增、编辑、下架;下架后不会影响历史记录。",
|
||||
"Items support create/update/disable; disabling does not affect historical records."
|
||||
)}
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
{tx(
|
||||
"可同时配置假期/学习日单价与持续时长(永久或分钟)。",
|
||||
"You can configure holiday/study-day cost and duration (permanent or minutes)."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{tx(
|
||||
"兑换记录支持按 user_id 筛选,便于核对扣分与备注。",
|
||||
"Redeem records can be filtered by user_id for auditing costs and notes."
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
{msg && <p className="mt-3 text-sm text-emerald-700">{msg}</p>}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { apiFetch, type RatingHistoryItem } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
@@ -30,12 +33,38 @@ type ListResp = {
|
||||
page_size: number;
|
||||
};
|
||||
|
||||
type SourceCrystalSettings = {
|
||||
monthly_interest_rate: number;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type UserSourceCrystalSummary = {
|
||||
user_id: number;
|
||||
balance: number;
|
||||
monthly_interest_rate: number;
|
||||
last_interest_at: number;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type UserSourceCrystalRecord = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
tx_type: string;
|
||||
amount: number;
|
||||
balance_after: number;
|
||||
note: string;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type SubmissionRow = {
|
||||
id: number;
|
||||
problem_id: number;
|
||||
status: string;
|
||||
score: number;
|
||||
language: string;
|
||||
rating_delta: number;
|
||||
time_ms: number;
|
||||
memory_kb: number;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
@@ -49,51 +78,171 @@ type RedeemRow = {
|
||||
};
|
||||
|
||||
function fmtTs(v: number): string {
|
||||
if (!v) return "-";
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
return formatUnixDateTime(v);
|
||||
}
|
||||
|
||||
function DetailPanel({ userId, tx }: { userId: number; tx: (zh: string, en: string) => string }) {
|
||||
const [tab, setTab] = useState<"subs" | "rating" | "redeem">("subs");
|
||||
const [tab, setTab] = useState<"subs" | "rating" | "redeem" | "crystal">("subs");
|
||||
const [subRange, setSubRange] = useState<"7d" | "30d" | "all">("all");
|
||||
const [subs, setSubs] = useState<SubmissionRow[]>([]);
|
||||
const [subsPage, setSubsPage] = useState(1);
|
||||
const [subsHasMore, setSubsHasMore] = useState(false);
|
||||
const [subsLoadingMore, setSubsLoadingMore] = useState(false);
|
||||
const [ratingH, setRatingH] = useState<RatingHistoryItem[]>([]);
|
||||
const [redeems, setRedeems] = useState<RedeemRow[]>([]);
|
||||
const [crystalSummary, setCrystalSummary] = useState<UserSourceCrystalSummary | null>(null);
|
||||
const [crystalRecords, setCrystalRecords] = useState<UserSourceCrystalRecord[]>([]);
|
||||
const [crystalAmount, setCrystalAmount] = useState("10");
|
||||
const [crystalNote, setCrystalNote] = useState("");
|
||||
const [crystalSaving, setCrystalSaving] = useState(false);
|
||||
const [crystalMsg, setCrystalMsg] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const token = readToken() ?? "";
|
||||
const subRangeDays = subRange === "7d" ? 7 : subRange === "30d" ? 30 : 0;
|
||||
const subRangeFromTs =
|
||||
subRangeDays > 0
|
||||
? Math.floor(Date.now() / 1000) - subRangeDays * 24 * 60 * 60
|
||||
: 0;
|
||||
const subRangeLabel =
|
||||
subRange === "7d"
|
||||
? tx("近7天", "Last 7 days")
|
||||
: subRange === "30d"
|
||||
? tx("近30天", "Last 30 days")
|
||||
: tx("全部", "All");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const loadTab = async () => {
|
||||
const subPageSize = 200;
|
||||
const loadSubs = async (page: number, append: boolean) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("user_id", String(userId));
|
||||
params.set("page", String(page));
|
||||
params.set("page_size", String(subPageSize));
|
||||
if (subRangeFromTs > 0) params.set("created_from", String(subRangeFromTs));
|
||||
const d = await apiFetch<{ items: SubmissionRow[] }>(
|
||||
`/api/v1/submissions?${params.toString()}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
const rows = d.items ?? [];
|
||||
setSubs((prev) => (append ? [...prev, ...rows] : rows));
|
||||
setSubsPage(page);
|
||||
setSubsHasMore(rows.length >= subPageSize);
|
||||
};
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (tab === "subs") {
|
||||
const d = await apiFetch<{ items: SubmissionRow[] }>(
|
||||
`/api/v1/submissions?user_id=${userId}&page=1&page_size=50`,
|
||||
undefined, token
|
||||
);
|
||||
setSubs(d.items ?? []);
|
||||
await loadSubs(1, false);
|
||||
} else if (tab === "rating") {
|
||||
const d = await apiFetch<RatingHistoryItem[]>(
|
||||
`/api/v1/admin/users/${userId}/rating-history?limit=100`,
|
||||
undefined, token
|
||||
);
|
||||
setRatingH(Array.isArray(d) ? d : []);
|
||||
} else {
|
||||
} else if (tab === "redeem") {
|
||||
const d = await apiFetch<RedeemRow[]>(
|
||||
`/api/v1/admin/redeem-records?user_id=${userId}&limit=100`,
|
||||
undefined, token
|
||||
);
|
||||
setRedeems(Array.isArray(d) ? d : []);
|
||||
} else {
|
||||
const [summary, records] = await Promise.all([
|
||||
apiFetch<UserSourceCrystalSummary>(
|
||||
`/api/v1/admin/users/${userId}/source-crystal`,
|
||||
undefined,
|
||||
token
|
||||
),
|
||||
apiFetch<UserSourceCrystalRecord[]>(
|
||||
`/api/v1/admin/users/${userId}/source-crystal/records?limit=100`,
|
||||
undefined,
|
||||
token
|
||||
),
|
||||
]);
|
||||
setCrystalSummary(summary ?? null);
|
||||
setCrystalRecords(Array.isArray(records) ? records : []);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false);
|
||||
};
|
||||
void loadTab();
|
||||
}, [tab, userId, token]);
|
||||
}, [tab, userId, token, subRangeFromTs]);
|
||||
|
||||
const loadMoreSubs = async () => {
|
||||
if (tab !== "subs" || !subsHasMore || subsLoadingMore) return;
|
||||
setSubsLoadingMore(true);
|
||||
const nextPage = subsPage + 1;
|
||||
const subPageSize = 200;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set("user_id", String(userId));
|
||||
params.set("page", String(nextPage));
|
||||
params.set("page_size", String(subPageSize));
|
||||
if (subRangeFromTs > 0) params.set("created_from", String(subRangeFromTs));
|
||||
const d = await apiFetch<{ items: SubmissionRow[] }>(
|
||||
`/api/v1/submissions?${params.toString()}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
const rows = d.items ?? [];
|
||||
setSubs((prev) => [...prev, ...rows]);
|
||||
setSubsPage(nextPage);
|
||||
setSubsHasMore(rows.length >= subPageSize);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setSubsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const tabCls = (t: string) =>
|
||||
`px-3 py-1 text-xs border ${tab === t ? "bg-zinc-900 text-white" : "bg-white text-zinc-700 hover:bg-zinc-100"}`;
|
||||
|
||||
const depositSourceCrystal = async () => {
|
||||
setCrystalMsg("");
|
||||
const amount = Number(crystalAmount);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
setCrystalMsg(tx("请输入大于 0 的存入数量", "Please enter a deposit amount > 0"));
|
||||
return;
|
||||
}
|
||||
setCrystalSaving(true);
|
||||
try {
|
||||
await apiFetch(
|
||||
`/api/v1/admin/users/${userId}/source-crystal/deposit`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ amount, note: crystalNote }),
|
||||
},
|
||||
token
|
||||
);
|
||||
setCrystalNote("");
|
||||
setCrystalMsg(tx("源晶存入成功", "Source crystal deposited"));
|
||||
const [summary, records] = await Promise.all([
|
||||
apiFetch<UserSourceCrystalSummary>(
|
||||
`/api/v1/admin/users/${userId}/source-crystal`,
|
||||
undefined,
|
||||
token
|
||||
),
|
||||
apiFetch<UserSourceCrystalRecord[]>(
|
||||
`/api/v1/admin/users/${userId}/source-crystal/records?limit=100`,
|
||||
undefined,
|
||||
token
|
||||
),
|
||||
]);
|
||||
setCrystalSummary(summary ?? null);
|
||||
setCrystalRecords(Array.isArray(records) ? records : []);
|
||||
} catch (e: unknown) {
|
||||
setCrystalMsg(String(e));
|
||||
} finally {
|
||||
setCrystalSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const crystalBalance = crystalSummary?.balance ?? 0;
|
||||
const crystalMonthlyRate = crystalSummary?.monthly_interest_rate ?? 0;
|
||||
const estimatedMonthlyInterest = Math.max(0, crystalBalance * crystalMonthlyRate);
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-50 border-t p-3 text-xs">
|
||||
<div className="flex gap-1 mb-2">
|
||||
@@ -106,15 +255,50 @@ function DetailPanel({ userId, tx }: { userId: number; tx: (zh: string, en: stri
|
||||
<button className={tabCls("redeem")} onClick={() => setTab("redeem")}>
|
||||
{tx("兑换记录", "Redeem Records")}
|
||||
</button>
|
||||
<button className={tabCls("crystal")} onClick={() => setTab("crystal")}>
|
||||
{tx("源晶管理", "Source Crystal")}
|
||||
</button>
|
||||
</div>
|
||||
{loading && <p className="text-zinc-500">{tx("加载中...", "Loading...")}</p>}
|
||||
{!loading && tab === "subs" && (
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
className={`rounded border px-2 py-1 text-[11px] ${subRange === "7d" ? "bg-zinc-900 text-white" : "bg-white hover:bg-zinc-100"}`}
|
||||
onClick={() => setSubRange("7d")}
|
||||
>
|
||||
{tx("近7天", "Last 7 days")}
|
||||
</button>
|
||||
<button
|
||||
className={`rounded border px-2 py-1 text-[11px] ${subRange === "30d" ? "bg-zinc-900 text-white" : "bg-white hover:bg-zinc-100"}`}
|
||||
onClick={() => setSubRange("30d")}
|
||||
>
|
||||
{tx("近30天", "Last 30 days")}
|
||||
</button>
|
||||
<button
|
||||
className={`rounded border px-2 py-1 text-[11px] ${subRange === "all" ? "bg-zinc-900 text-white" : "bg-white hover:bg-zinc-100"}`}
|
||||
onClick={() => setSubRange("all")}
|
||||
>
|
||||
{tx("全部", "All")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2 text-[11px] text-zinc-500">
|
||||
<span>{tx(`${subRangeLabel} 已加载 ${subs.length} 条`, `${subRangeLabel}: ${subs.length} loaded`)}</span>
|
||||
<Link
|
||||
href={`/submissions?user_id=${userId}${subRangeFromTs > 0 ? `&created_from=${subRangeFromTs}` : ""}`}
|
||||
className="underline text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
{tx("打开完整提交页", "Open full submissions page")}
|
||||
</Link>
|
||||
</div>
|
||||
<table className="min-w-full text-xs">
|
||||
<thead><tr className="text-left text-zinc-500">
|
||||
<th className="pr-2">ID</th><th className="pr-2">{tx("题目", "Problem")}</th>
|
||||
<th className="pr-2">{tx("状态", "Status")}</th><th className="pr-2">{tx("分数", "Score")}</th>
|
||||
<th className="pr-2">ΔR</th>
|
||||
<th className="pr-2">{tx("耗时", "Time")}</th>
|
||||
<th>{tx("时间", "Time")}</th>
|
||||
<th>{tx("详情", "Detail")}</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{subs.map((s) => (
|
||||
@@ -123,12 +307,35 @@ function DetailPanel({ userId, tx }: { userId: number; tx: (zh: string, en: stri
|
||||
<td className="pr-2">P{s.problem_id}</td>
|
||||
<td className={`pr-2 font-bold ${s.status === "AC" ? "text-emerald-600" : "text-red-600"}`}>{s.status}</td>
|
||||
<td className="pr-2">{s.score}</td>
|
||||
<td className={`pr-2 ${s.rating_delta > 0 ? "text-emerald-600" : s.rating_delta < 0 ? "text-red-600" : "text-zinc-500"}`}>
|
||||
{s.rating_delta > 0 ? `+${s.rating_delta}` : s.rating_delta}
|
||||
</td>
|
||||
<td className="pr-2">{s.time_ms}ms</td>
|
||||
<td className="text-zinc-500">{fmtTs(s.created_at)}</td>
|
||||
<td>
|
||||
<Link
|
||||
href={`/submissions/${s.id}`}
|
||||
className="underline text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
{tx("查看", "View")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{subs.length === 0 && <tr><td colSpan={5} className="text-zinc-400 py-2">{tx("无记录", "No records")}</td></tr>}
|
||||
{subs.length === 0 && <tr><td colSpan={8} className="text-zinc-400 py-2">{tx("无记录", "No records")}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{subsHasMore && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
className="rounded border px-2 py-1 text-xs hover:bg-zinc-100 disabled:opacity-60"
|
||||
onClick={() => void loadMoreSubs()}
|
||||
disabled={subsLoadingMore}
|
||||
>
|
||||
{subsLoadingMore ? tx("加载中...", "Loading...") : tx("加载更多", "Load more")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!loading && tab === "rating" && (
|
||||
@@ -168,6 +375,70 @@ function DetailPanel({ userId, tx }: { userId: number; tx: (zh: string, en: stri
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{!loading && tab === "crystal" && (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border bg-white p-2 text-xs">
|
||||
<p>
|
||||
{tx("当前余额", "Current Balance")}:{" "}
|
||||
<span className="font-bold text-[color:var(--mc-diamond)]">
|
||||
{crystalBalance.toFixed(2)}
|
||||
</span>
|
||||
<span className="ml-2 text-[11px] text-zinc-500">
|
||||
{tx("预计月息", "Est. monthly interest")}: +{estimatedMonthlyInterest.toFixed(2)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-zinc-500">
|
||||
{tx("月利率", "Monthly rate")}: {(crystalMonthlyRate * 100).toFixed(2)}% ·{" "}
|
||||
{tx("上次计息", "Last interest")}: {crystalSummary ? fmtTs(crystalSummary.last_interest_at) : "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="w-24 rounded border px-2 py-1 text-xs"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
value={crystalAmount}
|
||||
onChange={(e) => setCrystalAmount(e.target.value)}
|
||||
placeholder={tx("数量", "Amount")}
|
||||
/>
|
||||
<input
|
||||
className="flex-1 min-w-40 rounded border px-2 py-1 text-xs"
|
||||
value={crystalNote}
|
||||
onChange={(e) => setCrystalNote(e.target.value)}
|
||||
placeholder={tx("备注(可选)", "Note (optional)")}
|
||||
/>
|
||||
<button
|
||||
className="rounded border px-2 py-1 text-xs hover:bg-zinc-100 disabled:opacity-60"
|
||||
onClick={() => void depositSourceCrystal()}
|
||||
disabled={crystalSaving}
|
||||
>
|
||||
{crystalSaving ? tx("存入中...", "Depositing...") : tx("管理员存入", "Admin Deposit")}
|
||||
</button>
|
||||
</div>
|
||||
{crystalMsg && <p className="text-xs text-zinc-600">{crystalMsg}</p>}
|
||||
<div className="max-h-48 overflow-y-auto rounded border bg-white p-2">
|
||||
{crystalRecords.map((r) => (
|
||||
<div key={r.id} className="flex justify-between border-b border-zinc-100 py-1 text-xs">
|
||||
<span>
|
||||
<span className={r.amount >= 0 ? "font-bold text-emerald-700" : "font-bold text-red-600"}>
|
||||
{r.amount >= 0 ? "+" : ""}
|
||||
{r.amount.toFixed(2)}
|
||||
</span>
|
||||
<span className="ml-2">{r.tx_type}</span>
|
||||
{r.note ? <span className="ml-2 text-zinc-500">· {r.note}</span> : null}
|
||||
</span>
|
||||
<span className="text-zinc-500">
|
||||
{tx("余额", "Bal")}: {r.balance_after.toFixed(2)} · {fmtTs(r.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{crystalRecords.length === 0 && (
|
||||
<p className="text-xs text-zinc-400">{tx("暂无源晶流水", "No source crystal records")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -179,6 +450,9 @@ export default function AdminUsersPage() {
|
||||
const [error, setError] = useState("");
|
||||
const [msg, setMsg] = useState("");
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [monthlyInterestRate, setMonthlyInterestRate] = useState(0.02);
|
||||
const [rateUpdatedAt, setRateUpdatedAt] = useState<number | null>(null);
|
||||
const [savingRate, setSavingRate] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
@@ -186,8 +460,15 @@ export default function AdminUsersPage() {
|
||||
try {
|
||||
const token = readToken();
|
||||
if (!token) throw new Error(tx("请先登录管理员账号", "Please sign in with admin account first"));
|
||||
const data = await apiFetch<ListResp>("/api/v1/admin/users?page=1&page_size=200", undefined, token);
|
||||
const [data, settings] = await Promise.all([
|
||||
apiFetch<ListResp>("/api/v1/admin/users?page=1&page_size=200", undefined, token),
|
||||
apiFetch<SourceCrystalSettings>("/api/v1/admin/source-crystal/settings", undefined, token),
|
||||
]);
|
||||
setItems(data.items ?? []);
|
||||
setMonthlyInterestRate(
|
||||
Number.isFinite(settings?.monthly_interest_rate) ? settings.monthly_interest_rate : 0.02
|
||||
);
|
||||
setRateUpdatedAt(Number.isFinite(settings?.updated_at) ? settings.updated_at : null);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
@@ -221,16 +502,67 @@ export default function AdminUsersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const saveMonthlyInterestRate = async () => {
|
||||
setMsg("");
|
||||
setError("");
|
||||
setSavingRate(true);
|
||||
try {
|
||||
const token = readToken();
|
||||
if (!token) throw new Error(tx("请先登录管理员账号", "Please sign in with admin account first"));
|
||||
const nextRate = Math.max(0, Math.min(1, Number(monthlyInterestRate) || 0));
|
||||
const resp = await apiFetch<SourceCrystalSettings>(
|
||||
"/api/v1/admin/source-crystal/settings",
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ monthly_interest_rate: nextRate }),
|
||||
},
|
||||
token
|
||||
);
|
||||
setMonthlyInterestRate(resp.monthly_interest_rate);
|
||||
setRateUpdatedAt(resp.updated_at);
|
||||
setMsg(
|
||||
tx(
|
||||
`已更新源晶月利率为 ${(resp.monthly_interest_rate * 100).toFixed(2)}%`,
|
||||
`Updated source crystal monthly rate to ${(resp.monthly_interest_rate * 100).toFixed(2)}%`
|
||||
)
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSavingRate(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl flex items-center gap-2">
|
||||
<Users size={24} />
|
||||
{tx("管理员用户与积分", "Admin Users & Rating")}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600">
|
||||
{tx("默认管理员账号:", "Default admin account: ")}
|
||||
<code>admin</code> / <code>whoami139</code>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-zinc-600">
|
||||
<p>
|
||||
{tx(
|
||||
"管理员入口(账号与密码请通过安全渠道配置)",
|
||||
"Admin entry (account/password should be managed via secure channels)"
|
||||
)}
|
||||
</p>
|
||||
<HintTip title={tx("页面说明", "Page Notes")} align="left">
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
<li>
|
||||
{tx(
|
||||
"可直接修改并保存用户 Rating,实时刷新统计。",
|
||||
"You can update and save user rating directly with live refresh."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{tx(
|
||||
"展开详情可查看该用户提交、积分历史、兑换记录。",
|
||||
"Expand a row to inspect submissions, rating history, and redeem records."
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -243,6 +575,40 @@ export default function AdminUsersPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="mt-4 rounded-xl border bg-white p-4">
|
||||
<h2 className="text-sm font-semibold">{tx("源晶月利率设置", "Source Crystal Monthly Rate")}</h2>
|
||||
<p className="mt-1 text-xs text-zinc-600">
|
||||
{tx(
|
||||
"默认 0.02(2%/月)。修改后将用于后续计息。",
|
||||
"Default is 0.02 (2%/month). New value applies to future interest accrual."
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="w-36 rounded border px-3 py-2 text-sm"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.001}
|
||||
value={monthlyInterestRate}
|
||||
onChange={(e) => setMonthlyInterestRate(Number(e.target.value))}
|
||||
/>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{tx("当前显示", "Preview")}: {(Number(monthlyInterestRate) * 100).toFixed(2)}%
|
||||
</span>
|
||||
<button
|
||||
className="rounded border px-3 py-2 text-xs hover:bg-zinc-100 disabled:opacity-60"
|
||||
onClick={() => void saveMonthlyInterestRate()}
|
||||
disabled={savingRate}
|
||||
>
|
||||
{savingRate ? tx("保存中...", "Saving...") : tx("保存月利率", "Save Rate")}
|
||||
</button>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{tx("更新时间", "Updated")}: {rateUpdatedAt ? fmtTs(rateUpdatedAt) : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{msg && <p className="mt-3 text-sm text-emerald-700">{msg}</p>}
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
|
||||
export default function AdminEntryPage() {
|
||||
@@ -16,10 +17,12 @@ export default function AdminEntryPage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-10">
|
||||
<h1 className="text-2xl font-semibold">{t("admin.entry.title")}</h1>
|
||||
<p className="mt-3 text-sm text-zinc-600">{t("admin.entry.desc")}</p>
|
||||
<p className="mt-2 text-sm text-zinc-500">
|
||||
{t("admin.entry.moved_to_platform")}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2 text-sm text-zinc-600">
|
||||
<p>{t("admin.entry.desc")}</p>
|
||||
<HintTip title="Hint" align="left">
|
||||
<p>{t("admin.entry.moved_to_platform")}</p>
|
||||
</HintTip>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { FileCode, ArrowLeft } from "lucide-react";
|
||||
|
||||
import { API_BASE, apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
|
||||
const SwaggerUI = dynamic(() => import("swagger-ui-react"), { ssr: false });
|
||||
@@ -14,62 +14,6 @@ const SwaggerUI = dynamic(() => import("swagger-ui-react"), { ssr: false });
|
||||
export default function ApiDocsPage() {
|
||||
const { tx } = useI18nText();
|
||||
const specUrl = useMemo(() => `${API_BASE}/api/openapi.json`, []);
|
||||
const [checkingAdmin, setCheckingAdmin] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
const checkAdmin = async () => {
|
||||
const token = readToken();
|
||||
if (!token) {
|
||||
if (!canceled) {
|
||||
setIsAdmin(false);
|
||||
setError(tx("请先登录管理员账号", "Please sign in with admin account first"));
|
||||
setCheckingAdmin(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const me = await apiFetch<{ username?: string }>("/api/v1/me", {}, token);
|
||||
if (!canceled) {
|
||||
const allowed = (me?.username ?? "") === "admin";
|
||||
setIsAdmin(allowed);
|
||||
setError(allowed ? "" : tx("仅管理员可查看 API 文档", "API docs are visible to admin only"));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (!canceled) {
|
||||
setIsAdmin(false);
|
||||
setError(String(e));
|
||||
}
|
||||
} finally {
|
||||
if (!canceled) setCheckingAdmin(false);
|
||||
}
|
||||
};
|
||||
void checkAdmin();
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [tx]);
|
||||
|
||||
if (checkingAdmin) {
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-3 py-8 text-sm text-zinc-600">
|
||||
{tx("正在校验管理员权限...", "Checking admin access...")}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-3 py-8">
|
||||
<h1 className="text-xl font-semibold">{tx("API 文档(Swagger)", "API Docs (Swagger)")}</h1>
|
||||
<p className="mt-3 text-sm text-red-600">
|
||||
{error || tx("仅管理员可查看此页面", "This page is available for admin only")}
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-3 py-5 max-[390px]:px-2 sm:px-4 md:px-6 md:py-6">
|
||||
@@ -83,6 +27,25 @@ export default function ApiDocsPage() {
|
||||
{tx("返回", "Back")}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mb-3 flex items-center gap-2 text-sm text-zinc-600">
|
||||
<p>{tx("查看与调试平台 API。", "Inspect and debug platform APIs.")}</p>
|
||||
<HintTip title={tx("使用说明", "Usage Notes")} align="left">
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
<li>
|
||||
{tx(
|
||||
"文档来源于后端 OpenAPI:/api/openapi.json。",
|
||||
"Docs are generated from backend OpenAPI: /api/openapi.json."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{tx(
|
||||
"鉴权支持 Bearer Token 与 Basic(账号:密码);管理员接口仍需管理员账号。",
|
||||
"Protected APIs support Bearer token and Basic (username:password); admin endpoints still require admin account."
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</HintTip>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border bg-white p-2">
|
||||
<SwaggerUI url={specUrl} docExpansion="list" defaultModelsExpandDepth={1} />
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Eye, EyeOff, Key, LogIn, User, UserPlus } from "lucide-react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { API_BASE, apiFetch } from "@/lib/api";
|
||||
import { readToken, saveToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
@@ -26,8 +27,8 @@ export default function AuthPage() {
|
||||
const [checkingAuth, setCheckingAuth] = useState(true);
|
||||
|
||||
const [mode, setMode] = useState<"register" | "login">("login");
|
||||
const [username, setUsername] = useState(process.env.NEXT_PUBLIC_TEST_USERNAME ?? "");
|
||||
const [password, setPassword] = useState(process.env.NEXT_PUBLIC_TEST_PASSWORD ?? "");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -47,6 +48,7 @@ export default function AuthPage() {
|
||||
mode === "register" && password !== confirmPassword ? tx("两次密码不一致", "Passwords do not match") : "";
|
||||
|
||||
const canSubmit = !loading && !usernameErr && !passwordErr && !confirmErr;
|
||||
const disabledReason = usernameErr || passwordErr || confirmErr;
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit) return;
|
||||
@@ -88,13 +90,14 @@ export default function AuthPage() {
|
||||
<h1 className="text-2xl font-bold text-[color:var(--mc-diamond)] mc-text-shadow leading-relaxed">
|
||||
{tx("欢迎回来,冒险者!", "Welcome Back, Adventurer!")}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm text-[color:var(--mc-stone)] font-mono">
|
||||
{tx("登录服务器以访问任务布告栏、保存冒险进度、查看错题卷轴和个人成就。", "Login to access Quest Board, save Game Progress, review Grimoire, and track Achievements.")}
|
||||
</p>
|
||||
<div className="mt-6 space-y-2 text-sm text-[color:var(--mc-stone)] font-mono">
|
||||
<p>{tx("• 任务按 CSP-J / CSP-S / NOIP 难度分级", "• Quests organized by CSP-J / CSP-S / NOIP Tiers")}</p>
|
||||
<p>{tx("• 任务卷轴支持本地草稿与试炼运行", "• Quest Scrolls support local drafting and trial runs")}</p>
|
||||
<p>{tx("• 先知题解异步生成,包含多种解法", "• Oracles provide asynchronous wisdom with multiple paths")}</p>
|
||||
<div className="mt-3 inline-flex items-center gap-2 text-sm text-[color:var(--mc-stone)] font-mono">
|
||||
<span>{tx("登录后可同步你的学习进度。", "Sign in to sync your learning progress.")}</span>
|
||||
<HintTip title={tx("平台说明", "Platform Overview")} align="left">
|
||||
{tx(
|
||||
"登录后可访问任务布告栏、保存草稿、查看错题卷轴和个人成长。题目按 CSP-J/CSP-S/NOIP 分级,题解支持异步生成。",
|
||||
"After sign-in you can access quests, save drafts, review wrong-book notes, and track growth. Problems are tiered by CSP-J/CSP-S/NOIP, and solutions are generated asynchronously."
|
||||
)}
|
||||
</HintTip>
|
||||
</div>
|
||||
<p className="mt-6 text-xs text-[color:var(--mc-stone-dark)]">
|
||||
Server API: <span className="font-mono text-[color:var(--mc-red)]">{apiBase}</span>
|
||||
@@ -116,7 +119,7 @@ export default function AuthPage() {
|
||||
disabled={loading}
|
||||
>
|
||||
<LogIn size={16} />
|
||||
{tx("登录服务器", "Login")}
|
||||
{tx("已有账号", "Sign In")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -131,7 +134,7 @@ export default function AuthPage() {
|
||||
disabled={loading}
|
||||
>
|
||||
<UserPlus size={16} />
|
||||
{tx("新玩家注册", "New Player")}
|
||||
{tx("新玩家注册", "Create Account")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -197,12 +200,28 @@ export default function AuthPage() {
|
||||
</label>
|
||||
|
||||
<button
|
||||
className={`w-full mc-btn ${mode === "register" ? "mc-btn-success" : ""}`}
|
||||
className={`w-full min-h-[44px] mc-btn ${mode === "register" ? "mc-btn-success" : ""}`}
|
||||
onClick={() => void submit()}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{loading ? tx("连接中...", "Connecting...") : mode === "register" ? tx("创建档案并连接", "Create & Connect") : tx("连接服务器", "Connect")}
|
||||
{loading
|
||||
? tx("连接中...", "Signing in...")
|
||||
: mode === "register"
|
||||
? tx("创建账号", "Create Account")
|
||||
: tx("登录", "Sign In")}
|
||||
</button>
|
||||
{!canSubmit && !loading && disabledReason && (
|
||||
<p className="text-xs text-[color:var(--mc-gold)]">
|
||||
{tx("当前不可提交:", "Cannot submit yet: ")}
|
||||
{disabledReason}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-[color:var(--mc-stone-dark)]">
|
||||
<span>{tx("只想先看看内容?", "Just want to browse first?")}</span>
|
||||
<Link className="underline text-[color:var(--mc-diamond)] hover:text-[color:var(--mc-gold)]" href="/problems">
|
||||
{tx("游客模式", "Guest Mode")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resp && (
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { Activity, AlertCircle, List, Play, RefreshCw, Server, Trash2, Zap } from "lucide-react";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
import { Activity, AlertCircle, List, RefreshCw, Server, Zap } from "lucide-react";
|
||||
|
||||
type BackendLogItem = {
|
||||
id: number;
|
||||
@@ -75,8 +77,72 @@ type AdminUsersResp = {
|
||||
};
|
||||
|
||||
function fmtTs(v: number | null | undefined): string {
|
||||
if (!v) return "-";
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
return formatUnixDateTime(v);
|
||||
}
|
||||
|
||||
function renderStatusLabel(raw: string, tx: (zhText: string, enText: string) => string): string {
|
||||
const value = raw.toLowerCase();
|
||||
if (value.includes("queue") || value === "queued" || value === "pending") {
|
||||
return tx("排队中", "Queued");
|
||||
}
|
||||
if (value.includes("run") || value === "running" || value === "processing") {
|
||||
return tx("施法中", "Running");
|
||||
}
|
||||
if (value.includes("success") || value === "done" || value === "completed") {
|
||||
return tx("已完成", "Completed");
|
||||
}
|
||||
if (value.includes("fail") || value === "error") {
|
||||
return tx("失败", "Failed");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function statusToneClass(raw: string): string {
|
||||
const value = raw.toLowerCase();
|
||||
if (value.includes("queue") || value === "queued" || value === "pending") {
|
||||
return "mc-status-warning text-amber-700";
|
||||
}
|
||||
if (value.includes("run") || value === "running" || value === "processing") {
|
||||
return "mc-status-running text-blue-700";
|
||||
}
|
||||
if (value.includes("success") || value === "done" || value === "completed") {
|
||||
return "mc-status-success text-emerald-700";
|
||||
}
|
||||
if (value.includes("fail") || value === "error") {
|
||||
return "mc-status-danger text-red-700";
|
||||
}
|
||||
return "mc-status-muted text-zinc-700";
|
||||
}
|
||||
|
||||
function resolveAccessIssue(
|
||||
hasToken: boolean,
|
||||
rawError: string,
|
||||
tx: (zhText: string, enText: string) => string
|
||||
): { kind: "forbidden" | "expired" | "signin"; title: string; detail: string } {
|
||||
if (!hasToken) {
|
||||
return {
|
||||
kind: "signin",
|
||||
title: tx("登录状态缺失", "Not Signed In"),
|
||||
detail: tx("请先登录,再尝试访问后台日志。", "Please sign in first, then try again."),
|
||||
};
|
||||
}
|
||||
const normalized = rawError.toLowerCase();
|
||||
if (
|
||||
normalized.includes("expired") ||
|
||||
normalized.includes("invalid or expired token") ||
|
||||
normalized.includes("token")
|
||||
) {
|
||||
return {
|
||||
kind: "expired",
|
||||
title: tx("登录已过期", "Session Expired"),
|
||||
detail: tx("当前登录令牌已失效,请重新登录后再访问。", "Your token is no longer valid. Please sign in again."),
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "forbidden",
|
||||
title: tx("权限不足", "No Permission"),
|
||||
detail: tx("该页面仅管理员可见。", "This page is for administrators only."),
|
||||
};
|
||||
}
|
||||
|
||||
export default function BackendLogsPage() {
|
||||
@@ -265,12 +331,35 @@ export default function BackendLogsPage() {
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
const issue = resolveAccessIssue(Boolean(token), error, tx);
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-3 py-8">
|
||||
<h1 className="text-xl font-semibold">{tx("后台日志(题解异步队列)", "Backend Logs (Async Solution Queue)")}</h1>
|
||||
<p className="mt-3 text-sm text-red-600">
|
||||
{error || tx("仅管理员可查看此页面", "This page is available for admin only")}
|
||||
</p>
|
||||
<div className="mt-4 rounded-xl border-[3px] border-black bg-[color:var(--surface)] p-4 text-sm shadow-[4px_4px_0_rgba(0,0,0,0.45)]">
|
||||
<div className="flex items-center gap-2 text-[color:var(--mc-red)]">
|
||||
<AlertCircle size={18} />
|
||||
<p className="font-bold">{issue.title}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-zinc-300">{issue.detail}</p>
|
||||
{!!error && (
|
||||
<details className="mt-3 text-xs text-zinc-400">
|
||||
<summary className="cursor-pointer">{tx("查看原始错误", "Show raw error")}</summary>
|
||||
<pre className="mt-2 overflow-auto whitespace-pre-wrap rounded border border-black bg-black/40 p-2">
|
||||
{error}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Link className="mc-btn mc-btn-primary min-h-[44px]" href="/problems">
|
||||
{tx("返回任务板", "Back to Quest Board")}
|
||||
</Link>
|
||||
<Link className="mc-btn min-h-[44px]" href="/auth">
|
||||
{issue.kind === "expired" || issue.kind === "signin"
|
||||
? tx("重新登录", "Sign In Again")
|
||||
: tx("切换管理员账号", "Switch Admin Account")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -316,12 +405,31 @@ export default function BackendLogsPage() {
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
{triggerMsg && <p className="mt-3 text-sm text-emerald-700">{triggerMsg}</p>}
|
||||
{userMsg && <p className="mt-3 text-sm text-emerald-700">{userMsg}</p>}
|
||||
<p className="mt-3 text-xs text-zinc-500">
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-zinc-500">
|
||||
<p>{tx("队列会自动异步处理。", "Queue is processed automatically in async mode.")}</p>
|
||||
<HintTip title={tx("队列说明", "Queue Notes")} align="left">
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
<li>
|
||||
{tx(
|
||||
"系统已自动单线程异步处理待队列任务,无需手工点击;上方按钮仅用于立即手动补全。",
|
||||
"System auto-processes queued jobs in single-thread async mode; the button above is only for manual trigger."
|
||||
"页面每 5 秒自动刷新一次运行态与排队态。",
|
||||
"This page auto-refreshes running and queued states every 5 seconds."
|
||||
)}
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
{tx(
|
||||
"“手动补全”只是在当前时刻额外触发一次补题,不影响后台常驻处理。",
|
||||
"\"Manual fill\" triggers one extra generation batch and does not replace background processing."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{tx(
|
||||
"删除用户会级联删除其提交、错题本、草稿与积分记录。",
|
||||
"Deleting a user cascades submissions, wrong-book, drafts, and rating records."
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
<section className="mt-4 rounded-xl border bg-white p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
@@ -420,7 +528,12 @@ export default function BackendLogsPage() {
|
||||
{tx("任务", "Job")} #{job.id} · {tx("题目", "Problem")} #{job.problem_id} {job.problem_title || tx("(未命名题目)", "(Untitled)")}
|
||||
</p>
|
||||
<p className="text-zinc-600">
|
||||
{tx("状态", "Status")} {job.status} · {tx("进度", "Progress")} {job.progress}% · {tx("开始", "Start")} {fmtTs(job.started_at ?? null)}
|
||||
{tx("状态", "Status")}{" "}
|
||||
<span className={statusToneClass(job.status)}>
|
||||
{renderStatusLabel(job.status, tx)} ({job.status})
|
||||
</span>{" "}
|
||||
· {tx("进度", "Progress")} {job.progress}% · {tx("开始", "Start")}{" "}
|
||||
{fmtTs(job.started_at ?? null)}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap break-words text-zinc-600">{job.message || "-"}</p>
|
||||
</li>
|
||||
@@ -445,7 +558,12 @@ export default function BackendLogsPage() {
|
||||
{tx("任务", "Job")} #{job.id} · {tx("题目", "Problem")} #{job.problem_id} {job.problem_title || tx("(未命名题目)", "(Untitled)")}
|
||||
</p>
|
||||
<p className="text-zinc-600">
|
||||
{tx("状态", "Status")} {job.status} · {tx("进度", "Progress")} {job.progress}% · {tx("更新", "Updated")} {fmtTs(job.updated_at)}
|
||||
{tx("状态", "Status")}{" "}
|
||||
<span className={statusToneClass(job.status)}>
|
||||
{renderStatusLabel(job.status, tx)} ({job.status})
|
||||
</span>{" "}
|
||||
· {tx("进度", "Progress")} {job.progress}% · {tx("更新", "Updated")}{" "}
|
||||
{fmtTs(job.updated_at)}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap break-words text-zinc-600">{job.message || "-"}</p>
|
||||
</li>
|
||||
@@ -464,7 +582,10 @@ export default function BackendLogsPage() {
|
||||
{tx("任务", "Job")} #{item.id}
|
||||
</p>
|
||||
<span className={item.runner_pending ? "text-emerald-700" : "text-zinc-700"}>
|
||||
{item.status} · {item.progress}%
|
||||
<span className={statusToneClass(item.status)}>
|
||||
{renderStatusLabel(item.status, tx)} ({item.status})
|
||||
</span>{" "}
|
||||
· {item.progress}%
|
||||
</span>
|
||||
</div>
|
||||
<Link className="text-blue-600 hover:underline" href={`/problems/${item.problem_id}`}>
|
||||
@@ -504,7 +625,9 @@ export default function BackendLogsPage() {
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<span className={item.runner_pending ? "text-emerald-700" : "text-zinc-700"}>
|
||||
{item.status}
|
||||
<span className={statusToneClass(item.status)}>
|
||||
{renderStatusLabel(item.status, tx)} ({item.status})
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2">{item.progress}%</td>
|
||||
|
||||
@@ -4,9 +4,12 @@ import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { PageCrumbs } from "@/components/page-crumbs";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
|
||||
type Contest = {
|
||||
id: number;
|
||||
@@ -81,9 +84,35 @@ export default function ContestDetailPage() {
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
||||
<PageCrumbs
|
||||
items={[
|
||||
{ label: tx("主城", "Town"), href: "/" },
|
||||
{ label: tx("副本", "Dungeon"), href: "/contests" },
|
||||
{ label: `#${contestId}` },
|
||||
]}
|
||||
/>
|
||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
||||
{tx("比赛详情", "Contest Detail")} #{contestId}
|
||||
</h1>
|
||||
<div className="mt-2 flex items-center gap-2 text-sm text-zinc-600">
|
||||
<p>{tx("查看赛程、题目与当前榜单。", "View schedule, problems, and current leaderboard.")}</p>
|
||||
<HintTip title={tx("页面说明", "Page Notes")} align="left">
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
<li>
|
||||
{tx(
|
||||
"可报名后再开始提交;重复点击报名用于刷新状态。",
|
||||
"Register first before submitting; clicking register again refreshes status."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{tx(
|
||||
"Penalty 为罚时秒数,Solved 为已解题数。",
|
||||
"Penalty is time penalty in seconds, Solved is number of solved problems."
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</HintTip>
|
||||
</div>
|
||||
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>}
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
@@ -92,12 +121,17 @@ export default function ContestDetailPage() {
|
||||
<section className="rounded-xl border bg-white p-4">
|
||||
<h2 className="text-lg font-medium">{detail.contest.title}</h2>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{new Date(detail.contest.starts_at * 1000).toLocaleString()} - {" "}
|
||||
{new Date(detail.contest.ends_at * 1000).toLocaleString()}
|
||||
{formatUnixDateTime(detail.contest.starts_at)} - {" "}
|
||||
{formatUnixDateTime(detail.contest.ends_at)}
|
||||
</p>
|
||||
<pre className="mt-3 overflow-x-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||
<details className="mt-3 rounded border border-zinc-200 bg-zinc-50 p-2">
|
||||
<summary className="cursor-pointer text-xs font-medium text-zinc-700">
|
||||
{tx("查看完整规则 JSON", "View full rule JSON")}
|
||||
</summary>
|
||||
<pre className="mt-2 overflow-x-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||
{detail.contest.rule_json}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<button
|
||||
className="mt-3 w-full rounded bg-zinc-900 px-4 py-2 text-white sm:w-auto"
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
import { Calendar, Swords, Timer, Trophy, Shield } from "lucide-react";
|
||||
import { Calendar, Shield, Swords, Timer } from "lucide-react";
|
||||
|
||||
type Contest = {
|
||||
id: number;
|
||||
@@ -47,6 +49,12 @@ export default function ContestsPage() {
|
||||
<span className="flex items-center gap-2">
|
||||
<Swords size={24} />
|
||||
{tx("突袭公告板", "Raid Board")}
|
||||
<HintTip title={tx("比赛说明", "Contest Notes")} align="left">
|
||||
{tx(
|
||||
"比赛页展示时间窗口与入口。进入详情后可报名、查看题单和排行榜。",
|
||||
"This page shows contest windows and entries. Open contest details to register, view problem sets, and check leaderboard."
|
||||
)}
|
||||
</HintTip>
|
||||
</span>
|
||||
) : (
|
||||
tx("模拟竞赛", "Contests")
|
||||
@@ -74,11 +82,11 @@ export default function ContestsPage() {
|
||||
<div className={`mt-2 text-xs flex flex-col gap-1 ${isMc ? "text-zinc-400" : "text-zinc-500"}`}>
|
||||
<p className="flex items-center gap-2">
|
||||
{isMc && <Calendar size={14} />}
|
||||
{tx("开始", "Start")}: {new Date(c.starts_at * 1000).toLocaleString()}
|
||||
{tx("开始", "Start")}: {formatUnixDateTime(c.starts_at)}
|
||||
</p>
|
||||
<p className="flex items-center gap-2">
|
||||
{isMc && <Timer size={14} />}
|
||||
{tx("结束", "End")}: {new Date(c.ends_at * 1000).toLocaleString()}
|
||||
{tx("结束", "End")}: {formatUnixDateTime(c.ends_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { Activity, HardDrive, Play, RefreshCw, Server, FileText, CheckCircle, XCircle, Clock } from "lucide-react";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
import { Activity, HardDrive, Play, RefreshCw, Server } from "lucide-react";
|
||||
|
||||
type ImportJob = {
|
||||
id: number;
|
||||
@@ -55,8 +57,7 @@ type MeProfile = {
|
||||
};
|
||||
|
||||
function fmtTs(v: number | null | undefined): string {
|
||||
if (!v) return "-";
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
return formatUnixDateTime(v);
|
||||
}
|
||||
|
||||
type ImportJobOptions = {
|
||||
@@ -78,6 +79,23 @@ function parseOptions(raw: string): ImportJobOptions | null {
|
||||
}
|
||||
}
|
||||
|
||||
function statusToneClass(raw: string): string {
|
||||
const value = raw.toLowerCase();
|
||||
if (value.includes("queue") || value === "queued" || value === "pending") {
|
||||
return "mc-status-warning text-amber-700";
|
||||
}
|
||||
if (value.includes("run") || value === "running" || value === "processing") {
|
||||
return "mc-status-running text-blue-700";
|
||||
}
|
||||
if (value.includes("success") || value === "done" || value === "completed") {
|
||||
return "mc-status-success text-emerald-700";
|
||||
}
|
||||
if (value.includes("fail") || value === "failed" || value === "error") {
|
||||
return "mc-status-danger text-red-700";
|
||||
}
|
||||
return "mc-status-muted text-zinc-700";
|
||||
}
|
||||
|
||||
export default function ImportsPage() {
|
||||
const { tx } = useI18nText();
|
||||
const [token, setToken] = useState("");
|
||||
@@ -254,15 +272,26 @@ export default function ImportsPage() {
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl flex items-center gap-2">
|
||||
<HardDrive size={24} />
|
||||
{tx("题库导入/出题任务", "Import / Generation Jobs")}
|
||||
</h1>
|
||||
<HintTip title={tx("管理说明", "Management Guide")}>
|
||||
{tx(
|
||||
"该页面仅管理员可用。支持 Luogu 导入与本地 PDF + RAG 出题两种模式,建议先小规模验证再扩大批量。",
|
||||
"Admin-only page. Supports Luogu import and Local PDF + RAG generation. Start with a small batch before scaling up."
|
||||
)}
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
<section className="mt-4 rounded-xl border bg-white p-4">
|
||||
<h2 className="text-base font-medium">{tx("平台管理快捷入口(原 /admin139)", "Platform Shortcuts (moved from /admin139)")}</h2>
|
||||
<p className="mt-1 text-xs text-zinc-600">
|
||||
{tx("默认管理员账号:admin / whoami139", "Default admin account: admin / whoami139")}
|
||||
{tx(
|
||||
"管理员凭据已配置,请使用授权账号登录。",
|
||||
"Admin credentials are configured separately. Sign in with an authorized account."
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<Link className="rounded border bg-zinc-50 px-3 py-2 text-sm hover:bg-zinc-100" href="/auth">
|
||||
@@ -369,22 +398,24 @@ export default function ImportsPage() {
|
||||
{running ? tx("运行中", "Running") : tx("空闲", "Idle")}
|
||||
</span>
|
||||
</div>
|
||||
{runMode === "luogu" && (
|
||||
<p className="mt-2 text-xs text-zinc-500">
|
||||
{tx(
|
||||
"抓取洛谷 CSP-J/CSP-S/NOIP 标签题;容器重启后可自动触发(可通过环境变量关闭)。",
|
||||
"Fetch Luogu problems tagged CSP-J/CSP-S/NOIP. It can auto-start after container restart (configurable via env)."
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{runMode === "local_pdf_rag" && (
|
||||
<p className="mt-2 text-xs text-zinc-500">
|
||||
{tx(
|
||||
"从本地 PDF 提取文本做 RAG,调用 LLM 生成 CSP-J/S 题目,按现有题库难度分布补齐到目标题量并自动去重跳过。",
|
||||
"Extract text from local PDFs for RAG, then call LLM to generate CSP-J/S problems with dedupe and target distribution."
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-zinc-500 inline-flex items-center gap-2">
|
||||
<span>
|
||||
{runMode === "luogu"
|
||||
? tx("当前模式:Luogu 标签导入。", "Mode: Luogu tag import.")
|
||||
: tx("当前模式:本地 PDF + RAG 出题。", "Mode: Local PDF + RAG generation.")}
|
||||
</span>
|
||||
<HintTip title={tx("模式说明", "Mode Notes")} align="left">
|
||||
{runMode === "luogu"
|
||||
? tx(
|
||||
"抓取洛谷 CSP-J/CSP-S/NOIP 标签题。可按需选择启动前清空历史题库。",
|
||||
"Fetch Luogu problems tagged CSP-J/CSP-S/NOIP. You can choose to clear historical problem sets before start."
|
||||
)
|
||||
: tx(
|
||||
"从本地 PDF 提取文本做 RAG,再调用 LLM 生成题目。系统会按目标规模与去重策略补齐题库。",
|
||||
"Extract text from local PDFs for RAG and generate problems with LLM. The system fills the set based on target size and dedupe strategy."
|
||||
)}
|
||||
</HintTip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
@@ -395,7 +426,8 @@ export default function ImportsPage() {
|
||||
{job && (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
<p>
|
||||
{tx("任务", "Job")} #{job.id} · {tx("状态", "Status")} <b>{job.status}</b> · {tx("触发方式", "Trigger")} {job.trigger}
|
||||
{tx("任务", "Job")} #{job.id} · {tx("状态", "Status")}{" "}
|
||||
<b className={statusToneClass(job.status)}>{job.status}</b> · {tx("触发方式", "Trigger")} {job.trigger}
|
||||
</p>
|
||||
<p className="text-zinc-600">
|
||||
{tx("模式", "Mode")} {jobOpts?.mode || jobOpts?.source || "luogu"} · {tx("线程", "Workers")} {jobOpts?.workers ?? "-"}
|
||||
@@ -452,7 +484,7 @@ export default function ImportsPage() {
|
||||
<p className="font-medium">
|
||||
{tx("明细", "Detail")} #{item.id}
|
||||
</p>
|
||||
<span>{item.status}</span>
|
||||
<span className={statusToneClass(item.status)}>{item.status}</span>
|
||||
</div>
|
||||
<p className="break-all text-zinc-600">{tx("路径:", "Path: ")}{item.source_path}</p>
|
||||
<p className="text-zinc-600">{tx("标题:", "Title: ")}{item.title || "-"}</p>
|
||||
@@ -487,7 +519,7 @@ export default function ImportsPage() {
|
||||
{item.source_path}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-2">{item.status}</td>
|
||||
<td className={`px-2 py-2 ${statusToneClass(item.status)}`}>{item.status}</td>
|
||||
<td className="max-w-[220px] px-2 py-2">
|
||||
<div className="truncate" title={item.title}>
|
||||
{item.title || "-"}
|
||||
|
||||
@@ -4,9 +4,14 @@ import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { MarkdownRenderer } from "@/components/markdown-renderer";
|
||||
import { PageCrumbs } from "@/components/page-crumbs";
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
|
||||
type Article = {
|
||||
id: number;
|
||||
@@ -16,19 +21,100 @@ type Article = {
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type SkillPoint = {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
difficulty: string;
|
||||
reward: number;
|
||||
prerequisites?: string[];
|
||||
};
|
||||
|
||||
type DetailResp = {
|
||||
article: Article;
|
||||
related_problems: { problem_id: number; title: string }[];
|
||||
skill_points: SkillPoint[];
|
||||
};
|
||||
|
||||
type ClaimListResp = {
|
||||
article_id: number;
|
||||
slug: string;
|
||||
total_reward: number;
|
||||
total_count: number;
|
||||
claimed_keys: string[];
|
||||
};
|
||||
|
||||
type ClaimResp = {
|
||||
article_id: number;
|
||||
slug: string;
|
||||
knowledge_key: string;
|
||||
claimed: boolean;
|
||||
reward: number;
|
||||
rating_after: number;
|
||||
total_claimed: number;
|
||||
};
|
||||
|
||||
type WeeklyTask = {
|
||||
id: number;
|
||||
week_key: string;
|
||||
article_id: number;
|
||||
article_slug: string;
|
||||
article_title: string;
|
||||
knowledge_key: string;
|
||||
knowledge_title: string;
|
||||
knowledge_description: string;
|
||||
difficulty: string;
|
||||
reward: number;
|
||||
prerequisites: string[];
|
||||
completed: boolean;
|
||||
completed_at: number | null;
|
||||
};
|
||||
|
||||
type WeeklyPlanResp = {
|
||||
week_key: string;
|
||||
tasks: WeeklyTask[];
|
||||
total_reward: number;
|
||||
gained_reward: number;
|
||||
bonus_reward: number;
|
||||
bonus_claimed: boolean;
|
||||
completion_percent: number;
|
||||
};
|
||||
|
||||
type WeeklyBonusResp = {
|
||||
claimed: boolean;
|
||||
reward: number;
|
||||
rating_after: number;
|
||||
completion_percent: number;
|
||||
week_key: string;
|
||||
};
|
||||
|
||||
function difficultyClass(level: string): string {
|
||||
const lv = level.toLowerCase();
|
||||
if (lv === "bronze") return "text-[color:var(--mc-wood)]";
|
||||
if (lv === "silver") return "text-zinc-300";
|
||||
if (lv === "gold") return "text-[color:var(--mc-gold)]";
|
||||
return "text-[color:var(--mc-stone)]";
|
||||
}
|
||||
|
||||
export default function KbDetailPage() {
|
||||
const { tx } = useI18nText();
|
||||
const { theme } = useUiPreferences();
|
||||
const isMc = theme === "minecraft";
|
||||
const params = useParams<{ slug: string }>();
|
||||
const slug = useMemo(() => params.slug, [params.slug]);
|
||||
|
||||
const [data, setData] = useState<DetailResp | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [claimedKeys, setClaimedKeys] = useState<Set<string>>(new Set());
|
||||
const [claimTotalReward, setClaimTotalReward] = useState(0);
|
||||
const [claimLoadingKey, setClaimLoadingKey] = useState("");
|
||||
const [claimMsg, setClaimMsg] = useState("");
|
||||
const [weeklyPlan, setWeeklyPlan] = useState<WeeklyPlanResp | null>(null);
|
||||
const [weeklyLoading, setWeeklyLoading] = useState(false);
|
||||
const [weeklyError, setWeeklyError] = useState("");
|
||||
const [weeklyHint, setWeeklyHint] = useState("");
|
||||
const [weeklyClaiming, setWeeklyClaiming] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
@@ -46,8 +132,130 @@ export default function KbDetailPage() {
|
||||
if (slug) void load();
|
||||
}, [slug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
const token = readToken();
|
||||
if (!token) {
|
||||
setClaimedKeys(new Set());
|
||||
setClaimTotalReward(0);
|
||||
setWeeklyPlan(null);
|
||||
setWeeklyError("");
|
||||
return;
|
||||
}
|
||||
void apiFetch<ClaimListResp>(`/api/v1/kb/articles/${slug}/claims`, undefined, token)
|
||||
.then((resp) => {
|
||||
setClaimedKeys(new Set(resp.claimed_keys ?? []));
|
||||
setClaimTotalReward(resp.total_reward ?? 0);
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore auth/api errors for non-blocking reading
|
||||
});
|
||||
|
||||
setWeeklyLoading(true);
|
||||
setWeeklyError("");
|
||||
void apiFetch<WeeklyPlanResp>("/api/v1/kb/weekly-plan", {}, token)
|
||||
.then((plan) => setWeeklyPlan(plan))
|
||||
.catch((e: unknown) => {
|
||||
setWeeklyPlan(null);
|
||||
setWeeklyError(String(e));
|
||||
})
|
||||
.finally(() => setWeeklyLoading(false));
|
||||
}, [slug]);
|
||||
|
||||
const claimSkill = async (skillKey: string) => {
|
||||
const token = readToken();
|
||||
if (!token) {
|
||||
setClaimMsg(tx("请先登录后领取知识点奖励。", "Please sign in before claiming skill points."));
|
||||
return;
|
||||
}
|
||||
setClaimLoadingKey(skillKey);
|
||||
setClaimMsg("");
|
||||
try {
|
||||
const resp = await apiFetch<ClaimResp>(
|
||||
`/api/v1/kb/articles/${slug}/claim`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ knowledge_key: skillKey }),
|
||||
},
|
||||
token
|
||||
);
|
||||
if (resp.claimed) {
|
||||
setClaimedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(skillKey);
|
||||
return next;
|
||||
});
|
||||
setClaimTotalReward((v) => v + (resp.reward ?? 0));
|
||||
setClaimMsg(
|
||||
tx(
|
||||
`领取成功:+${resp.reward} 知识点积分(当前积分 ${resp.rating_after})`,
|
||||
`Claimed: +${resp.reward} knowledge points (rating ${resp.rating_after})`
|
||||
)
|
||||
);
|
||||
const refreshToken = readToken();
|
||||
if (refreshToken) {
|
||||
void apiFetch<WeeklyPlanResp>("/api/v1/kb/weekly-plan", {}, refreshToken)
|
||||
.then((plan) => setWeeklyPlan(plan))
|
||||
.catch(() => {
|
||||
// keep current UI when weekly refresh fails
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setClaimMsg(tx("该知识点已领取过。", "This skill point is already claimed."));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setClaimMsg(String(e));
|
||||
} finally {
|
||||
setClaimLoadingKey("");
|
||||
}
|
||||
};
|
||||
|
||||
const claimWeeklyBonus = async () => {
|
||||
const token = readToken();
|
||||
if (!token) {
|
||||
setWeeklyHint(tx("请先登录。", "Please sign in first."));
|
||||
return;
|
||||
}
|
||||
setWeeklyClaiming(true);
|
||||
setWeeklyError("");
|
||||
setWeeklyHint("");
|
||||
try {
|
||||
const result = await apiFetch<WeeklyBonusResp>(
|
||||
"/api/v1/kb/weekly-bonus/claim",
|
||||
{ method: "POST", body: JSON.stringify({}) },
|
||||
token
|
||||
);
|
||||
setWeeklyHint(
|
||||
result.claimed
|
||||
? tx(
|
||||
`领取成功:+${result.reward} 周奖励积分(当前积分 ${result.rating_after})`,
|
||||
`Claimed: +${result.reward} weekly bonus (rating ${result.rating_after})`
|
||||
)
|
||||
: tx("本周奖励已领取过。", "Weekly bonus already claimed.")
|
||||
);
|
||||
const latest = await apiFetch<WeeklyPlanResp>("/api/v1/kb/weekly-plan", {}, token);
|
||||
setWeeklyPlan(latest);
|
||||
} catch (e: unknown) {
|
||||
setWeeklyError(String(e));
|
||||
} finally {
|
||||
setWeeklyClaiming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const relatedWeeklyTasks = useMemo(() => {
|
||||
if (!weeklyPlan || !data) return [];
|
||||
return weeklyPlan.tasks.filter((task) => task.article_slug === data.article.slug);
|
||||
}, [data, weeklyPlan]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
||||
<PageCrumbs
|
||||
items={[
|
||||
{ label: tx("主城", "Town"), href: "/" },
|
||||
{ label: tx("附魔", "Enchant"), href: "/kb" },
|
||||
{ label: data?.article.title || slug },
|
||||
]}
|
||||
/>
|
||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
||||
{tx("知识库文章", "Knowledge Article")}
|
||||
</h1>
|
||||
@@ -56,17 +264,189 @@ export default function KbDetailPage() {
|
||||
|
||||
{data && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<section
|
||||
className={`p-4 ${
|
||||
isMc
|
||||
? "rounded-none border-[3px] border-black bg-[color:var(--mc-card)] shadow-[6px_6px_0_rgba(0,0,0,0.48)]"
|
||||
: "rounded-xl border bg-white"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className={`text-sm font-medium inline-flex items-center gap-2 ${
|
||||
isMc ? "text-[color:var(--mc-text-main)]" : ""
|
||||
}`}>
|
||||
{tx("本周学习进度", "Weekly Progress")}
|
||||
<HintTip title={tx("进度说明", "Progress Notes")} align="left">
|
||||
{tx(
|
||||
"完成所有周任务后可领取 100% 奖励。当前会额外显示与本文章相关的周任务,方便边学边完成。",
|
||||
"Complete all weekly tasks to claim the 100% bonus. This panel highlights tasks related to this article so you can learn and complete them together."
|
||||
)}
|
||||
</HintTip>
|
||||
</h3>
|
||||
{weeklyPlan && (
|
||||
<span className={`text-xs ${isMc ? "text-[color:var(--mc-text-muted)]" : "text-zinc-500"}`}>
|
||||
{tx("周起始", "Week")} {weeklyPlan.week_key}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{weeklyLoading && (
|
||||
<p className={`mt-2 text-xs ${isMc ? "text-[color:var(--mc-text-dim)]" : "text-zinc-500"}`}>
|
||||
{tx("加载周任务中...", "Loading weekly plan...")}
|
||||
</p>
|
||||
)}
|
||||
{weeklyError && <p className="mt-2 text-xs text-red-600">{weeklyError}</p>}
|
||||
{weeklyPlan && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className={`text-xs flex flex-wrap items-center justify-between gap-2 ${
|
||||
isMc ? "text-[color:var(--mc-text-muted)]" : "text-zinc-600"
|
||||
}`}>
|
||||
<span>
|
||||
{tx("完成度", "Progress")}:{" "}
|
||||
<span className={isMc ? "font-semibold text-[color:var(--mc-text-main)]" : "font-semibold text-zinc-900"}>
|
||||
{weeklyPlan.completion_percent}%
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
{tx("任务积分", "Task Reward")}:{" "}
|
||||
<span className={isMc ? "font-semibold text-[color:var(--mc-text-main)]" : "font-semibold text-zinc-900"}>
|
||||
{weeklyPlan.gained_reward}/{weeklyPlan.total_reward}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
{tx("周奖励", "Bonus")}:{" "}
|
||||
<span className={isMc ? "font-semibold text-[color:var(--mc-warning)]" : "font-semibold text-[color:var(--mc-gold)]"}>
|
||||
+{weeklyPlan.bonus_reward}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={`h-2 w-full overflow-hidden rounded-full ${isMc ? "bg-black/50" : "bg-zinc-200"}`}>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
isMc
|
||||
? "bg-gradient-to-r from-[color:var(--mc-success)] to-[color:var(--mc-warning)]"
|
||||
: "bg-gradient-to-r from-emerald-500 to-amber-500"
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, Math.max(0, weeklyPlan.completion_percent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className={`text-xs ${isMc ? "text-[color:var(--mc-text-muted)]" : "text-zinc-600"}`}>
|
||||
{weeklyPlan.bonus_claimed
|
||||
? tx("本周 100% 奖励已领取", "100% weekly bonus already claimed")
|
||||
: tx("完成全部任务后可领取 100% 奖励", "Claim 100% bonus after all tasks are completed")}
|
||||
</span>
|
||||
<button
|
||||
className={`min-h-[44px] text-xs disabled:opacity-50 ${
|
||||
isMc ? "mc-btn mc-btn-warning" : "mc-btn mc-btn-primary"
|
||||
}`}
|
||||
disabled={
|
||||
weeklyClaiming || weeklyPlan.bonus_claimed || weeklyPlan.completion_percent < 100
|
||||
}
|
||||
onClick={() => void claimWeeklyBonus()}
|
||||
>
|
||||
{weeklyClaiming ? tx("领取中...", "Claiming...") : tx("领取 100% 奖励", "Claim 100% Bonus")}
|
||||
</button>
|
||||
</div>
|
||||
{weeklyHint && (
|
||||
<p className={`text-xs ${isMc ? "text-[color:var(--mc-success)]" : "text-emerald-700"}`}>
|
||||
{weeklyHint}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{relatedWeeklyTasks.length > 0 && (
|
||||
<div className={`rounded border p-3 ${
|
||||
isMc
|
||||
? "border-[color:var(--mc-border-soft)] bg-[color:var(--mc-card-inner)]"
|
||||
: "border-zinc-200 bg-zinc-50"
|
||||
}`}>
|
||||
<p className={`text-xs font-medium ${isMc ? "text-[color:var(--mc-text-main)]" : "text-zinc-700"}`}>
|
||||
{tx("本文章相关周任务", "Weekly Tasks Related to This Article")}
|
||||
</p>
|
||||
<ul className={`mt-2 space-y-1 text-xs ${isMc ? "text-[color:var(--mc-text-muted)]" : "text-zinc-600"}`}>
|
||||
{relatedWeeklyTasks.map((task) => (
|
||||
<li key={task.id}>
|
||||
{task.completed ? "✅" : "⬜"} {task.knowledge_title} (+{task.reward})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border bg-white p-4">
|
||||
<h2 className="text-xl font-medium">{data.article.title}</h2>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{tx("更新时间:", "Updated: ")}
|
||||
{new Date(data.article.created_at * 1000).toLocaleString()}
|
||||
{formatUnixDateTime(data.article.created_at)}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<MarkdownRenderer markdown={data.article.content_md} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{data.skill_points.length > 0 && (
|
||||
<section className="rounded-xl border bg-white p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium inline-flex items-center gap-2">
|
||||
{tx("技能打卡与积分奖励", "Skill Checkpoints & Rewards")}
|
||||
<HintTip title={tx("领取规则", "Claim Rules")} align="left">
|
||||
{tx(
|
||||
"每个知识点仅可领取一次。存在前置依赖时,必须先完成前置点才可领取后续点。",
|
||||
"Each skill point can be claimed once. If prerequisites exist, complete them first before claiming follow-up points."
|
||||
)}
|
||||
</HintTip>
|
||||
</h3>
|
||||
<span className="text-xs text-[color:var(--mc-gold)]">
|
||||
{tx("已领取积分", "Claimed Points")}: {claimTotalReward}
|
||||
</span>
|
||||
</div>
|
||||
{claimMsg && (
|
||||
<p className="mt-2 text-xs text-[color:var(--mc-diamond)]">{claimMsg}</p>
|
||||
)}
|
||||
<div className="mt-3 space-y-2">
|
||||
{data.skill_points.map((point) => {
|
||||
const done = claimedKeys.has(point.key);
|
||||
return (
|
||||
<article key={point.key} className="rounded border border-black/20 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold">
|
||||
{done ? "✅" : "⬜"} {point.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs ${difficultyClass(point.difficulty)}`}>
|
||||
{point.difficulty}
|
||||
</span>
|
||||
<span className="text-xs text-[color:var(--mc-gold)]">+{point.reward}</span>
|
||||
<button
|
||||
className={`mc-btn min-h-[44px] text-xs ${done ? "" : "mc-btn-primary"}`}
|
||||
onClick={() => void claimSkill(point.key)}
|
||||
disabled={done || claimLoadingKey === point.key}
|
||||
>
|
||||
{done
|
||||
? tx("已领取", "Claimed")
|
||||
: claimLoadingKey === point.key
|
||||
? tx("领取中...", "Claiming...")
|
||||
: tx("领取奖励", "Claim")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-600">{point.description}</p>
|
||||
{(point.prerequisites?.length ?? 0) > 0 && (
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
{tx("前置:", "Prerequisites: ")}
|
||||
{point.prerequisites?.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-[11px] text-zinc-500">ID: {point.key}</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="rounded-xl border bg-white p-4">
|
||||
<h3 className="text-sm font-medium">{tx("关联题目", "Related Problems")}</h3>
|
||||
{data.related_problems.length ? (
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
import {
|
||||
Book,
|
||||
CheckCircle2,
|
||||
Code2,
|
||||
FileQuestion,
|
||||
Library,
|
||||
@@ -15,6 +19,8 @@ import {
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Sword,
|
||||
Target,
|
||||
Trophy,
|
||||
} from "lucide-react";
|
||||
|
||||
type Article = {
|
||||
@@ -38,14 +44,51 @@ type TriggerKbRefreshResp = KbRefreshStatus & {
|
||||
message: string;
|
||||
};
|
||||
|
||||
type WeeklyTask = {
|
||||
id: number;
|
||||
week_key: string;
|
||||
article_id: number;
|
||||
article_slug: string;
|
||||
article_title: string;
|
||||
knowledge_key: string;
|
||||
knowledge_title: string;
|
||||
knowledge_description: string;
|
||||
difficulty: string;
|
||||
reward: number;
|
||||
prerequisites: string[];
|
||||
completed: boolean;
|
||||
completed_at: number | null;
|
||||
};
|
||||
|
||||
type WeeklyPlanResp = {
|
||||
week_key: string;
|
||||
tasks: WeeklyTask[];
|
||||
total_reward: number;
|
||||
gained_reward: number;
|
||||
bonus_reward: number;
|
||||
bonus_claimed: boolean;
|
||||
completion_percent: number;
|
||||
};
|
||||
|
||||
type WeeklyBonusResp = {
|
||||
claimed: boolean;
|
||||
reward: number;
|
||||
rating_after: number;
|
||||
completion_percent: number;
|
||||
week_key: string;
|
||||
};
|
||||
|
||||
function fmtTs(v: number | null | undefined): string {
|
||||
if (!v) return "-";
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
return formatUnixDateTime(v);
|
||||
}
|
||||
|
||||
export default function KbListPage() {
|
||||
const { tx } = useI18nText();
|
||||
const { theme } = useUiPreferences();
|
||||
const isMc = theme === "minecraft";
|
||||
const router = useRouter();
|
||||
const [refreshToken, setRefreshToken] = useState("");
|
||||
const [userToken, setUserToken] = useState("");
|
||||
const [canManageRefresh, setCanManageRefresh] = useState(false);
|
||||
const [items, setItems] = useState<Article[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -55,31 +98,59 @@ export default function KbListPage() {
|
||||
const [hint, setHint] = useState("");
|
||||
const [refreshStatus, setRefreshStatus] = useState<KbRefreshStatus | null>(null);
|
||||
const [lastSyncedFinishedAt, setLastSyncedFinishedAt] = useState(0);
|
||||
const [queryInput, setQueryInput] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [weeklyPlan, setWeeklyPlan] = useState<WeeklyPlanResp | null>(null);
|
||||
const [weeklyLoading, setWeeklyLoading] = useState(false);
|
||||
const [weeklyError, setWeeklyError] = useState("");
|
||||
const [weeklyHint, setWeeklyHint] = useState("");
|
||||
const [claimWeeklyLoading, setClaimWeeklyLoading] = useState(false);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const source = query.trim()
|
||||
? items.filter((article) => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
return article.title.toLowerCase().includes(needle) || article.slug.toLowerCase().includes(needle);
|
||||
})
|
||||
: items;
|
||||
const buckets: Record<string, Article[]> = {
|
||||
roadmap: [],
|
||||
cpp: [],
|
||||
web: [],
|
||||
game: [],
|
||||
cspj: [],
|
||||
csps: [],
|
||||
github: [],
|
||||
linux: [],
|
||||
csfund: [],
|
||||
other: [],
|
||||
};
|
||||
for (const article of items) {
|
||||
for (const article of source) {
|
||||
const slug = article.slug.toLowerCase();
|
||||
if (slug.includes("roadmap")) {
|
||||
buckets.roadmap.push(article);
|
||||
} else if (slug.includes("web")) {
|
||||
buckets.web.push(article);
|
||||
} else if (slug.includes("game")) {
|
||||
buckets.game.push(article);
|
||||
} else if (slug.includes("cpp")) {
|
||||
buckets.cpp.push(article);
|
||||
} else if (slug.includes("csp-j") || slug.includes("cspj")) {
|
||||
buckets.cspj.push(article);
|
||||
} else if (slug.includes("csp-s") || slug.includes("csps")) {
|
||||
buckets.csps.push(article);
|
||||
} else if (slug.includes("github") || slug.includes("git-")) {
|
||||
buckets.github.push(article);
|
||||
} else if (slug.includes("linux")) {
|
||||
buckets.linux.push(article);
|
||||
} else if (slug.includes("computer") || slug.includes("cs-") || slug.includes("fundamental")) {
|
||||
buckets.csfund.push(article);
|
||||
} else {
|
||||
buckets.other.push(article);
|
||||
}
|
||||
}
|
||||
return buckets;
|
||||
}, [items]);
|
||||
}, [items, query]);
|
||||
|
||||
const loadArticles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -110,12 +181,32 @@ export default function KbListPage() {
|
||||
}
|
||||
}, [canManageRefresh, refreshToken]);
|
||||
|
||||
const loadWeeklyPlan = useCallback(async () => {
|
||||
if (!userToken) {
|
||||
setWeeklyPlan(null);
|
||||
setWeeklyError("");
|
||||
return;
|
||||
}
|
||||
setWeeklyLoading(true);
|
||||
setWeeklyError("");
|
||||
try {
|
||||
const data = await apiFetch<WeeklyPlanResp>("/api/v1/kb/weekly-plan", {}, userToken);
|
||||
setWeeklyPlan(data);
|
||||
} catch (e: unknown) {
|
||||
setWeeklyPlan(null);
|
||||
setWeeklyError(String(e));
|
||||
} finally {
|
||||
setWeeklyLoading(false);
|
||||
}
|
||||
}, [userToken]);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
const refreshAdminState = async () => {
|
||||
const tk = readToken();
|
||||
if (!tk) {
|
||||
if (!canceled) {
|
||||
setUserToken("");
|
||||
setRefreshToken("");
|
||||
setCanManageRefresh(false);
|
||||
}
|
||||
@@ -124,12 +215,14 @@ export default function KbListPage() {
|
||||
try {
|
||||
const me = await apiFetch<{ username?: string }>("/api/v1/me", {}, tk);
|
||||
if (!canceled) {
|
||||
setUserToken(tk);
|
||||
const isAdmin = (me?.username ?? "") === "admin";
|
||||
setCanManageRefresh(isAdmin);
|
||||
setRefreshToken(isAdmin ? tk : "");
|
||||
}
|
||||
} catch {
|
||||
if (!canceled) {
|
||||
setUserToken("");
|
||||
setRefreshToken("");
|
||||
setCanManageRefresh(false);
|
||||
}
|
||||
@@ -151,6 +244,17 @@ export default function KbListPage() {
|
||||
void loadStatus();
|
||||
}, [loadArticles, loadStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadWeeklyPlan();
|
||||
}, [loadWeeklyPlan]);
|
||||
|
||||
useEffect(() => {
|
||||
const q = new URLSearchParams(window.location.search).get("q")?.trim() ?? "";
|
||||
if (!q) return;
|
||||
setQueryInput(q);
|
||||
setQuery(q);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
void loadStatus();
|
||||
@@ -205,6 +309,47 @@ export default function KbListPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const applyQuery = () => {
|
||||
const normalized = queryInput.trim();
|
||||
setQuery(normalized);
|
||||
router.replace(normalized ? `/kb?q=${encodeURIComponent(normalized)}` : "/kb");
|
||||
};
|
||||
|
||||
const applyQuickQuery = (value: string) => {
|
||||
setQueryInput(value);
|
||||
setQuery(value);
|
||||
router.replace(`/kb?q=${encodeURIComponent(value)}`);
|
||||
};
|
||||
|
||||
const claimWeeklyBonus = async () => {
|
||||
if (!userToken || !weeklyPlan) return;
|
||||
setClaimWeeklyLoading(true);
|
||||
setWeeklyError("");
|
||||
setWeeklyHint("");
|
||||
try {
|
||||
const result = await apiFetch<WeeklyBonusResp>(
|
||||
"/api/v1/kb/weekly-bonus/claim",
|
||||
{ method: "POST", body: JSON.stringify({}) },
|
||||
userToken
|
||||
);
|
||||
if (result.claimed) {
|
||||
setWeeklyHint(
|
||||
tx(
|
||||
`领取成功:+${result.reward} 周奖励积分(当前积分 ${result.rating_after})`,
|
||||
`Claimed: +${result.reward} weekly bonus (rating ${result.rating_after})`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setWeeklyHint(tx("本周奖励已领取过。", "Weekly bonus already claimed."));
|
||||
}
|
||||
await loadWeeklyPlan();
|
||||
} catch (e: unknown) {
|
||||
setWeeklyError(String(e));
|
||||
} finally {
|
||||
setClaimWeeklyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -232,11 +377,38 @@ export default function KbListPage() {
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-zinc-600">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{tx("按路线学习并完成每周任务。", "Follow the roadmap and finish weekly tasks.")}
|
||||
<HintTip title={tx("学习说明", "Learning Guide")}>
|
||||
{tx(
|
||||
"已整理 C++ 基础、CSP-J、CSP-S 学习资料,可按阶段逐步学习。",
|
||||
"Curated learning materials for C++ fundamentals, CSP-J, and CSP-S."
|
||||
"这里整合了 C++ 基础、CSP-J、CSP-S 与工程协作资料。建议先从路线图开始,再按每周任务推进,学习后去做对应题目巩固。",
|
||||
"This hub covers C++ basics, CSP-J/S, and engineering collaboration. Start with the roadmap, follow weekly tasks, then solve related problems for reinforcement."
|
||||
)}
|
||||
</HintTip>
|
||||
</span>
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<input
|
||||
className="min-h-[44px] flex-1 rounded border px-3 py-2 text-sm"
|
||||
value={queryInput}
|
||||
placeholder={tx("搜索知识点 / 标题 / slug...", "Search keyword / title / slug...")}
|
||||
onChange={(e) => setQueryInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") applyQuery();
|
||||
}}
|
||||
/>
|
||||
<button className="mc-btn min-h-[44px]" onClick={applyQuery}>
|
||||
{tx("搜索", "Search")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||
<button className="mc-btn min-h-[44px]" onClick={() => applyQuickQuery("cpp14")}>C++14</button>
|
||||
<button className="mc-btn min-h-[44px]" onClick={() => applyQuickQuery("web")}>{tx("Web开发", "Web Dev")}</button>
|
||||
<button className="mc-btn min-h-[44px]" onClick={() => applyQuickQuery("game")}>{tx("游戏开发", "Game Dev")}</button>
|
||||
<button className="mc-btn min-h-[44px]" onClick={() => applyQuickQuery("github")}>GitHub</button>
|
||||
<button className="mc-btn min-h-[44px]" onClick={() => applyQuickQuery("linux")}>Linux</button>
|
||||
<button className="mc-btn min-h-[44px]" onClick={() => applyQuickQuery("computer")}>{tx("计算机基础", "Computer Fundamentals")}</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{canManageRefresh
|
||||
? tx("更新状态:", "Refresh status:")
|
||||
@@ -253,13 +425,227 @@ export default function KbListPage() {
|
||||
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>}
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
{hint && <p className="mt-3 text-sm text-emerald-700">{hint}</p>}
|
||||
{query && (
|
||||
<p className="mt-2 text-xs text-[color:var(--mc-gold)]">
|
||||
{tx("当前筛选:", "Current filter: ")} {query}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section
|
||||
className={`mt-4 overflow-hidden ${
|
||||
isMc
|
||||
? "rounded-none border-[3px] border-black bg-[color:var(--mc-card)] shadow-[6px_6px_0_rgba(0,0,0,0.48)]"
|
||||
: "rounded-2xl border border-zinc-300 bg-gradient-to-br from-amber-50 via-white to-zinc-100"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-wrap items-center justify-between gap-3 px-4 py-3 ${
|
||||
isMc ? "border-b border-black/80 bg-[color:var(--mc-card-inner)]" : "border-b border-zinc-200/80"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Target size={18} className={isMc ? "text-[color:var(--mc-accent)]" : "text-amber-700"} />
|
||||
<h2 className={`text-sm font-semibold ${isMc ? "text-[color:var(--mc-text-main)]" : "text-zinc-800"}`}>
|
||||
{tx("本周学习任务", "Weekly Learning Plan")}
|
||||
</h2>
|
||||
<HintTip title={tx("周任务规则", "Weekly Rules")} align="left">
|
||||
{tx(
|
||||
"周任务会自动生成。完成全部任务后可领取 100% 奖励。若任务有前置依赖,需先完成前置知识点。",
|
||||
"Weekly tasks are generated automatically. Complete all tasks to claim the 100% bonus. If a task has prerequisites, finish those first."
|
||||
)}
|
||||
</HintTip>
|
||||
</div>
|
||||
{weeklyPlan && (
|
||||
<span className={`rounded-full px-2 py-1 text-[11px] ${
|
||||
isMc
|
||||
? "border border-[color:var(--mc-border-soft)] bg-black/40 text-[color:var(--mc-text-muted)]"
|
||||
: "bg-zinc-900 text-white"
|
||||
}`}>
|
||||
{tx("周起始", "Week")} {weeklyPlan.week_key}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!userToken && (
|
||||
<div className={`px-4 py-4 text-sm ${isMc ? "text-[color:var(--mc-text-muted)]" : "text-zinc-600"}`}>
|
||||
{tx("登录后可查看每周任务与 100% 完成奖励。", "Sign in to see weekly tasks and 100% completion bonus.")}
|
||||
<Link href="/auth" className={`ml-2 underline ${isMc ? "text-[color:var(--mc-accent)]" : "text-zinc-900"}`}>
|
||||
{tx("去登录", "Sign in")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userToken && weeklyLoading && (
|
||||
<p className={`px-4 py-4 text-sm ${isMc ? "text-[color:var(--mc-text-dim)]" : "text-zinc-500"}`}>
|
||||
{tx("周任务加载中...", "Loading weekly tasks...")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{userToken && !weeklyLoading && weeklyError && (
|
||||
<p className="px-4 py-4 text-sm text-red-600">{weeklyError}</p>
|
||||
)}
|
||||
|
||||
{userToken && !weeklyLoading && !weeklyError && weeklyPlan && (
|
||||
<div className="px-4 py-4">
|
||||
<div
|
||||
className={`p-3 ${
|
||||
isMc
|
||||
? "rounded-none border-[2px] border-black bg-[color:var(--mc-card-inner)]"
|
||||
: "rounded-xl border border-zinc-200 bg-white/90"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||
<span className={isMc ? "text-[color:var(--mc-text-muted)]" : "text-zinc-600"}>
|
||||
{tx("完成度", "Progress")}:{" "}
|
||||
<span className={isMc ? "font-semibold text-[color:var(--mc-text-main)]" : "font-semibold text-zinc-900"}>
|
||||
{weeklyPlan.completion_percent}%
|
||||
</span>
|
||||
</span>
|
||||
<span className={isMc ? "text-[color:var(--mc-text-muted)]" : "text-zinc-600"}>
|
||||
{tx("任务积分", "Task Reward")}:{" "}
|
||||
<span className={isMc ? "font-semibold text-[color:var(--mc-text-main)]" : "font-semibold text-zinc-900"}>
|
||||
{weeklyPlan.gained_reward}/{weeklyPlan.total_reward}
|
||||
</span>
|
||||
</span>
|
||||
<span className={isMc ? "text-[color:var(--mc-text-muted)]" : "text-zinc-600"}>
|
||||
{tx("周奖励", "Bonus")}:{" "}
|
||||
<span className={isMc ? "font-semibold text-[color:var(--mc-warning)]" : "font-semibold text-amber-700"}>
|
||||
+{weeklyPlan.bonus_reward}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={`mt-2 h-2 w-full overflow-hidden rounded-full ${isMc ? "bg-black/50" : "bg-zinc-200"}`}>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
isMc
|
||||
? "bg-gradient-to-r from-[color:var(--mc-success)] to-[color:var(--mc-warning)]"
|
||||
: "bg-gradient-to-r from-emerald-500 to-amber-500"
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, Math.max(0, weeklyPlan.completion_percent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className={`text-xs ${isMc ? "text-[color:var(--mc-text-muted)]" : "text-zinc-600"}`}>
|
||||
{weeklyPlan.bonus_claimed
|
||||
? tx("本周 100% 奖励已领取", "100% weekly bonus already claimed")
|
||||
: tx("完成所有任务后可领取 100% 奖励", "Complete all tasks to claim 100% bonus")}
|
||||
</div>
|
||||
<button
|
||||
className={`min-h-[44px] text-xs disabled:opacity-50 ${
|
||||
isMc ? "mc-btn mc-btn-warning" : "mc-btn mc-btn-primary"
|
||||
}`}
|
||||
disabled={
|
||||
claimWeeklyLoading ||
|
||||
weeklyPlan.bonus_claimed ||
|
||||
weeklyPlan.completion_percent < 100
|
||||
}
|
||||
onClick={() => void claimWeeklyBonus()}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Trophy size={14} />
|
||||
{claimWeeklyLoading
|
||||
? tx("领取中...", "Claiming...")
|
||||
: tx("领取 100% 奖励", "Claim 100% Bonus")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{weeklyHint && (
|
||||
<p className={`mt-2 text-xs ${isMc ? "text-[color:var(--mc-success)]" : "text-emerald-700"}`}>
|
||||
{weeklyHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2">
|
||||
{weeklyPlan.tasks.map((task) => (
|
||||
<article
|
||||
key={task.id}
|
||||
className={`p-3 ${
|
||||
isMc
|
||||
? task.completed
|
||||
? "rounded-none border-[2px] border-[color:var(--mc-success)] bg-[color:rgba(46,204,113,0.12)]"
|
||||
: "rounded-none border-[2px] border-zinc-700 bg-[#252525]"
|
||||
: task.completed
|
||||
? "rounded-xl border border-emerald-300 bg-emerald-50/70"
|
||||
: "rounded-xl border border-zinc-200 bg-white/90"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className={`text-sm font-semibold flex items-center gap-1 ${
|
||||
isMc ? "text-[color:var(--mc-text-main)]" : "text-zinc-900"
|
||||
}`}>
|
||||
{task.completed && (
|
||||
<CheckCircle2 size={15} className={isMc ? "text-[color:var(--mc-success)]" : "text-emerald-600"} />
|
||||
)}
|
||||
{task.knowledge_title}
|
||||
</p>
|
||||
<p className={`mt-1 text-xs ${isMc ? "text-[color:var(--mc-text-muted)]" : "text-zinc-500"}`}>
|
||||
{task.article_title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className={`rounded border px-2 py-0.5 ${
|
||||
isMc
|
||||
? "border-[color:var(--mc-border-soft)] text-[color:var(--mc-text-main)]"
|
||||
: "border-zinc-300 text-zinc-700"
|
||||
}`}>
|
||||
{task.difficulty}
|
||||
</span>
|
||||
<span className={`rounded px-2 py-0.5 ${
|
||||
isMc
|
||||
? "bg-[color:rgba(242,201,76,0.18)] text-[color:var(--mc-warning)]"
|
||||
: "bg-amber-100 text-amber-800"
|
||||
}`}>
|
||||
+{task.reward}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className={`mt-2 text-xs ${isMc ? "text-[color:var(--mc-text-muted)]" : "text-zinc-600"}`}>
|
||||
{task.knowledge_description}
|
||||
</p>
|
||||
{task.prerequisites?.length > 0 && (
|
||||
<p className={`mt-2 text-[11px] ${isMc ? "text-[color:var(--mc-text-dim)]" : "text-zinc-500"}`}>
|
||||
{tx("前置:", "Prerequisites: ")}
|
||||
{task.prerequisites.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<span className={`text-[11px] ${
|
||||
task.completed
|
||||
? isMc
|
||||
? "text-[color:var(--mc-success)]"
|
||||
: "text-emerald-700"
|
||||
: isMc
|
||||
? "text-[color:var(--mc-warning)]"
|
||||
: "text-zinc-500"
|
||||
}`}>
|
||||
{task.completed
|
||||
? tx("状态:已完成", "Status: Completed")
|
||||
: tx("状态:待完成", "Status: Pending")}
|
||||
</span>
|
||||
<Link href={`/kb/${task.article_slug}`} className="mc-btn min-h-[44px] text-xs">
|
||||
{tx("去学习", "Study")}
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mt-4 space-y-5">
|
||||
{[
|
||||
["roadmap", tx("学习总路线", "Learning Roadmap"), MapIcon],
|
||||
["cpp", tx("C++ 基础", "C++ Fundamentals"), Code2],
|
||||
["web", tx("Web 开发(C++)", "Web Dev (C++)"), Library],
|
||||
["game", tx("游戏开发(C++)", "Game Dev (C++)"), Library],
|
||||
["cspj", "CSP-J", Sword],
|
||||
["csps", "CSP-S", Shield],
|
||||
["github", tx("GitHub 协作", "GitHub Collaboration"), Library],
|
||||
["linux", tx("Linux 服务器", "Linux Server"), Library],
|
||||
["csfund", tx("计算机基础", "Computer Fundamentals"), Library],
|
||||
["other", tx("其他资料", "Other Resources"), FileQuestion],
|
||||
].map(([key, label, Icon]) => {
|
||||
const group = grouped[key as string] ?? [];
|
||||
@@ -271,16 +657,22 @@ export default function KbListPage() {
|
||||
{label as string}
|
||||
</h2>
|
||||
{group.map((a) => (
|
||||
<Link
|
||||
key={a.slug}
|
||||
href={`/kb/${a.slug}`}
|
||||
className="block rounded-xl border bg-white p-4 hover:border-zinc-400"
|
||||
>
|
||||
<div key={a.slug} className="rounded-xl border bg-white p-4 hover:border-zinc-400">
|
||||
<Link href={`/kb/${a.slug}`} className="block">
|
||||
<h3 className="text-lg font-medium">{a.title}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
slug: {a.slug} · {new Date(a.created_at * 1000).toLocaleString()}
|
||||
slug: {a.slug} · {formatUnixDateTime(a.created_at)}
|
||||
</p>
|
||||
</Link>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Link href={`/kb/${a.slug}`} className="mc-btn min-h-[44px] text-xs">
|
||||
{tx("阅读知识", "Read")}
|
||||
</Link>
|
||||
<Link href={`/problems?q=${encodeURIComponent(a.title)}`} className="mc-btn mc-btn-primary min-h-[44px] text-xs">
|
||||
{tx("做相关任务", "Related Problems")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
import { Crown, Medal, Trophy, User, Calendar } from "lucide-react";
|
||||
|
||||
@@ -11,9 +14,12 @@ type Row = {
|
||||
user_id: number;
|
||||
username: string;
|
||||
rating: number;
|
||||
period_score: number;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type Scope = "all" | "week" | "today";
|
||||
|
||||
export default function LeaderboardPage() {
|
||||
const { tx } = useI18nText();
|
||||
const { theme } = useUiPreferences();
|
||||
@@ -21,13 +27,23 @@ export default function LeaderboardPage() {
|
||||
const [items, setItems] = useState<Row[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [scope, setScope] = useState<Scope>("all");
|
||||
const [meId, setMeId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const token = readToken();
|
||||
if (!token) return;
|
||||
void apiFetch<{ id?: number }>("/api/v1/me", {}, token)
|
||||
.then((me) => setMeId(typeof me?.id === "number" ? me.id : null))
|
||||
.catch(() => setMeId(null));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await apiFetch<Row[]>("/api/v1/leaderboard/global?limit=200");
|
||||
const data = await apiFetch<Row[]>(`/api/v1/leaderboard/global?limit=200&scope=${scope}`);
|
||||
setItems(data);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
@@ -36,14 +52,14 @@ export default function LeaderboardPage() {
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, []);
|
||||
}, [scope]);
|
||||
|
||||
const getRankColor = (index: number) => {
|
||||
if (!isMc) return "";
|
||||
switch (index) {
|
||||
case 0: return "text-[color:var(--mc-gold)] drop-shadow-sm"; // Gold
|
||||
case 1: return "text-zinc-300"; // Iron
|
||||
case 2: return "text-orange-700"; // Copper
|
||||
case 0: return "text-[color:var(--mc-gold)] drop-shadow-sm";
|
||||
case 1: return "text-zinc-300";
|
||||
case 2: return "text-orange-700";
|
||||
default: return "text-zinc-400";
|
||||
}
|
||||
};
|
||||
@@ -58,6 +74,32 @@ export default function LeaderboardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const scoreLabel = useMemo(
|
||||
() => (scope === "all" ? tx("总 XP", "Total XP") : scope === "week" ? tx("本周 XP", "Weekly XP") : tx("今日 XP", "Today XP")),
|
||||
[scope, tx]
|
||||
);
|
||||
|
||||
const currentItems = useMemo(
|
||||
() =>
|
||||
items.map((item) => ({
|
||||
...item,
|
||||
boardScore: scope === "all" ? item.rating : item.period_score,
|
||||
})),
|
||||
[items, scope]
|
||||
);
|
||||
|
||||
const meGap = useMemo(() => {
|
||||
if (meId == null) return null;
|
||||
const idx = currentItems.findIndex((item) => item.user_id === meId);
|
||||
if (idx <= 0) return null;
|
||||
const me = currentItems[idx];
|
||||
const prev = currentItems[idx - 1];
|
||||
return {
|
||||
rank: idx + 1,
|
||||
gap: Math.max(1, prev.boardScore - me.boardScore),
|
||||
};
|
||||
}, [currentItems, meId]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
|
||||
<h1 className={`text-xl font-bold max-[390px]:text-lg sm:text-2xl ${isMc ? "text-[color:var(--mc-diamond)] mc-text-shadow" : ""}`}>
|
||||
@@ -70,27 +112,56 @@ export default function LeaderboardPage() {
|
||||
tx("全站排行榜", "Global Leaderboard")
|
||||
)}
|
||||
</h1>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-zinc-500">
|
||||
<p>{tx("切换时间范围查看排行。", "Switch scope to compare rankings.")}</p>
|
||||
<HintTip title={tx("榜单说明", "Leaderboard Notes")} align="left">
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
<li>{tx("总榜按总 Rating 排序。", "All-time board is ranked by total rating.")}</li>
|
||||
<li>{tx("本周/今日按周期增量 XP 排序。", "Week/today boards use period XP gain.")}</li>
|
||||
<li>{tx("提示会显示你与上一名的差距。", "Gap hint shows distance to the player above you.")}</li>
|
||||
</ul>
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
<button className={`mc-btn min-h-[44px] ${scope === "all" ? "mc-btn-primary" : ""}`} onClick={() => setScope("all")}>
|
||||
{tx("总榜", "All Time")}
|
||||
</button>
|
||||
<button className={`mc-btn min-h-[44px] ${scope === "week" ? "mc-btn-primary" : ""}`} onClick={() => setScope("week")}>
|
||||
{tx("本周", "This Week")}
|
||||
</button>
|
||||
<button className={`mc-btn min-h-[44px] ${scope === "today" ? "mc-btn-primary" : ""}`} onClick={() => setScope("today")}>
|
||||
{tx("今日", "Today")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{meGap && (
|
||||
<p className="mt-3 rounded border border-black bg-[color:var(--surface)] px-3 py-2 text-xs text-[color:var(--mc-gold)]">
|
||||
{tx(`你当前第 ${meGap.rank} 名,距离上一名还差 ${meGap.gap} ${scoreLabel}。`, `You are #${meGap.rank}, ${meGap.gap} ${scoreLabel} behind the next player.`)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("正在读取卷轴...", "Reading scrolls...")}</p>}
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
<div className={`mt-4 rounded-xl border ${isMc ? "border-[3px] border-black bg-[color:var(--mc-deep-slate)] shadow-[4px_4px_0_rgba(0,0,0,0.5)] text-white" : "bg-white border-zinc-200"}`}>
|
||||
<div className="divide-y md:hidden">
|
||||
{items.map((row, i) => (
|
||||
{currentItems.map((row, i) => (
|
||||
<article key={row.user_id} className={`space-y-1 p-3 text-sm ${isMc ? "border-zinc-700" : ""}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className={`font-medium ${getRankColor(i)}`}>
|
||||
<span className="mr-2 text-lg">{getRankIcon(i)}</span>
|
||||
{row.username}
|
||||
</p>
|
||||
<span className="text-[color:var(--mc-emerald)] font-bold">{row.rating}</span>
|
||||
<span className="text-[color:var(--mc-green)] font-bold">{row.boardScore}</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{tx("注册时间:", "Registered: ")}
|
||||
{new Date(row.created_at * 1000).toLocaleString()}
|
||||
{formatUnixDateTime(row.created_at)}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
{!loading && currentItems.length === 0 && (
|
||||
<p className="px-3 py-5 text-center text-sm text-zinc-500">
|
||||
{tx("暂无数据", "No legends yet")}
|
||||
</p>
|
||||
@@ -108,6 +179,7 @@ export default function LeaderboardPage() {
|
||||
{tx("用户", "User")}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left">{scoreLabel}</th>
|
||||
<th className="px-3 py-2 text-left">Rating</th>
|
||||
<th className="px-3 py-2 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -118,19 +190,20 @@ export default function LeaderboardPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={isMc ? "divide-y divide-zinc-700" : ""}>
|
||||
{items.map((row, i) => (
|
||||
{currentItems.map((row, i) => (
|
||||
<tr key={row.user_id} className={isMc ? "hover:bg-white/5 transition-colors" : "border-t"}>
|
||||
<td className={`px-3 py-2 font-bold flex items-center justify-center ${getRankColor(i)}`}>{getRankIcon(i)}</td>
|
||||
<td className={`px-3 py-2 font-bold ${getRankColor(i)}`}>{getRankIcon(i)}</td>
|
||||
<td className={`px-3 py-2 font-medium ${getRankColor(i)}`}>{row.username}</td>
|
||||
<td className="px-3 py-2 text-[color:var(--mc-emerald)]">{row.rating}</td>
|
||||
<td className="px-3 py-2 text-[color:var(--mc-green)]">{row.boardScore}</td>
|
||||
<td className="px-3 py-2">{row.rating}</td>
|
||||
<td className="px-3 py-2 text-zinc-500">
|
||||
{new Date(row.created_at * 1000).toLocaleString()}
|
||||
{formatUnixDateTime(row.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
{!loading && currentItems.length === 0 && (
|
||||
<tr>
|
||||
<td className="px-3 py-5 text-center text-zinc-500" colSpan={4}>
|
||||
<td className="px-3 py-5 text-center text-zinc-500" colSpan={5}>
|
||||
{tx("暂无数据", "No legends yet")}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
redirect("/auth");
|
||||
}
|
||||
@@ -1,23 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
CircleMinus,
|
||||
History,
|
||||
IdCard,
|
||||
RefreshCw,
|
||||
ShoppingBag,
|
||||
LogOut,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { PixelAvatar } from "@/components/pixel-avatar";
|
||||
import { SourceCrystalIcon } from "@/components/source-crystal-icon";
|
||||
import { apiFetch, listRatingHistory, type RatingHistoryItem } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { clearToken, readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { dayKeyInShanghai, dayKeySerial, formatUnixDate, formatUnixDateTime, serialToDayKey } from "@/lib/time";
|
||||
|
||||
type Me = {
|
||||
id: number;
|
||||
@@ -26,10 +32,6 @@ type Me = {
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
// Use a distinct style for inputs/selects
|
||||
const inputClass = "w-full bg-[color:var(--mc-surface)] text-[color:var(--mc-plank-light)] border-2 border-[color:var(--mc-stone-dark)] px-3 py-2 text-base focus:border-[color:var(--mc-gold)] focus:outline-none transition-colors font-minecraft";
|
||||
const labelClass = "text-sm text-[color:var(--mc-stone)] mb-1 block";
|
||||
|
||||
type RedeemItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -57,6 +59,53 @@ type RedeemCreateResp = RedeemRecord & {
|
||||
rating_after?: number;
|
||||
};
|
||||
|
||||
type RedeemDayTypeInfo = {
|
||||
day_type: "holiday" | "studyday";
|
||||
is_holiday: boolean;
|
||||
reason: string;
|
||||
source: string;
|
||||
date_ymd: string;
|
||||
checked_at: number;
|
||||
};
|
||||
|
||||
type SourceCrystalSummary = {
|
||||
user_id: number;
|
||||
balance: number;
|
||||
monthly_interest_rate: number;
|
||||
last_interest_at: number;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type SourceCrystalRecord = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
tx_type: "deposit" | "withdraw" | "interest" | string;
|
||||
amount: number;
|
||||
balance_after: number;
|
||||
note: string;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type ExperienceSummary = {
|
||||
user_id: number;
|
||||
experience: number;
|
||||
level: number;
|
||||
current_level_base: number;
|
||||
next_level_experience: number;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type ExperienceHistoryItem = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
xp_delta: number;
|
||||
rating_before: number;
|
||||
rating_after: number;
|
||||
source: string;
|
||||
note: string;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type DailyTaskItem = {
|
||||
code: string;
|
||||
title: string;
|
||||
@@ -74,8 +123,12 @@ type DailyTaskPayload = {
|
||||
};
|
||||
|
||||
function fmtTs(v: number | null | undefined): string {
|
||||
if (!v) return "-";
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
return formatUnixDateTime(v);
|
||||
}
|
||||
|
||||
function fmtCrystal(v: number | null | undefined): string {
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) return "0.00";
|
||||
return v.toFixed(2);
|
||||
}
|
||||
|
||||
function resolveRank(rating: number): { label: string; color: string; icon: string } {
|
||||
@@ -86,30 +139,76 @@ function resolveRank(rating: number): { label: string; color: string; icon: stri
|
||||
return { label: "Wood", color: "text-[color:var(--mc-wood)]", icon: "🪵" };
|
||||
}
|
||||
|
||||
function calcLearningStreak(items: RatingHistoryItem[]): number {
|
||||
const daySet = new Set(
|
||||
items
|
||||
.filter((item) => item.type === "daily_task" && item.change > 0)
|
||||
.map((item) => dayKeyInShanghai(item.created_at))
|
||||
.filter((key) => key.length > 0)
|
||||
);
|
||||
if (daySet.size === 0) return 0;
|
||||
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const todaySerial = dayKeySerial(dayKeyInShanghai(nowSec));
|
||||
if (todaySerial == null) return 0;
|
||||
|
||||
let cursor = todaySerial;
|
||||
const todayKey = serialToDayKey(cursor);
|
||||
if (!daySet.has(todayKey)) {
|
||||
const yesterday = cursor - 1;
|
||||
const yesterdayKey = serialToDayKey(yesterday);
|
||||
if (!daySet.has(yesterdayKey)) return 0;
|
||||
cursor = yesterday;
|
||||
}
|
||||
|
||||
let streak = 0;
|
||||
while (true) {
|
||||
const key = serialToDayKey(cursor);
|
||||
if (!daySet.has(key)) break;
|
||||
streak += 1;
|
||||
cursor -= 1;
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
|
||||
export default function MePage() {
|
||||
const { isZh, tx } = useI18nText();
|
||||
const router = useRouter();
|
||||
const [token, setToken] = useState("");
|
||||
const [profile, setProfile] = useState<Me | null>(null);
|
||||
const [items, setItems] = useState<RedeemItem[]>([]);
|
||||
const [records, setRecords] = useState<RedeemRecord[]>([]);
|
||||
const [historyItems, setHistoryItems] = useState<RatingHistoryItem[]>([]);
|
||||
const [ratingHistoryTypeFilter, setRatingHistoryTypeFilter] = useState("all");
|
||||
const [tradeTypeFilter, setTradeTypeFilter] = useState("all");
|
||||
const [dailyTasks, setDailyTasks] = useState<DailyTaskItem[]>([]);
|
||||
const [dailyDayKey, setDailyDayKey] = useState("");
|
||||
const [dailyTotalReward, setDailyTotalReward] = useState(0);
|
||||
const [dailyGainedReward, setDailyGainedReward] = useState(0);
|
||||
const [learningStreak, setLearningStreak] = useState(0);
|
||||
const [redeemDayType, setRedeemDayType] = useState<RedeemDayTypeInfo | null>(null);
|
||||
const [sourceCrystal, setSourceCrystal] = useState<SourceCrystalSummary | null>(null);
|
||||
const [sourceCrystalRecords, setSourceCrystalRecords] = useState<SourceCrystalRecord[]>([]);
|
||||
const [experience, setExperience] = useState<ExperienceSummary | null>(null);
|
||||
const [experienceHistory, setExperienceHistory] = useState<ExperienceHistoryItem[]>([]);
|
||||
const [experienceHistoryOpen, setExperienceHistoryOpen] = useState(false);
|
||||
const [experienceHistoryLoading, setExperienceHistoryLoading] = useState(false);
|
||||
|
||||
const [selectedItemId, setSelectedItemId] = useState<number>(0);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [dayType, setDayType] = useState<"holiday" | "studyday">("holiday");
|
||||
const [note, setNote] = useState("");
|
||||
const [crystalAmount, setCrystalAmount] = useState("10");
|
||||
const [crystalNote, setCrystalNote] = useState("");
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [redeemLoading, setRedeemLoading] = useState(false);
|
||||
const [crystalLoading, setCrystalLoading] = useState(false);
|
||||
|
||||
// Toast notification system
|
||||
const [toast, setToast] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
const [toastVisible, setToastVisible] = useState(false);
|
||||
const toastTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const lastCompletedTaskCountRef = useRef<number>(-1);
|
||||
|
||||
const showToast = useCallback((type: "success" | "error", text: string) => {
|
||||
if (toastTimer.current) clearTimeout(toastTimer.current);
|
||||
@@ -134,22 +233,6 @@ export default function MePage() {
|
||||
[items, selectedItemId]
|
||||
);
|
||||
|
||||
const unitCost = useMemo(() => {
|
||||
if (!selectedItem) return 0;
|
||||
return dayType === "holiday" ? selectedItem.holiday_cost : selectedItem.studyday_cost;
|
||||
}, [dayType, selectedItem]);
|
||||
|
||||
const totalCost = useMemo(() => Math.max(0, unitCost * Math.max(1, quantity)), [quantity, unitCost]);
|
||||
|
||||
const taskTitle = (task: DailyTaskItem): string => {
|
||||
if (isZh) return task.title;
|
||||
if (task.code === "login_checkin") return "Daily Sign-in";
|
||||
if (task.code === "daily_submit") return "Daily Submission";
|
||||
if (task.code === "first_ac") return "First Blood";
|
||||
if (task.code === "code_quality") return "Craftsman";
|
||||
return task.title;
|
||||
};
|
||||
|
||||
const itemName = (name: string): string => {
|
||||
if (isZh) return name;
|
||||
if (name === "私人玩游戏时间") return "Game Time pass";
|
||||
@@ -201,21 +284,30 @@ export default function MePage() {
|
||||
setToken(tk);
|
||||
if (!tk) throw new Error(tx("请先登录", "Please sign in first"));
|
||||
|
||||
const [me, redeemItems, redeemRecords, daily, history] = await Promise.all([
|
||||
const [me, redeemItems, redeemRecords, daily, history, dayTypeInfo, crystalSummary, crystalRows, expSummary] = await Promise.all([
|
||||
apiFetch<Me>("/api/v1/me", {}, tk),
|
||||
apiFetch<RedeemItem[]>("/api/v1/me/redeem/items", {}, tk),
|
||||
apiFetch<RedeemRecord[]>("/api/v1/me/redeem/records?limit=200", {}, tk),
|
||||
apiFetch<DailyTaskPayload>("/api/v1/me/daily-tasks", {}, tk),
|
||||
listRatingHistory(50, tk),
|
||||
apiFetch<RedeemDayTypeInfo>("/api/v1/me/redeem/day-type", {}, tk),
|
||||
apiFetch<SourceCrystalSummary>("/api/v1/me/source-crystal", {}, tk),
|
||||
apiFetch<SourceCrystalRecord[]>("/api/v1/me/source-crystal/records?limit=200", {}, tk),
|
||||
apiFetch<ExperienceSummary>("/api/v1/me/experience", {}, tk),
|
||||
]);
|
||||
setProfile(me);
|
||||
setItems(redeemItems ?? []);
|
||||
setRecords(redeemRecords ?? []);
|
||||
setHistoryItems(history ?? []);
|
||||
setLearningStreak(calcLearningStreak(history ?? []));
|
||||
setDailyTasks(daily?.tasks ?? []);
|
||||
setDailyDayKey(daily?.day_key ?? "");
|
||||
setDailyTotalReward(daily?.total_reward ?? 0);
|
||||
setDailyGainedReward(daily?.gained_reward ?? 0);
|
||||
setRedeemDayType(dayTypeInfo ?? null);
|
||||
setSourceCrystal(crystalSummary ?? null);
|
||||
setSourceCrystalRecords(crystalRows ?? []);
|
||||
setExperience(expSummary ?? null);
|
||||
|
||||
if ((redeemItems ?? []).length > 0) {
|
||||
setSelectedItemId((prev) => prev || redeemItems[0].id);
|
||||
@@ -227,11 +319,54 @@ export default function MePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExperienceHistory = async () => {
|
||||
const nextOpen = !experienceHistoryOpen;
|
||||
setExperienceHistoryOpen(nextOpen);
|
||||
if (!nextOpen || experienceHistory.length > 0) return;
|
||||
try {
|
||||
const tk = token || readToken();
|
||||
if (!tk) throw new Error(tx("请先登录", "Please sign in first"));
|
||||
setExperienceHistoryLoading(true);
|
||||
const rows = await apiFetch<ExperienceHistoryItem[]>(
|
||||
"/api/v1/me/experience/history?limit=200",
|
||||
{},
|
||||
tk
|
||||
);
|
||||
setExperienceHistory(rows ?? []);
|
||||
} catch (e: unknown) {
|
||||
showToast("error", String(e));
|
||||
} finally {
|
||||
setExperienceHistoryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLearningStreak(calcLearningStreak(historyItems));
|
||||
}, [historyItems]);
|
||||
|
||||
useEffect(() => {
|
||||
const completedCount = dailyTasks.filter((task) => task.completed).length;
|
||||
if (lastCompletedTaskCountRef.current < 0) {
|
||||
lastCompletedTaskCountRef.current = completedCount;
|
||||
return;
|
||||
}
|
||||
if (completedCount > lastCompletedTaskCountRef.current) {
|
||||
const gained = Math.max(0, dailyGainedReward);
|
||||
showToast(
|
||||
"success",
|
||||
isZh
|
||||
? `🎉 每日任务推进!当前奖励 ${gained}/${dailyTotalReward} XP`
|
||||
: `🎉 Daily quest progressed! Reward ${gained}/${dailyTotalReward} XP`
|
||||
);
|
||||
}
|
||||
lastCompletedTaskCountRef.current = completedCount;
|
||||
}, [dailyGainedReward, dailyTasks, dailyTotalReward, isZh, showToast]);
|
||||
|
||||
const redeem = async () => {
|
||||
setRedeemLoading(true);
|
||||
try {
|
||||
@@ -248,7 +383,6 @@ export default function MePage() {
|
||||
body: JSON.stringify({
|
||||
item_id: selectedItemId,
|
||||
quantity,
|
||||
day_type: dayType,
|
||||
note,
|
||||
}),
|
||||
},
|
||||
@@ -269,13 +403,190 @@ export default function MePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const withdrawSourceCrystal = async () => {
|
||||
setCrystalLoading(true);
|
||||
try {
|
||||
if (!token) throw new Error(tx("请先登录", "Please sign in first"));
|
||||
const amount = Number(crystalAmount);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
throw new Error(tx("源晶数量必须大于 0", "Source crystal amount must be greater than 0"));
|
||||
}
|
||||
|
||||
const payload = { amount, note: crystalNote };
|
||||
await apiFetch(
|
||||
"/api/v1/me/source-crystal/withdraw",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
token
|
||||
);
|
||||
|
||||
showToast(
|
||||
"success",
|
||||
tx(`已支出 ${fmtCrystal(amount)} 源晶`, `Spent ${fmtCrystal(amount)} Source Crystals`)
|
||||
);
|
||||
setCrystalNote("");
|
||||
await loadAll();
|
||||
} catch (e: unknown) {
|
||||
showToast("error", String(e));
|
||||
} finally {
|
||||
setCrystalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = useCallback(() => {
|
||||
const confirmed = window.confirm(
|
||||
tx(
|
||||
"确认断开连接并退出当前账号?",
|
||||
"Disconnect and sign out from current account?"
|
||||
)
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
clearToken();
|
||||
setToken("");
|
||||
setProfile(null);
|
||||
setItems([]);
|
||||
setRecords([]);
|
||||
setHistoryItems([]);
|
||||
setDailyTasks([]);
|
||||
setDailyDayKey("");
|
||||
setDailyTotalReward(0);
|
||||
setDailyGainedReward(0);
|
||||
setRedeemDayType(null);
|
||||
setSourceCrystal(null);
|
||||
setSourceCrystalRecords([]);
|
||||
setExperience(null);
|
||||
setExperienceHistory([]);
|
||||
setExperienceHistoryOpen(false);
|
||||
showToast(
|
||||
"success",
|
||||
tx("已断开连接并退出登录。", "Disconnected and signed out.")
|
||||
);
|
||||
router.replace("/auth");
|
||||
}, [router, showToast, tx]);
|
||||
|
||||
const rank = resolveRank(profile?.rating ?? 0);
|
||||
const expValue = experience?.experience ?? 0;
|
||||
const expLevel = experience?.level ?? 1;
|
||||
const expCurrentBase = experience?.current_level_base ?? 0;
|
||||
const expNext = experience?.next_level_experience ?? 100;
|
||||
const expProgress = Math.max(
|
||||
0,
|
||||
Math.min(1, (expValue - expCurrentBase) / Math.max(1, expNext - expCurrentBase))
|
||||
);
|
||||
const expToNext = Math.max(0, expNext - expValue);
|
||||
const currentRedeemDayType = redeemDayType?.day_type === "holiday" ? "holiday" : "studyday";
|
||||
const sourceCrystalBalance = sourceCrystal?.balance ?? 0;
|
||||
const sourceCrystalMonthlyRate = sourceCrystal?.monthly_interest_rate ?? 0;
|
||||
const sourceCrystalEstimatedMonthlyInterest = Math.max(0, sourceCrystalBalance * sourceCrystalMonthlyRate);
|
||||
const sectionTitleClass =
|
||||
"text-lg font-extrabold text-[color:var(--mc-gold)] drop-shadow-sm tracking-wide";
|
||||
const sectionIconClass = "text-[color:var(--mc-diamond)]";
|
||||
const crystalTxLabel = (txType: string): string => {
|
||||
if (txType === "deposit") return tx("存入", "Deposit");
|
||||
if (txType === "withdraw") return tx("取出", "Withdraw");
|
||||
if (txType === "interest") return tx("月息", "Interest");
|
||||
return txType;
|
||||
};
|
||||
const ratingTypeLabel = (type: string): string => {
|
||||
if (type === "daily_task") return tx("每日任务", "Daily Task");
|
||||
if (type === "redeem") return tx("兑换消费", "Redeem");
|
||||
if (type === "solution_view") return tx("题解查看", "Solution View");
|
||||
if (type === "kb_skill") return tx("知识库奖励", "KB Reward");
|
||||
return type;
|
||||
};
|
||||
const tradeTypeLabel = (type: string): string => {
|
||||
if (type === "studyday") return tx("学习日", "Study Day");
|
||||
if (type === "holiday") return tx("假期", "Holiday");
|
||||
if (type === "unknown") return tx("未标注", "Unknown");
|
||||
return type;
|
||||
};
|
||||
const ratingHistoryTypes = useMemo(
|
||||
() => Array.from(new Set(historyItems.map((item) => item.type).filter((v) => v.length > 0))),
|
||||
[historyItems]
|
||||
);
|
||||
const filteredHistoryItems = useMemo(
|
||||
() =>
|
||||
historyItems.filter(
|
||||
(item) => ratingHistoryTypeFilter === "all" || item.type === ratingHistoryTypeFilter
|
||||
),
|
||||
[historyItems, ratingHistoryTypeFilter]
|
||||
);
|
||||
const tradeTypes = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set(records.map((row) => (row.day_type && row.day_type.length > 0 ? row.day_type : "unknown")))
|
||||
),
|
||||
[records]
|
||||
);
|
||||
const filteredTradeRecords = useMemo(
|
||||
() =>
|
||||
records.filter((row) => {
|
||||
const rowType = row.day_type && row.day_type.length > 0 ? row.day_type : "unknown";
|
||||
return tradeTypeFilter === "all" || rowType === tradeTypeFilter;
|
||||
}),
|
||||
[records, tradeTypeFilter]
|
||||
);
|
||||
const achievementItems = useMemo(() => {
|
||||
const hasFirstAc = historyItems.some((item) => item.type === "daily_task" && item.note === "first_ac");
|
||||
const hasSubmit = historyItems.some((item) => item.type === "daily_task" && item.note === "daily_submit");
|
||||
return [
|
||||
{
|
||||
key: "workbench",
|
||||
icon: "🧰",
|
||||
label: tx("工作台", "Workbench"),
|
||||
unlock: hasSubmit || hasFirstAc,
|
||||
hint: tx("完成一次提交", "Complete one submission"),
|
||||
},
|
||||
{
|
||||
key: "torch",
|
||||
icon: "🕯️",
|
||||
label: tx("火把", "Torch"),
|
||||
unlock: learningStreak >= 3,
|
||||
hint: tx("连续学习 3 天", "Study 3 days in a row"),
|
||||
},
|
||||
{
|
||||
key: "compass",
|
||||
icon: "🧭",
|
||||
label: tx("指南针", "Compass"),
|
||||
unlock: historyItems.length >= 10,
|
||||
hint: tx("累计 10 次成长记录", "Collect 10 progress logs"),
|
||||
},
|
||||
{
|
||||
key: "iron-pickaxe",
|
||||
icon: "⛏️",
|
||||
label: tx("铁镐", "Iron Pickaxe"),
|
||||
unlock: hasFirstAc,
|
||||
hint: tx("首次通过题目", "Get first AC"),
|
||||
},
|
||||
];
|
||||
}, [historyItems, learningStreak, tx]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-xl font-bold max-[390px]:text-lg sm:text-2xl text-[color:var(--mc-diamond)] mc-text-shadow">
|
||||
{tx("冒险者档案 & 交易站", "Character Sheet & Trading Post")}
|
||||
</h1>
|
||||
<HintTip title={tx("页面说明", "Page Guide")}>
|
||||
{tx(
|
||||
"这里汇总每日任务、成长记录与交易记录。建议优先完成每日任务,再按需求在交易站兑换物品。",
|
||||
"This page combines daily tasks, growth history, and trade records. Complete daily tasks first, then redeem items in the trading post when needed."
|
||||
)}
|
||||
</HintTip>
|
||||
<button
|
||||
type="button"
|
||||
className="mc-btn mc-btn-danger ml-auto min-h-[40px] px-3 py-1 text-xs"
|
||||
onClick={logout}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<LogOut size={14} />
|
||||
{tx("断开连接", "Disconnect")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{loading && <p className="mt-3 text-sm text-[color:var(--mc-stone)]">{tx("读取存档中...", "Loading Save...")}</p>}
|
||||
|
||||
{/* Toast notification */}
|
||||
@@ -349,6 +660,38 @@ export default function MePage() {
|
||||
<span>{tx("下一等级", "Next Lv")}</span>
|
||||
</div>
|
||||
<span className="text-right text-zinc-600">{100 - (profile.rating % 100)} XP</span>
|
||||
|
||||
<div className="flex items-center gap-1 text-zinc-800">
|
||||
<span className="text-xs">🔥</span>
|
||||
<span>{tx("连学", "Streak")}</span>
|
||||
</div>
|
||||
<span className="text-right font-bold text-[color:var(--mc-red)]">
|
||||
{learningStreak} {tx("天", "days")}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1 text-zinc-800">
|
||||
<Zap size={13} className="text-[color:var(--mc-diamond)]" />
|
||||
<span>{tx("经验值", "Experience")}</span>
|
||||
</div>
|
||||
<span className="text-right font-bold text-[color:var(--mc-diamond)]">{expValue}</span>
|
||||
|
||||
<div className="flex items-center gap-1 text-zinc-800">
|
||||
<TrendingUp size={13} className="text-[color:var(--mc-diamond)]" />
|
||||
<span>{tx("经验等级", "XP Level")}</span>
|
||||
</div>
|
||||
<span className="text-right font-bold text-[color:var(--mc-diamond)]">Lv.{expLevel}</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full mb-2">
|
||||
<div className="h-2 overflow-hidden rounded bg-black/20">
|
||||
<div
|
||||
className="h-full bg-[color:var(--mc-diamond)]"
|
||||
style={{ width: `${(expProgress * 100).toFixed(2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-right text-[11px] text-zinc-600">
|
||||
{tx("下一级经验", "XP to next")}: {expToNext}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full border-t border-zinc-300 my-1"></div>
|
||||
@@ -366,7 +709,32 @@ export default function MePage() {
|
||||
<Calendar size={14} className="text-zinc-500" />
|
||||
{tx("加入时间", "Joined")}
|
||||
</span>
|
||||
<span className="text-zinc-600 font-mono">{new Date(profile.created_at * 1000).toLocaleDateString()}</span>
|
||||
<span className="text-zinc-600 font-mono">{formatUnixDate(profile.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full border-t border-zinc-300 mt-3 pt-3 text-left">
|
||||
<div className="rounded border border-zinc-300 bg-white p-3">
|
||||
<p className="flex items-center gap-1 text-sm font-bold text-[color:var(--mc-gold)]">
|
||||
<SourceCrystalIcon size={14} className={sectionIconClass} />
|
||||
{tx("源晶账户", "Source Crystal Account")}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-bold text-[color:var(--mc-diamond)]">
|
||||
{fmtCrystal(sourceCrystalBalance)} {tx("源晶", "SC")}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
{tx("月利率", "Monthly Interest")}: {(sourceCrystalMonthlyRate * 100).toFixed(2)}% ·{" "}
|
||||
{tx("预计月息", "Est. monthly interest")}: +{fmtCrystal(sourceCrystalEstimatedMonthlyInterest)}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
{tx("上次计息", "Last interest update")}: {fmtTs(sourceCrystal?.last_interest_at)}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
{tx(
|
||||
"仅管理员可在管理页为你存入源晶;你可在此自行支出并填写备注。",
|
||||
"Only admin can deposit Source Crystals for you; you can spend here with notes."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -375,9 +743,9 @@ export default function MePage() {
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Daily Tasks */}
|
||||
<div className="bg-[color:var(--mc-surface)] border-4 border-black p-4 relative">
|
||||
<h2 className="text-xl text-[color:var(--mc-dirt)] mb-4 flex justify-between items-center font-minecraft">
|
||||
<h2 className={`${sectionTitleClass} mb-4 flex justify-between items-center font-minecraft`}>
|
||||
<span>每日悬赏任务</span>
|
||||
<span className="text-xs text-[color:var(--mc-gold)]">进度: {dailyGainedReward} / {dailyTotalReward} XP</span>
|
||||
<span className="text-xs text-[color:var(--mc-gold)]">进度: {dailyGainedReward} / {dailyTotalReward} XP · 🔥 {learningStreak}d · {dailyDayKey || "--"}</span>
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
@@ -407,8 +775,33 @@ export default function MePage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[color:var(--mc-surface)] border-4 border-black p-4">
|
||||
<h3 className={`${sectionTitleClass} mb-3 font-minecraft`}>
|
||||
{tx("物品图鉴", "Item Collection")}
|
||||
</h3>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{achievementItems.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`border-2 border-[color:var(--mc-stone-dark)] p-2 text-sm ${
|
||||
item.unlock
|
||||
? "bg-[color:var(--mc-grass-top)]/20 text-[color:var(--mc-plank-light)]"
|
||||
: "bg-black/20 text-[color:var(--mc-stone)]"
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold flex items-center gap-2">
|
||||
<span>{item.icon}</span>
|
||||
{item.label}
|
||||
{item.unlock ? <span className="text-[color:var(--mc-gold)]">✓</span> : null}
|
||||
</p>
|
||||
<p className="mt-1 text-xs">{item.hint}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<section className="flex-1 rounded-none border-[3px] border-black bg-[color:var(--mc-stone)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)] text-white">
|
||||
<h2 className="text-xl text-[color:var(--mc-obsidian)] mb-6 flex items-center gap-2 border-b-2 border-[color:var(--mc-stone)]/30 pb-2 font-minecraft">
|
||||
<h2 className={`${sectionTitleClass} mb-6 flex items-center gap-2 border-b-2 border-[color:var(--mc-stone)]/30 pb-2 font-minecraft`}>
|
||||
<span className="text-2xl">💎</span>
|
||||
<span>村民交易站</span>
|
||||
<span className="ml-auto text-sm text-[color:var(--mc-stone-dark)]">消耗: RATING</span>
|
||||
@@ -445,20 +838,27 @@ export default function MePage() {
|
||||
<div className="bg-[color:var(--mc-stone)]/20 p-3 border border-[color:var(--mc-stone)]/30 rounded-none text-base text-[color:var(--mc-obsidian)]">
|
||||
<p>{selectedItem.description}</p>
|
||||
<p className="mt-1 text-[color:var(--mc-wood-dark)]">
|
||||
单价: {dayType === 'holiday' ? selectedItem.holiday_cost : selectedItem.studyday_cost} Rating / {selectedItem.unit_label}
|
||||
{tx("单价", "Unit cost")}:{" "}
|
||||
{currentRedeemDayType === "holiday"
|
||||
? selectedItem.holiday_cost
|
||||
: selectedItem.studyday_cost}{" "}
|
||||
Rating / {selectedItem.unit_label}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[color:var(--mc-stone-dark)]">
|
||||
{currentRedeemDayType === "holiday"
|
||||
? tx("今日判定:假期", "Today: Holiday")
|
||||
: tx("今日判定:学习日", "Today: Study Day")}
|
||||
{redeemDayType?.reason ? ` · ${redeemDayType.reason}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="flex-1 rounded-none border-2 border-black bg-[color:var(--stone-dark)] text-black px-2 py-1 text-base"
|
||||
value={dayType}
|
||||
onChange={(e) => setDayType(e.target.value === "studyday" ? "studyday" : "holiday")}
|
||||
>
|
||||
<option value="holiday">{tx("假期特惠", "Holiday Price")}</option>
|
||||
<option value="studyday">{tx("工作日价格", "Workday Price")}</option>
|
||||
</select>
|
||||
<div className="flex-1 rounded-none border-2 border-black bg-[color:var(--stone-dark)] px-2 py-2 text-sm text-black">
|
||||
{currentRedeemDayType === "holiday"
|
||||
? tx("自动使用假期价格", "Auto using holiday price")
|
||||
: tx("自动使用学习日价格", "Auto using study-day price")}
|
||||
</div>
|
||||
<button
|
||||
className="mc-btn mc-btn-success text-xs px-4 flex items-center gap-2"
|
||||
onClick={() => void redeem()}
|
||||
@@ -474,28 +874,168 @@ export default function MePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="mt-4 rounded-none border-[3px] border-black bg-[color:var(--mc-surface)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className={`${sectionTitleClass} flex items-center gap-2`}>
|
||||
<SourceCrystalIcon size={20} className={sectionIconClass} />
|
||||
{tx("源晶支出与流水", "Source Crystal Spend & History")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-[160px_1fr_auto]">
|
||||
<input
|
||||
className="rounded border px-3 py-2 text-sm"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
value={crystalAmount}
|
||||
onChange={(e) => setCrystalAmount(e.target.value)}
|
||||
placeholder={tx("数量", "Amount")}
|
||||
/>
|
||||
<input
|
||||
className="rounded border px-3 py-2 text-sm"
|
||||
value={crystalNote}
|
||||
onChange={(e) => setCrystalNote(e.target.value)}
|
||||
placeholder={tx("备注(可选)", "Note (optional)")}
|
||||
/>
|
||||
<button
|
||||
className="mc-btn mc-btn-danger text-xs flex items-center gap-1"
|
||||
onClick={() => void withdrawSourceCrystal()}
|
||||
disabled={crystalLoading}
|
||||
>
|
||||
<CircleMinus size={14} />
|
||||
{tx("支出", "Spend")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 max-h-56 space-y-1 overflow-y-auto rounded border border-zinc-200 bg-white p-2 text-xs">
|
||||
{sourceCrystalRecords.map((row) => (
|
||||
<div key={row.id} className="flex items-center justify-between border-b border-zinc-100 pb-1">
|
||||
<span>
|
||||
<span className={`font-bold ${row.amount >= 0 ? "text-emerald-700" : "text-red-600"}`}>
|
||||
{row.amount >= 0 ? "+" : ""}
|
||||
{fmtCrystal(row.amount)} {tx("源晶", "SC")}
|
||||
</span>
|
||||
<span className="ml-2 text-zinc-700">{crystalTxLabel(row.tx_type)}</span>
|
||||
{row.note ? <span className="ml-2 text-zinc-500">· {row.note}</span> : null}
|
||||
</span>
|
||||
<span className="text-zinc-500">
|
||||
{tx("余额", "Bal")}: {fmtCrystal(row.balance_after)} · {fmtTs(row.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{!loading && sourceCrystalRecords.length === 0 && (
|
||||
<p className="text-zinc-500">{tx("暂无源晶流水。", "No source crystal records yet.")}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-4 rounded-none border-[3px] border-black bg-[color:var(--mc-surface)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className={`${sectionTitleClass} flex items-center gap-2`}>
|
||||
<Zap size={18} className={sectionIconClass} />
|
||||
{tx("经验值系统", "Experience")}
|
||||
</h2>
|
||||
<button
|
||||
className="mc-btn text-xs px-3 py-1"
|
||||
onClick={() => void toggleExperienceHistory()}
|
||||
disabled={experienceHistoryLoading}
|
||||
>
|
||||
{experienceHistoryOpen
|
||||
? tx("收起经验历史", "Hide XP History")
|
||||
: tx("查看经验历史", "View XP History")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-600">
|
||||
{tx(
|
||||
"规则:1 经验值 = 1 Rating 增量;消费 Rating 不会减少经验值。",
|
||||
"Rule: 1 XP = 1 rating gain; spending rating never decreases XP."
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-3 rounded border border-zinc-300 bg-white p-3">
|
||||
<p className="text-sm font-bold text-[color:var(--mc-diamond)]">
|
||||
{tx("当前经验", "Current XP")}: {expValue}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-zinc-600">
|
||||
{tx("当前等级", "Level")}: Lv.{expLevel}
|
||||
</p>
|
||||
<div className="mt-2 h-2 overflow-hidden rounded bg-zinc-200">
|
||||
<div
|
||||
className="h-full bg-[color:var(--mc-diamond)]"
|
||||
style={{ width: `${(expProgress * 100).toFixed(2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
{expValue} / {expNext} {tx("(下一级)", "(next level)")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{experienceHistoryOpen && (
|
||||
<div className="mt-3 max-h-56 space-y-1 overflow-y-auto rounded border border-zinc-200 bg-white p-2 text-xs">
|
||||
{experienceHistoryLoading && (
|
||||
<p className="text-zinc-500">{tx("加载经验历史中...", "Loading XP history...")}</p>
|
||||
)}
|
||||
{!experienceHistoryLoading && experienceHistory.map((row) => (
|
||||
<div key={row.id} className="flex items-center justify-between border-b border-zinc-100 pb-1">
|
||||
<span>
|
||||
<span className="font-bold text-emerald-700">+{row.xp_delta} XP</span>
|
||||
<span className="ml-2 text-zinc-700">
|
||||
{tx("Rating", "Rating")} {row.rating_before} → {row.rating_after}
|
||||
</span>
|
||||
{row.note ? <span className="ml-2 text-zinc-500">· {row.note}</span> : null}
|
||||
</span>
|
||||
<span className="text-zinc-500">{fmtTs(row.created_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
{!experienceHistoryLoading && experienceHistory.length === 0 && (
|
||||
<p className="text-zinc-500">{tx("暂无经验历史。", "No XP history yet.")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Rating History Section */}
|
||||
<section className="mt-4 rounded-none border-[3px] border-black bg-[color:var(--mc-surface)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||
<h2 className="text-base font-bold text-black mb-2 flex items-center gap-2">
|
||||
<History size={18} />
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className={`${sectionTitleClass} flex items-center gap-2`}>
|
||||
<History size={18} className={sectionIconClass} />
|
||||
{tx("积分变动记录", "Rating History")}
|
||||
</h2>
|
||||
<div className="max-h-60 overflow-y-auto space-y-1">
|
||||
{historyItems.map((item, idx) => (
|
||||
<div key={idx} className="flex justify-between text-xs text-zinc-800 border-b border-zinc-200 pb-1">
|
||||
<label className="flex items-center gap-1 text-xs text-zinc-600">
|
||||
<span>{tx("类型", "Type")}</span>
|
||||
<select
|
||||
className="rounded border px-2 py-1 text-xs text-zinc-700"
|
||||
value={ratingHistoryTypeFilter}
|
||||
onChange={(e) => setRatingHistoryTypeFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">{tx("全部", "All")}</option>
|
||||
{ratingHistoryTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{ratingTypeLabel(type)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto space-y-1 rounded border border-zinc-200 bg-white p-2 text-xs">
|
||||
{filteredHistoryItems.map((item, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between border-b border-zinc-100 pb-1">
|
||||
<span>
|
||||
<span className={`font-bold flex items-center gap-1 ${item.change > 0 ? 'text-[color:var(--mc-green)]' : 'text-[color:var(--mc-red)]'}`}>
|
||||
{item.change > 0 ? <TrendingUp size={14} /> : <TrendingDown size={14} />}
|
||||
{item.change > 0 ? `+${item.change}` : item.change}
|
||||
</span>
|
||||
<span className="ml-2">{formatRatingNote(item.note, item.type)}</span>
|
||||
<span className="ml-2 text-zinc-700">{formatRatingNote(item.note, item.type)}</span>
|
||||
<span className="ml-2 rounded border border-zinc-200 px-1 text-[11px] text-zinc-500">
|
||||
{ratingTypeLabel(item.type)}
|
||||
</span>
|
||||
<span className="text-[color:var(--mc-stone-dark)]">
|
||||
{new Date(item.created_at * 1000).toLocaleString()}
|
||||
</span>
|
||||
<span className="text-zinc-500">
|
||||
{formatUnixDateTime(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{!loading && historyItems.length === 0 && (
|
||||
{!loading && filteredHistoryItems.length === 0 && (
|
||||
<p className="text-xs text-zinc-500">{tx("暂无记录。", "No history.")}</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -504,9 +1044,28 @@ export default function MePage() {
|
||||
{/* Trades Section */}
|
||||
<section className="mt-4 rounded-none border-[3px] border-black bg-[color:var(--mc-surface)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<h2 className="text-base font-bold text-black">{tx("交易记录", "Trade History")}</h2>
|
||||
<h2 className={`${sectionTitleClass} flex items-center gap-2`}>
|
||||
<ArrowRightLeft size={18} className={sectionIconClass} />
|
||||
{tx("交易记录", "Trade History")}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1 text-xs text-zinc-600">
|
||||
<span>{tx("类型", "Type")}</span>
|
||||
<select
|
||||
className="rounded border px-2 py-1 text-xs text-zinc-700"
|
||||
value={tradeTypeFilter}
|
||||
onChange={(e) => setTradeTypeFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">{tx("全部", "All")}</option>
|
||||
{tradeTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{tradeTypeLabel(type)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
className="text-xs text-[color:var(--mc-stone-dark)] underline flex items-center gap-1 hover:text-black"
|
||||
className="mc-btn text-xs px-3 py-1 flex items-center gap-1"
|
||||
onClick={() => void loadAll()}
|
||||
disabled={loading}
|
||||
>
|
||||
@@ -514,19 +1073,23 @@ export default function MePage() {
|
||||
{tx("刷新", "Refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto space-y-1">
|
||||
{records.map((row) => (
|
||||
<div key={row.id} className="flex justify-between text-xs text-zinc-800 border-b border-zinc-200 pb-1">
|
||||
<span>
|
||||
<div className="max-h-56 overflow-y-auto space-y-1 rounded border border-zinc-200 bg-white p-2 text-xs">
|
||||
{filteredTradeRecords.map((row) => (
|
||||
<div key={row.id} className="flex items-center justify-between border-b border-zinc-100 pb-1">
|
||||
<span className="text-zinc-700">
|
||||
{itemName(row.item_name)} × {row.quantity}
|
||||
<span className="ml-2 rounded border border-zinc-200 px-1 text-[11px] text-zinc-500">
|
||||
{tradeTypeLabel(row.day_type && row.day_type.length > 0 ? row.day_type : "unknown")}
|
||||
</span>
|
||||
<span className="text-[color:var(--mc-stone-dark)]">
|
||||
-{row.total_cost} Gems · {new Date(row.created_at * 1000).toLocaleDateString()}
|
||||
</span>
|
||||
<span className="text-zinc-500">
|
||||
-{row.total_cost} Gems · {formatUnixDate(row.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{!loading && records.length === 0 && (
|
||||
{!loading && filteredTradeRecords.length === 0 && (
|
||||
<p className="text-xs text-zinc-500">{tx("暂无交易。", "No trades.")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import NextImage from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { CodeEditor } from "@/components/code-editor";
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { MarkdownRenderer } from "@/components/markdown-renderer";
|
||||
import { PageCrumbs } from "@/components/page-crumbs";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { type Cpp14PolicyIssue } from "@/lib/cpp14-policy";
|
||||
@@ -77,29 +80,12 @@ function normalizeCodeText(raw: string): string {
|
||||
return `${merged}\n`;
|
||||
}
|
||||
|
||||
function codeLineCount(raw: string): number {
|
||||
const text = normalizeCodeText(raw);
|
||||
if (!text) return 0;
|
||||
return text.split("\n").length - 1;
|
||||
}
|
||||
|
||||
function countCompileWarnings(log: string): number {
|
||||
if (!log) return 0;
|
||||
const lines = log.split("\n");
|
||||
return lines.filter((line) => line.toLowerCase().includes("warning:")).length;
|
||||
}
|
||||
|
||||
function countCompileErrors(log: string): number {
|
||||
if (!log) return 0;
|
||||
const lines = log.split("\n");
|
||||
return lines.filter((line) => line.toLowerCase().includes("error:")).length;
|
||||
}
|
||||
|
||||
function scoreRatio(score: number): number {
|
||||
if (!Number.isFinite(score)) return 0;
|
||||
return Math.max(0, Math.min(100, score));
|
||||
}
|
||||
|
||||
function buildDraftSignature(code: string, stdinText: string): string {
|
||||
const normCode = normalizeCodeText(code);
|
||||
const normStdin = (stdinText ?? "").replace(/\r\n?/g, "\n");
|
||||
@@ -280,6 +266,10 @@ int main() {
|
||||
|
||||
const defaultRunInput = ``;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function difficultyIcon(diff: number): string {
|
||||
if (diff <= 2) return "🪵";
|
||||
if (diff <= 4) return "🪨";
|
||||
@@ -296,6 +286,8 @@ export default function ProblemDetailPage() {
|
||||
const [problem, setProblem] = useState<Problem | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [reloadNonce, setReloadNonce] = useState(0);
|
||||
const [mobilePanel, setMobilePanel] = useState<"statement" | "code">("statement");
|
||||
|
||||
const [code, setCode] = useState(starterCode);
|
||||
const [runInput, setRunInput] = useState(defaultRunInput);
|
||||
@@ -306,7 +298,6 @@ export default function ProblemDetailPage() {
|
||||
const [submitResp, setSubmitResp] = useState<Submission | null>(null);
|
||||
const [runResp, setRunResp] = useState<RunResult | null>(null);
|
||||
const [draftMsg, setDraftMsg] = useState("");
|
||||
const [showPolicyTips, setShowPolicyTips] = useState(false);
|
||||
const [policyIssues, setPolicyIssues] = useState<Cpp14PolicyIssue[]>([]);
|
||||
const [policyMsg, setPolicyMsg] = useState("");
|
||||
|
||||
@@ -328,6 +319,7 @@ export default function ProblemDetailPage() {
|
||||
const [solutionData, setSolutionData] = useState<SolutionResp | null>(null);
|
||||
const [solutionMsg, setSolutionMsg] = useState("");
|
||||
const [printAnswerMarkdown, setPrintAnswerMarkdown] = useState("");
|
||||
const outputAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||
const draftLatestRef = useRef<{ code: string; stdin: string }>({
|
||||
code: starterCode,
|
||||
stdin: defaultRunInput,
|
||||
@@ -438,7 +430,7 @@ export default function ProblemDetailPage() {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ filename }),
|
||||
}, token);
|
||||
if (Array.isArray((resp as any).note_images)) setNoteImages((resp as any).note_images);
|
||||
if (Array.isArray(resp.note_images)) setNoteImages(resp.note_images);
|
||||
setNoteMsg(tx("图片已删除。", "Image deleted."));
|
||||
} catch (e: unknown) {
|
||||
setNoteMsg(String(e));
|
||||
@@ -457,7 +449,7 @@ export default function ProblemDetailPage() {
|
||||
await apiFetch<{ note: string }>(`/api/v1/me/wrong-book/${problemId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ note: noteText }),
|
||||
}, token);
|
||||
}, token, { retryOnStatus: [500], retryCount: 10, retryDelayMs: 500 });
|
||||
setNoteMsg(tx("笔记已保存。", "Notes saved."));
|
||||
} catch (e: unknown) {
|
||||
setNoteMsg(String(e));
|
||||
@@ -473,6 +465,15 @@ export default function ProblemDetailPage() {
|
||||
return;
|
||||
}
|
||||
if (!problemId) return;
|
||||
if (!noteText.trim()) {
|
||||
setNoteMsg(
|
||||
tx(
|
||||
"请先填写或保存学习笔记后再进行鉴定。",
|
||||
"Write or save your learning note before appraising."
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
setNoteScoring(true);
|
||||
setNoteMsg("");
|
||||
try {
|
||||
@@ -483,7 +484,7 @@ export default function ProblemDetailPage() {
|
||||
}>(`/api/v1/me/wrong-book/${problemId}/note-score`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ note: noteText }),
|
||||
}, token);
|
||||
}, token, { retryOnStatus: [500], retryCount: 10, retryDelayMs: 500 });
|
||||
setNoteScore(resp.note_score);
|
||||
setNoteRating(resp.note_rating);
|
||||
setNoteFeedback(resp.note_feedback_md || "");
|
||||
@@ -533,11 +534,6 @@ export default function ProblemDetailPage() {
|
||||
() => policyIssues.filter((item) => item.severity === "warning").length,
|
||||
[policyIssues]
|
||||
);
|
||||
const policyHintCount = useMemo(
|
||||
() => policyIssues.filter((item) => item.severity === "hint").length,
|
||||
[policyIssues]
|
||||
);
|
||||
const visiblePolicyIssues = useMemo(() => policyIssues.slice(0, 6), [policyIssues]);
|
||||
const hasSolutionAnswer = useMemo(
|
||||
() => Boolean(solutionData?.has_solutions),
|
||||
[solutionData]
|
||||
@@ -564,18 +560,6 @@ export default function ProblemDetailPage() {
|
||||
],
|
||||
[tx]
|
||||
);
|
||||
const submitWarningCount = useMemo(
|
||||
() => countCompileWarnings(submitResp?.compile_log ?? ""),
|
||||
[submitResp?.compile_log]
|
||||
);
|
||||
const submitErrorCount = useMemo(
|
||||
() => countCompileErrors(submitResp?.compile_log ?? ""),
|
||||
[submitResp?.compile_log]
|
||||
);
|
||||
const runWarningCount = useMemo(
|
||||
() => countCompileWarnings(runResp?.compile_log ?? ""),
|
||||
[runResp?.compile_log]
|
||||
);
|
||||
const runErrorCount = useMemo(
|
||||
() => countCompileErrors(runResp?.compile_log ?? ""),
|
||||
[runResp?.compile_log]
|
||||
@@ -598,12 +582,29 @@ export default function ProblemDetailPage() {
|
||||
if (solutionStatusLoading) return "text-[color:var(--mc-stone)]";
|
||||
return "text-[color:var(--mc-gold)]";
|
||||
}, [hasSolutionAnswer, solutionStatusLoading]);
|
||||
const isProblemNotFound = /problem not found|404/i.test(error);
|
||||
const missionProgress = useMemo(
|
||||
() => [
|
||||
{ label: tx("运行", "Run"), done: Boolean(runResp) },
|
||||
{ label: tx("提交", "Submit"), done: Boolean(submitResp) },
|
||||
{ label: tx("笔记", "Notes"), done: noteText.trim().length >= 20 },
|
||||
],
|
||||
[noteText, runResp, submitResp, tx]
|
||||
);
|
||||
const relatedKnowledge = useMemo(() => {
|
||||
const raw = [...(llmProfile?.knowledge_points ?? []), ...(llmProfile?.tags ?? [])]
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
const uniq = Array.from(new Set(raw));
|
||||
return uniq.slice(0, 8);
|
||||
}, [llmProfile?.knowledge_points, llmProfile?.tags]);
|
||||
|
||||
useEffect(() => {
|
||||
setRunInput(defaultRunInput);
|
||||
setShowSolutions(false);
|
||||
setSolutionData(null);
|
||||
setSolutionMsg("");
|
||||
setMobilePanel("statement");
|
||||
draftLatestRef.current = { code: starterCode, stdin: defaultRunInput };
|
||||
draftLastSavedSigRef.current = buildDraftSignature(starterCode, defaultRunInput);
|
||||
}, [id]);
|
||||
@@ -646,16 +647,35 @@ export default function ProblemDetailPage() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await apiFetch<Problem>(`/api/v1/problems/${id}`);
|
||||
setProblem(data);
|
||||
let loaded: Problem | null = null;
|
||||
let lastErr: unknown = null;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
loaded = await apiFetch<Problem>(`/api/v1/problems/${id}`);
|
||||
break;
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
lastErr = e;
|
||||
if (attempt < 2) {
|
||||
await sleep(300 * (2 ** attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!loaded) throw lastErr ?? new Error("load failed");
|
||||
setProblem(loaded);
|
||||
} catch (e: unknown) {
|
||||
const msg = String(e);
|
||||
console.error("[problem-detail] load failed", {
|
||||
problemId: id,
|
||||
error: msg,
|
||||
});
|
||||
setProblem(null);
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, [id]);
|
||||
}, [id, reloadNonce]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -778,6 +798,7 @@ export default function ProblemDetailPage() {
|
||||
token
|
||||
);
|
||||
setSubmitResp(resp);
|
||||
setMobilePanel("code");
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
@@ -796,6 +817,7 @@ export default function ProblemDetailPage() {
|
||||
body: JSON.stringify({ code: sourceCode, input: runInput }),
|
||||
});
|
||||
setRunResp(resp);
|
||||
setMobilePanel("code");
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
@@ -970,18 +992,106 @@ export default function ProblemDetailPage() {
|
||||
window.print();
|
||||
};
|
||||
|
||||
const focusOutput = () => {
|
||||
setMobilePanel("code");
|
||||
window.setTimeout(() => {
|
||||
outputAnchorRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}, 80);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-[1400px] px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
|
||||
<PageCrumbs
|
||||
items={[
|
||||
{ label: tx("主城", "Town"), href: "/" },
|
||||
{ label: tx("任务", "Quests"), href: "/problems" },
|
||||
{ label: problem ? `${problem.id}` : `${id}` },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="text-xl font-bold max-[390px]:text-lg sm:text-2xl text-[color:var(--mc-diamond)] mc-text-shadow">
|
||||
{tx("任务详情与试炼", "Mission Details")}
|
||||
</h1>
|
||||
<Link href="/problems" className="mc-btn min-h-[44px]">
|
||||
{tx("返回任务板", "Back to Quest Board")}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-[color:var(--mc-stone)]">
|
||||
<p>{tx("题面、代码、评测输出都在本页完成。", "Solve, run, and review outputs on this page.")}</p>
|
||||
<HintTip title={tx("页面说明", "Page Notes")} align="left">
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
<li>{tx("支持自动保存草稿,默认每分钟检查并保存。", "Draft auto-save checks and persists changes every minute.")}</li>
|
||||
<li>{tx("可提交学习笔记评分,60 分对应 +6 XP。", "Learning notes can be scored; 60 points yields +6 XP.")}</li>
|
||||
<li>{tx("先知题解支持预览状态、生成、解锁与一键写入代码。", "Oracle solutions support preview, generation, unlock, and one-click code insert.")}</li>
|
||||
</ul>
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
{loading && <p className="mt-4 text-sm text-[color:var(--mc-stone)]">{tx("加载地图中...", "Loading Map...")}</p>}
|
||||
{error && <p className="mt-4 text-sm text-[color:var(--mc-red)]">{error}</p>}
|
||||
{!loading && error && !problem && (
|
||||
<div className="mt-4 rounded border-[3px] border-black bg-[color:var(--surface)] p-4 text-sm shadow-[4px_4px_0_rgba(0,0,0,0.45)]">
|
||||
<p className="font-bold text-[color:var(--mc-red)]">
|
||||
{isProblemNotFound
|
||||
? tx("该任务不存在或已下线。", "This problem does not exist or was removed.")
|
||||
: tx("任务加载失败。", "Failed to load this problem.")}
|
||||
</p>
|
||||
<p className="mt-2 text-[color:var(--mc-stone)]">{error}</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button className="mc-btn mc-btn-primary min-h-[44px]" onClick={() => setReloadNonce((v) => v + 1)}>
|
||||
{tx("重试加载", "Retry")}
|
||||
</button>
|
||||
<Link href="/problems" className="mc-btn min-h-[44px]">
|
||||
{tx("返回任务板", "Back to Quest Board")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!error && !!problem && <p className="mt-4 text-sm text-[color:var(--mc-red)]">{error}</p>}
|
||||
|
||||
{problem && (
|
||||
<div className="problem-detail-grid mt-4 grid gap-4 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]">
|
||||
<section className="problem-print-section rounded-none border-[3px] border-black bg-[color:var(--mc-plank-light)] p-4 sm:p-5 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="rounded border-[2px] border-black bg-black/20 px-3 py-2 text-xs text-[color:var(--mc-gold)]">
|
||||
{tx("任务简报", "Mission Brief")}
|
||||
</div>
|
||||
<div className="rounded border-[3px] border-black bg-[color:var(--surface)] p-3 text-xs text-[color:var(--mc-stone)] shadow-[3px_3px_0_rgba(0,0,0,0.35)]">
|
||||
<p className="font-bold text-[color:var(--mc-gold)]">{tx("本任务进度", "Mission Progress")}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{missionProgress.map((item) => (
|
||||
<span
|
||||
key={item.label}
|
||||
className={`rounded border border-black px-2 py-1 ${item.done
|
||||
? "bg-[color:var(--mc-grass-top)] text-white"
|
||||
: "bg-black/30 text-[color:var(--mc-stone)]"
|
||||
}`}
|
||||
>
|
||||
{item.done ? "✅" : "⬜"} {item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 xl:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className={`mc-btn min-h-[44px] ${mobilePanel === "statement" ? "mc-btn-primary" : ""}`}
|
||||
onClick={() => setMobilePanel("statement")}
|
||||
>
|
||||
{tx("题面", "Statement")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`mc-btn min-h-[44px] ${mobilePanel === "code" ? "mc-btn-primary" : ""}`}
|
||||
onClick={() => setMobilePanel("code")}
|
||||
>
|
||||
{tx("代码与输出", "Code & Output")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="problem-detail-grid grid gap-4 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]">
|
||||
<section className={`problem-print-section rounded-none border-[3px] border-black bg-[color:var(--mc-plank-light)] p-4 sm:p-5 shadow-[4px_4px_0_rgba(0,0,0,0.5)] ${mobilePanel === "statement" ? "block" : "hidden"} xl:block`}>
|
||||
<div className="mb-2 text-xs font-semibold text-[color:var(--mc-stone-dark)]">
|
||||
{tx("阅读区", "Reading Zone")}
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-black mc-text-shadow-sm">{problem.title}</h2>
|
||||
<div className="mt-1 flex items-center gap-2 text-sm">
|
||||
<span className={`font-bold ${problem.difficulty > 6 ? "text-[color:var(--mc-diamond)]" :
|
||||
@@ -994,6 +1104,22 @@ export default function ProblemDetailPage() {
|
||||
<span className="text-zinc-500">·</span>
|
||||
<span className="text-zinc-800">{tx("来源", "Origin")}: {problem.source}</span>
|
||||
</div>
|
||||
{relatedKnowledge.length > 0 && (
|
||||
<div className="mt-2 rounded border-[2px] border-black bg-[color:var(--mc-plank)] p-2">
|
||||
<p className="text-xs font-bold text-black">{tx("相关知识", "Related Knowledge")}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{relatedKnowledge.map((item) => (
|
||||
<Link
|
||||
key={item}
|
||||
href={`/kb?q=${encodeURIComponent(item)}`}
|
||||
className="rounded border border-black bg-[color:var(--mc-plank-light)] px-2 py-1 text-[11px] text-black hover:bg-white"
|
||||
>
|
||||
{item}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="print-hidden mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -1007,24 +1133,15 @@ export default function ProblemDetailPage() {
|
||||
? tx("🖨️ 打印题目+答案", "🖨️ Print with Answer")
|
||||
: tx("🖨️ 打印题目", "🖨️ Print Problem")}
|
||||
</button>
|
||||
<button
|
||||
className="mc-btn text-sm py-1"
|
||||
onClick={() => setShowPolicyTips((v) => !v)}
|
||||
>
|
||||
{showPolicyTips ? tx("收起公约", "Hide Rules") : tx("福建考场公约", "Fujian Rules")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showPolicyTips && (
|
||||
<div className="mt-3 rounded border-[2px] border-black bg-[color:var(--mc-plank)] p-3 shadow-[2px_2px_0_rgba(0,0,0,0.4)]">
|
||||
<p className="font-bold text-black mb-1">{tx("考场生存指南:", "Survival Guide:")}</p>
|
||||
<ul className="list-disc space-y-1 pl-4 text-xs text-black">
|
||||
<HintTip title={tx("福建考场公约", "Fujian Rules")} align="left" widthClassName="w-80 sm:w-96">
|
||||
<p className="mb-1 font-semibold text-zinc-900">{tx("考场生存指南", "Contest Survival Guide")}</p>
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
{policyTips.map((tip, idx) => (
|
||||
<li key={idx}>{tip}</li>
|
||||
))}
|
||||
</ul>
|
||||
</HintTip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`print-content mt-4 ${printAnswerMarkdown ? "print-with-answer" : ""}`}>
|
||||
<MarkdownRenderer markdown={statementMarkdown} className="problem-markdown text-black" />
|
||||
@@ -1037,17 +1154,20 @@ export default function ProblemDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded border-[3px] border-black bg-[color:var(--mc-plank)] p-3 shadow-[3px_3px_0_rgba(0,0,0,0.45)]">
|
||||
<div className="print-hidden mt-5 rounded border-[3px] border-black bg-[color:var(--mc-plank)] p-3 shadow-[3px_3px_0_rgba(0,0,0,0.45)]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="font-bold text-black">📜 {tx("探索笔记(看完视频后记录)", "Explorer Notes (record after watching)")}</h3>
|
||||
<h3 className="font-bold text-black">📜 {tx("探索笔记", "Explorer Notes")}</h3>
|
||||
<span className="text-xs text-zinc-700">⚡ {tx("满分60 = 经验值+6", "Max 60 = +6 XP")}</span>
|
||||
<HintTip title={tx("笔记建议", "Note Guide")} align="left">
|
||||
<p>{tx("建议记录:题意、思路、关键代码、踩坑与修复、复盘结论。", "Suggested structure: understanding, approach, key code, pitfalls/fixes, recap.")}</p>
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="mt-2 w-full rounded-none border-[2px] border-black bg-[color:var(--mc-plank-light)] p-2 text-xs text-black shadow-[2px_2px_0_rgba(0,0,0,0.35)]"
|
||||
rows={8}
|
||||
value={noteText}
|
||||
placeholder={tx("⛏️ 记录你的探索:题意理解/解题思路/代码配方/踩坑与修复/总结", "⛏️ Log your adventure: problem understanding / approach / code recipe / pitfalls / summary")}
|
||||
placeholder={tx("⛏️ 记录题意、思路、关键代码与复盘结论", "⛏️ Record understanding, approach, key code, and recap")}
|
||||
onChange={(e) => setNoteText(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -1067,7 +1187,7 @@ export default function ProblemDetailPage() {
|
||||
<button className="mc-btn mc-btn-success text-xs" onClick={() => void saveLearningNote()} disabled={noteSaving}>
|
||||
{noteSaving ? tx("刻录中...", "Engraving...") : tx("💾 存入宝典", "💾 Save to Codex")}
|
||||
</button>
|
||||
<button className="mc-btn mc-btn-primary text-xs" onClick={() => void scoreLearningNote()} disabled={noteScoring}>
|
||||
<button className="mc-btn mc-btn-primary text-xs" onClick={() => void scoreLearningNote()} disabled={noteScoring || !noteText.trim()}>
|
||||
{noteScoring ? tx("⛏️ 鉴定中...", "⛏️ Appraising...") : tx("⛏️ 矿石鉴定", "⛏️ Appraise Ore")}
|
||||
</button>
|
||||
{noteScore !== null && noteRating !== null && (
|
||||
@@ -1084,9 +1204,12 @@ export default function ProblemDetailPage() {
|
||||
{noteImages.map((fn) => (
|
||||
<div key={fn} className="rounded border-2 border-black bg-[color:var(--mc-plank-light)] p-1 shadow-[2px_2px_0_rgba(0,0,0,0.25)]">
|
||||
<a href={`/files/note-images/${fn}`} target="_blank" rel="noreferrer">
|
||||
<img
|
||||
<NextImage
|
||||
src={`/files/note-images/${fn}`}
|
||||
alt={fn}
|
||||
width={240}
|
||||
height={80}
|
||||
unoptimized
|
||||
className="h-20 w-full object-cover"
|
||||
/>
|
||||
</a>
|
||||
@@ -1110,7 +1233,10 @@ export default function ProblemDetailPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4 print:hidden">
|
||||
<section className={`print:hidden flex-col gap-4 ${mobilePanel === "code" ? "flex" : "hidden"} xl:flex`}>
|
||||
<div className="text-xs font-semibold text-[color:var(--mc-gold)]">
|
||||
{tx("战斗区", "Combat Zone")}
|
||||
</div>
|
||||
<div className="rounded-none border-[3px] border-black bg-[color:var(--mc-obsidian)] p-1 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||
<div className="relative">
|
||||
<CodeEditor
|
||||
@@ -1178,21 +1304,22 @@ export default function ProblemDetailPage() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
className="mc-btn mc-btn-primary w-full py-3 text-sm"
|
||||
className="mc-btn mc-btn-primary w-full min-h-[44px] py-3 text-sm"
|
||||
onClick={() => void runCode()}
|
||||
disabled={runLoading || submitLoading}
|
||||
>
|
||||
{runLoading ? tx("施法中...", "Casting...") : tx("试运行", "Test Run")}
|
||||
{runLoading ? tx("运行中...", "Running...") : tx("运行", "Run")}
|
||||
</button>
|
||||
<button
|
||||
className="mc-btn mc-btn-success w-full py-3 text-sm"
|
||||
className="mc-btn mc-btn-success w-full min-h-[44px] py-3 text-sm"
|
||||
onClick={() => void submit()}
|
||||
disabled={submitLoading || runLoading}
|
||||
>
|
||||
{submitLoading ? tx("施法中...", "Casting...") : tx("施放咒语", "Cast Spell")}
|
||||
{submitLoading ? tx("提交中...", "Submitting...") : tx("提交评测", "Submit")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={outputAnchorRef} />
|
||||
{(runResp || submitResp) && (
|
||||
<div className="animation-slide-up space-y-3">
|
||||
{runResp && (
|
||||
@@ -1206,7 +1333,7 @@ export default function ProblemDetailPage() {
|
||||
</div>
|
||||
|
||||
{runResp.compile_log && (
|
||||
<details className="mb-2">
|
||||
<details className="mb-2" open={runErrorCount > 0 || runResp.status === "CE"}>
|
||||
<summary className="cursor-pointer text-xs font-bold text-[color:var(--mc-red)]">{tx("编译日志", "Compile Log")}</summary>
|
||||
<pre className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap rounded bg-black p-2 text-xs text-red-300 font-mono">
|
||||
{runResp.compile_log}
|
||||
@@ -1270,6 +1397,9 @@ export default function ProblemDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-none border-[3px] border-black bg-[color:var(--surface)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||
<div className="mb-2 text-xs font-semibold text-[color:var(--mc-gold)]">
|
||||
{tx("智库区", "Oracle Zone")}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-bold text-[color:var(--mc-diamond)] mc-text-shadow-sm">{tx("先知启示 (AI 题解)", "Oracle's Wisdom")}</h3>
|
||||
<button
|
||||
@@ -1298,15 +1428,18 @@ export default function ProblemDetailPage() {
|
||||
{/* Unlock Logic: Check if we need to show the unlock button */}
|
||||
{solutionData?.has_solutions && solutionData?.access?.mode !== "full" && (
|
||||
<div className="bg-[color:var(--mc-wood-dark)]/10 p-4 border-2 border-[color:var(--mc-gold)]/50 rounded-none mb-4">
|
||||
<p className="text-sm text-[color:var(--mc-red)] font-bold mb-3 flex items-start gap-2">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-bold text-[color:var(--mc-red)]">
|
||||
<span className="text-xl">⚠️</span>
|
||||
<span>
|
||||
<span>{tx("解锁后本题将锁分。", "Unlocking will lock this problem's score.")}</span>
|
||||
<HintTip title={tx("锁分说明", "Score Lock Notes")} align="left">
|
||||
<p>
|
||||
{tx(
|
||||
"查看题解后,本题分数将被锁定,再次提交无法获得更高评分。",
|
||||
"Viewing the solution will LOCK your score for this problem. Future submissions will not increase your rating."
|
||||
"查看完整题解后,本题后续提交不再提升积分;是否收费或免费由后端规则判定。",
|
||||
"After full solution unlock, future submissions on this problem no longer increase rating; billing/free eligibility is backend-controlled."
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
{unlockConfirm ? (
|
||||
<div className="flex gap-3 animate-in fade-in zoom-in duration-200">
|
||||
@@ -1471,6 +1604,35 @@ export default function ProblemDetailPage() {
|
||||
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="fixed inset-x-0 bottom-[calc(4.4rem+env(safe-area-inset-bottom))] z-40 border-t-[3px] border-black bg-[color:var(--surface)]/95 px-3 py-2 backdrop-blur xl:hidden print:hidden">
|
||||
<div className="mx-auto grid max-w-5xl grid-cols-3 gap-2">
|
||||
<button
|
||||
className="mc-btn mc-btn-primary min-h-[44px]"
|
||||
onClick={() => {
|
||||
setMobilePanel("code");
|
||||
void runCode();
|
||||
}}
|
||||
disabled={runLoading || submitLoading}
|
||||
>
|
||||
{runLoading ? tx("运行中", "Running") : tx("运行", "Run")}
|
||||
</button>
|
||||
<button
|
||||
className="mc-btn mc-btn-success min-h-[44px]"
|
||||
onClick={() => {
|
||||
setMobilePanel("code");
|
||||
void submit();
|
||||
}}
|
||||
disabled={submitLoading || runLoading}
|
||||
>
|
||||
{submitLoading ? tx("提交中", "Submitting") : tx("提交", "Submit")}
|
||||
</button>
|
||||
<button className="mc-btn min-h-[44px]" onClick={focusOutput}>
|
||||
{tx("输出", "Output")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -5,18 +5,19 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Book,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Globe,
|
||||
Search,
|
||||
Shield,
|
||||
Sword,
|
||||
Tag,
|
||||
Trophy,
|
||||
Filter,
|
||||
ArrowUpDown
|
||||
Filter
|
||||
} from "lucide-react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
@@ -219,6 +220,9 @@ export default function ProblemsPage() {
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
const [expandedMobileCards, setExpandedMobileCards] = useState<Set<number>>(new Set());
|
||||
const [showAdminLogEntry, setShowAdminLogEntry] = useState(false);
|
||||
|
||||
const preset = useMemo(
|
||||
() => PRESETS.find((item) => item.key === presetKey) ?? PRESETS[0],
|
||||
@@ -258,6 +262,35 @@ export default function ProblemsPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
const q = new URLSearchParams(window.location.search).get("q")?.trim() ?? "";
|
||||
if (!q) return;
|
||||
setKeywordInput(q);
|
||||
setKeyword(q);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
const token = readToken();
|
||||
if (!token) {
|
||||
setShowAdminLogEntry(false);
|
||||
return;
|
||||
}
|
||||
void apiFetch<{ username?: string }>("/api/v1/me", {}, token)
|
||||
.then((me) => {
|
||||
if (!canceled) {
|
||||
setShowAdminLogEntry((me?.username ?? "") === "admin");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!canceled) setShowAdminLogEntry(false);
|
||||
});
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
items.map((problem) => {
|
||||
@@ -277,6 +310,82 @@ export default function ProblemsPage() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const filterControls = (mobile: boolean) => (
|
||||
<>
|
||||
<div className={`relative ${mobile ? "" : ""}`}>
|
||||
<Filter className="absolute left-2 top-2.5 h-4 w-4 text-zinc-400" />
|
||||
<select
|
||||
className="w-full rounded-none border-2 border-black bg-[color:var(--surface)] text-white pl-8 pr-3 py-2 text-sm appearance-none min-h-[44px]"
|
||||
value={presetKey}
|
||||
onChange={(e) => {
|
||||
selectPreset(e.target.value);
|
||||
}}
|
||||
>
|
||||
{PRESETS.map((item) => (
|
||||
<option key={item.key} value={item.key}>
|
||||
{isZh ? item.labelZh : item.labelEn}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<input
|
||||
className={`rounded-none border-2 border-black bg-[color:var(--surface)] text-white px-3 py-2 text-sm min-h-[44px] ${mobile ? "" : "lg:col-span-2"}`}
|
||||
placeholder={tx("搜索任务 ID / 标题...", "Search Quest ID / Keyword...")}
|
||||
value={keywordInput}
|
||||
onChange={(e) => setKeywordInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") applySearch();
|
||||
}}
|
||||
/>
|
||||
|
||||
<select
|
||||
className="rounded-none border-2 border-black bg-[color:var(--surface)] text-white px-3 py-2 text-sm min-h-[44px]"
|
||||
value={difficulty}
|
||||
onChange={(e) => {
|
||||
setDifficulty(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{DIFFICULTY_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{tx("难度: ", "Tier: ")} {isZh ? item.labelZh : item.labelEn}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="rounded-none border-2 border-black bg-[color:var(--surface)] text-white px-3 py-2 text-sm min-h-[44px]"
|
||||
value={`${orderBy}:${order}`}
|
||||
onChange={(e) => {
|
||||
const [ob, od] = e.target.value.split(":");
|
||||
setOrderBy(ob || "id");
|
||||
setOrder(od || "asc");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="id:asc">{tx("编号升序", "ID Asc")}</option>
|
||||
<option value="id:desc">{tx("编号降序", "ID Desc")}</option>
|
||||
<option value="difficulty:asc">{tx("难度升序", "Tier Asc")}</option>
|
||||
<option value="difficulty:desc">{tx("难度降序", "Tier Desc")}</option>
|
||||
<option value="created_at:desc">{tx("最新发布", "Newest")}</option>
|
||||
<option value="title:asc">{tx("标题 A-Z", "Title A-Z")}</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
className="mc-btn mc-btn-primary flex items-center justify-center gap-2 min-h-[44px]"
|
||||
onClick={() => {
|
||||
applySearch();
|
||||
if (mobile) setMobileFiltersOpen(false);
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<Search size={16} />
|
||||
{loading ? tx("加载中...", "Loading...") : tx("搜索", "Search")}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
@@ -284,18 +393,27 @@ export default function ProblemsPage() {
|
||||
<h1 className="text-xl font-bold max-[390px]:text-lg sm:text-2xl text-[color:var(--mc-diamond)] mc-text-shadow">
|
||||
{tx("任务布告栏", "Quest Board")}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-[color:var(--mc-stone)]">
|
||||
<p className="mt-1 text-sm text-[color:var(--mc-stone)] inline-flex items-center gap-2">
|
||||
{tx("筛选任务并开始训练。", "Filter quests and start training.")}
|
||||
<HintTip title={tx("筛选说明", "Filter Guide")} align="left">
|
||||
{tx(
|
||||
"接受任务,赚取 XP,提升等级!",
|
||||
"Accept Quests, Earn XP, Level Up!"
|
||||
"可按预设频道、关键词、难度和排序筛选。建议从 C++ 基础或 CSP-J 频道开始,逐步提升到 CSP-S。",
|
||||
"Use preset channels, keywords, difficulty, and sorting to filter. It is recommended to start with C++ Basics or CSP-J and then move up to CSP-S."
|
||||
)}
|
||||
</HintTip>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap items-center gap-3 text-sm sm:w-auto sm:justify-end">
|
||||
<p className="text-[color:var(--mc-gold)]">{tx("总任务数: ", "Total Quests: ")} {totalCount}</p>
|
||||
<Link className="mc-btn w-full text-center sm:w-auto" href="/backend-logs">
|
||||
{showAdminLogEntry ? (
|
||||
<Link className="mc-btn w-full text-center sm:w-auto min-h-[44px]" href="/backend-logs">
|
||||
{tx("服务器日志", "Server Logs")}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-xs text-[color:var(--mc-stone-dark)]">
|
||||
{tx("仅管理员可查看服务器日志", "Server logs are admin-only")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -306,7 +424,7 @@ export default function ProblemsPage() {
|
||||
<button
|
||||
key={card.presetKey}
|
||||
type="button"
|
||||
className={`rounded-xl border px-4 py-3 text-left transition flex items-center gap-4 ${active
|
||||
className={`rounded-xl border px-4 py-3 text-left transition flex items-center gap-4 min-h-[84px] ${active
|
||||
? "bg-[color:var(--mc-grass-dark)] text-white"
|
||||
: "bg-[color:var(--mc-plank)] text-black hover:bg-[color:var(--mc-plank-light)]"
|
||||
}`}
|
||||
@@ -326,75 +444,26 @@ export default function ProblemsPage() {
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="mt-4 grid gap-3 rounded-xl border bg-[color:var(--mc-stone-dark)] p-4 md:grid-cols-2 lg:grid-cols-6 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||
<div className="relative">
|
||||
<Filter className="absolute left-2 top-2.5 h-4 w-4 text-zinc-400" />
|
||||
<select
|
||||
className="w-full rounded-none border-2 border-black bg-[color:var(--surface)] text-white pl-8 pr-3 py-2 text-sm appearance-none"
|
||||
value={presetKey}
|
||||
onChange={(e) => {
|
||||
selectPreset(e.target.value);
|
||||
}}
|
||||
>
|
||||
{PRESETS.map((item) => (
|
||||
<option key={item.key} value={item.key}>
|
||||
{isZh ? item.labelZh : item.labelEn}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="rounded-none border-2 border-black bg-[color:var(--surface)] text-white px-3 py-2 text-sm lg:col-span-2"
|
||||
placeholder={tx("搜索任务 ID / 标题...", "Search Quest ID / Keyword...")}
|
||||
value={keywordInput}
|
||||
onChange={(e) => setKeywordInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") applySearch();
|
||||
}}
|
||||
/>
|
||||
|
||||
<select
|
||||
className="rounded-none border-2 border-black bg-[color:var(--surface)] text-white px-3 py-2 text-sm"
|
||||
value={difficulty}
|
||||
onChange={(e) => {
|
||||
setDifficulty(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{DIFFICULTY_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{tx("难度: ", "Tier: ")} {isZh ? item.labelZh : item.labelEn}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="rounded-none border-2 border-black bg-[color:var(--surface)] text-white px-3 py-2 text-sm"
|
||||
value={`${orderBy}:${order}`}
|
||||
onChange={(e) => {
|
||||
const [ob, od] = e.target.value.split(":");
|
||||
setOrderBy(ob || "id");
|
||||
setOrder(od || "asc");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="id:asc">{tx("编号升序", "ID Asc")}</option>
|
||||
<option value="id:desc">{tx("编号降序", "ID Desc")}</option>
|
||||
<option value="difficulty:asc">{tx("难度升序", "Tier Asc")}</option>
|
||||
<option value="difficulty:desc">{tx("难度降序", "Tier Desc")}</option>
|
||||
<option value="created_at:desc">{tx("最新发布", "Newest")}</option>
|
||||
<option value="title:asc">{tx("标题 A-Z", "Title A-Z")}</option>
|
||||
</select>
|
||||
|
||||
<section className="mt-4 md:hidden">
|
||||
<button
|
||||
className="mc-btn mc-btn-primary flex items-center justify-center gap-2"
|
||||
onClick={applySearch}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
className="mc-btn sticky top-2 z-20 w-full min-h-[44px] flex items-center justify-center gap-2"
|
||||
onClick={() => setMobileFiltersOpen((v) => !v)}
|
||||
>
|
||||
<Search size={16} />
|
||||
{loading ? tx("加载中...", "Loading...") : tx("搜索", "Search")}
|
||||
<Filter size={16} />
|
||||
{mobileFiltersOpen
|
||||
? tx("收起筛选", "Hide Filters")
|
||||
: tx("筛选与搜索", "Filter & Search")}
|
||||
</button>
|
||||
{mobileFiltersOpen && (
|
||||
<div className="mt-2 grid gap-3 rounded-xl border bg-[color:var(--mc-stone-dark)] p-3 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||
{filterControls(true)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mt-4 hidden gap-3 rounded-xl border bg-[color:var(--mc-stone-dark)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)] md:grid md:grid-cols-2 lg:grid-cols-6">
|
||||
{filterControls(false)}
|
||||
</section>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-[color:var(--mc-red)]">{error}</p>}
|
||||
@@ -404,6 +473,7 @@ export default function ProblemsPage() {
|
||||
{rows.map(({ problem, profile }) => {
|
||||
const pid = resolvePid(problem, profile);
|
||||
const tags = resolveTags(profile);
|
||||
const expanded = expandedMobileCards.has(problem.id);
|
||||
return (
|
||||
<article key={problem.id} className="space-y-2 p-3 bg-[color:var(--surface)] text-zinc-100">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -414,16 +484,42 @@ export default function ProblemsPage() {
|
||||
{difficultyIcon(problem.difficulty)} T{problem.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-[color:var(--mc-stone)]">{tx("完成率:", "Clear Rate: ")}{resolvePassRate(problem)}</p>
|
||||
{problem.user_ac !== undefined && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs">
|
||||
{problem.user_ac
|
||||
? <span className="text-[color:var(--mc-green)] font-bold">✅ AC</span>
|
||||
? <span className="text-[color:var(--mc-green)] font-bold">{tx("已通关", "Cleared")}</span>
|
||||
: problem.user_fail_count && problem.user_fail_count > 0
|
||||
? <span className="text-[color:var(--mc-red)]">❌ ×{problem.user_fail_count}</span>
|
||||
: <span className="text-[color:var(--mc-stone-dark)]">-</span>}
|
||||
? <span className="text-[color:var(--mc-red)]">{tx("进行中", "In Progress")} ×{problem.user_fail_count}</span>
|
||||
: <span className="text-[color:var(--mc-stone)]">{tx("未开始", "Not Started")}</span>}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mc-btn px-2 py-1 text-[10px]"
|
||||
onClick={() =>
|
||||
setExpandedMobileCards((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(problem.id)) next.delete(problem.id);
|
||||
else next.add(problem.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
{expanded ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ChevronUp size={12} />
|
||||
{tx("收起", "Less")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ChevronDown size={12} />
|
||||
{tx("展开", "More")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<>
|
||||
<p className="text-xs text-[color:var(--mc-stone)]">{tx("完成率:", "Clear Rate: ")}{resolvePassRate(problem)}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.length === 0 && <span className="text-xs text-[color:var(--mc-stone-dark)]">-</span>}
|
||||
{tags.map((tag) => (
|
||||
@@ -432,6 +528,8 @@ export default function ProblemsPage() {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
@@ -518,6 +616,16 @@ export default function ProblemsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-4 flex justify-end md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="mc-btn min-h-[40px] px-3 py-1 text-xs"
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||
>
|
||||
{tx("回到顶部", "Back to Top")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 text-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { AlertTriangle, Code2, Monitor, Play, Terminal, Timer } from "lucide-react";
|
||||
import { formatMsTime } from "@/lib/time";
|
||||
import { AlertTriangle, Code2, Loader2, Monitor, Play, RotateCcw, Save, Terminal, Timer } from "lucide-react";
|
||||
|
||||
type RunResult = {
|
||||
status: string;
|
||||
@@ -16,7 +18,11 @@ type RunResult = {
|
||||
|
||||
const starterCode = `#include <bits/stdc++.h>
|
||||
using namespace std;
|
||||
|
||||
int main() {
|
||||
ios::sync_with_stdio(false);
|
||||
cin.tie(nullptr);
|
||||
|
||||
string s;
|
||||
getline(cin, s);
|
||||
cout << s << "\\n";
|
||||
@@ -24,6 +30,9 @@ int main() {
|
||||
}
|
||||
`;
|
||||
|
||||
const LOCAL_CODE_KEY = "csp.run.workspace.code";
|
||||
const LOCAL_INPUT_KEY = "csp.run.workspace.input";
|
||||
|
||||
export default function RunPage() {
|
||||
const { tx } = useI18nText();
|
||||
const [code, setCode] = useState(starterCode);
|
||||
@@ -31,6 +40,41 @@ export default function RunPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [result, setResult] = useState<RunResult | null>(null);
|
||||
const [runningSec, setRunningSec] = useState(0);
|
||||
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const savedCode = window.localStorage.getItem(LOCAL_CODE_KEY);
|
||||
const savedInput = window.localStorage.getItem(LOCAL_INPUT_KEY);
|
||||
if (savedCode) setCode(savedCode);
|
||||
if (savedInput !== null) setInput(savedInput);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(LOCAL_CODE_KEY, code);
|
||||
setLastSavedAt(Date.now());
|
||||
}, [code]);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(LOCAL_INPUT_KEY, input);
|
||||
setLastSavedAt(Date.now());
|
||||
}, [input]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
setRunningSec(0);
|
||||
return;
|
||||
}
|
||||
const timer = window.setInterval(() => {
|
||||
setRunningSec((v) => v + 1);
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loading]);
|
||||
|
||||
const shouldExpandCompileLog = useMemo(
|
||||
() => Boolean(result?.compile_log && (result.status === "CE" || result.status === "RE" || result.status === "WA")),
|
||||
[result?.compile_log, result?.status]
|
||||
);
|
||||
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
@@ -49,12 +93,52 @@ export default function RunPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const resetTemplate = () => {
|
||||
setCode(starterCode);
|
||||
setInput("hello csp");
|
||||
setResult(null);
|
||||
setError("");
|
||||
};
|
||||
|
||||
const restoreLastLocal = () => {
|
||||
const savedCode = window.localStorage.getItem(LOCAL_CODE_KEY);
|
||||
const savedInput = window.localStorage.getItem(LOCAL_INPUT_KEY);
|
||||
if (savedCode) setCode(savedCode);
|
||||
if (savedInput !== null) setInput(savedInput);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl flex items-center gap-2">
|
||||
<Terminal size={24} />
|
||||
{tx("在线 C++ 编写 / 编译 / 运行", "Online C++ Editor / Compile / Run")}
|
||||
</h1>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className="mc-btn min-h-[44px]" onClick={restoreLastLocal}>
|
||||
<Save size={14} />
|
||||
{tx("恢复上次代码", "Restore Last")}
|
||||
</button>
|
||||
<button className="mc-btn min-h-[44px]" onClick={resetTemplate}>
|
||||
<RotateCcw size={14} />
|
||||
{tx("加载模板", "Load Template")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-zinc-400">
|
||||
<p>
|
||||
{lastSavedAt
|
||||
? tx(`本地草稿已自动保存:${formatMsTime(lastSavedAt)}`, `Local draft saved at ${formatMsTime(lastSavedAt)}`)
|
||||
: tx("本地草稿将自动保存", "Local draft auto-save is enabled")}
|
||||
</p>
|
||||
<HintTip title={tx("运行说明", "Run Guide")} align="left">
|
||||
{tx(
|
||||
"这里用于快速验证代码片段,不会保存到题目提交记录。若运行时间持续过长,建议检查死循环或等待条件。",
|
||||
"This runner is for quick snippet checks and does not create formal submission records. If execution keeps running, check for infinite loops or blocking waits."
|
||||
)}
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<section className="rounded-xl border bg-white p-4">
|
||||
@@ -78,13 +162,13 @@ export default function RunPage() {
|
||||
/>
|
||||
|
||||
<button
|
||||
className="mt-3 w-full rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50 sm:w-auto"
|
||||
className="mt-3 w-full min-h-[44px] rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50 sm:w-auto"
|
||||
onClick={() => void run()}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Play size={16} className="animate-spin" />
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{tx("运行中...", "Running...")}
|
||||
</span>
|
||||
) : (
|
||||
@@ -95,6 +179,13 @@ export default function RunPage() {
|
||||
)}
|
||||
</button>
|
||||
|
||||
{loading && (
|
||||
<p className="mt-2 text-xs text-zinc-500 flex items-center gap-2">
|
||||
<Timer size={14} />
|
||||
{tx(`已运行 ${runningSec}s,若超过 10s 可重试`, `Running for ${runningSec}s. Retry if it exceeds 10s.`)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
{result && (
|
||||
@@ -122,12 +213,12 @@ export default function RunPage() {
|
||||
{result.stderr || "(empty)"}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">compile_log</h3>
|
||||
<details open={shouldExpandCompileLog}>
|
||||
<summary className="cursor-pointer font-medium">compile_log</summary>
|
||||
<pre className="mt-1 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||
{result.compile_log || "(empty)"}
|
||||
</pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -4,10 +4,13 @@ import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { MarkdownRenderer } from "@/components/markdown-renderer";
|
||||
import { PageCrumbs } from "@/components/page-crumbs";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
|
||||
type SubmissionAnalysis = {
|
||||
feedback_md: string;
|
||||
@@ -38,12 +41,13 @@ type Submission = {
|
||||
answer_view_count: number;
|
||||
answer_view_total_cost: number;
|
||||
last_answer_view_at: number | null;
|
||||
same_user_prev_submission_id: number | null;
|
||||
same_user_next_submission_id: number | null;
|
||||
analysis: SubmissionAnalysis | null;
|
||||
};
|
||||
|
||||
function fmtTs(v: number | null): string {
|
||||
if (!v) return "-";
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
return formatUnixDateTime(v);
|
||||
}
|
||||
|
||||
function fmtRatingDelta(delta: number): string {
|
||||
@@ -108,6 +112,13 @@ export default function SubmissionDetailPage() {
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
||||
<PageCrumbs
|
||||
items={[
|
||||
{ label: tx("主城", "Town"), href: "/" },
|
||||
{ label: tx("施法", "Spell Log"), href: "/submissions" },
|
||||
{ label: `#${id}` },
|
||||
]}
|
||||
/>
|
||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
||||
{tx("提交详情", "Submission Detail")} #{id}
|
||||
</h1>
|
||||
@@ -117,6 +128,41 @@ export default function SubmissionDetailPage() {
|
||||
{data && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<section className="rounded-xl border bg-white p-4 text-sm">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-zinc-500">
|
||||
{tx("同用户快速查看", "Quick view (same user)")}
|
||||
</span>
|
||||
<Link
|
||||
href={
|
||||
data.same_user_prev_submission_id
|
||||
? `/submissions/${data.same_user_prev_submission_id}`
|
||||
: "#"
|
||||
}
|
||||
aria-disabled={!data.same_user_prev_submission_id}
|
||||
className={`rounded border px-2 py-1 text-xs ${
|
||||
data.same_user_prev_submission_id
|
||||
? "hover:bg-zinc-100"
|
||||
: "pointer-events-none cursor-not-allowed opacity-40"
|
||||
}`}
|
||||
>
|
||||
{tx("← 上一个(更早)", "← Previous (older)")}
|
||||
</Link>
|
||||
<Link
|
||||
href={
|
||||
data.same_user_next_submission_id
|
||||
? `/submissions/${data.same_user_next_submission_id}`
|
||||
: "#"
|
||||
}
|
||||
aria-disabled={!data.same_user_next_submission_id}
|
||||
className={`rounded border px-2 py-1 text-xs ${
|
||||
data.same_user_next_submission_id
|
||||
? "hover:bg-zinc-100"
|
||||
: "pointer-events-none cursor-not-allowed opacity-40"
|
||||
}`}
|
||||
>
|
||||
{tx("下一个(更新) →", "Next (newer) →")}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid gap-1 sm:grid-cols-2">
|
||||
<p>{tx("用户", "User")}: {data.user_id}</p>
|
||||
<p>
|
||||
@@ -147,7 +193,15 @@ export default function SubmissionDetailPage() {
|
||||
|
||||
<section className="rounded-xl border bg-white p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-sm font-medium">{tx("LLM 评测建议(福建 CSP-J/S 规范)", "LLM Analysis (Fujian CSP-J/S style)")}</h2>
|
||||
<h2 className="text-sm font-medium inline-flex items-center gap-2">
|
||||
{tx("LLM 评测建议", "LLM Analysis")}
|
||||
<HintTip title={tx("建议说明", "Analysis Notes")} align="left">
|
||||
{tx(
|
||||
"评测建议用于辅助复盘,不替代正式判题结果。建议结合编译日志、运行日志和样例自行验证。",
|
||||
"Analysis is for review assistance and does not replace official judge results. Verify with compile log, runtime log, and your own sample tests."
|
||||
)}
|
||||
</HintTip>
|
||||
</h2>
|
||||
<button
|
||||
className="rounded border px-3 py-1 text-xs disabled:opacity-50"
|
||||
onClick={() => void generateAnalysis(false)}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -27,22 +30,26 @@ type Submission = {
|
||||
user_id: number;
|
||||
problem_id: number;
|
||||
contest_id: number | null;
|
||||
language: string;
|
||||
status: string;
|
||||
score: number;
|
||||
rating_delta: number;
|
||||
time_ms: number;
|
||||
memory_kb: number;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type ListResp = { items: Submission[]; page: number; page_size: number };
|
||||
|
||||
export default function SubmissionsPage() {
|
||||
function SubmissionsPageInner() {
|
||||
const { tx } = useI18nText();
|
||||
const { theme } = useUiPreferences();
|
||||
const searchParams = useSearchParams();
|
||||
const isMc = theme === "minecraft";
|
||||
const [userId, setUserId] = useState("");
|
||||
const [problemId, setProblemId] = useState("");
|
||||
const [contestId, setContestId] = useState("");
|
||||
const [createdFrom, setCreatedFrom] = useState("");
|
||||
const [items, setItems] = useState<Submission[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -60,38 +67,48 @@ export default function SubmissionsPage() {
|
||||
|
||||
/** Map raw status codes to themed display text */
|
||||
const statusLabel = (raw: string) => {
|
||||
const status = raw.toUpperCase();
|
||||
if (!isMc) return raw;
|
||||
switch (raw) {
|
||||
case "Accepted":
|
||||
switch (status) {
|
||||
case "ACCEPTED":
|
||||
case "AC":
|
||||
return <span className="flex items-center gap-1 text-[color:var(--mc-green)]"><Check size={14} /> AC</span>;
|
||||
return <span className="mc-status-success flex items-center gap-1"><Check size={14} /> AC</span>;
|
||||
case "WA":
|
||||
case "Wrong Answer":
|
||||
return <span className="flex items-center gap-1 text-[color:var(--mc-red)]"><X size={14} /> WA</span>;
|
||||
case "WRONG ANSWER":
|
||||
return <span className="mc-status-danger flex items-center gap-1"><X size={14} /> WA</span>;
|
||||
case "TLE":
|
||||
case "Time Limit Exceeded":
|
||||
return <span className="flex items-center gap-1 text-[color:var(--mc-gold)]"><Clock size={14} /> TLE</span>;
|
||||
case "TIME LIMIT EXCEEDED":
|
||||
return <span className="mc-status-warning flex items-center gap-1"><Clock size={14} /> TLE</span>;
|
||||
case "MLE":
|
||||
return <span className="flex items-center gap-1 text-[color:var(--mc-red)]"><Zap size={14} /> MLE</span>;
|
||||
return <span className="mc-status-danger flex items-center gap-1"><Zap size={14} /> MLE</span>;
|
||||
case "RE":
|
||||
case "Runtime Error":
|
||||
return <span className="flex items-center gap-1 text-orange-500"><AlertTriangle size={14} /> RE</span>;
|
||||
case "RUNTIME ERROR":
|
||||
return <span className="mc-status-danger flex items-center gap-1"><AlertTriangle size={14} /> RE</span>;
|
||||
case "CE":
|
||||
case "Compile Error":
|
||||
return <span className="flex items-center gap-1 text-zinc-500"><Wrench size={14} /> CE</span>;
|
||||
case "COMPILE ERROR":
|
||||
return <span className="mc-status-danger flex items-center gap-1"><Wrench size={14} /> CE</span>;
|
||||
case "PENDING":
|
||||
case "COMPILING":
|
||||
case "RUNNING":
|
||||
return <span className="mc-status-running flex items-center gap-1"><Clock size={14} /> {status}</span>;
|
||||
default:
|
||||
return raw;
|
||||
return <span className="mc-status-muted">{raw}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const load = async (override?: { userId?: string; problemId?: string; contestId?: string; createdFrom?: string }) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const finalUserId = override?.userId ?? userId;
|
||||
const finalProblemId = override?.problemId ?? problemId;
|
||||
const finalContestId = override?.contestId ?? contestId;
|
||||
const finalCreatedFrom = override?.createdFrom ?? createdFrom;
|
||||
const params = new URLSearchParams();
|
||||
if (userId) params.set("user_id", userId);
|
||||
if (problemId) params.set("problem_id", problemId);
|
||||
if (contestId) params.set("contest_id", contestId);
|
||||
if (finalUserId) params.set("user_id", finalUserId);
|
||||
if (finalProblemId) params.set("problem_id", finalProblemId);
|
||||
if (finalContestId) params.set("contest_id", finalContestId);
|
||||
if (finalCreatedFrom) params.set("created_from", finalCreatedFrom);
|
||||
const data = await apiFetch<ListResp>(`/api/v1/submissions?${params.toString()}`);
|
||||
setItems(data.items);
|
||||
} catch (e: unknown) {
|
||||
@@ -102,9 +119,22 @@ export default function SubmissionsPage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const fromUserId = searchParams.get("user_id") ?? "";
|
||||
const fromProblemId = searchParams.get("problem_id") ?? "";
|
||||
const fromContestId = searchParams.get("contest_id") ?? "";
|
||||
const fromCreatedFrom = searchParams.get("created_from") ?? "";
|
||||
setUserId(fromUserId);
|
||||
setProblemId(fromProblemId);
|
||||
setContestId(fromContestId);
|
||||
setCreatedFrom(fromCreatedFrom);
|
||||
void load({
|
||||
userId: fromUserId,
|
||||
problemId: fromProblemId,
|
||||
contestId: fromContestId,
|
||||
createdFrom: fromCreatedFrom,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
|
||||
@@ -118,6 +148,16 @@ export default function SubmissionsPage() {
|
||||
tx("提交记录", "Submissions")
|
||||
)}
|
||||
</h1>
|
||||
<div className={`mt-2 flex items-center gap-2 text-xs ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>
|
||||
<p>{tx("按用户/题目/比赛筛选提交记录。", "Filter submissions by user, problem, or contest.")}</p>
|
||||
<HintTip title={tx("字段说明", "Field Notes")} align="left">
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
<li>{tx("Rating 变化为该次提交带来的积分增减。", "Rating Delta is points gained/lost from this submission.")}</li>
|
||||
<li>{tx("状态包含 AC/WA/TLE/RE/CE 等评测结果。", "Status includes AC/WA/TLE/RE/CE judge results.")}</li>
|
||||
<li>{tx("点击详情可查看编译输出与运行日志。", "Open detail to inspect compile output and runtime logs.")}</li>
|
||||
</ul>
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className={`mt-4 grid gap-3 rounded-xl border p-4 md:grid-cols-4 ${isMc
|
||||
@@ -188,19 +228,28 @@ export default function SubmissionsPage() {
|
||||
<p className={`text-xs ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>
|
||||
{isMc ? tx("冒险者", "Player") : tx("用户", "User")} {s.user_id} · {tx("任务", "Quest")} {s.problem_id} · {tx("分数", "Score")} {s.score}
|
||||
</p>
|
||||
<p className={`text-xs ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>
|
||||
{tx("语言", "Language")}: {s.language} · {tx("内存", "Memory")}: {s.memory_kb} KB
|
||||
</p>
|
||||
<p className={`text-xs ${ratingDeltaClass(s.rating_delta)}`}>
|
||||
{isMc ? tx("绿宝石变化", "Emerald Δ") : tx("Rating 变化", "Rating Delta")} {fmtRatingDelta(s.rating_delta)}
|
||||
</p>
|
||||
<p className={`text-xs ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>{tx("耗时", "Time")} {s.time_ms} ms</p>
|
||||
<p className={`text-xs ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>{tx("提交时间", "Submitted")} {formatUnixDateTime(s.created_at)}</p>
|
||||
<Link className={`underline ${isMc ? "text-[color:var(--mc-diamond)]" : "text-blue-600"}`} href={`/submissions/${s.id}`}>
|
||||
{isMc ? tx("📜 查看详情", "📜 View Detail") : tx("查看详情", "View Detail")}
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
<p className={`px-3 py-5 text-center text-sm ${isMc ? "text-zinc-500" : "text-zinc-500"}`}>
|
||||
<div className="px-3 py-6 text-center text-sm">
|
||||
<p className={`${isMc ? "text-zinc-500" : "text-zinc-500"}`}>
|
||||
{isMc ? tx("暂无施法记录", "No spell casts yet") : tx("暂无提交记录", "No submissions yet")}
|
||||
</p>
|
||||
<Link href="/problems" className="mt-3 inline-flex min-h-[44px] items-center justify-center mc-btn mc-btn-primary px-4">
|
||||
{tx("先去做第一题", "Solve Your First Problem")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -241,6 +290,9 @@ export default function SubmissionsPage() {
|
||||
{tx("耗时(ms)", "Time(ms)")}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-3 py-2">{tx("内存(KB)", "Memory(KB)")}</th>
|
||||
<th className="px-3 py-2">{tx("语言", "Language")}</th>
|
||||
<th className="px-3 py-2">{tx("提交时间", "Submitted")}</th>
|
||||
<th className="px-3 py-2">{tx("详情", "Detail")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -256,6 +308,9 @@ export default function SubmissionsPage() {
|
||||
{fmtRatingDelta(s.rating_delta)}
|
||||
</td>
|
||||
<td className="px-3 py-2">{s.time_ms}</td>
|
||||
<td className="px-3 py-2">{s.memory_kb}</td>
|
||||
<td className="px-3 py-2">{s.language}</td>
|
||||
<td className="px-3 py-2">{formatUnixDateTime(s.created_at)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Link className={`underline ${isMc ? "text-[color:var(--mc-diamond)]" : "text-blue-600"}`} href={`/submissions/${s.id}`}>
|
||||
{isMc ? tx("📜 查看", "📜 View") : tx("查看", "View")}
|
||||
@@ -265,8 +320,11 @@ export default function SubmissionsPage() {
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
<tr>
|
||||
<td className={`px-3 py-5 text-center ${isMc ? "text-zinc-500" : "text-zinc-500"}`} colSpan={8}>
|
||||
{isMc ? tx("暂无施法记录", "No spell casts yet") : tx("暂无提交记录", "No submissions yet")}
|
||||
<td className={`px-3 py-5 text-center ${isMc ? "text-zinc-500" : "text-zinc-500"}`} colSpan={11}>
|
||||
<p>{isMc ? tx("暂无施法记录", "No spell casts yet") : tx("暂无提交记录", "No submissions yet")}</p>
|
||||
<Link href="/problems" className="mt-3 inline-flex min-h-[44px] items-center justify-center mc-btn mc-btn-primary px-4">
|
||||
{tx("先去做第一题", "Solve Your First Problem")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -277,3 +335,17 @@ export default function SubmissionsPage() {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SubmissionsPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
|
||||
<p className="text-sm text-zinc-500">Loading...</p>
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<SubmissionsPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
import { BookX, Trash2, RefreshCw, RotateCcw, Save, Search, Skull } from "lucide-react";
|
||||
|
||||
@@ -19,8 +21,7 @@ type WrongBookItem = {
|
||||
};
|
||||
|
||||
function fmtTs(v: number): string {
|
||||
if (!v) return "-";
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
return formatUnixDateTime(v);
|
||||
}
|
||||
|
||||
export default function WrongBookPage() {
|
||||
@@ -60,7 +61,7 @@ export default function WrongBookPage() {
|
||||
await apiFetch(`/api/v1/me/wrong-book/${problemId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ note }),
|
||||
}, token);
|
||||
}, token, { retryOnStatus: [500], retryCount: 10, retryDelayMs: 500 });
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
@@ -69,6 +70,10 @@ export default function WrongBookPage() {
|
||||
|
||||
const removeItem = async (problemId: number) => {
|
||||
try {
|
||||
const ok = window.confirm(
|
||||
tx("确认移除该条错题记录?", "Remove this wrong-book entry?")
|
||||
);
|
||||
if (!ok) return;
|
||||
await apiFetch(`/api/v1/me/wrong-book/${problemId}`, { method: "DELETE" }, token);
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
@@ -91,11 +96,29 @@ export default function WrongBookPage() {
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className={`mt-2 text-sm ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>
|
||||
<div className={`mt-2 flex items-center gap-2 text-sm ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>
|
||||
<p>
|
||||
{isMc
|
||||
? tx("失败的咒语会自动记录在诅咒卷轴中,复习并重新挑战!", "Failed spells are recorded in your Cursed Scrolls. Review and retry!")
|
||||
: tx("未通过提交会自动进入错题本。", "Failed submissions are added to the wrong-book automatically.")}
|
||||
? tx("失败记录会自动入卷。", "Failed attempts are auto-saved here.")
|
||||
: tx("未通过提交会自动加入错题本。", "Failed submissions are auto-added to wrong-book.")}
|
||||
</p>
|
||||
<HintTip title={tx("复盘说明", "Review Notes")} align="left">
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
<li>
|
||||
{tx(
|
||||
"每条记录可写复盘笔记,建议记录错因、修复思路与复测结论。",
|
||||
"Use notes to capture root cause, fix approach, and retest conclusion."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{tx(
|
||||
"可直接跳转到题目页重做,或查看最近一次提交详情。",
|
||||
"You can jump to problem page to retry or inspect latest submission detail."
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
|
||||
@@ -12,23 +12,21 @@ import type { ThemeId } from "@/themes/types";
|
||||
import {
|
||||
BookOpen,
|
||||
BookX,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Crown,
|
||||
FileText,
|
||||
Home,
|
||||
Key,
|
||||
Library,
|
||||
LogOut,
|
||||
Play,
|
||||
Settings,
|
||||
Sword,
|
||||
Settings2,
|
||||
ShieldAlert,
|
||||
Trophy,
|
||||
User,
|
||||
Users,
|
||||
Menu,
|
||||
X,
|
||||
Database,
|
||||
FileJson,
|
||||
Shield,
|
||||
Gift,
|
||||
FileCode
|
||||
} from "lucide-react";
|
||||
@@ -128,10 +126,11 @@ export function AppNav() {
|
||||
const directHttpAccessUrl =
|
||||
process.env.NEXT_PUBLIC_HTTP_ENTRY_URL?.trim() || "http://8.211.173.24:7888/";
|
||||
|
||||
const [hasToken, setHasToken] = useState<boolean>(() => Boolean(readToken()));
|
||||
const [hasToken, setHasToken] = useState(false);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [meProfile, setMeProfile] = useState<MeProfile | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [mobileSettingsOpen, setMobileSettingsOpen] = useState(false);
|
||||
const [desktopOpenGroup, setDesktopOpenGroup] = useState<string | null>(null);
|
||||
const desktopMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const navGroups = useMemo(() => buildNavGroups(t, isAdmin), [isAdmin, t]);
|
||||
@@ -192,18 +191,32 @@ export function AppNav() {
|
||||
? `${meProfile.username ?? "user"}-${meProfile.id ?? ""}`
|
||||
: "guest";
|
||||
const handleLogout = () => {
|
||||
const confirmed = window.confirm(
|
||||
language === "zh"
|
||||
? "确认断开连接并退出当前账号?"
|
||||
: "Disconnect and sign out from current account?"
|
||||
);
|
||||
if (!confirmed) return;
|
||||
clearToken();
|
||||
setHasToken(false);
|
||||
setIsAdmin(false);
|
||||
setMeProfile(null);
|
||||
setDesktopOpenGroup(null);
|
||||
setMobileSettingsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="print-hidden border-b bg-[color:var(--surface)]/95 backdrop-blur supports-[backdrop-filter]:bg-[color:var(--surface)]/85">
|
||||
<header className="print-hidden relative z-[120] border-b bg-[color:var(--surface)]/95 backdrop-blur supports-[backdrop-filter]:bg-[color:var(--surface)]/85">
|
||||
<div className="mx-auto max-w-6xl px-3 py-3 max-[390px]:px-2 max-[390px]:py-2 sm:px-4">
|
||||
<div className="flex items-center justify-between md:hidden">
|
||||
<span className="text-sm font-medium text-zinc-700">{t("nav.menu")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAdmin && (
|
||||
<span className="inline-flex items-center gap-1 rounded border border-amber-500/50 bg-amber-500/10 px-2 py-0.5 text-[10px] font-semibold text-amber-700">
|
||||
<ShieldAlert size={12} />
|
||||
{t("nav.admin_mode")}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border px-3 py-1 text-sm hover:bg-zinc-100 max-[390px]:px-2 max-[390px]:text-xs"
|
||||
@@ -214,6 +227,7 @@ export function AppNav() {
|
||||
{menuOpen ? t("nav.collapse") : t("nav.expand")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="main-nav-links" className={`${menuOpen ? "mt-3 block" : "hidden"} md:mt-0 md:block`}>
|
||||
<div className="space-y-3">
|
||||
@@ -237,7 +251,7 @@ export function AppNav() {
|
||||
</button>
|
||||
{opened && (
|
||||
<div
|
||||
className={`absolute left-0 top-full z-50 mt-2 rounded-md border bg-[color:var(--surface)] p-1 shadow-lg ${group.key === "account" ? "min-w-[18rem]" : "min-w-[11rem]"
|
||||
className={`absolute left-0 top-full z-[130] mt-2 rounded-md border bg-[color:var(--surface)] p-1 shadow-lg ${group.key === "account" ? "min-w-[18rem]" : "min-w-[11rem]"
|
||||
}`}
|
||||
>
|
||||
{group.links.map((item) => {
|
||||
@@ -287,6 +301,20 @@ export function AppNav() {
|
||||
<option value="zh">{t("prefs.lang.zh")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<details className="rounded border border-zinc-200 bg-zinc-50/80 px-2 py-1 text-xs">
|
||||
<summary className="cursor-pointer text-zinc-600">
|
||||
{t("nav.link.http_ip_port")}
|
||||
</summary>
|
||||
<a
|
||||
href={directHttpAccessUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 block rounded border px-2 py-1 text-xs hover:bg-zinc-100"
|
||||
title={directHttpAccessUrl}
|
||||
>
|
||||
{t("nav.link.http_ip_port")}
|
||||
</a>
|
||||
</details>
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-zinc-200 px-2 py-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={hasToken ? "text-emerald-700" : "text-zinc-500"}>
|
||||
@@ -361,6 +389,12 @@ export function AppNav() {
|
||||
)}
|
||||
|
||||
<div className="space-y-3 md:hidden">
|
||||
{isAdmin && (
|
||||
<div className="inline-flex items-center gap-1 rounded border border-amber-500/50 bg-amber-500/10 px-2 py-1 text-[10px] font-semibold text-amber-700">
|
||||
<ShieldAlert size={12} />
|
||||
{t("nav.admin_mode")}
|
||||
</div>
|
||||
)}
|
||||
{navGroups.map((group) => (
|
||||
<section key={group.key} className="rounded-lg border p-2">
|
||||
<h3 className="mb-2 flex items-center gap-2 text-xs font-semibold text-zinc-600">
|
||||
@@ -390,11 +424,85 @@ export function AppNav() {
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<section className="rounded-lg border p-2">
|
||||
<button
|
||||
type="button"
|
||||
className="mb-2 flex w-full items-center justify-between rounded border px-2 py-1 text-xs font-semibold text-zinc-700 hover:bg-zinc-50"
|
||||
onClick={() => setMobileSettingsOpen((v) => !v)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Settings2 size={14} />
|
||||
{t("nav.group.settings")}
|
||||
</span>
|
||||
{mobileSettingsOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
{mobileSettingsOpen && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs">
|
||||
<span className="text-zinc-500">{t("prefs.theme")}</span>
|
||||
<select
|
||||
className="mt-1 w-full rounded-md border px-2 py-1 text-xs"
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value as ThemeId)}
|
||||
>
|
||||
{themes.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.labels[language]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-xs">
|
||||
<span className="text-zinc-500">{t("prefs.language")}</span>
|
||||
<select
|
||||
className="mt-1 w-full rounded-md border px-2 py-1 text-xs"
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value === "zh" ? "zh" : "en")}
|
||||
>
|
||||
<option value="en">{t("prefs.lang.en")}</option>
|
||||
<option value="zh">{t("prefs.lang.zh")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<details className="rounded border border-zinc-200 bg-zinc-50/70 px-2 py-1">
|
||||
<summary className="cursor-pointer text-xs font-semibold text-zinc-700">
|
||||
{t("nav.link.http_ip_port")}
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
<a
|
||||
href={directHttpAccessUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block rounded border px-2 py-1 text-xs hover:bg-zinc-100"
|
||||
title={directHttpAccessUrl}
|
||||
>
|
||||
{t("nav.link.http_ip_port")}
|
||||
</a>
|
||||
<div className="flex items-center justify-between rounded border px-2 py-1">
|
||||
<span className={`text-xs ${hasToken ? "text-emerald-700" : "text-zinc-500"}`}>
|
||||
{hasToken ? t("nav.logged_in") : t("nav.logged_out")}
|
||||
</span>
|
||||
{hasToken && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="rounded border px-2 py-0.5 text-xs text-red-700 hover:bg-red-50"
|
||||
>
|
||||
{t("nav.logout")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`mt-2 flex flex-wrap items-center justify-end gap-2 text-xs sm:text-sm ${usePopupSecondary ? "md:hidden" : ""}`}>
|
||||
{!usePopupSecondary && (
|
||||
<div className="mt-2 hidden flex-wrap items-center justify-end gap-2 text-xs sm:text-sm md:flex">
|
||||
<label className="inline-flex items-center gap-1">
|
||||
<span className="text-zinc-500">{t("prefs.theme")}</span>
|
||||
<select
|
||||
@@ -459,6 +567,7 @@ export function AppNav() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Info, X } from "lucide-react";
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
|
||||
type HintTipProps = {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
align?: "left" | "right";
|
||||
widthClassName?: string;
|
||||
};
|
||||
|
||||
export function HintTip({
|
||||
title,
|
||||
children,
|
||||
align = "right",
|
||||
widthClassName = "w-72 sm:w-80",
|
||||
}: HintTipProps) {
|
||||
const { theme } = useUiPreferences();
|
||||
const isMc = theme === "minecraft";
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (!rootRef.current) return;
|
||||
if (!rootRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
|
||||
window.addEventListener("mousedown", onPointerDown);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", onPointerDown);
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`mc-reset inline-flex h-6 w-6 items-center justify-center rounded-full border ${
|
||||
isMc
|
||||
? "border-[color:var(--mc-border-soft)] bg-[color:var(--mc-card-inner)] text-[color:var(--mc-accent)] hover:bg-black/30 hover:text-white"
|
||||
: "border-zinc-400 bg-white text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900"
|
||||
}`}
|
||||
aria-label={title}
|
||||
title={title}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<Info size={14} />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className={`absolute z-30 mt-2 rounded-xl border p-3 text-xs shadow-xl ${
|
||||
isMc
|
||||
? "border-black bg-[color:var(--mc-card)] text-[color:var(--mc-text-muted)]"
|
||||
: "border-zinc-300 bg-white text-zinc-700"
|
||||
} ${
|
||||
align === "left" ? "left-0" : "right-0"
|
||||
} ${widthClassName}`}
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<p className={`text-xs font-semibold ${isMc ? "text-[color:var(--mc-text-main)]" : "text-zinc-900"}`}>{title}</p>
|
||||
<button
|
||||
type="button"
|
||||
className={`mc-reset rounded border p-0.5 ${
|
||||
isMc
|
||||
? "border-[color:var(--mc-border-soft)] text-[color:var(--mc-text-dim)] hover:text-[color:var(--mc-text-main)]"
|
||||
: "border-zinc-300 text-zinc-500 hover:text-zinc-800"
|
||||
}`}
|
||||
aria-label="Close hint"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="leading-relaxed">{children}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
import { BookOpen, Calendar, ScrollText, Swords, User } from "lucide-react";
|
||||
import { BookOpen, ScrollText, Swords, User, WandSparkles } from "lucide-react";
|
||||
|
||||
function isActivePath(pathname: string, href: string): boolean {
|
||||
if (pathname === href) return true;
|
||||
@@ -20,33 +20,56 @@ export function MobileTabBar() {
|
||||
{ label: t("mobile.tab.problems"), href: "/problems", icon: BookOpen },
|
||||
{ label: t("mobile.tab.submissions"), href: "/submissions", icon: ScrollText },
|
||||
{ label: t("mobile.tab.contests"), href: "/contests", icon: Swords },
|
||||
{ label: t("mobile.tab.kb"), href: "/kb", icon: Calendar }, // KB maps to "Library" usually but keeping order
|
||||
{ label: t("mobile.tab.kb"), href: "/kb", icon: WandSparkles },
|
||||
{ label: t("mobile.tab.me"), href: "/me", icon: User },
|
||||
] as const;
|
||||
|
||||
const accentByHref: Record<string, string> = {
|
||||
"/problems": "var(--mc-accent)",
|
||||
"/submissions": "var(--mc-admin)",
|
||||
"/contests": "var(--mc-contest)",
|
||||
"/kb": "var(--mc-admin)",
|
||||
"/me": "var(--mc-success)",
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className={`print-hidden fixed inset-x-0 bottom-0 z-40 border-t pb-[calc(0.3rem+env(safe-area-inset-bottom))] pt-2 md:hidden ${isMc
|
||||
? "bg-[color:var(--mc-stone-dark)] border-black border-t-[3px]"
|
||||
? "bg-[color:var(--mc-card)] border-black border-t-[3px] shadow-[0_-6px_0_rgba(0,0,0,0.45)]"
|
||||
: "bg-[color:var(--surface)]/95 backdrop-blur supports-[backdrop-filter]:bg-[color:var(--surface)]/85"
|
||||
}`}>
|
||||
<div className="mx-auto max-w-5xl px-2 max-[390px]:px-1.5">
|
||||
<div className="grid grid-cols-5 gap-1 max-[390px]:gap-0.5">
|
||||
<div className={`grid grid-cols-5 gap-1 max-[390px]:gap-0.5 ${isMc ? "rounded-lg border border-black bg-black/30 p-1" : ""}`}>
|
||||
{tabs.map((tab) => {
|
||||
const active = isActivePath(pathname, tab.href);
|
||||
const accentColor = accentByHref[tab.href] ?? "var(--mc-accent)";
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={`flex flex-col items-center justify-center rounded-none px-1 py-1 text-center text-[10px] sm:text-xs ${isMc
|
||||
? active
|
||||
? "bg-[color:var(--mc-diamond)] text-black font-bold border-2 border-black"
|
||||
: "bg-[color:var(--mc-stone)] text-zinc-300 border-2 border-black/50 hover:bg-[color:var(--mc-stone-light)]"
|
||||
? "bg-[color:var(--mc-card-inner)] font-bold border-2"
|
||||
: "bg-[color:var(--mc-obsidian)] text-[color:var(--mc-text-dim)] border-2 border-black/50 hover:bg-black/60"
|
||||
: active
|
||||
? "bg-zinc-900 font-semibold text-white rounded-md"
|
||||
: "text-zinc-600 hover:bg-zinc-100 rounded-md"
|
||||
}`}
|
||||
style={
|
||||
isMc && active
|
||||
? {
|
||||
borderColor: accentColor,
|
||||
color: accentColor,
|
||||
boxShadow: `inset 0 0 0 1px ${accentColor}`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isMc && <tab.icon size={18} className="mb-0.5" />}
|
||||
{isMc && (
|
||||
<tab.icon
|
||||
size={18}
|
||||
className={`mb-0.5 ${active ? "text-inherit" : "text-[color:var(--mc-border-soft)]"}`}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate w-full">{tab.label}</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
type Crumb = {
|
||||
label: string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export function PageCrumbs({ items }: { items: Crumb[] }) {
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<nav aria-label="breadcrumb" className="mb-2 flex flex-wrap items-center gap-1 text-xs text-zinc-500">
|
||||
{items.map((item, idx) => {
|
||||
const isLast = idx === items.length - 1;
|
||||
return (
|
||||
<span key={`${item.label}-${idx}`} className="inline-flex items-center gap-1">
|
||||
{item.href && !isLast ? (
|
||||
<Link href={item.href} className="hover:text-zinc-800 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={isLast ? "font-semibold text-zinc-700" : ""}>{item.label}</span>
|
||||
)}
|
||||
{!isLast && <ChevronRight size={12} />}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
type SourceCrystalIconProps = {
|
||||
size?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function SourceCrystalIcon({ size = 24, className = "" }: SourceCrystalIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="crystalMain" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#7FE7FF" />
|
||||
<stop offset="0.45" stopColor="#3AB7FF" />
|
||||
<stop offset="1" stopColor="#1C4DFF" />
|
||||
</linearGradient>
|
||||
<linearGradient id="crystalGlow" x1="32" y1="6" x2="32" y2="58" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#E8FFFF" stopOpacity="0.9" />
|
||||
<stop offset="1" stopColor="#7EE8FF" stopOpacity="0.15" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M32 4L56 24L44 56H20L8 24L32 4Z" fill="url(#crystalMain)" />
|
||||
<path d="M32 10L49 24L40 49H24L15 24L32 10Z" fill="url(#crystalGlow)" />
|
||||
<path d="M32 4V56M8 24H56" stroke="#B9F8FF" strokeOpacity="0.45" strokeWidth="2" />
|
||||
<circle cx="50" cy="12" r="3" fill="#DFFBFF" />
|
||||
<circle cx="14" cy="50" r="2.2" fill="#C7F4FF" />
|
||||
<circle cx="54" cy="44" r="1.8" fill="#E9FEFF" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -12,10 +12,17 @@ type ApiEnvelope<T> =
|
||||
| { ok: true; data?: T;[k: string]: unknown }
|
||||
| { ok: false; error?: string;[k: string]: unknown };
|
||||
|
||||
export type ApiFetchRetryOptions = {
|
||||
retryOnStatus?: number[];
|
||||
retryCount?: number;
|
||||
retryDelayMs?: number;
|
||||
};
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
token?: string
|
||||
token?: string,
|
||||
retryOptions?: ApiFetchRetryOptions
|
||||
): Promise<T> {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
@@ -25,10 +32,15 @@ export async function apiFetch<T>(
|
||||
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
const retryable = method === "GET" || method === "HEAD";
|
||||
const retryOnStatus = new Set((retryOptions?.retryOnStatus ?? []).filter((status) => Number.isInteger(status)));
|
||||
const retryDelayMs = retryOptions?.retryDelayMs ?? 400;
|
||||
let statusRetryRemaining = Math.max(0, retryOptions?.retryCount ?? 0);
|
||||
const requestUrl = `${API_BASE}${path}`;
|
||||
|
||||
const requestOnce = async (): Promise<Response> => {
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${API_BASE}${path}`, {
|
||||
resp = await fetch(requestUrl, {
|
||||
...init,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
@@ -44,7 +56,7 @@ export async function apiFetch<T>(
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
try {
|
||||
resp = await fetch(`${API_BASE}${path}`, {
|
||||
resp = await fetch(requestUrl, {
|
||||
...init,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
@@ -60,6 +72,15 @@ export async function apiFetch<T>(
|
||||
);
|
||||
}
|
||||
}
|
||||
return resp;
|
||||
};
|
||||
|
||||
let resp = await requestOnce();
|
||||
while (!resp.ok && statusRetryRemaining > 0 && retryOnStatus.has(resp.status)) {
|
||||
statusRetryRemaining -= 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
||||
resp = await requestOnce();
|
||||
}
|
||||
|
||||
const text = await resp.text();
|
||||
let payload: unknown = null;
|
||||
|
||||
45
frontend/src/lib/time.ts
普通文件
45
frontend/src/lib/time.ts
普通文件
@@ -0,0 +1,45 @@
|
||||
export const ASIA_SHANGHAI_TIME_ZONE = "Asia/Shanghai";
|
||||
|
||||
type LocaleArg = string | string[] | undefined;
|
||||
|
||||
export function formatUnixDateTime(tsSec: number | null | undefined, locale?: LocaleArg): string {
|
||||
if (!tsSec) return "-";
|
||||
return new Date(tsSec * 1000).toLocaleString(locale, {
|
||||
timeZone: ASIA_SHANGHAI_TIME_ZONE,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatUnixDate(tsSec: number | null | undefined, locale?: LocaleArg): string {
|
||||
if (!tsSec) return "-";
|
||||
return new Date(tsSec * 1000).toLocaleDateString(locale, {
|
||||
timeZone: ASIA_SHANGHAI_TIME_ZONE,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatMsTime(ms: number | null | undefined, locale?: LocaleArg): string {
|
||||
if (!ms) return "-";
|
||||
return new Date(ms).toLocaleTimeString(locale, {
|
||||
timeZone: ASIA_SHANGHAI_TIME_ZONE,
|
||||
});
|
||||
}
|
||||
|
||||
export function dayKeyInShanghai(tsSec: number): string {
|
||||
if (!Number.isFinite(tsSec)) return "";
|
||||
return new Date(tsSec * 1000).toLocaleDateString("sv-SE", {
|
||||
timeZone: ASIA_SHANGHAI_TIME_ZONE,
|
||||
});
|
||||
}
|
||||
|
||||
export function dayKeySerial(dayKey: string): number | null {
|
||||
const m = dayKey.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!m) return null;
|
||||
const y = Number(m[1]);
|
||||
const mon = Number(m[2]);
|
||||
const d = Number(m[3]);
|
||||
return Math.floor(Date.UTC(y, mon - 1, d) / 86400000);
|
||||
}
|
||||
|
||||
export function serialToDayKey(serial: number): string {
|
||||
if (!Number.isFinite(serial)) return "";
|
||||
return new Date(serial * 86400000).toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -10,34 +10,38 @@ export const enMessages: ThemeMessages = {
|
||||
"nav.logged_in": "Signed in",
|
||||
"nav.logged_out": "Signed out",
|
||||
"nav.logout": "Sign out",
|
||||
"nav.admin_mode": "Admin Mode",
|
||||
"nav.group.settings": "Settings",
|
||||
"nav.settings.show": "Open Settings",
|
||||
"nav.settings.hide": "Hide Settings",
|
||||
|
||||
"nav.group.learn": "Learning",
|
||||
"nav.group.contest": "Contests",
|
||||
"nav.group.system": "Platform",
|
||||
"nav.group.account": "Account",
|
||||
"nav.group.learn": "Adventure",
|
||||
"nav.group.contest": "Dungeon",
|
||||
"nav.group.system": "Server",
|
||||
"nav.group.account": "Player",
|
||||
|
||||
"nav.link.home": "Home",
|
||||
"nav.link.problems": "Problems",
|
||||
"nav.link.submissions": "Submissions",
|
||||
"nav.link.wrong_book": "Wrong Book",
|
||||
"nav.link.kb": "Knowledge Base",
|
||||
"nav.link.run": "Run Code",
|
||||
"nav.link.contests": "Contests",
|
||||
"nav.link.leaderboard": "Leaderboard",
|
||||
"nav.link.home": "Town",
|
||||
"nav.link.problems": "Quests",
|
||||
"nav.link.submissions": "Spell Log",
|
||||
"nav.link.wrong_book": "Curse Scroll",
|
||||
"nav.link.kb": "Enchant",
|
||||
"nav.link.run": "Workbench",
|
||||
"nav.link.contests": "Dungeon",
|
||||
"nav.link.leaderboard": "Hall of Fame",
|
||||
"nav.link.imports": "Imports",
|
||||
"nav.link.backend_logs": "Backend Logs",
|
||||
"nav.link.admin_users": "User Rating",
|
||||
"nav.link.admin_redeem": "Redeem Admin",
|
||||
"nav.link.api_docs": "API Docs",
|
||||
"nav.link.auth": "Sign In",
|
||||
"nav.link.me": "My Account",
|
||||
"nav.link.auth": "Server Login",
|
||||
"nav.link.me": "Character",
|
||||
"nav.link.http_ip_port": "HTTP (IP:Port)",
|
||||
|
||||
"mobile.tab.problems": "Problems",
|
||||
"mobile.tab.submissions": "Submits",
|
||||
"mobile.tab.contests": "Contests",
|
||||
"mobile.tab.kb": "KB",
|
||||
"mobile.tab.me": "Me",
|
||||
"mobile.tab.problems": "Quests",
|
||||
"mobile.tab.submissions": "Spell",
|
||||
"mobile.tab.contests": "Dungeon",
|
||||
"mobile.tab.kb": "Enchant",
|
||||
"mobile.tab.me": "Hero",
|
||||
|
||||
"prefs.theme": "Theme",
|
||||
"prefs.language": "Language",
|
||||
@@ -46,7 +50,7 @@ export const enMessages: ThemeMessages = {
|
||||
|
||||
"admin.entry.title": "Admin Entry",
|
||||
"admin.entry.desc":
|
||||
"Default admin account: admin / whoami139",
|
||||
"Admin entry (credentials managed separately)",
|
||||
"admin.entry.login": "Go to Sign In",
|
||||
"admin.entry.user_rating": "Manage User Rating",
|
||||
"admin.entry.redeem": "Manage Redeem Items",
|
||||
|
||||
@@ -10,34 +10,38 @@ export const zhMessages: ThemeMessages = {
|
||||
"nav.logged_in": "已登录",
|
||||
"nav.logged_out": "未登录",
|
||||
"nav.logout": "退出",
|
||||
"nav.admin_mode": "管理员模式",
|
||||
"nav.group.settings": "设置",
|
||||
"nav.settings.show": "打开设置",
|
||||
"nav.settings.hide": "收起设置",
|
||||
|
||||
"nav.group.learn": "学习训练",
|
||||
"nav.group.contest": "竞赛评测",
|
||||
"nav.group.system": "平台管理",
|
||||
"nav.group.account": "账号中心",
|
||||
"nav.group.learn": "冒险学习",
|
||||
"nav.group.contest": "副本竞技",
|
||||
"nav.group.system": "服务器管理",
|
||||
"nav.group.account": "玩家中心",
|
||||
|
||||
"nav.link.home": "首页",
|
||||
"nav.link.problems": "题库",
|
||||
"nav.link.submissions": "提交记录",
|
||||
"nav.link.wrong_book": "错题本",
|
||||
"nav.link.kb": "知识库",
|
||||
"nav.link.run": "在线运行",
|
||||
"nav.link.contests": "比赛",
|
||||
"nav.link.leaderboard": "排行榜",
|
||||
"nav.link.home": "主城",
|
||||
"nav.link.problems": "任务",
|
||||
"nav.link.submissions": "施法",
|
||||
"nav.link.wrong_book": "错题卷轴",
|
||||
"nav.link.kb": "附魔",
|
||||
"nav.link.run": "代码工作台",
|
||||
"nav.link.contests": "副本",
|
||||
"nav.link.leaderboard": "英雄榜",
|
||||
"nav.link.imports": "导入任务",
|
||||
"nav.link.backend_logs": "后台日志",
|
||||
"nav.link.admin_users": "用户积分",
|
||||
"nav.link.admin_redeem": "积分兑换",
|
||||
"nav.link.api_docs": "API文档",
|
||||
"nav.link.auth": "登录",
|
||||
"nav.link.me": "我的",
|
||||
"nav.link.auth": "登录服务器",
|
||||
"nav.link.me": "角色",
|
||||
"nav.link.http_ip_port": "IP+端口访问",
|
||||
|
||||
"mobile.tab.problems": "题库",
|
||||
"mobile.tab.submissions": "提交",
|
||||
"mobile.tab.contests": "比赛",
|
||||
"mobile.tab.kb": "知识库",
|
||||
"mobile.tab.me": "我的",
|
||||
"mobile.tab.problems": "任务",
|
||||
"mobile.tab.submissions": "施法",
|
||||
"mobile.tab.contests": "副本",
|
||||
"mobile.tab.kb": "附魔",
|
||||
"mobile.tab.me": "角色",
|
||||
|
||||
"prefs.theme": "主题",
|
||||
"prefs.language": "语言",
|
||||
@@ -45,7 +49,7 @@ export const zhMessages: ThemeMessages = {
|
||||
"prefs.lang.zh": "中文",
|
||||
|
||||
"admin.entry.title": "后台管理入口",
|
||||
"admin.entry.desc": "默认管理员账号:admin / whoami139",
|
||||
"admin.entry.desc": "管理员入口(凭据请联系管理员)",
|
||||
"admin.entry.login": "去登录",
|
||||
"admin.entry.user_rating": "用户积分管理",
|
||||
"admin.entry.redeem": "积分兑换管理",
|
||||
|
||||
某些文件未显示,因为此 diff 中更改的文件太多 显示更多
在新工单中引用
屏蔽一个用户