diff --git a/backend/CMakeLists.txt b/backend/CMakeLists.txt index 3de9416..9e31f81 100644 --- a/backend/CMakeLists.txt +++ b/backend/CMakeLists.txt @@ -14,6 +14,7 @@ add_library(csp_core src/app_state.cc src/services/crypto.cc src/services/auth_service.cc + src/services/achievement_service.cc src/services/experience_service.cc src/services/problem_service.cc src/services/user_service.cc @@ -101,6 +102,7 @@ add_executable(csp_tests tests/version_test.cc tests/sqlite_db_test.cc tests/auth_service_test.cc + tests/achievement_service_test.cc tests/experience_service_test.cc tests/auth_http_test.cc tests/domain_test.cc diff --git a/backend/include/csp/controllers/me_controller.h b/backend/include/csp/controllers/me_controller.h index ee03da4..6da75d8 100644 --- a/backend/include/csp/controllers/me_controller.h +++ b/backend/include/csp/controllers/me_controller.h @@ -33,6 +33,9 @@ public: drogon::Get); ADD_METHOD_TO(MeController::experienceHistory, "/api/v1/me/experience/history", drogon::Get); + ADD_METHOD_TO(MeController::achievementSnapshot, + "/api/v1/me/achievements", + drogon::Get); ADD_METHOD_TO(MeController::listLootDrops, "/api/v1/me/loot-drops", drogon::Get); ADD_METHOD_TO(MeController::listDailyTasks, "/api/v1/me/daily-tasks", @@ -96,6 +99,10 @@ public: const drogon::HttpRequestPtr &req, std::function &&cb); + void achievementSnapshot( + const drogon::HttpRequestPtr &req, + std::function &&cb); + void listLootDrops(const drogon::HttpRequestPtr &req, std::function &&cb); diff --git a/backend/include/csp/services/achievement_service.h b/backend/include/csp/services/achievement_service.h new file mode 100644 index 0000000..930a70f --- /dev/null +++ b/backend/include/csp/services/achievement_service.h @@ -0,0 +1,60 @@ +#pragma once + +#include "csp/db/sqlite_db.h" + +#include +#include +#include + +namespace csp::services { + +struct AchievementItem { + std::string key; + std::string icon; + std::string title; + std::string description; + int honor = 0; + int progress = 0; + int target = 0; + bool completed = false; +}; + +struct AchievementMilestone { + int milestone_no = 0; + int completed_count_snapshot = 0; + int rating_bonus = 0; + int64_t created_at = 0; +}; + +struct AchievementSummary { + int total_count = 0; + int completed_count = 0; + int honor_total = 0; + int milestone_step = 10; + int milestone_bonus_rating = 20; + int milestones_awarded = 0; + int bonus_milestones_added_now = 0; + int bonus_rating_added_now = 0; + int rating_bonus_awarded_total = 0; + int next_milestone_completed_required = 0; + int next_milestone_remaining = 0; + int rating_after_apply = 0; +}; + +struct AchievementSnapshot { + AchievementSummary summary; + std::vector items; + std::vector milestone_logs; +}; + +class AchievementService { + public: + explicit AchievementService(db::SqliteDb& db) : db_(db) {} + + AchievementSnapshot GetSnapshot(int64_t user_id); + + private: + db::SqliteDb& db_; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/daily_task_service.h b/backend/include/csp/services/daily_task_service.h index 102afbf..4e80c98 100644 --- a/backend/include/csp/services/daily_task_service.h +++ b/backend/include/csp/services/daily_task_service.h @@ -23,6 +23,8 @@ class DailyTaskService { static constexpr const char* kTaskDailySubmit = "daily_submit"; static constexpr const char* kTaskFirstAc = "first_ac"; static constexpr const char* kTaskCodeQuality = "code_quality"; + static constexpr const char* kTaskLearningStreak3Days = + "learning_streak_3d"; explicit DailyTaskService(db::SqliteDb& db) : db_(db) {} diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql index af35356..dc400c6 100644 --- a/backend/migrations/001_init.sql +++ b/backend/migrations/001_init.sql @@ -379,6 +379,16 @@ CREATE TABLE IF NOT EXISTS source_crystal_transactions ( FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS user_achievement_milestones ( + user_id INTEGER NOT NULL, + milestone_no INTEGER NOT NULL, + completed_count_snapshot INTEGER NOT NULL DEFAULT 0, + rating_bonus INTEGER NOT NULL DEFAULT 20, + created_at INTEGER NOT NULL, + PRIMARY KEY(user_id, milestone_no), + 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')); @@ -447,3 +457,4 @@ CREATE INDEX IF NOT EXISTS idx_problem_solution_jobs_problem ON problem_solution 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); +CREATE INDEX IF NOT EXISTS idx_user_achievement_milestones_user_created ON user_achievement_milestones(user_id, created_at DESC); diff --git a/backend/src/controllers/me_controller.cc b/backend/src/controllers/me_controller.cc index df98b81..430777e 100644 --- a/backend/src/controllers/me_controller.cc +++ b/backend/src/controllers/me_controller.cc @@ -6,6 +6,7 @@ #include "csp/services/redeem_service.h" #include "csp/services/season_service.h" #include "csp/services/solution_access_service.h" +#include "csp/services/achievement_service.h" #include "csp/services/experience_service.h" #include "csp/services/user_service.h" #include "csp/services/wrong_book_service.h" @@ -482,6 +483,70 @@ void MeController::experienceHistory( } } +void MeController::achievementSnapshot( + const drogon::HttpRequestPtr &req, + std::function &&cb) { + try { + const auto user_id = RequireAuth(req, cb); + if (!user_id.has_value()) + return; + + services::AchievementService achievements(csp::AppState::Instance().db()); + const auto snapshot = achievements.GetSnapshot(*user_id); + + Json::Value data; + Json::Value summary; + summary["total_count"] = snapshot.summary.total_count; + summary["completed_count"] = snapshot.summary.completed_count; + summary["honor_total"] = snapshot.summary.honor_total; + summary["milestone_step"] = snapshot.summary.milestone_step; + summary["milestone_bonus_rating"] = snapshot.summary.milestone_bonus_rating; + summary["milestones_awarded"] = snapshot.summary.milestones_awarded; + summary["bonus_milestones_added_now"] = + snapshot.summary.bonus_milestones_added_now; + summary["bonus_rating_added_now"] = snapshot.summary.bonus_rating_added_now; + summary["rating_bonus_awarded_total"] = + snapshot.summary.rating_bonus_awarded_total; + summary["next_milestone_completed_required"] = + snapshot.summary.next_milestone_completed_required; + summary["next_milestone_remaining"] = + snapshot.summary.next_milestone_remaining; + summary["rating_after_apply"] = snapshot.summary.rating_after_apply; + data["summary"] = summary; + + Json::Value items(Json::arrayValue); + for (const auto& item : snapshot.items) { + Json::Value one; + one["key"] = item.key; + one["icon"] = item.icon; + one["title"] = item.title; + one["description"] = item.description; + one["honor"] = item.honor; + one["progress"] = item.progress; + one["target"] = item.target; + one["completed"] = item.completed; + items.append(one); + } + data["items"] = items; + + Json::Value milestones(Json::arrayValue); + for (const auto& one : snapshot.milestone_logs) { + Json::Value row; + row["milestone_no"] = one.milestone_no; + row["completed_count_snapshot"] = one.completed_count_snapshot; + row["rating_bonus"] = one.rating_bonus; + row["created_at"] = Json::Int64(one.created_at); + milestones.append(row); + } + data["milestone_logs"] = milestones; + 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::listLootDrops( const drogon::HttpRequestPtr &req, std::function &&cb) { diff --git a/backend/src/controllers/meta_controller.cc b/backend/src/controllers/meta_controller.cc index 8899396..486f562 100644 --- a/backend/src/controllers/meta_controller.cc +++ b/backend/src/controllers/meta_controller.cc @@ -132,6 +132,7 @@ Json::Value BuildOpenApiSpec() { 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/achievements"]["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"] = "当前赛季信息"; diff --git a/backend/src/db/sqlite_db.cc b/backend/src/db/sqlite_db.cc index a968237..372cc81 100644 --- a/backend/src/db/sqlite_db.cc +++ b/backend/src/db/sqlite_db.cc @@ -930,6 +930,16 @@ CREATE TABLE IF NOT EXISTS source_crystal_transactions ( FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS user_achievement_milestones ( + user_id INTEGER NOT NULL, + milestone_no INTEGER NOT NULL, + completed_count_snapshot INTEGER NOT NULL DEFAULT 0, + rating_bonus INTEGER NOT NULL DEFAULT 20, + created_at INTEGER NOT NULL, + PRIMARY KEY(user_id, milestone_no), + 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, @@ -1049,6 +1059,7 @@ 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_user_achievement_milestones_user_created ON user_achievement_milestones(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"); diff --git a/backend/src/services/achievement_service.cc b/backend/src/services/achievement_service.cc new file mode 100644 index 0000000..de7be93 --- /dev/null +++ b/backend/src/services/achievement_service.cc @@ -0,0 +1,587 @@ +#include "csp/services/achievement_service.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace csp::services { + +namespace { + +constexpr int kMilestoneStep = 10; +constexpr int kMilestoneRatingBonus = 20; +constexpr int kExpectedAchievementCount = 100; +constexpr int64_t kShanghaiOffsetSeconds = 8LL * 3600LL; + +int64_t NowSec() { + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()).count(); +} + +void CheckSqlite(int rc, sqlite3* db, const char* what) { + if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return; + throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db)); +} + +void ExecSql(sqlite3* db, const std::string& sql, const char* what) { + char* err = nullptr; + const int rc = sqlite3_exec(db, sql.c_str(), nullptr, nullptr, &err); + if (rc == SQLITE_OK) return; + const std::string err_msg = err ? err : ""; + sqlite3_free(err); + throw std::runtime_error(std::string(what) + ": " + err_msg); +} + +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 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; +} + +int QueryInt(sqlite3* db, const char* sql, int64_t user_id) { + sqlite3_stmt* stmt = nullptr; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare query int"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + int out = 0; + if (sqlite3_step(stmt) == SQLITE_ROW) out = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + return out; +} + +double QueryDouble(sqlite3* db, const char* sql, int64_t user_id) { + sqlite3_stmt* stmt = nullptr; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare query double"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + double out = 0.0; + if (sqlite3_step(stmt) == SQLITE_ROW) out = sqlite3_column_double(stmt, 0); + sqlite3_finalize(stmt); + return out; +} + +int64_t QueryInt64(sqlite3* db, const char* sql, int64_t user_id) { + sqlite3_stmt* stmt = nullptr; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare query int64"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + int64_t out = 0; + if (sqlite3_step(stmt) == SQLITE_ROW) out = sqlite3_column_int64(stmt, 0); + sqlite3_finalize(stmt); + return out; +} + +void AddRating(sqlite3* db, int64_t user_id, int delta) { + sqlite3_stmt* stmt = nullptr; + const char* sql = "UPDATE users SET rating=rating+? WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare add achievement rating"); + CheckSqlite(sqlite3_bind_int(stmt, 1, delta), db, "bind rating delta"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_step(stmt), db, "exec add achievement rating"); + sqlite3_finalize(stmt); + if (sqlite3_changes(db) <= 0) throw std::runtime_error("user not found"); +} + +bool IsLeapYear(int year) { + return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); +} + +int DaysInMonth(int year, int month) { + static constexpr std::array kDays = {31, 28, 31, 30, 31, 30, + 31, 31, 30, 31, 30, 31}; + if (month < 1 || month > 12) return 0; + if (month == 2 && IsLeapYear(year)) return 29; + return kDays[static_cast(month - 1)]; +} + +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(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(doe) - 719468; +} + +std::optional 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)); + const int max_day = DaysInMonth(year, month); + if (max_day <= 0 || day < 1 || day > max_day) return std::nullopt; + return CivilToDays(year, static_cast(month), + static_cast(day)); + } catch (...) { + return std::nullopt; + } +} + +std::string BuildDayKey(int64_t ts_sec) { + const std::time_t shifted = + static_cast(ts_sec + kShanghaiOffsetSeconds); + std::tm tmv {}; +#ifdef _WIN32 + gmtime_s(&tmv, &shifted); +#else + gmtime_r(&shifted, &tmv); +#endif + std::ostringstream out; + out << std::setw(4) << std::setfill('0') << (tmv.tm_year + 1900) << '-' + << std::setw(2) << std::setfill('0') << (tmv.tm_mon + 1) << '-' + << std::setw(2) << std::setfill('0') << tmv.tm_mday; + return out.str(); +} + +int CountCurrentLoginStreak(sqlite3* db, int64_t user_id, const std::string& today_key) { + const auto today_serial = DaySerialFromKey(today_key); + if (!today_serial.has_value()) return 0; + + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT day_key " + "FROM daily_task_logs " + "WHERE user_id=? AND task_code='login_checkin' AND day_key<=? " + "ORDER BY day_key DESC " + "LIMIT 120"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare login streak query"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_text(stmt, 2, today_key.c_str(), -1, SQLITE_TRANSIENT), + db, "bind today_key"); + + int streak = 0; + int expected = *today_serial; + while (sqlite3_step(stmt) == SQLITE_ROW) { + const unsigned char* raw = sqlite3_column_text(stmt, 0); + const std::string day_key = + raw ? reinterpret_cast(raw) : std::string(); + const auto serial = DaySerialFromKey(day_key); + if (!serial.has_value()) continue; + if (*serial == expected) { + ++streak; + --expected; + continue; + } + if (*serial < expected) break; + } + sqlite3_finalize(stmt); + return streak; +} + +struct AchievementMetrics { + int total_submissions = 0; + int ac_submissions = 0; + int unique_ac_problems = 0; + int login_streak = 0; + int growth_records = 0; + int daily_task_logs = 0; + int experience = 0; + double source_crystal_balance = 0.0; + int source_crystal_tx_count = 0; + int source_crystal_interest_count = 0; + int redeem_records = 0; + int wrong_book_total = 0; + int wrong_book_noted = 0; + int kb_claims = 0; + int contest_registrations = 0; + int contest_submissions = 0; + int loot_drops = 0; + int solution_views = 0; +}; + +AchievementMetrics CollectMetrics(sqlite3* db, int64_t user_id) { + AchievementMetrics m; + m.total_submissions = QueryInt(db, + "SELECT COUNT(1) FROM submissions WHERE user_id=?", user_id); + m.ac_submissions = QueryInt(db, + "SELECT COUNT(1) FROM submissions WHERE user_id=? AND status='AC'", user_id); + m.unique_ac_problems = QueryInt( + db, + "SELECT COUNT(DISTINCT problem_id) FROM submissions WHERE user_id=? AND status='AC'", + user_id); + m.daily_task_logs = QueryInt(db, + "SELECT COUNT(1) FROM daily_task_logs WHERE user_id=?", user_id); + m.redeem_records = QueryInt(db, + "SELECT COUNT(1) FROM redeem_records WHERE user_id=?", user_id); + m.solution_views = QueryInt( + db, + "SELECT COUNT(1) FROM problem_solution_view_logs WHERE user_id=? AND cost>0", + user_id); + m.kb_claims = QueryInt(db, + "SELECT COUNT(1) FROM kb_knowledge_claims WHERE user_id=?", user_id); + m.growth_records = + m.daily_task_logs + m.redeem_records + m.solution_views + m.kb_claims; + m.experience = QueryInt(db, + "SELECT COALESCE((SELECT xp FROM user_experience WHERE user_id=?),0)", user_id); + m.source_crystal_balance = QueryDouble( + db, + "SELECT COALESCE((SELECT balance FROM source_crystal_accounts WHERE user_id=?),0)", + user_id); + m.source_crystal_tx_count = QueryInt( + db, "SELECT COUNT(1) FROM source_crystal_transactions WHERE user_id=?", + user_id); + m.source_crystal_interest_count = QueryInt( + db, + "SELECT COUNT(1) FROM source_crystal_transactions WHERE user_id=? AND tx_type='interest'", + user_id); + m.wrong_book_total = QueryInt(db, + "SELECT COUNT(1) FROM wrong_book WHERE user_id=?", user_id); + m.wrong_book_noted = QueryInt( + db, + "SELECT COUNT(1) FROM wrong_book WHERE user_id=? AND LENGTH(TRIM(note))>0", + user_id); + m.contest_registrations = QueryInt( + db, "SELECT COUNT(1) FROM contest_registrations WHERE user_id=?", user_id); + m.contest_submissions = QueryInt( + db, "SELECT COUNT(1) FROM submissions WHERE user_id=? AND contest_id IS NOT NULL", + user_id); + m.loot_drops = QueryInt(db, + "SELECT COUNT(1) FROM loot_drop_logs WHERE user_id=?", user_id); + m.login_streak = CountCurrentLoginStreak(db, user_id, BuildDayKey(NowSec())); + return m; +} + +enum class MetricKey { + kSubmissions, + kAcSubmissions, + kUniqueAcProblems, + kLoginStreak, + kGrowthRecords, + kDailyTaskLogs, + kExperience, + kSourceCrystalBalance, + kSourceCrystalTxCount, + kSourceCrystalInterestCount, + kRedeemRecords, + kWrongBookTotal, + kWrongBookNoted, + kKbClaims, + kContestRegistrations, + kContestSubmissions, + kLootDrops, + kSolutionViews, +}; + +int MetricValue(const AchievementMetrics& m, MetricKey key) { + switch (key) { + case MetricKey::kSubmissions: + return m.total_submissions; + case MetricKey::kAcSubmissions: + return m.ac_submissions; + case MetricKey::kUniqueAcProblems: + return m.unique_ac_problems; + case MetricKey::kLoginStreak: + return m.login_streak; + case MetricKey::kGrowthRecords: + return m.growth_records; + case MetricKey::kDailyTaskLogs: + return m.daily_task_logs; + case MetricKey::kExperience: + return m.experience; + case MetricKey::kSourceCrystalBalance: + return static_cast(std::floor(std::max(0.0, m.source_crystal_balance))); + case MetricKey::kSourceCrystalTxCount: + return m.source_crystal_tx_count; + case MetricKey::kSourceCrystalInterestCount: + return m.source_crystal_interest_count; + case MetricKey::kRedeemRecords: + return m.redeem_records; + case MetricKey::kWrongBookTotal: + return m.wrong_book_total; + case MetricKey::kWrongBookNoted: + return m.wrong_book_noted; + case MetricKey::kKbClaims: + return m.kb_claims; + case MetricKey::kContestRegistrations: + return m.contest_registrations; + case MetricKey::kContestSubmissions: + return m.contest_submissions; + case MetricKey::kLootDrops: + return m.loot_drops; + case MetricKey::kSolutionViews: + return m.solution_views; + } + return 0; +} + +struct AchievementDefinition { + std::string key; + std::string icon; + std::string title; + std::string description; + int honor = 0; + int target = 1; + MetricKey metric = MetricKey::kSubmissions; +}; + +void AppendSeries(std::vector& defs, + const std::string& key_prefix, + const std::string& icon, + const std::string& title_prefix, + const std::string& desc_prefix, + const std::string& desc_unit, + MetricKey metric, + const std::vector& thresholds, + int honor_base, + int honor_step) { + for (size_t i = 0; i < thresholds.size(); ++i) { + const int target = thresholds[i]; + AchievementDefinition d; + d.key = key_prefix + "_" + std::to_string(target); + d.icon = icon; + d.title = title_prefix + " " + std::to_string(i + 1); + d.description = desc_prefix + std::to_string(target) + desc_unit; + d.honor = honor_base + static_cast(i) * honor_step; + d.target = target; + d.metric = metric; + defs.push_back(std::move(d)); + } +} + +std::vector BuildDefinitions() { + std::vector defs; + defs.reserve(kExpectedAchievementCount); + + // Legacy four achievements keep original identity. + defs.push_back({"workbench", "🧰", "工作台", "完成一次提交", 8, 1, + MetricKey::kSubmissions}); + defs.push_back({"torch", "🕯️", "火把", "连续学习 3 天", 10, 3, + MetricKey::kLoginStreak}); + defs.push_back({"compass", "🧭", "指南针", "累计 10 次成长记录", 12, 10, + MetricKey::kGrowthRecords}); + defs.push_back({"iron_pickaxe", "⛏️", "铁镐", "首次通过题目", 10, 1, + MetricKey::kUniqueAcProblems}); + + AppendSeries(defs, "submit_master", "🧰", "铸码工匠", "累计提交达到 ", " 次", + MetricKey::kSubmissions, + {3, 5, 8, 12, 16, 24, 32, 48, 64, 96, 128, 192, 256, 384, 512, + 768}, + 4, 2); + AppendSeries(defs, "ac_hunter", "✅", "通关猎手", "累计 AC 达到 ", " 次", + MetricKey::kAcSubmissions, + {2, 3, 5, 8, 12, 18, 27, 40, 60, 90, 135, 200}, 6, 2); + AppendSeries(defs, "solver_path", "⛏️", "题目征服者", "累计解锁不同题目 ", " 道", + MetricKey::kUniqueAcProblems, + {2, 3, 5, 8, 12, 18, 27, 40, 60, 90, 135, 200}, 6, 2); + AppendSeries(defs, "daily_ritual", "🔥", "悬赏常客", "累计完成每日任务 ", " 次", + MetricKey::kDailyTaskLogs, {5, 10, 20, 40, 80, 120, 180, 260}, + 5, 2); + AppendSeries(defs, "xp_archivist", "✨", "经验档案员", "累计经验达到 ", " 点", + MetricKey::kExperience, {20, 50, 80, 120, 180, 260, 400, 600}, 6, + 3); + AppendSeries(defs, "crystal_bank", "💎", "源晶宝库", "源晶余额达到 ", " 枚", + MetricKey::kSourceCrystalBalance, + {1, 5, 10, 20, 50, 100, 200, 500}, 6, 3); + AppendSeries(defs, "crystal_flow", "📦", "源晶流水师", "源晶流水达到 ", " 条", + MetricKey::kSourceCrystalTxCount, {3, 5, 10, 20, 40}, 5, 3); + AppendSeries(defs, "moon_interest", "🌙", "月息观察员", "累计获得月息 ", " 次", + MetricKey::kSourceCrystalInterestCount, {1, 2, 3}, 8, 4); + AppendSeries(defs, "trader_badge", "🛒", "交易达人", "累计交易记录达到 ", " 条", + MetricKey::kRedeemRecords, {1, 3, 5, 8, 12}, 6, 3); + AppendSeries(defs, "wrongbook_keep", "📘", "错题整理官", "错题本累计达到 ", " 条", + MetricKey::kWrongBookTotal, {1, 3, 5, 8}, 7, 3); + AppendSeries(defs, "wrongbook_notes", "📝", "复盘记录员", "有备注错题达到 ", " 条", + MetricKey::kWrongBookNoted, {1, 2, 3, 5}, 7, 3); + AppendSeries(defs, "kb_explorer", "📚", "知识探索者", "知识领取累计达到 ", " 条", + MetricKey::kKbClaims, {1, 3, 5, 10}, 7, 3); + AppendSeries(defs, "contest_reg", "🎯", "赛事报名者", "累计报名赛事 ", " 场", + MetricKey::kContestRegistrations, {1, 3}, 8, 4); + AppendSeries(defs, "contest_run", "🏁", "赛场行动派", "赛事提交累计达到 ", " 次", + MetricKey::kContestSubmissions, {1, 5}, 8, 4); + AppendSeries(defs, "loot_collector", "🎁", "战利品收集者", "累计掉落记录 ", " 条", + MetricKey::kLootDrops, {1, 3}, 8, 4); + AppendSeries(defs, "solution_reader", "👁️", "题解观察员", "累计查看题解 ", " 次", + MetricKey::kSolutionViews, {1}, 9, 0); + + if (defs.size() != kExpectedAchievementCount) { + throw std::runtime_error("achievement definition count mismatch"); + } + return defs; +} + +void EnsureMilestoneTable(sqlite3* db) { + ExecSql( + db, + "CREATE TABLE IF NOT EXISTS user_achievement_milestones (" + " user_id INTEGER NOT NULL," + " milestone_no INTEGER NOT NULL," + " completed_count_snapshot INTEGER NOT NULL DEFAULT 0," + " rating_bonus INTEGER NOT NULL DEFAULT 20," + " created_at INTEGER NOT NULL," + " PRIMARY KEY(user_id, milestone_no)," + " FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE" + ");", + "ensure achievement milestone table"); + ExecSql( + db, + "CREATE INDEX IF NOT EXISTS idx_user_achievement_milestones_user_created " + "ON user_achievement_milestones(user_id, created_at DESC);", + "ensure achievement milestone index"); +} + +int CountMilestones(sqlite3* db, int64_t user_id) { + return QueryInt( + db, "SELECT COUNT(1) FROM user_achievement_milestones WHERE user_id=?", + user_id); +} + +int QueryMilestoneBonusTotal(sqlite3* db, int64_t user_id) { + return QueryInt( + db, + "SELECT COALESCE(SUM(rating_bonus),0) FROM user_achievement_milestones " + "WHERE user_id=?", + user_id); +} + +void InsertMilestone(sqlite3* db, int64_t user_id, int milestone_no, + int completed_snapshot, int64_t now) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "INSERT OR IGNORE INTO user_achievement_milestones(" + "user_id,milestone_no,completed_count_snapshot,rating_bonus,created_at) " + "VALUES(?,?,?,?,?)"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare insert achievement milestone"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_int(stmt, 2, milestone_no), db, "bind milestone_no"); + CheckSqlite(sqlite3_bind_int(stmt, 3, completed_snapshot), + db, "bind completed_count_snapshot"); + CheckSqlite(sqlite3_bind_int(stmt, 4, kMilestoneRatingBonus), + db, "bind rating_bonus"); + CheckSqlite(sqlite3_bind_int64(stmt, 5, now), db, "bind created_at"); + CheckSqlite(sqlite3_step(stmt), db, "insert achievement milestone"); + sqlite3_finalize(stmt); +} + +std::vector ListMilestones(sqlite3* db, int64_t user_id, + int limit) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT milestone_no,completed_count_snapshot,rating_bonus,created_at " + "FROM user_achievement_milestones " + "WHERE user_id=? " + "ORDER BY milestone_no DESC " + "LIMIT ?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare list achievement milestones"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_int(stmt, 2, std::max(1, limit)), db, "bind limit"); + + std::vector out; + while (sqlite3_step(stmt) == SQLITE_ROW) { + AchievementMilestone one; + one.milestone_no = sqlite3_column_int(stmt, 0); + one.completed_count_snapshot = sqlite3_column_int(stmt, 1); + one.rating_bonus = sqlite3_column_int(stmt, 2); + one.created_at = sqlite3_column_int64(stmt, 3); + out.push_back(one); + } + sqlite3_finalize(stmt); + return out; +} + +} // namespace + +AchievementSnapshot AchievementService::GetSnapshot(int64_t user_id) { + if (user_id <= 0) throw std::runtime_error("invalid user_id"); + + sqlite3* db = db_.raw(); + if (!UserExists(db, user_id)) throw std::runtime_error("user not found"); + + const auto metrics = CollectMetrics(db, user_id); + const auto defs = BuildDefinitions(); + + AchievementSnapshot out; + out.items.reserve(defs.size()); + out.summary.total_count = static_cast(defs.size()); + out.summary.milestone_step = kMilestoneStep; + out.summary.milestone_bonus_rating = kMilestoneRatingBonus; + + for (const auto& def : defs) { + AchievementItem item; + item.key = def.key; + item.icon = def.icon; + item.title = def.title; + item.description = def.description; + item.honor = def.honor; + item.target = def.target; + item.progress = MetricValue(metrics, def.metric); + item.completed = item.progress >= item.target; + if (item.completed) { + ++out.summary.completed_count; + out.summary.honor_total += item.honor; + } + out.items.push_back(std::move(item)); + } + + db_.Exec("BEGIN IMMEDIATE"); + bool committed = false; + try { + EnsureMilestoneTable(db); + const int milestones_before = CountMilestones(db, user_id); + const int target_milestones = + out.summary.completed_count / kMilestoneStep; + const int64_t now = NowSec(); + for (int next = milestones_before + 1; next <= target_milestones; ++next) { + InsertMilestone(db, user_id, next, out.summary.completed_count, now); + AddRating(db, user_id, kMilestoneRatingBonus); + out.summary.bonus_milestones_added_now += 1; + out.summary.bonus_rating_added_now += kMilestoneRatingBonus; + } + + out.summary.milestones_awarded = CountMilestones(db, user_id); + out.summary.rating_bonus_awarded_total = QueryMilestoneBonusTotal(db, user_id); + out.summary.rating_after_apply = + QueryInt(db, "SELECT rating FROM users WHERE id=?", user_id); + + db_.Exec("COMMIT"); + committed = true; + } catch (...) { + if (!committed) { + try { + db_.Exec("ROLLBACK"); + } catch (...) { + } + } + throw; + } + + if (out.summary.completed_count >= out.summary.total_count) { + out.summary.next_milestone_completed_required = 0; + out.summary.next_milestone_remaining = 0; + } else { + int next_target = + ((out.summary.completed_count / kMilestoneStep) + 1) * kMilestoneStep; + next_target = std::min(next_target, out.summary.total_count); + out.summary.next_milestone_completed_required = next_target; + out.summary.next_milestone_remaining = + std::max(0, next_target - out.summary.completed_count); + } + + out.milestone_logs = ListMilestones(db, user_id, 20); + return out; +} + +} // namespace csp::services diff --git a/backend/src/services/daily_task_service.cc b/backend/src/services/daily_task_service.cc index cc6ba8d..ed155cd 100644 --- a/backend/src/services/daily_task_service.cc +++ b/backend/src/services/daily_task_service.cc @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -22,11 +23,15 @@ struct DailyTaskDef { int reward; }; -constexpr std::array kTaskDefs = {{ +constexpr std::array kTaskDefs = {{ {DailyTaskService::kTaskLoginCheckin, "登录签到", "登录签到 1 分(本日首次可得)", 1}, {DailyTaskService::kTaskDailySubmit, "每日提交", "每日提交 1 分(本日首次可得)", 1}, {DailyTaskService::kTaskFirstAc, "正确一题", "正确一题 1 分(本日首次可得)", 1}, {DailyTaskService::kTaskCodeQuality, "代码达标", "代码超过 10 行 1 分(本日首次可得)", 1}, + {DailyTaskService::kTaskLearningStreak3Days, + "连学奖励", + "连学 >= 3 天,额外 +1 分(本日首次可得)", + 1}, }}; int64_t NowSec() { @@ -70,6 +75,113 @@ void AddRating(sqlite3* db, int64_t user_id, int delta) { if (sqlite3_changes(db) <= 0) throw std::runtime_error("user not found"); } +bool IsLeapYear(int year) { + return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); +} + +int DaysInMonth(int year, int month) { + static constexpr std::array kDays = {31, 28, 31, 30, 31, 30, + 31, 31, 30, 31, 30, 31}; + if (month < 1 || month > 12) return 0; + if (month == 2 && IsLeapYear(year)) return 29; + return kDays[static_cast(month - 1)]; +} + +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(year - era * 400); // [0, 399] + const unsigned doy = + (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1; // [0, 365] + const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096] + return era * 146097 + static_cast(doe) - 719468; // 1970-01-01 => 0 +} + +std::optional 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)); + const int max_day = DaysInMonth(year, month); + if (max_day <= 0 || day < 1 || day > max_day) return std::nullopt; + return CivilToDays(year, static_cast(month), + static_cast(day)); + } catch (...) { + return std::nullopt; + } +} + +int CountLoginCheckinStreak(sqlite3* db, int64_t user_id, const std::string& day_key) { + const auto today_serial = DaySerialFromKey(day_key); + if (!today_serial.has_value()) return 0; + + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT day_key " + "FROM daily_task_logs " + "WHERE user_id=? AND task_code=? AND day_key<=? " + "ORDER BY day_key DESC " + "LIMIT 90"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare count login streak"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_text(stmt, 2, DailyTaskService::kTaskLoginCheckin, -1, + SQLITE_STATIC), + db, "bind task_code"); + CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT), + db, "bind day_key"); + + int streak = 0; + int expected = *today_serial; + while (sqlite3_step(stmt) == SQLITE_ROW) { + const unsigned char* raw = sqlite3_column_text(stmt, 0); + const std::string key = + raw ? reinterpret_cast(raw) : std::string(); + const auto serial = DaySerialFromKey(key); + if (!serial.has_value()) continue; + if (*serial == expected) { + ++streak; + --expected; + continue; + } + if (*serial < expected) break; // gap => streak ends + } + sqlite3_finalize(stmt); + return streak; +} + +bool GrantLearningStreakRewardIfQualified(sqlite3* db, int64_t user_id, + const std::string& day_key, + int64_t now) { + if (CountLoginCheckinStreak(db, user_id, day_key) < 3) return false; + + const auto* task = FindTask(DailyTaskService::kTaskLearningStreak3Days); + if (!task) return false; + + sqlite3_stmt* stmt = nullptr; + const char* ins_sql = + "INSERT OR IGNORE INTO daily_task_logs(user_id,task_code,day_key,reward,created_at) " + "VALUES(?,?,?,?,?)"; + CheckSqlite(sqlite3_prepare_v2(db, ins_sql, -1, &stmt, nullptr), db, + "prepare insert learning streak reward"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_text(stmt, 2, task->code, -1, SQLITE_STATIC), 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, task->reward), db, "bind reward"); + CheckSqlite(sqlite3_bind_int64(stmt, 5, now), db, "bind created_at"); + CheckSqlite(sqlite3_step(stmt), db, "insert learning streak reward"); + sqlite3_finalize(stmt); + + const bool inserted = sqlite3_changes(db) > 0; + if (inserted) AddRating(db, user_id, task->reward); + return inserted; +} + } // namespace std::string DailyTaskService::CurrentDayKey() const { @@ -151,6 +263,9 @@ bool DailyTaskService::CompleteTaskIfFirstToday(int64_t user_id, const bool inserted = sqlite3_changes(db) > 0; if (inserted) { AddRating(db, user_id, task->reward); + if (task_code == DailyTaskService::kTaskLoginCheckin) { + GrantLearningStreakRewardIfQualified(db, user_id, day_key, now); + } } db_.Exec("COMMIT"); diff --git a/backend/tests/achievement_service_test.cc b/backend/tests/achievement_service_test.cc new file mode 100644 index 0000000..93c46d0 --- /dev/null +++ b/backend/tests/achievement_service_test.cc @@ -0,0 +1,108 @@ +#include + +#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 + +#include +#include +#include + +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); +} diff --git a/backend/tests/daily_task_service_test.cc b/backend/tests/daily_task_service_test.cc index 611e562..36c95e0 100644 --- a/backend/tests/daily_task_service_test.cc +++ b/backend/tests/daily_task_service_test.cc @@ -5,6 +5,130 @@ #include "csp/services/daily_task_service.h" #include "csp/services/user_service.h" +#include + +#include +#include +#include +#include +#include +#include + +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(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(doe) - 719468; +} + +std::tuple DaysToCivil(int z) { + z += 719468; + const int era = (z >= 0 ? z : z - 146096) / 146097; + const unsigned doe = static_cast(z - era * 146097); + const unsigned yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + int year = static_cast(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 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(month), + static_cast(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); } diff --git a/backend/tests/sqlite_db_test.cc b/backend/tests/sqlite_db_test.cc index 8390b20..522f557 100644 --- a/backend/tests/sqlite_db_test.cc +++ b/backend/tests/sqlite_db_test.cc @@ -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); } diff --git a/frontend/src/app/me/page.tsx b/frontend/src/app/me/page.tsx index c283b24..d91eb96 100644 --- a/frontend/src/app/me/page.tsx +++ b/frontend/src/app/me/page.tsx @@ -106,6 +106,45 @@ type ExperienceHistoryItem = { created_at: number; }; +type AchievementItem = { + key: string; + icon: string; + title: string; + description: string; + honor: number; + progress: number; + target: number; + completed: boolean; +}; + +type AchievementMilestone = { + milestone_no: number; + completed_count_snapshot: number; + rating_bonus: number; + created_at: number; +}; + +type AchievementSummary = { + total_count: number; + completed_count: number; + honor_total: number; + milestone_step: number; + milestone_bonus_rating: number; + milestones_awarded: number; + bonus_milestones_added_now: number; + bonus_rating_added_now: number; + rating_bonus_awarded_total: number; + next_milestone_completed_required: number; + next_milestone_remaining: number; + rating_after_apply: number; +}; + +type AchievementSnapshot = { + summary: AchievementSummary; + items: AchievementItem[]; + milestone_logs: AchievementMilestone[]; +}; + type DailyTaskItem = { code: string; title: string; @@ -193,6 +232,13 @@ export default function MePage() { const [experienceHistory, setExperienceHistory] = useState([]); const [experienceHistoryOpen, setExperienceHistoryOpen] = useState(false); const [experienceHistoryLoading, setExperienceHistoryLoading] = useState(false); + const [achievementPanelOpen, setAchievementPanelOpen] = useState(false); + const [achievementLoading, setAchievementLoading] = useState(false); + const [achievementLoaded, setAchievementLoaded] = useState(false); + const [achievementSummary, setAchievementSummary] = useState(null); + const [achievementItems, setAchievementItems] = useState([]); + const [achievementMilestones, setAchievementMilestones] = useState([]); + const [achievementError, setAchievementError] = useState(""); const [selectedItemId, setSelectedItemId] = useState(0); const [quantity, setQuantity] = useState(1); @@ -246,6 +292,7 @@ export default function MePage() { daily_submit: ["每日提交 📝", "Daily Submission 📝"], first_ac: ["首次通过 ⭐", "First AC ⭐"], code_quality: ["代码质量 🛠️", "Code Quality 🛠️"], + learning_streak_3d: ["连学奖励 🔥", "Streak Bonus 🔥"], }; if (type === "daily_task") { if (taskLabels[note]) { @@ -460,6 +507,13 @@ export default function MePage() { setExperience(null); setExperienceHistory([]); setExperienceHistoryOpen(false); + setAchievementPanelOpen(false); + setAchievementLoading(false); + setAchievementLoaded(false); + setAchievementSummary(null); + setAchievementItems([]); + setAchievementMilestones([]); + setAchievementError(""); showToast( "success", tx("已断开连接并退出登录。", "Disconnected and signed out.") @@ -529,40 +583,43 @@ export default function MePage() { }), [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]); + const achievementCompletedCount = achievementSummary?.completed_count ?? 0; + const achievementTotalCount = achievementSummary?.total_count ?? 100; + const achievementHonorTotal = achievementSummary?.honor_total ?? 0; + const toggleAchievementPanel = useCallback(async () => { + const next = !achievementPanelOpen; + setAchievementPanelOpen(next); + if (!next || achievementLoaded || achievementLoading) return; + if (!token) return; + + setAchievementError(""); + setAchievementLoading(true); + try { + const data = await apiFetch("/api/v1/me/achievements", {}, token); + setAchievementSummary(data.summary ?? null); + setAchievementItems(Array.isArray(data.items) ? data.items : []); + setAchievementMilestones(Array.isArray(data.milestone_logs) ? data.milestone_logs : []); + setAchievementLoaded(true); + if (Number.isFinite(data.summary?.rating_after_apply)) { + setProfile((prev) => + prev ? { ...prev, rating: Number(data.summary.rating_after_apply) } : prev + ); + } + if ((data.summary?.bonus_rating_added_now ?? 0) > 0) { + showToast( + "success", + tx( + `成就里程碑达成,额外获得 +${data.summary.bonus_rating_added_now} Rating`, + `Achievement milestone reached: +${data.summary.bonus_rating_added_now} rating bonus` + ) + ); + } + } catch (e: unknown) { + setAchievementError(String(e)); + } finally { + setAchievementLoading(false); + } + }, [achievementLoaded, achievementLoading, achievementPanelOpen, showToast, token, tx]); return (
@@ -777,28 +834,109 @@ export default function MePage() {
-

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

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

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

-

{item.hint}

-
- ))} +
+

+ {tx("物品鉴定(成就系统)", "Item Appraisal (Achievements)")} +

+
+

+ {tx("默认隐藏,点击展开查看。", "Hidden by default, click to expand.")} ·{" "} + {tx("已完成", "Completed")}: {achievementCompletedCount}/{achievementTotalCount} ·{" "} + {tx("荣誉分", "Honor")}: {achievementHonorTotal} +

+ + {achievementPanelOpen && ( +
+ {achievementLoading && ( +

+ {tx("加载成就中...", "Loading achievements...")} +

+ )} + {!achievementLoading && achievementError && ( +

{achievementError}

+ )} + {!achievementLoading && !achievementError && achievementSummary && ( +
+

+ {tx("规则", "Rule")}: {tx("每完成", "Every")}{" "} + {achievementSummary.milestone_step}{" "} + {tx("个成就奖励", "achievements reward")}{" "} + + +{achievementSummary.milestone_bonus_rating} Rating + +

+

+ {tx("已获里程碑奖励", "Milestones awarded")}:{" "} + {achievementSummary.milestones_awarded} · {tx("累计奖励", "Total bonus")}{" "} + +{achievementSummary.rating_bonus_awarded_total} Rating +

+ {achievementSummary.next_milestone_completed_required > 0 ? ( +

+ {tx("下个里程碑", "Next milestone")}:{" "} + {achievementSummary.next_milestone_completed_required} ·{" "} + {tx("还差", "Remaining")} {achievementSummary.next_milestone_remaining} +

+ ) : ( +

+ {tx("已完成全部成就!", "All achievements completed!")} +

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

+ {tx("最近里程碑奖励", "Recent milestone bonuses")} +

+ {achievementMilestones.slice(0, 3).map((row) => ( +

+ #{row.milestone_no} · +{row.rating_bonus} Rating ·{" "} + {tx("完成", "Completed")} {row.completed_count_snapshot} · {fmtTs(row.created_at)} +

+ ))} +
+ )} +
+ )} + {!achievementLoading && !achievementError && ( +
+
+ {achievementItems.map((item) => ( +
+

+ {item.icon} + {item.title} + {item.completed ? : null} +

+

{item.description}

+

+ {tx("进度", "Progress")}: {item.progress}/{item.target} ·{" "} + {tx("荣誉", "Honor")} +{item.honor} +

+
+ ))} + {!achievementLoading && achievementItems.length === 0 && ( +

+ {tx("暂无成就数据。", "No achievements yet.")} +

+ )} +
+
+ )} +
+ )}