feat: add streak reward and 100-achievement milestone system

这个提交包含在:
cryptocommuniums-afk
2026-02-23 20:29:14 +08:00
父节点 43cbd38bac
当前提交 0b53113a4b
修改 14 个文件,包含 1334 行新增57 行删除

查看文件

@@ -0,0 +1,108 @@
#include <catch2/catch_test_macros.hpp>
#include "csp/db/sqlite_db.h"
#include "csp/services/achievement_service.h"
#include "csp/services/auth_service.h"
#include "csp/services/user_service.h"
#include <sqlite3.h>
#include <cstdint>
#include <stdexcept>
#include <string>
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 InsertProblem(sqlite3* db, const std::string& slug, int64_t created_at) {
sqlite3_stmt* stmt = nullptr;
const char* sql =
"INSERT INTO problems(slug,title,statement_md,difficulty,source,created_at) "
"VALUES(?,?,?,?,?,?)";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare insert problem");
CheckSqlite(sqlite3_bind_text(stmt, 1, slug.c_str(), -1, SQLITE_TRANSIENT), db,
"bind slug");
CheckSqlite(sqlite3_bind_text(stmt, 2, slug.c_str(), -1, SQLITE_TRANSIENT), db,
"bind title");
CheckSqlite(sqlite3_bind_text(stmt, 3, "stub", -1, SQLITE_STATIC), db,
"bind statement");
CheckSqlite(sqlite3_bind_int(stmt, 4, 1), db, "bind difficulty");
CheckSqlite(sqlite3_bind_text(stmt, 5, "test", -1, SQLITE_STATIC), db,
"bind source");
CheckSqlite(sqlite3_bind_int64(stmt, 6, created_at), db, "bind created_at");
CheckSqlite(sqlite3_step(stmt), db, "insert problem");
sqlite3_finalize(stmt);
return sqlite3_last_insert_rowid(db);
}
void InsertSubmission(sqlite3* db, int64_t user_id, int64_t problem_id,
int64_t created_at) {
sqlite3_stmt* stmt = nullptr;
const char* sql =
"INSERT INTO submissions(user_id,problem_id,language,code,status,score,time_ms,memory_kb,created_at) "
"VALUES(?,?,?,?,?,?,?,?,?)";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare insert submission");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
CheckSqlite(sqlite3_bind_text(stmt, 3, "cpp", -1, SQLITE_STATIC), db,
"bind language");
CheckSqlite(sqlite3_bind_text(stmt, 4, "int main(){return 0;}", -1, SQLITE_STATIC),
db, "bind code");
CheckSqlite(sqlite3_bind_text(stmt, 5, "AC", -1, SQLITE_STATIC), db,
"bind status");
CheckSqlite(sqlite3_bind_int(stmt, 6, 100), db, "bind score");
CheckSqlite(sqlite3_bind_int(stmt, 7, 1), db, "bind time");
CheckSqlite(sqlite3_bind_int(stmt, 8, 1), db, "bind memory");
CheckSqlite(sqlite3_bind_int64(stmt, 9, created_at), db, "bind created_at");
CheckSqlite(sqlite3_step(stmt), db, "insert submission");
sqlite3_finalize(stmt);
}
} // namespace
TEST_CASE("achievement snapshot grants milestone bonus once per milestone") {
auto db = csp::db::SqliteDb::OpenMemory();
csp::db::ApplyMigrations(db);
csp::services::AuthService auth(db);
const auto login = auth.Register("achievement_user", "password123");
csp::services::UserService users(db);
csp::services::AchievementService achievements(db);
sqlite3* raw = db.raw();
int64_t now = 1700000000;
for (int i = 0; i < 40; ++i) {
const auto pid = InsertProblem(raw, "ach-test-" + std::to_string(i + 1), now + i);
InsertSubmission(raw, login.user_id, pid, now + i);
}
const auto before = users.GetById(login.user_id);
REQUIRE(before.has_value());
REQUIRE(before->rating == 1); // register triggers one login_checkin reward
const auto snap1 = achievements.GetSnapshot(login.user_id);
REQUIRE(snap1.items.size() == 100);
REQUIRE(snap1.summary.total_count == 100);
REQUIRE(snap1.summary.completed_count >= 10);
REQUIRE(snap1.summary.bonus_milestones_added_now >= 1);
REQUIRE(snap1.summary.bonus_rating_added_now ==
snap1.summary.bonus_milestones_added_now * 20);
const auto after_first = users.GetById(login.user_id);
REQUIRE(after_first.has_value());
REQUIRE(after_first->rating == before->rating + snap1.summary.bonus_rating_added_now);
const auto snap2 = achievements.GetSnapshot(login.user_id);
REQUIRE(snap2.summary.bonus_milestones_added_now == 0);
REQUIRE(snap2.summary.bonus_rating_added_now == 0);
const auto after_second = users.GetById(login.user_id);
REQUIRE(after_second.has_value());
REQUIRE(after_second->rating == after_first->rating);
}

查看文件

@@ -5,6 +5,130 @@
#include "csp/services/daily_task_service.h"
#include "csp/services/user_service.h"
#include <sqlite3.h>
#include <array>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <string>
#include <tuple>
namespace {
int CivilToDays(int year, unsigned month, unsigned day) {
year -= month <= 2 ? 1 : 0;
const int era = (year >= 0 ? year : year - 399) / 400;
const unsigned yoe = static_cast<unsigned>(year - era * 400);
const unsigned doy =
(153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1;
const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
return era * 146097 + static_cast<int>(doe) - 719468;
}
std::tuple<int, unsigned, unsigned> DaysToCivil(int z) {
z += 719468;
const int era = (z >= 0 ? z : z - 146096) / 146097;
const unsigned doe = static_cast<unsigned>(z - era * 146097);
const unsigned yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
int year = static_cast<int>(yoe) + era * 400;
const unsigned doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
const unsigned mp = (5 * doy + 2) / 153;
const unsigned day = doy - (153 * mp + 2) / 5 + 1;
const unsigned month = mp < 10 ? mp + 3 : mp - 9;
year += month <= 2 ? 1 : 0;
return {year, month, day};
}
std::optional<int> DaySerialFromKey(const std::string& day_key) {
if (day_key.size() != 10 || day_key[4] != '-' || day_key[7] != '-') {
return std::nullopt;
}
try {
const int year = std::stoi(day_key.substr(0, 4));
const int month = std::stoi(day_key.substr(5, 2));
const int day = std::stoi(day_key.substr(8, 2));
if (month < 1 || month > 12 || day < 1 || day > 31) return std::nullopt;
return CivilToDays(year, static_cast<unsigned>(month),
static_cast<unsigned>(day));
} catch (...) {
return std::nullopt;
}
}
std::string ShiftDayKey(const std::string& day_key, int delta_days) {
const auto serial = DaySerialFromKey(day_key);
REQUIRE(serial.has_value());
const auto [year, month, day] = DaysToCivil(*serial + delta_days);
std::ostringstream out;
out << year << '-';
if (month < 10) out << '0';
out << month << '-';
if (day < 10) out << '0';
out << day;
return out.str();
}
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));
}
void InsertDailyTaskLog(sqlite3* db, int64_t user_id, const std::string& task_code,
const std::string& day_key, int reward, int64_t created_at) {
sqlite3_stmt* stmt = nullptr;
const char* sql =
"INSERT INTO daily_task_logs(user_id,task_code,day_key,reward,created_at) "
"VALUES(?,?,?,?,?)";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare insert daily_task_logs");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_text(stmt, 2, task_code.c_str(), -1, SQLITE_TRANSIENT),
db, "bind task_code");
CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT),
db, "bind day_key");
CheckSqlite(sqlite3_bind_int(stmt, 4, reward), db, "bind reward");
CheckSqlite(sqlite3_bind_int64(stmt, 5, created_at), db, "bind created_at");
CheckSqlite(sqlite3_step(stmt), db, "insert daily_task_logs");
sqlite3_finalize(stmt);
}
void DeleteDailyTaskLog(sqlite3* db, int64_t user_id, const std::string& task_code,
const std::string& day_key) {
sqlite3_stmt* stmt = nullptr;
const char* sql =
"DELETE FROM daily_task_logs WHERE user_id=? AND task_code=? AND day_key=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare delete daily_task_logs");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_text(stmt, 2, task_code.c_str(), -1, SQLITE_TRANSIENT),
db, "bind task_code");
CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT),
db, "bind day_key");
CheckSqlite(sqlite3_step(stmt), db, "delete daily_task_logs");
sqlite3_finalize(stmt);
}
int CountTaskLog(sqlite3* 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=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare count daily_task_logs");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_text(stmt, 2, task_code.c_str(), -1, SQLITE_TRANSIENT),
db, "bind task_code");
CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT),
db, "bind day_key");
REQUIRE(sqlite3_step(stmt) == SQLITE_ROW);
const int count = sqlite3_column_int(stmt, 0);
sqlite3_finalize(stmt);
return count;
}
} // namespace
TEST_CASE("daily task reward only once per day") {
auto db = csp::db::SqliteDb::OpenMemory();
csp::db::ApplyMigrations(db);
@@ -30,5 +154,50 @@ TEST_CASE("daily task reward only once per day") {
REQUIRE(after->rating == 2);
const auto tasks = daily.ListTodayTasks(user.user_id);
REQUIRE(tasks.size() == 4);
REQUIRE(tasks.size() == 5);
}
TEST_CASE("learning streak reward grants +1 when streak >= 3 days") {
auto db = csp::db::SqliteDb::OpenMemory();
csp::db::ApplyMigrations(db);
csp::services::AuthService auth(db);
const auto user = auth.Register("daily_task_streak_user", "password123");
csp::services::UserService users(db);
csp::services::DailyTaskService daily(db);
sqlite3* raw = db.raw();
const std::string today = daily.CurrentDayKey();
const std::string yesterday = ShiftDayKey(today, -1);
const std::string two_days_ago = ShiftDayKey(today, -2);
// Register inserts today's login_checkin automatically; remove it so this test
// can call login_checkin and verify reward delta in one step.
DeleteDailyTaskLog(raw, user.user_id,
csp::services::DailyTaskService::kTaskLoginCheckin, today);
InsertDailyTaskLog(raw, user.user_id,
csp::services::DailyTaskService::kTaskLoginCheckin,
yesterday, 1, 0);
InsertDailyTaskLog(raw, user.user_id,
csp::services::DailyTaskService::kTaskLoginCheckin,
two_days_ago, 1, 0);
const auto before = users.GetById(user.user_id);
REQUIRE(before.has_value());
REQUIRE(daily.CompleteTaskIfFirstToday(
user.user_id, csp::services::DailyTaskService::kTaskLoginCheckin));
REQUIRE_FALSE(daily.CompleteTaskIfFirstToday(
user.user_id, csp::services::DailyTaskService::kTaskLoginCheckin));
const auto after = users.GetById(user.user_id);
REQUIRE(after.has_value());
// login_checkin + streak bonus
REQUIRE(after->rating == before->rating + 2);
REQUIRE(CountTaskLog(raw, user.user_id,
csp::services::DailyTaskService::kTaskLearningStreak3Days,
today) == 1);
}

查看文件

@@ -55,4 +55,5 @@ TEST_CASE("migrations create core tables") {
REQUIRE(CountTable(db.raw(), "source_crystal_settings") == 1);
REQUIRE(CountTable(db.raw(), "source_crystal_accounts") == 1);
REQUIRE(CountTable(db.raw(), "source_crystal_transactions") == 1);
REQUIRE(CountTable(db.raw(), "user_achievement_milestones") == 1);
}