588 行
22 KiB
C++
588 行
22 KiB
C++
#include "csp/services/achievement_service.h"
|
|
|
|
#include <sqlite3.h>
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <ctime>
|
|
#include <iomanip>
|
|
#include <optional>
|
|
#include <sstream>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
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<seconds>(system_clock::now().time_since_epoch()).count();
|
|
}
|
|
|
|
void CheckSqlite(int rc, sqlite3* db, const char* what) {
|
|
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return;
|
|
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
|
|
}
|
|
|
|
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<int, 12> 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<size_t>(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<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::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));
|
|
const int max_day = DaysInMonth(year, month);
|
|
if (max_day <= 0 || day < 1 || day > max_day) return std::nullopt;
|
|
return CivilToDays(year, static_cast<unsigned>(month),
|
|
static_cast<unsigned>(day));
|
|
} catch (...) {
|
|
return std::nullopt;
|
|
}
|
|
}
|
|
|
|
std::string BuildDayKey(int64_t ts_sec) {
|
|
const std::time_t shifted =
|
|
static_cast<std::time_t>(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<const char*>(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<int>(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<AchievementDefinition>& 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<int>& 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<int>(i) * honor_step;
|
|
d.target = target;
|
|
d.metric = metric;
|
|
defs.push_back(std::move(d));
|
|
}
|
|
}
|
|
|
|
std::vector<AchievementDefinition> BuildDefinitions() {
|
|
std::vector<AchievementDefinition> 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<AchievementMilestone> 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<AchievementMilestone> 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<int>(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
|