#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