feat: add streak reward and 100-achievement milestone system

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

查看文件

@@ -0,0 +1,587 @@
#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