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

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

查看文件

@@ -20,4 +20,14 @@ TEST_CASE("auth register/login/verify") {
const auto r2 = auth.Login("alice", "password123");
REQUIRE(r2.user_id == r.user_id);
REQUIRE(r2.token != r.token);
const auto verified = auth.VerifyCredentials("alice", "password123");
REQUIRE(verified.has_value());
REQUIRE(verified.value() == r.user_id);
const auto wrong_password = auth.VerifyCredentials("alice", "wrongpass");
REQUIRE_FALSE(wrong_password.has_value());
const auto wrong_user = auth.VerifyCredentials("missing_user", "password123");
REQUIRE_FALSE(wrong_user.has_value());
}

查看文件

@@ -0,0 +1,55 @@
#include <catch2/catch_test_macros.hpp>
#include "csp/app_state.h"
#include "csp/services/crawler_service.h"
TEST_CASE("crawler target upsert and queue lifecycle") {
csp::AppState::Instance().Init(":memory:");
csp::services::CrawlerService svc(csp::AppState::Instance().db());
const auto first =
svc.UpsertTarget("https://Example.com/news/?a=1", "test", "u1", "tester");
REQUIRE(first.inserted);
REQUIRE(first.target.id > 0);
REQUIRE(first.target.normalized_url == "https://example.com/news");
const auto second =
svc.UpsertTarget("https://example.com/news", "test", "u1", "tester");
REQUIRE_FALSE(second.inserted);
REQUIRE(second.target.id == first.target.id);
auto listed = svc.ListTargets("", 50);
REQUIRE(listed.size() == 1);
csp::services::CrawlerTarget claimed;
REQUIRE(svc.ClaimNextTarget(claimed));
REQUIRE(claimed.id == first.target.id);
REQUIRE(claimed.status == "generating");
svc.UpdateGenerated(claimed.id, "{}", "/tmp/demo.py");
svc.MarkTesting(claimed.id);
svc.InsertRun(claimed.id, "success", 200, "{}", "");
svc.MarkActive(claimed.id, 1700000000);
const auto got = svc.GetTargetById(claimed.id);
REQUIRE(got.has_value());
REQUIRE(got->status == "active");
csp::services::CrawlerTarget due;
REQUIRE_FALSE(svc.EnqueueDueActiveTarget(3600, 1700002000, due));
REQUIRE(svc.EnqueueDueActiveTarget(3600, 1700004000, due));
REQUIRE(due.id == claimed.id);
REQUIRE(due.status == "queued");
const auto runs = svc.ListRuns(claimed.id, 20);
REQUIRE(runs.size() == 1);
REQUIRE(runs[0].status == "success");
}
TEST_CASE("crawler extract urls from mixed text") {
const auto urls = csp::services::CrawlerService::ExtractUrls(
"请收录 https://one.hao.work/path/?a=1 和 www.Example.com/docs, 谢谢");
REQUIRE(urls.size() == 2);
REQUIRE(urls[0] == "https://one.hao.work/path");
REQUIRE(urls[1] == "https://www.example.com/docs");
}

查看文件

@@ -0,0 +1,41 @@
#include <catch2/catch_test_macros.hpp>
#include "csp/db/sqlite_db.h"
#include "csp/services/auth_service.h"
#include "csp/services/experience_service.h"
TEST_CASE("experience only increases on rating gains") {
auto db = csp::db::SqliteDb::OpenMemory();
csp::db::ApplyMigrations(db);
csp::db::SeedDemoData(db);
csp::services::AuthService auth(db);
const auto user = auth.Register("xp_user", "password123");
csp::services::ExperienceService xp(db);
const auto s0 = xp.GetSummary(user.user_id);
REQUIRE(s0.user_id == user.user_id);
REQUIRE(s0.experience >= 0);
const int base_exp = s0.experience;
db.Exec("UPDATE users SET rating=rating+10 WHERE id=" +
std::to_string(user.user_id));
const auto s1 = xp.GetSummary(user.user_id);
REQUIRE(s1.experience == base_exp + 10);
db.Exec("UPDATE users SET rating=rating-4 WHERE id=" +
std::to_string(user.user_id));
const auto s2 = xp.GetSummary(user.user_id);
REQUIRE(s2.experience == base_exp + 10);
db.Exec("UPDATE users SET rating=rating+3 WHERE id=" +
std::to_string(user.user_id));
const auto s3 = xp.GetSummary(user.user_id);
REQUIRE(s3.experience == base_exp + 13);
const auto rows = xp.ListHistory(user.user_id, 20);
REQUIRE(rows.size() >= 2);
REQUIRE(rows[0].xp_delta == 3);
REQUIRE(rows[1].xp_delta == 10);
}

查看文件

@@ -2,6 +2,7 @@
#include "csp/app_state.h"
#include "csp/controllers/import_controller.h"
#include "csp/services/auth_service.h"
#include <drogon/HttpRequest.h>
@@ -9,17 +10,22 @@
namespace {
drogon::HttpResponsePtr CallLatest(csp::controllers::ImportController& ctl) {
drogon::HttpResponsePtr CallLatest(csp::controllers::ImportController& ctl,
const std::string& token) {
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
req->addHeader("Authorization", "Bearer " + token);
std::promise<drogon::HttpResponsePtr> p;
ctl.latestJob(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); });
return p.get_future().get();
}
drogon::HttpResponsePtr CallItems(csp::controllers::ImportController& ctl, int64_t job_id) {
drogon::HttpResponsePtr CallItems(csp::controllers::ImportController& ctl,
int64_t job_id,
const std::string& token) {
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
req->addHeader("Authorization", "Bearer " + token);
req->setParameter("page", "1");
req->setParameter("page_size", "20");
std::promise<drogon::HttpResponsePtr> p;
@@ -31,6 +37,8 @@ drogon::HttpResponsePtr CallItems(csp::controllers::ImportController& ctl, int64
TEST_CASE("import controller latest and items") {
csp::AppState::Instance().Init(":memory:");
csp::services::AuthService auth(csp::AppState::Instance().db());
const auto login = auth.Register("admin", "password123");
auto& db = csp::AppState::Instance().db();
db.Exec(
"INSERT INTO import_jobs(status,trigger,total_count,processed_count,success_count,"
@@ -43,14 +51,14 @@ TEST_CASE("import controller latest and items") {
csp::controllers::ImportController ctl;
auto latest = CallLatest(ctl);
auto latest = CallLatest(ctl, login.token);
REQUIRE(latest->statusCode() == drogon::k200OK);
auto latest_json = latest->jsonObject();
REQUIRE(latest_json != nullptr);
REQUIRE((*latest_json)["ok"].asBool());
REQUIRE((*latest_json)["data"]["job"]["id"].asInt64() == 1);
auto items = CallItems(ctl, 1);
auto items = CallItems(ctl, 1, login.token);
REQUIRE(items->statusCode() == drogon::k200OK);
auto items_json = items->jsonObject();
REQUIRE(items_json != nullptr);

查看文件

@@ -1,7 +1,12 @@
#include <catch2/catch_test_macros.hpp>
#include "csp/db/sqlite_db.h"
#include "csp/services/auth_service.h"
#include "csp/services/kb_service.h"
#include "csp/services/user_service.h"
#include <string>
#include <unordered_set>
TEST_CASE("kb service list/detail") {
auto db = csp::db::SqliteDb::OpenMemory();
@@ -16,3 +21,107 @@ TEST_CASE("kb service list/detail") {
REQUIRE(detail.has_value());
REQUIRE(detail->article.slug == rows.front().slug);
}
TEST_CASE("kb skill claim requires prerequisites") {
auto db = csp::db::SqliteDb::OpenMemory();
csp::db::ApplyMigrations(db);
csp::db::SeedDemoData(db);
csp::services::AuthService auth(db);
const auto login = auth.Register("kb_pre_user", "password123");
csp::services::KbService svc(db);
const auto detail = svc.GetBySlug("cpp14-skill-tree");
REQUIRE(detail.has_value());
REQUIRE(detail->article.id > 0);
bool prerequisite_throw = false;
try {
(void)svc.ClaimSkillPoint(login.user_id, detail->article.id, detail->article.slug,
"cpp14-type-02");
} catch (const std::runtime_error& e) {
prerequisite_throw = true;
REQUIRE(std::string(e.what()).find("prerequisite not completed") != std::string::npos);
}
REQUIRE(prerequisite_throw);
const auto first =
svc.ClaimSkillPoint(login.user_id, detail->article.id, detail->article.slug,
"cpp14-io-01");
REQUIRE(first.claimed);
REQUIRE(first.reward == 1);
const auto second =
svc.ClaimSkillPoint(login.user_id, detail->article.id, detail->article.slug,
"cpp14-type-02");
REQUIRE(second.claimed);
REQUIRE(second.reward == 1);
const auto second_again =
svc.ClaimSkillPoint(login.user_id, detail->article.id, detail->article.slug,
"cpp14-type-02");
REQUIRE_FALSE(second_again.claimed);
REQUIRE(second_again.reward == 0);
}
TEST_CASE("kb weekly tasks auto-generate and bonus awarded at 100 percent") {
auto db = csp::db::SqliteDb::OpenMemory();
csp::db::ApplyMigrations(db);
csp::db::SeedDemoData(db);
csp::services::AuthService auth(db);
const auto login = auth.Register("kb_weekly_user", "password123");
csp::services::KbService svc(db);
csp::services::UserService users(db);
auto plan = svc.GetWeeklyPlan(login.user_id);
REQUIRE_FALSE(plan.tasks.empty());
REQUIRE(plan.tasks.size() <= 8);
REQUIRE(plan.completion_percent == 0);
std::unordered_set<std::string> unlocked;
for (const auto& task : plan.tasks) {
for (const auto& pre : task.prerequisites) {
REQUIRE(unlocked.count(pre) > 0);
}
unlocked.insert(task.knowledge_key);
}
bool bonus_throw = false;
try {
(void)svc.ClaimWeeklyBonus(login.user_id);
} catch (const std::runtime_error& e) {
bonus_throw = true;
REQUIRE(std::string(e.what()).find("100% completed") != std::string::npos);
}
REQUIRE(bonus_throw);
int weekly_reward_sum = 0;
for (const auto& task : plan.tasks) {
const auto claim = svc.ClaimSkillPoint(login.user_id, task.article_id,
task.article_slug, task.knowledge_key);
weekly_reward_sum += claim.reward;
REQUIRE(claim.claimed);
}
plan = svc.GetWeeklyPlan(login.user_id);
REQUIRE(plan.completion_percent == 100);
REQUIRE(plan.gained_reward == plan.total_reward);
const auto bonus = svc.ClaimWeeklyBonus(login.user_id);
REQUIRE(bonus.claimed);
REQUIRE(bonus.reward == plan.bonus_reward);
REQUIRE(bonus.completion_percent == 100);
REQUIRE(bonus.week_key == plan.week_key);
const auto user = users.GetById(login.user_id);
REQUIRE(user.has_value());
// Register auto-login grants +1 via login check-in task.
REQUIRE(user->rating == 1 + weekly_reward_sum + plan.bonus_reward);
const auto bonus_again = svc.ClaimWeeklyBonus(login.user_id);
REQUIRE_FALSE(bonus_again.claimed);
REQUIRE(bonus_again.reward == 0);
REQUIRE(bonus_again.rating_after == user->rating);
}

查看文件

@@ -0,0 +1,149 @@
#include <catch2/catch_test_macros.hpp>
#include "csp/app_state.h"
#include "csp/controllers/lark_controller.h"
#include "csp/services/crawler_service.h"
#include "csp/services/lark_bot_service.h"
#include <drogon/HttpRequest.h>
#include <cstdlib>
#include <future>
#include <optional>
#include <string>
namespace {
class ScopedEnv {
public:
ScopedEnv(std::string key, std::optional<std::string> value)
: key_(std::move(key)) {
const char* old = std::getenv(key_.c_str());
if (old) old_ = std::string(old);
if (value.has_value()) {
::setenv(key_.c_str(), value->c_str(), 1);
} else {
::unsetenv(key_.c_str());
}
}
~ScopedEnv() {
if (old_.has_value()) {
::setenv(key_.c_str(), old_->c_str(), 1);
} else {
::unsetenv(key_.c_str());
}
}
private:
std::string key_;
std::optional<std::string> old_;
};
drogon::HttpResponsePtr CallEvents(csp::controllers::LarkController& ctl,
const Json::Value& body) {
auto req = drogon::HttpRequest::newHttpJsonRequest(body);
req->setMethod(drogon::Post);
std::promise<drogon::HttpResponsePtr> p;
ctl.events(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); });
return p.get_future().get();
}
Json::Value MakeTextEventBody() {
Json::Value body;
body["header"]["event_type"] = "im.message.receive_v1";
body["header"]["event_id"] = "evt-1";
body["event"]["sender"]["sender_id"]["open_id"] = "ou_xxx";
body["event"]["message"]["message_type"] = "text";
body["event"]["message"]["message_id"] = "om_xxx";
body["event"]["message"]["chat_id"] = "oc_xxx";
Json::Value content;
content["text"] = "你好";
Json::StreamWriterBuilder wb;
wb["indentation"] = "";
body["event"]["message"]["content"] = Json::writeString(wb, content);
return body;
}
} // namespace
TEST_CASE("lark url verification challenge pass") {
ScopedEnv enabled("CSP_LARK_BOT_ENABLED", "1");
ScopedEnv token("CSP_LARK_VERIFICATION_TOKEN", "verify_token");
ScopedEnv app_id("CSP_LARK_APP_ID", "cli_test");
ScopedEnv app_secret("CSP_LARK_APP_SECRET", "secret_test");
csp::services::LarkBotService::Instance().ConfigureFromEnv();
csp::controllers::LarkController ctl;
Json::Value body;
body["challenge"] = "challenge-abc";
body["token"] = "verify_token";
auto resp = CallEvents(ctl, body);
REQUIRE(resp->statusCode() == drogon::k200OK);
const auto json = resp->jsonObject();
REQUIRE(json != nullptr);
REQUIRE((*json)["challenge"].asString() == "challenge-abc");
}
TEST_CASE("lark url verification token mismatch") {
ScopedEnv enabled("CSP_LARK_BOT_ENABLED", "1");
ScopedEnv token("CSP_LARK_VERIFICATION_TOKEN", "verify_token");
ScopedEnv app_id("CSP_LARK_APP_ID", "cli_test");
ScopedEnv app_secret("CSP_LARK_APP_SECRET", "secret_test");
csp::services::LarkBotService::Instance().ConfigureFromEnv();
csp::controllers::LarkController ctl;
Json::Value body;
body["challenge"] = "challenge-abc";
body["token"] = "bad_token";
auto resp = CallEvents(ctl, body);
REQUIRE(resp->statusCode() == drogon::k401Unauthorized);
}
TEST_CASE("lark events ignored when bot disabled") {
ScopedEnv enabled("CSP_LARK_BOT_ENABLED", "0");
ScopedEnv token("CSP_LARK_VERIFICATION_TOKEN", std::nullopt);
ScopedEnv app_id("CSP_LARK_APP_ID", std::nullopt);
ScopedEnv app_secret("CSP_LARK_APP_SECRET", std::nullopt);
csp::services::LarkBotService::Instance().ConfigureFromEnv();
csp::controllers::LarkController ctl;
auto resp = CallEvents(ctl, MakeTextEventBody());
REQUIRE(resp->statusCode() == drogon::k200OK);
const auto json = resp->jsonObject();
REQUIRE(json != nullptr);
REQUIRE((*json)["code"].asInt() == 0);
}
TEST_CASE("lark text url queued into crawler targets") {
csp::AppState::Instance().Init(":memory:");
ScopedEnv enabled("CSP_LARK_BOT_ENABLED", "1");
ScopedEnv token("CSP_LARK_VERIFICATION_TOKEN", std::nullopt);
ScopedEnv app_id("CSP_LARK_APP_ID", "cli_test");
ScopedEnv app_secret("CSP_LARK_APP_SECRET", "secret_test");
ScopedEnv open_base("CSP_LARK_OPEN_BASE_URL", "invalid-url");
csp::services::LarkBotService::Instance().ConfigureFromEnv();
csp::controllers::LarkController ctl;
auto body = MakeTextEventBody();
Json::Value content;
content["text"] = "请收录 https://one.hao.work/news/?a=1";
Json::StreamWriterBuilder wb;
wb["indentation"] = "";
body["event"]["message"]["content"] = Json::writeString(wb, content);
auto resp = CallEvents(ctl, body);
REQUIRE(resp->statusCode() == drogon::k200OK);
const auto json = resp->jsonObject();
REQUIRE(json != nullptr);
REQUIRE((*json)["code"].asInt() == 0);
REQUIRE((*json)["msg"].asString() == "crawler targets queued");
csp::services::CrawlerService crawler(csp::AppState::Instance().db());
const auto targets = crawler.ListTargets("", 10);
REQUIRE(targets.size() == 1);
REQUIRE(targets[0].normalized_url == "https://one.hao.work/news");
}

查看文件

@@ -3,9 +3,12 @@
#include "csp/app_state.h"
#include "csp/controllers/me_controller.h"
#include "csp/services/auth_service.h"
#include "csp/services/daily_task_service.h"
#include "csp/services/problem_service.h"
#include "csp/services/user_service.h"
#include <drogon/HttpRequest.h>
#include <sqlite3.h>
#include <future>
@@ -22,6 +25,18 @@ drogon::HttpResponsePtr CallProfile(csp::controllers::MeController& ctl,
return p.get_future().get();
}
drogon::HttpResponsePtr CallProfileWithAuthHeader(
csp::controllers::MeController& ctl,
const std::string& auth_header) {
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
req->addHeader("Authorization", auth_header);
std::promise<drogon::HttpResponsePtr> p;
ctl.profile(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); });
return p.get_future().get();
}
drogon::HttpResponsePtr CallListWrongBook(csp::controllers::MeController& ctl,
const std::string& token) {
auto req = drogon::HttpRequest::newHttpRequest();
@@ -69,6 +84,44 @@ drogon::HttpResponsePtr CallDeleteWrongBook(csp::controllers::MeController& ctl,
return p.get_future().get();
}
drogon::HttpResponsePtr CallScoreWrongBook(csp::controllers::MeController& ctl,
const std::string& token,
int64_t problem_id,
const std::string& note) {
Json::Value body;
body["note"] = note;
auto req = drogon::HttpRequest::newHttpJsonRequest(body);
req->setMethod(drogon::Post);
req->addHeader("Authorization", "Bearer " + token);
std::promise<drogon::HttpResponsePtr> p;
ctl.scoreWrongBookNote(req,
[&p](const drogon::HttpResponsePtr& resp) {
p.set_value(resp);
},
problem_id);
return p.get_future().get();
}
int QueryDailyTaskCount(csp::db::SqliteDb& db,
int64_t user_id,
const std::string& task_code,
const std::string& day_key) {
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT COUNT(1) FROM daily_task_logs WHERE user_id=? AND task_code=? AND day_key=?";
REQUIRE(sqlite3_prepare_v2(db.raw(), sql, -1, &stmt, nullptr) == SQLITE_OK);
REQUIRE(sqlite3_bind_int64(stmt, 1, user_id) == SQLITE_OK);
REQUIRE(sqlite3_bind_text(stmt, 2, task_code.c_str(), -1, SQLITE_TRANSIENT) ==
SQLITE_OK);
REQUIRE(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT) ==
SQLITE_OK);
REQUIRE(sqlite3_step(stmt) == SQLITE_ROW);
const int count = sqlite3_column_int(stmt, 0);
sqlite3_finalize(stmt);
return count;
}
} // namespace
TEST_CASE("me controller profile and wrong-book") {
@@ -90,9 +143,24 @@ TEST_CASE("me controller profile and wrong-book") {
REQUIRE(profile_json != nullptr);
REQUIRE((*profile_json)["ok"].asBool());
auto profile_basic =
CallProfileWithAuthHeader(ctl, "Basic bWVfaHR0cF91c2VyOnBhc3N3b3JkMTIz");
REQUIRE(profile_basic->statusCode() == drogon::k200OK);
auto patch = CallPatchWrongBook(ctl, login.token, problem_id, "复盘记录");
REQUIRE(patch->statusCode() == drogon::k200OK);
auto score = CallScoreWrongBook(ctl, login.token, problem_id, "题意+思路+踩坑总结");
REQUIRE(score->statusCode() == drogon::k200OK);
const auto score_json = score->jsonObject();
REQUIRE(score_json != nullptr);
REQUIRE((*score_json)["ok"].asBool());
REQUIRE((*score_json)["data"].isObject());
REQUIRE((*score_json)["data"]["note_score"].asInt() >= 0);
REQUIRE((*score_json)["data"]["note_score"].asInt() <= 60);
REQUIRE((*score_json)["data"]["note_rating"].asInt() >= 0);
REQUIRE((*score_json)["data"]["note_rating"].asInt() <= 6);
auto list_resp = CallListWrongBook(ctl, login.token);
REQUIRE(list_resp->statusCode() == drogon::k200OK);
auto list_json = list_resp->jsonObject();
@@ -103,3 +171,50 @@ TEST_CASE("me controller profile and wrong-book") {
auto del = CallDeleteWrongBook(ctl, login.token, problem_id);
REQUIRE(del->statusCode() == drogon::k200OK);
}
TEST_CASE("me profile auto recovers daily login checkin for stale session") {
csp::AppState::Instance().Init(":memory:");
csp::services::AuthService auth(csp::AppState::Instance().db());
const auto login = auth.Register("me_daily_user", "password123");
csp::controllers::MeController ctl;
csp::services::DailyTaskService daily(csp::AppState::Instance().db());
csp::services::UserService users(csp::AppState::Instance().db());
const auto day_key = daily.CurrentDayKey();
// Simulate a stale session user whose today's login_checkin wasn't recorded.
{
sqlite3* db = csp::AppState::Instance().db().raw();
sqlite3_stmt* del = nullptr;
const char* del_sql =
"DELETE FROM daily_task_logs WHERE user_id=? AND task_code=? AND day_key=?";
REQUIRE(sqlite3_prepare_v2(db, del_sql, -1, &del, nullptr) == SQLITE_OK);
REQUIRE(sqlite3_bind_int64(del, 1, login.user_id) == SQLITE_OK);
REQUIRE(sqlite3_bind_text(
del, 2, csp::services::DailyTaskService::kTaskLoginCheckin, -1,
SQLITE_STATIC) == SQLITE_OK);
REQUIRE(sqlite3_bind_text(del, 3, day_key.c_str(), -1, SQLITE_TRANSIENT) ==
SQLITE_OK);
REQUIRE(sqlite3_step(del) == SQLITE_DONE);
sqlite3_finalize(del);
// Keep rating consistent with removed daily task log.
csp::AppState::Instance().db().Exec("UPDATE users SET rating=rating-1 WHERE id=" +
std::to_string(login.user_id));
}
REQUIRE(QueryDailyTaskCount(csp::AppState::Instance().db(), login.user_id,
csp::services::DailyTaskService::kTaskLoginCheckin,
day_key) == 0);
auto profile = CallProfile(ctl, login.token);
REQUIRE(profile->statusCode() == drogon::k200OK);
const auto user = users.GetById(login.user_id);
REQUIRE(user.has_value());
REQUIRE(user->rating == 1);
REQUIRE(QueryDailyTaskCount(csp::AppState::Instance().db(), login.user_id,
csp::services::DailyTaskService::kTaskLoginCheckin,
day_key) == 1);
}

查看文件

@@ -3,6 +3,7 @@
#include "csp/db/sqlite_db.h"
#include "csp/services/problem_workspace_service.h"
#include <algorithm>
#include <sqlite3.h>
TEST_CASE("problem workspace service drafts and solution jobs") {
@@ -54,9 +55,12 @@ TEST_CASE("problem workspace service drafts and solution jobs") {
REQUIRE(solutions.empty());
REQUIRE(svc.CountProblemsWithoutSolutions() >= 1);
const auto missing_all = svc.ListProblemIdsWithoutSolutions(10, false);
const auto missing_all = svc.ListProblemIdsWithoutSolutions(200, false);
REQUIRE(!missing_all.empty());
const auto missing_skip_busy = svc.ListProblemIdsWithoutSolutions(10, true);
const auto missing_skip_busy = svc.ListProblemIdsWithoutSolutions(200, true);
REQUIRE(!missing_skip_busy.empty());
REQUIRE(missing_skip_busy.size() < missing_all.size());
REQUIRE(std::find(missing_all.begin(), missing_all.end(), pid) != missing_all.end());
REQUIRE(std::find(missing_skip_busy.begin(), missing_skip_busy.end(), pid) ==
missing_skip_busy.end());
REQUIRE(missing_skip_busy.size() <= missing_all.size());
}

查看文件

@@ -0,0 +1,274 @@
#include <catch2/catch_test_macros.hpp>
#include "csp/app_state.h"
#include "csp/controllers/admin_controller.h"
#include "csp/controllers/contest_controller.h"
#include "csp/controllers/me_controller.h"
#include "csp/controllers/season_controller.h"
#include "csp/services/auth_service.h"
#include <drogon/HttpRequest.h>
#include <future>
namespace {
drogon::HttpResponsePtr CallSeasonCurrent(csp::controllers::SeasonController& ctl) {
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
std::promise<drogon::HttpResponsePtr> p;
ctl.currentSeason(req, [&p](const drogon::HttpResponsePtr& resp) {
p.set_value(resp);
});
return p.get_future().get();
}
drogon::HttpResponsePtr CallSeasonMe(csp::controllers::SeasonController& ctl,
int64_t season_id,
const std::string& token) {
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
req->addHeader("Authorization", "Bearer " + token);
std::promise<drogon::HttpResponsePtr> p;
ctl.mySeasonProgress(req, [&p](const drogon::HttpResponsePtr& resp) {
p.set_value(resp);
}, season_id);
return p.get_future().get();
}
drogon::HttpResponsePtr CallSeasonClaim(csp::controllers::SeasonController& ctl,
int64_t season_id,
const std::string& token,
int tier_no,
const std::string& reward_type) {
Json::Value body;
body["tier_no"] = tier_no;
body["reward_type"] = reward_type;
auto req = drogon::HttpRequest::newHttpJsonRequest(body);
req->setMethod(drogon::Post);
req->addHeader("Authorization", "Bearer " + token);
std::promise<drogon::HttpResponsePtr> p;
ctl.claimSeasonReward(req, [&p](const drogon::HttpResponsePtr& resp) {
p.set_value(resp);
}, season_id);
return p.get_future().get();
}
drogon::HttpResponsePtr CallMeLoot(csp::controllers::MeController& ctl,
const std::string& token) {
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
req->addHeader("Authorization", "Bearer " + token);
req->setParameter("limit", "20");
std::promise<drogon::HttpResponsePtr> p;
ctl.listLootDrops(req, [&p](const drogon::HttpResponsePtr& resp) {
p.set_value(resp);
});
return p.get_future().get();
}
drogon::HttpResponsePtr CallContestList(csp::controllers::ContestController& ctl) {
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
std::promise<drogon::HttpResponsePtr> p;
ctl.list(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); });
return p.get_future().get();
}
drogon::HttpResponsePtr CallContestModifiers(csp::controllers::ContestController& ctl,
int64_t contest_id,
bool include_inactive) {
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
if (include_inactive) req->setParameter("include_inactive", "true");
std::promise<drogon::HttpResponsePtr> p;
ctl.modifiers(req, [&p](const drogon::HttpResponsePtr& resp) {
p.set_value(resp);
}, contest_id);
return p.get_future().get();
}
drogon::HttpResponsePtr CallAdminCreateSeason(csp::controllers::AdminController& ctl,
const std::string& token,
const Json::Value& body) {
auto req = drogon::HttpRequest::newHttpJsonRequest(body);
req->setMethod(drogon::Post);
req->addHeader("Authorization", "Bearer " + token);
std::promise<drogon::HttpResponsePtr> p;
ctl.createSeason(req, [&p](const drogon::HttpResponsePtr& resp) {
p.set_value(resp);
});
return p.get_future().get();
}
drogon::HttpResponsePtr CallAdminUpdateSeason(csp::controllers::AdminController& ctl,
const std::string& token,
int64_t season_id,
const Json::Value& body) {
auto req = drogon::HttpRequest::newHttpJsonRequest(body);
req->setMethod(drogon::Patch);
req->addHeader("Authorization", "Bearer " + token);
std::promise<drogon::HttpResponsePtr> p;
ctl.updateSeason(req, [&p](const drogon::HttpResponsePtr& resp) {
p.set_value(resp);
}, season_id);
return p.get_future().get();
}
drogon::HttpResponsePtr CallAdminCreateModifier(csp::controllers::AdminController& ctl,
const std::string& token,
int64_t contest_id,
const Json::Value& body) {
auto req = drogon::HttpRequest::newHttpJsonRequest(body);
req->setMethod(drogon::Post);
req->addHeader("Authorization", "Bearer " + token);
std::promise<drogon::HttpResponsePtr> p;
ctl.createContestModifier(req, [&p](const drogon::HttpResponsePtr& resp) {
p.set_value(resp);
}, contest_id);
return p.get_future().get();
}
drogon::HttpResponsePtr CallAdminUpdateModifier(csp::controllers::AdminController& ctl,
const std::string& token,
int64_t contest_id,
int64_t modifier_id,
const Json::Value& body) {
auto req = drogon::HttpRequest::newHttpJsonRequest(body);
req->setMethod(drogon::Patch);
req->addHeader("Authorization", "Bearer " + token);
std::promise<drogon::HttpResponsePtr> p;
ctl.updateContestModifier(req, [&p](const drogon::HttpResponsePtr& resp) {
p.set_value(resp);
}, contest_id, modifier_id);
return p.get_future().get();
}
} // namespace
TEST_CASE("season controller current/me/claim and loot endpoint") {
csp::AppState::Instance().Init(":memory:");
csp::services::AuthService auth(csp::AppState::Instance().db());
const auto user = auth.Register("season_http_user", "password123");
csp::controllers::SeasonController season_ctl;
csp::controllers::MeController me_ctl;
auto current_resp = CallSeasonCurrent(season_ctl);
REQUIRE(current_resp->statusCode() == drogon::k200OK);
auto current_json = current_resp->jsonObject();
REQUIRE(current_json != nullptr);
const int64_t season_id = (*current_json)["data"]["season"]["id"].asInt64();
REQUIRE(season_id > 0);
REQUIRE((*current_json)["data"]["reward_tracks"].isArray());
REQUIRE((*current_json)["data"]["reward_tracks"].size() >= 1);
auto me_resp = CallSeasonMe(season_ctl, season_id, user.token);
REQUIRE(me_resp->statusCode() == drogon::k200OK);
auto me_json = me_resp->jsonObject();
REQUIRE(me_json != nullptr);
REQUIRE((*me_json)["data"]["progress"].isObject());
REQUIRE((*me_json)["data"]["reward_tracks"].isArray());
const int tier_no =
(*me_json)["data"]["reward_tracks"][0]["tier_no"].asInt();
const std::string reward_type =
(*me_json)["data"]["reward_tracks"][0]["reward_type"].asString();
auto claim_resp = CallSeasonClaim(
season_ctl, season_id, user.token, tier_no, reward_type);
REQUIRE(claim_resp->statusCode() == drogon::k200OK);
auto claim_json = claim_resp->jsonObject();
REQUIRE(claim_json != nullptr);
REQUIRE((*claim_json)["data"]["track"]["tier_no"].asInt() == tier_no);
auto loot_resp = CallMeLoot(me_ctl, user.token);
REQUIRE(loot_resp->statusCode() == drogon::k200OK);
auto loot_json = loot_resp->jsonObject();
REQUIRE(loot_json != nullptr);
REQUIRE((*loot_json)["data"].isArray());
REQUIRE((*loot_json)["data"].size() >= 1);
}
TEST_CASE("admin season/modifier endpoints and contest modifier read endpoint") {
csp::AppState::Instance().Init(":memory:");
csp::services::AuthService auth(csp::AppState::Instance().db());
const auto admin = auth.Register("admin", "password123");
csp::controllers::AdminController admin_ctl;
csp::controllers::ContestController contest_ctl;
auto contests_resp = CallContestList(contest_ctl);
REQUIRE(contests_resp->statusCode() == drogon::k200OK);
auto contests_json = contests_resp->jsonObject();
REQUIRE(contests_json != nullptr);
REQUIRE((*contests_json)["data"].isArray());
REQUIRE((*contests_json)["data"].size() >= 1);
const int64_t contest_id = (*contests_json)["data"][0]["id"].asInt64();
Json::Value create_season_body;
create_season_body["key"] = "season-http-admin";
create_season_body["title"] = "HTTP 管理赛季";
create_season_body["starts_at"] = Json::Int64(1700000000);
create_season_body["ends_at"] = Json::Int64(1900000000);
create_season_body["status"] = "active";
Json::Value tracks(Json::arrayValue);
Json::Value t1;
t1["tier_no"] = 1;
t1["required_xp"] = 0;
t1["reward_type"] = "free";
t1["reward_value"] = 3;
tracks.append(t1);
create_season_body["reward_tracks"] = tracks;
auto create_season_resp =
CallAdminCreateSeason(admin_ctl, admin.token, create_season_body);
REQUIRE(create_season_resp->statusCode() == drogon::k200OK);
auto create_season_json = create_season_resp->jsonObject();
REQUIRE(create_season_json != nullptr);
const int64_t new_season_id =
(*create_season_json)["data"]["season"]["id"].asInt64();
REQUIRE(new_season_id > 0);
Json::Value update_season_body;
update_season_body["title"] = "HTTP 管理赛季(更新)";
auto update_season_resp =
CallAdminUpdateSeason(admin_ctl, admin.token, new_season_id, update_season_body);
REQUIRE(update_season_resp->statusCode() == drogon::k200OK);
auto update_season_json = update_season_resp->jsonObject();
REQUIRE(update_season_json != nullptr);
REQUIRE((*update_season_json)["data"]["season"]["title"].asString() ==
"HTTP 管理赛季(更新)");
Json::Value create_modifier_body;
create_modifier_body["code"] = "limit10";
create_modifier_body["title"] = "限时十分钟";
create_modifier_body["description"] = "每道题建议 10 分钟内完成。";
create_modifier_body["is_active"] = true;
create_modifier_body["rule_json"] = R"({"time_limit_min":10})";
auto create_modifier_resp = CallAdminCreateModifier(
admin_ctl, admin.token, contest_id, create_modifier_body);
REQUIRE(create_modifier_resp->statusCode() == drogon::k200OK);
auto create_modifier_json = create_modifier_resp->jsonObject();
REQUIRE(create_modifier_json != nullptr);
const int64_t modifier_id = (*create_modifier_json)["data"]["id"].asInt64();
REQUIRE(modifier_id > 0);
Json::Value update_modifier_body;
update_modifier_body["is_active"] = false;
update_modifier_body["title"] = "限时十分钟(更新)";
auto update_modifier_resp = CallAdminUpdateModifier(
admin_ctl, admin.token, contest_id, modifier_id, update_modifier_body);
REQUIRE(update_modifier_resp->statusCode() == drogon::k200OK);
auto update_modifier_json = update_modifier_resp->jsonObject();
REQUIRE(update_modifier_json != nullptr);
REQUIRE((*update_modifier_json)["data"]["is_active"].asBool() == false);
REQUIRE((*update_modifier_json)["data"]["title"].asString() == "限时十分钟(更新)");
auto modifiers_resp = CallContestModifiers(contest_ctl, contest_id, true);
REQUIRE(modifiers_resp->statusCode() == drogon::k200OK);
auto modifiers_json = modifiers_resp->jsonObject();
REQUIRE(modifiers_json != nullptr);
REQUIRE((*modifiers_json)["data"].isArray());
REQUIRE((*modifiers_json)["data"].size() >= 1);
}

查看文件

@@ -0,0 +1,115 @@
#include <catch2/catch_test_macros.hpp>
#include "csp/db/sqlite_db.h"
#include "csp/services/auth_service.h"
#include "csp/services/contest_service.h"
#include "csp/services/season_service.h"
#include "csp/services/user_service.h"
#include <string>
TEST_CASE("season reward claim is idempotent and writes loot log") {
auto db = csp::db::SqliteDb::OpenMemory();
csp::db::ApplyMigrations(db);
csp::db::SeedDemoData(db);
csp::services::AuthService auth(db);
const auto login = auth.Register("season_user_1", "password123");
csp::services::SeasonService seasons(db);
const auto season = seasons.GetCurrentSeason();
REQUIRE(season.has_value());
const auto tracks = seasons.ListRewardTracks(season->id);
REQUIRE_FALSE(tracks.empty());
const auto target_track = tracks.back();
db.Exec("UPDATE users SET rating=200 WHERE id=" + std::to_string(login.user_id));
const auto before_progress =
seasons.GetOrSyncUserProgress(season->id, login.user_id);
REQUIRE(before_progress.xp >= target_track.required_xp);
const auto first_claim = seasons.ClaimReward(
season->id, login.user_id, target_track.tier_no, target_track.reward_type);
REQUIRE(first_claim.claimed);
REQUIRE(first_claim.claim.has_value());
REQUIRE(first_claim.rating_after >= 200 + target_track.reward_value);
const auto second_claim = seasons.ClaimReward(
season->id, login.user_id, target_track.tier_no, target_track.reward_type);
REQUIRE_FALSE(second_claim.claimed);
REQUIRE(second_claim.claim.has_value());
REQUIRE(second_claim.rating_after == first_claim.rating_after);
const auto loot = seasons.ListLootDropsByUser(login.user_id, 20);
REQUIRE_FALSE(loot.empty());
REQUIRE(loot.front().source_type == "season");
REQUIRE(loot.front().source_id == season->id);
csp::services::UserService users(db);
const auto user = users.GetById(login.user_id);
REQUIRE(user.has_value());
REQUIRE(user->rating == first_claim.rating_after);
}
TEST_CASE("contest modifiers create update and filtered list") {
auto db = csp::db::SqliteDb::OpenMemory();
csp::db::ApplyMigrations(db);
csp::db::SeedDemoData(db);
csp::services::ContestService contests(db);
const auto contest_list = contests.ListContests();
REQUIRE_FALSE(contest_list.empty());
const int64_t contest_id = contest_list.front().id;
csp::services::SeasonService seasons(db);
csp::services::ContestModifierWrite create;
create.code = "no_recursion";
create.title = "禁用递归";
create.description = "仅允许循环写法。";
create.rule_json = R"({"forbid":["recursion"]})";
create.is_active = true;
const auto created = seasons.CreateContestModifier(contest_id, create);
REQUIRE(created.id > 0);
REQUIRE(created.contest_id == contest_id);
REQUIRE(created.is_active);
const auto active_list = seasons.ListContestModifiers(contest_id, false);
bool found_created = false;
for (const auto& one : active_list) {
if (one.id == created.id) {
found_created = true;
break;
}
}
REQUIRE(found_created);
csp::services::ContestModifierPatch patch;
patch.title = "禁用递归(更新)";
patch.is_active = false;
const auto updated =
seasons.UpdateContestModifier(contest_id, created.id, patch);
REQUIRE(updated.title == "禁用递归(更新)");
REQUIRE_FALSE(updated.is_active);
const auto active_after = seasons.ListContestModifiers(contest_id, false);
bool still_active = false;
for (const auto& one : active_after) {
if (one.id == created.id) {
still_active = true;
break;
}
}
REQUIRE_FALSE(still_active);
const auto all_after = seasons.ListContestModifiers(contest_id, true);
bool found_updated = false;
for (const auto& one : all_after) {
if (one.id == created.id && one.title == "禁用递归(更新)" &&
!one.is_active) {
found_updated = true;
break;
}
}
REQUIRE(found_updated);
}

查看文件

@@ -0,0 +1,61 @@
#include <catch2/catch_test_macros.hpp>
#include "csp/db/sqlite_db.h"
#include "csp/services/auth_service.h"
#include "csp/services/source_crystal_service.h"
namespace {
double AbsDiff(double a, double b) {
const double d = a - b;
return d < 0 ? -d : d;
}
} // namespace
TEST_CASE("source crystal deposit withdraw interest and settings") {
auto db = csp::db::SqliteDb::OpenMemory();
csp::db::ApplyMigrations(db);
csp::db::SeedDemoData(db);
csp::services::AuthService auth(db);
const auto user = auth.Register("crystal_user", "password123");
csp::services::SourceCrystalService crystal(db);
const auto s0 = crystal.GetSummary(user.user_id);
REQUIRE(s0.user_id == user.user_id);
REQUIRE(AbsDiff(s0.balance, 0.0) < 1e-9);
REQUIRE(s0.monthly_interest_rate >= 0.0);
const auto d1 = crystal.Deposit(user.user_id, 100.0, "initial deposit");
REQUIRE(d1.tx_type == "deposit");
REQUIRE(AbsDiff(d1.amount, 100.0) < 1e-9);
REQUIRE(d1.balance_after > 99.99);
const auto w1 = crystal.Withdraw(user.user_id, 30.0, "buy resources");
REQUIRE(w1.tx_type == "withdraw");
REQUIRE(AbsDiff(w1.amount, -30.0) < 1e-9);
REQUIRE(w1.balance_after > 69.99);
REQUIRE(w1.balance_after < 70.01);
db.Exec("UPDATE source_crystal_accounts "
"SET last_interest_at = last_interest_at - 7776000 "
"WHERE user_id=" + std::to_string(user.user_id));
db.Exec("UPDATE source_crystal_transactions "
"SET created_at = created_at - 7776000 "
"WHERE user_id=" + std::to_string(user.user_id));
const auto s1 = crystal.GetSummary(user.user_id);
REQUIRE(s1.balance > 72.0);
REQUIRE(s1.balance < 75.0);
const auto records = crystal.ListTransactions(user.user_id, 50);
REQUIRE(records.size() >= 3);
const auto cfg = crystal.UpdateMonthlyInterestRate(0.05);
REQUIRE(AbsDiff(cfg.monthly_interest_rate, 0.05) < 1e-9);
const auto cfg2 = crystal.GetSettings();
REQUIRE(AbsDiff(cfg2.monthly_interest_rate, 0.05) < 1e-9);
}

查看文件

@@ -27,6 +27,8 @@ TEST_CASE("migrations create core tables") {
REQUIRE(CountTable(db.raw(), "users") == 1);
REQUIRE(CountTable(db.raw(), "sessions") == 1);
REQUIRE(CountTable(db.raw(), "user_experience") == 1);
REQUIRE(CountTable(db.raw(), "user_experience_logs") == 1);
REQUIRE(CountTable(db.raw(), "problems") == 1);
REQUIRE(CountTable(db.raw(), "problem_tags") == 1);
REQUIRE(CountTable(db.raw(), "submissions") == 1);
@@ -34,11 +36,23 @@ TEST_CASE("migrations create core tables") {
REQUIRE(CountTable(db.raw(), "contests") == 1);
REQUIRE(CountTable(db.raw(), "contest_problems") == 1);
REQUIRE(CountTable(db.raw(), "contest_registrations") == 1);
REQUIRE(CountTable(db.raw(), "contest_modifiers") == 1);
REQUIRE(CountTable(db.raw(), "seasons") == 1);
REQUIRE(CountTable(db.raw(), "season_reward_tracks") == 1);
REQUIRE(CountTable(db.raw(), "season_user_progress") == 1);
REQUIRE(CountTable(db.raw(), "season_reward_claims") == 1);
REQUIRE(CountTable(db.raw(), "loot_drop_logs") == 1);
REQUIRE(CountTable(db.raw(), "kb_articles") == 1);
REQUIRE(CountTable(db.raw(), "kb_article_links") == 1);
REQUIRE(CountTable(db.raw(), "kb_knowledge_claims") == 1);
REQUIRE(CountTable(db.raw(), "kb_weekly_tasks") == 1);
REQUIRE(CountTable(db.raw(), "kb_weekly_bonus_logs") == 1);
REQUIRE(CountTable(db.raw(), "import_jobs") == 1);
REQUIRE(CountTable(db.raw(), "import_job_items") == 1);
REQUIRE(CountTable(db.raw(), "problem_drafts") == 1);
REQUIRE(CountTable(db.raw(), "problem_solution_jobs") == 1);
REQUIRE(CountTable(db.raw(), "problem_solutions") == 1);
REQUIRE(CountTable(db.raw(), "source_crystal_settings") == 1);
REQUIRE(CountTable(db.raw(), "source_crystal_accounts") == 1);
REQUIRE(CountTable(db.raw(), "source_crystal_transactions") == 1);
}