feat: add streak reward and 100-achievement milestone system
这个提交包含在:
@@ -14,6 +14,7 @@ add_library(csp_core
|
|||||||
src/app_state.cc
|
src/app_state.cc
|
||||||
src/services/crypto.cc
|
src/services/crypto.cc
|
||||||
src/services/auth_service.cc
|
src/services/auth_service.cc
|
||||||
|
src/services/achievement_service.cc
|
||||||
src/services/experience_service.cc
|
src/services/experience_service.cc
|
||||||
src/services/problem_service.cc
|
src/services/problem_service.cc
|
||||||
src/services/user_service.cc
|
src/services/user_service.cc
|
||||||
@@ -101,6 +102,7 @@ add_executable(csp_tests
|
|||||||
tests/version_test.cc
|
tests/version_test.cc
|
||||||
tests/sqlite_db_test.cc
|
tests/sqlite_db_test.cc
|
||||||
tests/auth_service_test.cc
|
tests/auth_service_test.cc
|
||||||
|
tests/achievement_service_test.cc
|
||||||
tests/experience_service_test.cc
|
tests/experience_service_test.cc
|
||||||
tests/auth_http_test.cc
|
tests/auth_http_test.cc
|
||||||
tests/domain_test.cc
|
tests/domain_test.cc
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ public:
|
|||||||
drogon::Get);
|
drogon::Get);
|
||||||
ADD_METHOD_TO(MeController::experienceHistory, "/api/v1/me/experience/history",
|
ADD_METHOD_TO(MeController::experienceHistory, "/api/v1/me/experience/history",
|
||||||
drogon::Get);
|
drogon::Get);
|
||||||
|
ADD_METHOD_TO(MeController::achievementSnapshot,
|
||||||
|
"/api/v1/me/achievements",
|
||||||
|
drogon::Get);
|
||||||
ADD_METHOD_TO(MeController::listLootDrops, "/api/v1/me/loot-drops",
|
ADD_METHOD_TO(MeController::listLootDrops, "/api/v1/me/loot-drops",
|
||||||
drogon::Get);
|
drogon::Get);
|
||||||
ADD_METHOD_TO(MeController::listDailyTasks, "/api/v1/me/daily-tasks",
|
ADD_METHOD_TO(MeController::listDailyTasks, "/api/v1/me/daily-tasks",
|
||||||
@@ -96,6 +99,10 @@ public:
|
|||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
||||||
|
|
||||||
|
void achievementSnapshot(
|
||||||
|
const drogon::HttpRequestPtr &req,
|
||||||
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
||||||
|
|
||||||
void
|
void
|
||||||
listLootDrops(const drogon::HttpRequestPtr &req,
|
listLootDrops(const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "csp/db/sqlite_db.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
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<AchievementItem> items;
|
||||||
|
std::vector<AchievementMilestone> 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
|
||||||
@@ -23,6 +23,8 @@ class DailyTaskService {
|
|||||||
static constexpr const char* kTaskDailySubmit = "daily_submit";
|
static constexpr const char* kTaskDailySubmit = "daily_submit";
|
||||||
static constexpr const char* kTaskFirstAc = "first_ac";
|
static constexpr const char* kTaskFirstAc = "first_ac";
|
||||||
static constexpr const char* kTaskCodeQuality = "code_quality";
|
static constexpr const char* kTaskCodeQuality = "code_quality";
|
||||||
|
static constexpr const char* kTaskLearningStreak3Days =
|
||||||
|
"learning_streak_3d";
|
||||||
|
|
||||||
explicit DailyTaskService(db::SqliteDb& db) : db_(db) {}
|
explicit DailyTaskService(db::SqliteDb& db) : db_(db) {}
|
||||||
|
|
||||||
|
|||||||
@@ -379,6 +379,16 @@ CREATE TABLE IF NOT EXISTS source_crystal_transactions (
|
|||||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
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)
|
INSERT OR IGNORE INTO source_crystal_settings(id, monthly_interest_rate, updated_at)
|
||||||
VALUES(1, 0.02, strftime('%s','now'));
|
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_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_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_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);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include "csp/services/redeem_service.h"
|
#include "csp/services/redeem_service.h"
|
||||||
#include "csp/services/season_service.h"
|
#include "csp/services/season_service.h"
|
||||||
#include "csp/services/solution_access_service.h"
|
#include "csp/services/solution_access_service.h"
|
||||||
|
#include "csp/services/achievement_service.h"
|
||||||
#include "csp/services/experience_service.h"
|
#include "csp/services/experience_service.h"
|
||||||
#include "csp/services/user_service.h"
|
#include "csp/services/user_service.h"
|
||||||
#include "csp/services/wrong_book_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<void(const drogon::HttpResponsePtr &)> &&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(
|
void MeController::listLootDrops(
|
||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ Json::Value BuildOpenApiSpec() {
|
|||||||
paths["/api/v1/me/source-crystal/withdraw"]["post"]["summary"] = "取出源晶";
|
paths["/api/v1/me/source-crystal/withdraw"]["post"]["summary"] = "取出源晶";
|
||||||
paths["/api/v1/me/experience"]["get"]["summary"] = "我的经验值概览";
|
paths["/api/v1/me/experience"]["get"]["summary"] = "我的经验值概览";
|
||||||
paths["/api/v1/me/experience/history"]["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/loot-drops"]["get"]["summary"] = "我的掉落日志";
|
||||||
paths["/api/v1/me/daily-tasks"]["get"]["summary"] = "我的每日任务列表";
|
paths["/api/v1/me/daily-tasks"]["get"]["summary"] = "我的每日任务列表";
|
||||||
paths["/api/v1/seasons/current"]["get"]["summary"] = "当前赛季信息";
|
paths["/api/v1/seasons/current"]["get"]["summary"] = "当前赛季信息";
|
||||||
|
|||||||
@@ -930,6 +930,16 @@ CREATE TABLE IF NOT EXISTS source_crystal_transactions (
|
|||||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
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 (
|
CREATE TABLE IF NOT EXISTS daily_task_logs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL,
|
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_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_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_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);
|
CREATE INDEX IF NOT EXISTS idx_daily_task_logs_user_day ON daily_task_logs(user_id, day_key, created_at DESC);
|
||||||
)SQL");
|
)SQL");
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
|
#include <optional>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -22,11 +23,15 @@ struct DailyTaskDef {
|
|||||||
int reward;
|
int reward;
|
||||||
};
|
};
|
||||||
|
|
||||||
constexpr std::array<DailyTaskDef, 4> kTaskDefs = {{
|
constexpr std::array<DailyTaskDef, 5> kTaskDefs = {{
|
||||||
{DailyTaskService::kTaskLoginCheckin, "登录签到", "登录签到 1 分(本日首次可得)", 1},
|
{DailyTaskService::kTaskLoginCheckin, "登录签到", "登录签到 1 分(本日首次可得)", 1},
|
||||||
{DailyTaskService::kTaskDailySubmit, "每日提交", "每日提交 1 分(本日首次可得)", 1},
|
{DailyTaskService::kTaskDailySubmit, "每日提交", "每日提交 1 分(本日首次可得)", 1},
|
||||||
{DailyTaskService::kTaskFirstAc, "正确一题", "正确一题 1 分(本日首次可得)", 1},
|
{DailyTaskService::kTaskFirstAc, "正确一题", "正确一题 1 分(本日首次可得)", 1},
|
||||||
{DailyTaskService::kTaskCodeQuality, "代码达标", "代码超过 10 行 1 分(本日首次可得)", 1},
|
{DailyTaskService::kTaskCodeQuality, "代码达标", "代码超过 10 行 1 分(本日首次可得)", 1},
|
||||||
|
{DailyTaskService::kTaskLearningStreak3Days,
|
||||||
|
"连学奖励",
|
||||||
|
"连学 >= 3 天,额外 +1 分(本日首次可得)",
|
||||||
|
1},
|
||||||
}};
|
}};
|
||||||
|
|
||||||
int64_t NowSec() {
|
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");
|
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); // [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<int>(doe) - 719468; // 1970-01-01 => 0
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<const char*>(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
|
} // namespace
|
||||||
|
|
||||||
std::string DailyTaskService::CurrentDayKey() const {
|
std::string DailyTaskService::CurrentDayKey() const {
|
||||||
@@ -151,6 +263,9 @@ bool DailyTaskService::CompleteTaskIfFirstToday(int64_t user_id,
|
|||||||
const bool inserted = sqlite3_changes(db) > 0;
|
const bool inserted = sqlite3_changes(db) > 0;
|
||||||
if (inserted) {
|
if (inserted) {
|
||||||
AddRating(db, user_id, task->reward);
|
AddRating(db, user_id, task->reward);
|
||||||
|
if (task_code == DailyTaskService::kTaskLoginCheckin) {
|
||||||
|
GrantLearningStreakRewardIfQualified(db, user_id, day_key, now);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
db_.Exec("COMMIT");
|
db_.Exec("COMMIT");
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
|
||||||
|
#include "csp/db/sqlite_db.h"
|
||||||
|
#include "csp/services/achievement_service.h"
|
||||||
|
#include "csp/services/auth_service.h"
|
||||||
|
#include "csp/services/user_service.h"
|
||||||
|
|
||||||
|
#include <sqlite3.h>
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void CheckSqlite(int rc, sqlite3* db, const char* what) {
|
||||||
|
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return;
|
||||||
|
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
|
||||||
|
}
|
||||||
|
|
||||||
|
int64_t InsertProblem(sqlite3* db, const std::string& slug, int64_t created_at) {
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
const char* sql =
|
||||||
|
"INSERT INTO problems(slug,title,statement_md,difficulty,source,created_at) "
|
||||||
|
"VALUES(?,?,?,?,?,?)";
|
||||||
|
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||||
|
"prepare insert problem");
|
||||||
|
CheckSqlite(sqlite3_bind_text(stmt, 1, slug.c_str(), -1, SQLITE_TRANSIENT), db,
|
||||||
|
"bind slug");
|
||||||
|
CheckSqlite(sqlite3_bind_text(stmt, 2, slug.c_str(), -1, SQLITE_TRANSIENT), db,
|
||||||
|
"bind title");
|
||||||
|
CheckSqlite(sqlite3_bind_text(stmt, 3, "stub", -1, SQLITE_STATIC), db,
|
||||||
|
"bind statement");
|
||||||
|
CheckSqlite(sqlite3_bind_int(stmt, 4, 1), db, "bind difficulty");
|
||||||
|
CheckSqlite(sqlite3_bind_text(stmt, 5, "test", -1, SQLITE_STATIC), db,
|
||||||
|
"bind source");
|
||||||
|
CheckSqlite(sqlite3_bind_int64(stmt, 6, created_at), db, "bind created_at");
|
||||||
|
CheckSqlite(sqlite3_step(stmt), db, "insert problem");
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return sqlite3_last_insert_rowid(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
void InsertSubmission(sqlite3* db, int64_t user_id, int64_t problem_id,
|
||||||
|
int64_t created_at) {
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
const char* sql =
|
||||||
|
"INSERT INTO submissions(user_id,problem_id,language,code,status,score,time_ms,memory_kb,created_at) "
|
||||||
|
"VALUES(?,?,?,?,?,?,?,?,?)";
|
||||||
|
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||||
|
"prepare insert submission");
|
||||||
|
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
|
||||||
|
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
|
||||||
|
CheckSqlite(sqlite3_bind_text(stmt, 3, "cpp", -1, SQLITE_STATIC), db,
|
||||||
|
"bind language");
|
||||||
|
CheckSqlite(sqlite3_bind_text(stmt, 4, "int main(){return 0;}", -1, SQLITE_STATIC),
|
||||||
|
db, "bind code");
|
||||||
|
CheckSqlite(sqlite3_bind_text(stmt, 5, "AC", -1, SQLITE_STATIC), db,
|
||||||
|
"bind status");
|
||||||
|
CheckSqlite(sqlite3_bind_int(stmt, 6, 100), db, "bind score");
|
||||||
|
CheckSqlite(sqlite3_bind_int(stmt, 7, 1), db, "bind time");
|
||||||
|
CheckSqlite(sqlite3_bind_int(stmt, 8, 1), db, "bind memory");
|
||||||
|
CheckSqlite(sqlite3_bind_int64(stmt, 9, created_at), db, "bind created_at");
|
||||||
|
CheckSqlite(sqlite3_step(stmt), db, "insert submission");
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("achievement snapshot grants milestone bonus once per milestone") {
|
||||||
|
auto db = csp::db::SqliteDb::OpenMemory();
|
||||||
|
csp::db::ApplyMigrations(db);
|
||||||
|
|
||||||
|
csp::services::AuthService auth(db);
|
||||||
|
const auto login = auth.Register("achievement_user", "password123");
|
||||||
|
csp::services::UserService users(db);
|
||||||
|
csp::services::AchievementService achievements(db);
|
||||||
|
|
||||||
|
sqlite3* raw = db.raw();
|
||||||
|
int64_t now = 1700000000;
|
||||||
|
for (int i = 0; i < 40; ++i) {
|
||||||
|
const auto pid = InsertProblem(raw, "ach-test-" + std::to_string(i + 1), now + i);
|
||||||
|
InsertSubmission(raw, login.user_id, pid, now + i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto before = users.GetById(login.user_id);
|
||||||
|
REQUIRE(before.has_value());
|
||||||
|
REQUIRE(before->rating == 1); // register triggers one login_checkin reward
|
||||||
|
|
||||||
|
const auto snap1 = achievements.GetSnapshot(login.user_id);
|
||||||
|
REQUIRE(snap1.items.size() == 100);
|
||||||
|
REQUIRE(snap1.summary.total_count == 100);
|
||||||
|
REQUIRE(snap1.summary.completed_count >= 10);
|
||||||
|
REQUIRE(snap1.summary.bonus_milestones_added_now >= 1);
|
||||||
|
REQUIRE(snap1.summary.bonus_rating_added_now ==
|
||||||
|
snap1.summary.bonus_milestones_added_now * 20);
|
||||||
|
|
||||||
|
const auto after_first = users.GetById(login.user_id);
|
||||||
|
REQUIRE(after_first.has_value());
|
||||||
|
REQUIRE(after_first->rating == before->rating + snap1.summary.bonus_rating_added_now);
|
||||||
|
|
||||||
|
const auto snap2 = achievements.GetSnapshot(login.user_id);
|
||||||
|
REQUIRE(snap2.summary.bonus_milestones_added_now == 0);
|
||||||
|
REQUIRE(snap2.summary.bonus_rating_added_now == 0);
|
||||||
|
|
||||||
|
const auto after_second = users.GetById(login.user_id);
|
||||||
|
REQUIRE(after_second.has_value());
|
||||||
|
REQUIRE(after_second->rating == after_first->rating);
|
||||||
|
}
|
||||||
@@ -5,6 +5,130 @@
|
|||||||
#include "csp/services/daily_task_service.h"
|
#include "csp/services/daily_task_service.h"
|
||||||
#include "csp/services/user_service.h"
|
#include "csp/services/user_service.h"
|
||||||
|
|
||||||
|
#include <sqlite3.h>
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <optional>
|
||||||
|
#include <sstream>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <tuple>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int CivilToDays(int year, unsigned month, unsigned day) {
|
||||||
|
year -= month <= 2 ? 1 : 0;
|
||||||
|
const int era = (year >= 0 ? year : year - 399) / 400;
|
||||||
|
const unsigned yoe = static_cast<unsigned>(year - era * 400);
|
||||||
|
const unsigned doy =
|
||||||
|
(153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1;
|
||||||
|
const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||||
|
return era * 146097 + static_cast<int>(doe) - 719468;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::tuple<int, unsigned, unsigned> DaysToCivil(int z) {
|
||||||
|
z += 719468;
|
||||||
|
const int era = (z >= 0 ? z : z - 146096) / 146097;
|
||||||
|
const unsigned doe = static_cast<unsigned>(z - era * 146097);
|
||||||
|
const unsigned yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||||
|
int year = static_cast<int>(yoe) + era * 400;
|
||||||
|
const unsigned doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
|
const unsigned mp = (5 * doy + 2) / 153;
|
||||||
|
const unsigned day = doy - (153 * mp + 2) / 5 + 1;
|
||||||
|
const unsigned month = mp < 10 ? mp + 3 : mp - 9;
|
||||||
|
year += month <= 2 ? 1 : 0;
|
||||||
|
return {year, month, day};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<int> DaySerialFromKey(const std::string& day_key) {
|
||||||
|
if (day_key.size() != 10 || day_key[4] != '-' || day_key[7] != '-') {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const int year = std::stoi(day_key.substr(0, 4));
|
||||||
|
const int month = std::stoi(day_key.substr(5, 2));
|
||||||
|
const int day = std::stoi(day_key.substr(8, 2));
|
||||||
|
if (month < 1 || month > 12 || day < 1 || day > 31) return std::nullopt;
|
||||||
|
return CivilToDays(year, static_cast<unsigned>(month),
|
||||||
|
static_cast<unsigned>(day));
|
||||||
|
} catch (...) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ShiftDayKey(const std::string& day_key, int delta_days) {
|
||||||
|
const auto serial = DaySerialFromKey(day_key);
|
||||||
|
REQUIRE(serial.has_value());
|
||||||
|
const auto [year, month, day] = DaysToCivil(*serial + delta_days);
|
||||||
|
std::ostringstream out;
|
||||||
|
out << year << '-';
|
||||||
|
if (month < 10) out << '0';
|
||||||
|
out << month << '-';
|
||||||
|
if (day < 10) out << '0';
|
||||||
|
out << day;
|
||||||
|
return out.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CheckSqlite(int rc, sqlite3* db, const char* what) {
|
||||||
|
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return;
|
||||||
|
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
|
||||||
|
}
|
||||||
|
|
||||||
|
void InsertDailyTaskLog(sqlite3* db, int64_t user_id, const std::string& task_code,
|
||||||
|
const std::string& day_key, int reward, int64_t created_at) {
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
const char* sql =
|
||||||
|
"INSERT INTO daily_task_logs(user_id,task_code,day_key,reward,created_at) "
|
||||||
|
"VALUES(?,?,?,?,?)";
|
||||||
|
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||||
|
"prepare insert daily_task_logs");
|
||||||
|
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
|
||||||
|
CheckSqlite(sqlite3_bind_text(stmt, 2, task_code.c_str(), -1, SQLITE_TRANSIENT),
|
||||||
|
db, "bind task_code");
|
||||||
|
CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT),
|
||||||
|
db, "bind day_key");
|
||||||
|
CheckSqlite(sqlite3_bind_int(stmt, 4, reward), db, "bind reward");
|
||||||
|
CheckSqlite(sqlite3_bind_int64(stmt, 5, created_at), db, "bind created_at");
|
||||||
|
CheckSqlite(sqlite3_step(stmt), db, "insert daily_task_logs");
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DeleteDailyTaskLog(sqlite3* db, int64_t user_id, const std::string& task_code,
|
||||||
|
const std::string& day_key) {
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
const char* sql =
|
||||||
|
"DELETE FROM daily_task_logs WHERE user_id=? AND task_code=? AND day_key=?";
|
||||||
|
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||||
|
"prepare delete daily_task_logs");
|
||||||
|
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
|
||||||
|
CheckSqlite(sqlite3_bind_text(stmt, 2, task_code.c_str(), -1, SQLITE_TRANSIENT),
|
||||||
|
db, "bind task_code");
|
||||||
|
CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT),
|
||||||
|
db, "bind day_key");
|
||||||
|
CheckSqlite(sqlite3_step(stmt), db, "delete daily_task_logs");
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
int CountTaskLog(sqlite3* db, int64_t user_id, const std::string& task_code,
|
||||||
|
const std::string& day_key) {
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
const char* sql =
|
||||||
|
"SELECT COUNT(1) FROM daily_task_logs WHERE user_id=? AND task_code=? AND day_key=?";
|
||||||
|
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||||
|
"prepare count daily_task_logs");
|
||||||
|
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
|
||||||
|
CheckSqlite(sqlite3_bind_text(stmt, 2, task_code.c_str(), -1, SQLITE_TRANSIENT),
|
||||||
|
db, "bind task_code");
|
||||||
|
CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT),
|
||||||
|
db, "bind day_key");
|
||||||
|
REQUIRE(sqlite3_step(stmt) == SQLITE_ROW);
|
||||||
|
const int count = sqlite3_column_int(stmt, 0);
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
TEST_CASE("daily task reward only once per day") {
|
TEST_CASE("daily task reward only once per day") {
|
||||||
auto db = csp::db::SqliteDb::OpenMemory();
|
auto db = csp::db::SqliteDb::OpenMemory();
|
||||||
csp::db::ApplyMigrations(db);
|
csp::db::ApplyMigrations(db);
|
||||||
@@ -30,5 +154,50 @@ TEST_CASE("daily task reward only once per day") {
|
|||||||
REQUIRE(after->rating == 2);
|
REQUIRE(after->rating == 2);
|
||||||
|
|
||||||
const auto tasks = daily.ListTodayTasks(user.user_id);
|
const auto tasks = daily.ListTodayTasks(user.user_id);
|
||||||
REQUIRE(tasks.size() == 4);
|
REQUIRE(tasks.size() == 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("learning streak reward grants +1 when streak >= 3 days") {
|
||||||
|
auto db = csp::db::SqliteDb::OpenMemory();
|
||||||
|
csp::db::ApplyMigrations(db);
|
||||||
|
|
||||||
|
csp::services::AuthService auth(db);
|
||||||
|
const auto user = auth.Register("daily_task_streak_user", "password123");
|
||||||
|
|
||||||
|
csp::services::UserService users(db);
|
||||||
|
csp::services::DailyTaskService daily(db);
|
||||||
|
sqlite3* raw = db.raw();
|
||||||
|
|
||||||
|
const std::string today = daily.CurrentDayKey();
|
||||||
|
const std::string yesterday = ShiftDayKey(today, -1);
|
||||||
|
const std::string two_days_ago = ShiftDayKey(today, -2);
|
||||||
|
|
||||||
|
// Register inserts today's login_checkin automatically; remove it so this test
|
||||||
|
// can call login_checkin and verify reward delta in one step.
|
||||||
|
DeleteDailyTaskLog(raw, user.user_id,
|
||||||
|
csp::services::DailyTaskService::kTaskLoginCheckin, today);
|
||||||
|
|
||||||
|
InsertDailyTaskLog(raw, user.user_id,
|
||||||
|
csp::services::DailyTaskService::kTaskLoginCheckin,
|
||||||
|
yesterday, 1, 0);
|
||||||
|
InsertDailyTaskLog(raw, user.user_id,
|
||||||
|
csp::services::DailyTaskService::kTaskLoginCheckin,
|
||||||
|
two_days_ago, 1, 0);
|
||||||
|
|
||||||
|
const auto before = users.GetById(user.user_id);
|
||||||
|
REQUIRE(before.has_value());
|
||||||
|
|
||||||
|
REQUIRE(daily.CompleteTaskIfFirstToday(
|
||||||
|
user.user_id, csp::services::DailyTaskService::kTaskLoginCheckin));
|
||||||
|
REQUIRE_FALSE(daily.CompleteTaskIfFirstToday(
|
||||||
|
user.user_id, csp::services::DailyTaskService::kTaskLoginCheckin));
|
||||||
|
|
||||||
|
const auto after = users.GetById(user.user_id);
|
||||||
|
REQUIRE(after.has_value());
|
||||||
|
// login_checkin + streak bonus
|
||||||
|
REQUIRE(after->rating == before->rating + 2);
|
||||||
|
|
||||||
|
REQUIRE(CountTaskLog(raw, user.user_id,
|
||||||
|
csp::services::DailyTaskService::kTaskLearningStreak3Days,
|
||||||
|
today) == 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,4 +55,5 @@ TEST_CASE("migrations create core tables") {
|
|||||||
REQUIRE(CountTable(db.raw(), "source_crystal_settings") == 1);
|
REQUIRE(CountTable(db.raw(), "source_crystal_settings") == 1);
|
||||||
REQUIRE(CountTable(db.raw(), "source_crystal_accounts") == 1);
|
REQUIRE(CountTable(db.raw(), "source_crystal_accounts") == 1);
|
||||||
REQUIRE(CountTable(db.raw(), "source_crystal_transactions") == 1);
|
REQUIRE(CountTable(db.raw(), "source_crystal_transactions") == 1);
|
||||||
|
REQUIRE(CountTable(db.raw(), "user_achievement_milestones") == 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,45 @@ type ExperienceHistoryItem = {
|
|||||||
created_at: number;
|
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 = {
|
type DailyTaskItem = {
|
||||||
code: string;
|
code: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -193,6 +232,13 @@ export default function MePage() {
|
|||||||
const [experienceHistory, setExperienceHistory] = useState<ExperienceHistoryItem[]>([]);
|
const [experienceHistory, setExperienceHistory] = useState<ExperienceHistoryItem[]>([]);
|
||||||
const [experienceHistoryOpen, setExperienceHistoryOpen] = useState(false);
|
const [experienceHistoryOpen, setExperienceHistoryOpen] = useState(false);
|
||||||
const [experienceHistoryLoading, setExperienceHistoryLoading] = 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<AchievementSummary | null>(null);
|
||||||
|
const [achievementItems, setAchievementItems] = useState<AchievementItem[]>([]);
|
||||||
|
const [achievementMilestones, setAchievementMilestones] = useState<AchievementMilestone[]>([]);
|
||||||
|
const [achievementError, setAchievementError] = useState("");
|
||||||
|
|
||||||
const [selectedItemId, setSelectedItemId] = useState<number>(0);
|
const [selectedItemId, setSelectedItemId] = useState<number>(0);
|
||||||
const [quantity, setQuantity] = useState(1);
|
const [quantity, setQuantity] = useState(1);
|
||||||
@@ -246,6 +292,7 @@ export default function MePage() {
|
|||||||
daily_submit: ["每日提交 📝", "Daily Submission 📝"],
|
daily_submit: ["每日提交 📝", "Daily Submission 📝"],
|
||||||
first_ac: ["首次通过 ⭐", "First AC ⭐"],
|
first_ac: ["首次通过 ⭐", "First AC ⭐"],
|
||||||
code_quality: ["代码质量 🛠️", "Code Quality 🛠️"],
|
code_quality: ["代码质量 🛠️", "Code Quality 🛠️"],
|
||||||
|
learning_streak_3d: ["连学奖励 🔥", "Streak Bonus 🔥"],
|
||||||
};
|
};
|
||||||
if (type === "daily_task") {
|
if (type === "daily_task") {
|
||||||
if (taskLabels[note]) {
|
if (taskLabels[note]) {
|
||||||
@@ -460,6 +507,13 @@ export default function MePage() {
|
|||||||
setExperience(null);
|
setExperience(null);
|
||||||
setExperienceHistory([]);
|
setExperienceHistory([]);
|
||||||
setExperienceHistoryOpen(false);
|
setExperienceHistoryOpen(false);
|
||||||
|
setAchievementPanelOpen(false);
|
||||||
|
setAchievementLoading(false);
|
||||||
|
setAchievementLoaded(false);
|
||||||
|
setAchievementSummary(null);
|
||||||
|
setAchievementItems([]);
|
||||||
|
setAchievementMilestones([]);
|
||||||
|
setAchievementError("");
|
||||||
showToast(
|
showToast(
|
||||||
"success",
|
"success",
|
||||||
tx("已断开连接并退出登录。", "Disconnected and signed out.")
|
tx("已断开连接并退出登录。", "Disconnected and signed out.")
|
||||||
@@ -529,40 +583,43 @@ export default function MePage() {
|
|||||||
}),
|
}),
|
||||||
[records, tradeTypeFilter]
|
[records, tradeTypeFilter]
|
||||||
);
|
);
|
||||||
const achievementItems = useMemo(() => {
|
const achievementCompletedCount = achievementSummary?.completed_count ?? 0;
|
||||||
const hasFirstAc = historyItems.some((item) => item.type === "daily_task" && item.note === "first_ac");
|
const achievementTotalCount = achievementSummary?.total_count ?? 100;
|
||||||
const hasSubmit = historyItems.some((item) => item.type === "daily_task" && item.note === "daily_submit");
|
const achievementHonorTotal = achievementSummary?.honor_total ?? 0;
|
||||||
return [
|
const toggleAchievementPanel = useCallback(async () => {
|
||||||
{
|
const next = !achievementPanelOpen;
|
||||||
key: "workbench",
|
setAchievementPanelOpen(next);
|
||||||
icon: "🧰",
|
if (!next || achievementLoaded || achievementLoading) return;
|
||||||
label: tx("工作台", "Workbench"),
|
if (!token) return;
|
||||||
unlock: hasSubmit || hasFirstAc,
|
|
||||||
hint: tx("完成一次提交", "Complete one submission"),
|
setAchievementError("");
|
||||||
},
|
setAchievementLoading(true);
|
||||||
{
|
try {
|
||||||
key: "torch",
|
const data = await apiFetch<AchievementSnapshot>("/api/v1/me/achievements", {}, token);
|
||||||
icon: "🕯️",
|
setAchievementSummary(data.summary ?? null);
|
||||||
label: tx("火把", "Torch"),
|
setAchievementItems(Array.isArray(data.items) ? data.items : []);
|
||||||
unlock: learningStreak >= 3,
|
setAchievementMilestones(Array.isArray(data.milestone_logs) ? data.milestone_logs : []);
|
||||||
hint: tx("连续学习 3 天", "Study 3 days in a row"),
|
setAchievementLoaded(true);
|
||||||
},
|
if (Number.isFinite(data.summary?.rating_after_apply)) {
|
||||||
{
|
setProfile((prev) =>
|
||||||
key: "compass",
|
prev ? { ...prev, rating: Number(data.summary.rating_after_apply) } : prev
|
||||||
icon: "🧭",
|
);
|
||||||
label: tx("指南针", "Compass"),
|
}
|
||||||
unlock: historyItems.length >= 10,
|
if ((data.summary?.bonus_rating_added_now ?? 0) > 0) {
|
||||||
hint: tx("累计 10 次成长记录", "Collect 10 progress logs"),
|
showToast(
|
||||||
},
|
"success",
|
||||||
{
|
tx(
|
||||||
key: "iron-pickaxe",
|
`成就里程碑达成,额外获得 +${data.summary.bonus_rating_added_now} Rating`,
|
||||||
icon: "⛏️",
|
`Achievement milestone reached: +${data.summary.bonus_rating_added_now} rating bonus`
|
||||||
label: tx("铁镐", "Iron Pickaxe"),
|
)
|
||||||
unlock: hasFirstAc,
|
);
|
||||||
hint: tx("首次通过题目", "Get first AC"),
|
}
|
||||||
},
|
} catch (e: unknown) {
|
||||||
];
|
setAchievementError(String(e));
|
||||||
}, [historyItems, learningStreak, tx]);
|
} finally {
|
||||||
|
setAchievementLoading(false);
|
||||||
|
}
|
||||||
|
}, [achievementLoaded, achievementLoading, achievementPanelOpen, showToast, token, tx]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
|
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
|
||||||
@@ -777,28 +834,109 @@ export default function MePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-[color:var(--mc-surface)] border-4 border-black p-4">
|
<div className="bg-[color:var(--mc-surface)] border-4 border-black p-4">
|
||||||
<h3 className={`${sectionTitleClass} mb-3 font-minecraft`}>
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
{tx("物品图鉴", "Item Collection")}
|
<h3 className={`${sectionTitleClass} font-minecraft`}>
|
||||||
</h3>
|
{tx("物品鉴定(成就系统)", "Item Appraisal (Achievements)")}
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
</h3>
|
||||||
{achievementItems.map((item) => (
|
<button
|
||||||
<div
|
className="mc-btn text-xs px-3 py-1"
|
||||||
key={item.key}
|
onClick={() => void toggleAchievementPanel()}
|
||||||
className={`border-2 border-[color:var(--mc-stone-dark)] p-2 text-sm ${
|
disabled={achievementLoading}
|
||||||
item.unlock
|
>
|
||||||
? "bg-[color:var(--mc-grass-top)]/20 text-[color:var(--mc-plank-light)]"
|
{achievementPanelOpen ? tx("收起", "Collapse") : tx("展开", "Expand")}
|
||||||
: "bg-black/20 text-[color:var(--mc-stone)]"
|
</button>
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p className="font-bold flex items-center gap-2">
|
|
||||||
<span>{item.icon}</span>
|
|
||||||
{item.label}
|
|
||||||
{item.unlock ? <span className="text-[color:var(--mc-gold)]">✓</span> : null}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs">{item.hint}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-[color:var(--mc-stone-dark)]">
|
||||||
|
{tx("默认隐藏,点击展开查看。", "Hidden by default, click to expand.")} ·{" "}
|
||||||
|
{tx("已完成", "Completed")}: {achievementCompletedCount}/{achievementTotalCount} ·{" "}
|
||||||
|
{tx("荣誉分", "Honor")}: {achievementHonorTotal}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{achievementPanelOpen && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{achievementLoading && (
|
||||||
|
<p className="text-xs text-[color:var(--mc-stone-dark)]">
|
||||||
|
{tx("加载成就中...", "Loading achievements...")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!achievementLoading && achievementError && (
|
||||||
|
<p className="text-xs text-red-700">{achievementError}</p>
|
||||||
|
)}
|
||||||
|
{!achievementLoading && !achievementError && achievementSummary && (
|
||||||
|
<div className="rounded border border-zinc-300 bg-white p-3 text-xs text-zinc-700">
|
||||||
|
<p>
|
||||||
|
{tx("规则", "Rule")}: {tx("每完成", "Every")}{" "}
|
||||||
|
{achievementSummary.milestone_step}{" "}
|
||||||
|
{tx("个成就奖励", "achievements reward")}{" "}
|
||||||
|
<span className="font-bold text-[color:var(--mc-gold)]">
|
||||||
|
+{achievementSummary.milestone_bonus_rating} Rating
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
{tx("已获里程碑奖励", "Milestones awarded")}:{" "}
|
||||||
|
{achievementSummary.milestones_awarded} · {tx("累计奖励", "Total bonus")}{" "}
|
||||||
|
+{achievementSummary.rating_bonus_awarded_total} Rating
|
||||||
|
</p>
|
||||||
|
{achievementSummary.next_milestone_completed_required > 0 ? (
|
||||||
|
<p className="mt-1">
|
||||||
|
{tx("下个里程碑", "Next milestone")}:{" "}
|
||||||
|
{achievementSummary.next_milestone_completed_required} ·{" "}
|
||||||
|
{tx("还差", "Remaining")} {achievementSummary.next_milestone_remaining}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-emerald-700">
|
||||||
|
{tx("已完成全部成就!", "All achievements completed!")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{achievementMilestones.length > 0 && (
|
||||||
|
<div className="mt-2 rounded border border-zinc-200 bg-zinc-50 p-2 text-[11px] text-zinc-600">
|
||||||
|
<p className="font-semibold text-zinc-700">
|
||||||
|
{tx("最近里程碑奖励", "Recent milestone bonuses")}
|
||||||
|
</p>
|
||||||
|
{achievementMilestones.slice(0, 3).map((row) => (
|
||||||
|
<p key={row.milestone_no} className="mt-1">
|
||||||
|
#{row.milestone_no} · +{row.rating_bonus} Rating ·{" "}
|
||||||
|
{tx("完成", "Completed")} {row.completed_count_snapshot} · {fmtTs(row.created_at)}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!achievementLoading && !achievementError && (
|
||||||
|
<div className="max-h-80 overflow-y-auto rounded border border-zinc-300 bg-white p-2">
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{achievementItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
className={`border-2 border-[color:var(--mc-stone-dark)] p-2 text-sm ${
|
||||||
|
item.completed
|
||||||
|
? "bg-[color:var(--mc-grass-top)]/20 text-[color:var(--mc-plank-light)]"
|
||||||
|
: "bg-black/20 text-[color:var(--mc-stone)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="font-bold flex items-center gap-2">
|
||||||
|
<span>{item.icon}</span>
|
||||||
|
{item.title}
|
||||||
|
{item.completed ? <span className="text-[color:var(--mc-gold)]">✓</span> : null}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs">{item.description}</p>
|
||||||
|
<p className="mt-1 text-[11px]">
|
||||||
|
{tx("进度", "Progress")}: {item.progress}/{item.target} ·{" "}
|
||||||
|
{tx("荣誉", "Honor")} +{item.honor}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!achievementLoading && achievementItems.length === 0 && (
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
{tx("暂无成就数据。", "No achievements yet.")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<section className="flex-1 rounded-none border-[3px] border-black bg-[color:var(--mc-stone)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)] text-white">
|
<section className="flex-1 rounded-none border-[3px] border-black bg-[color:var(--mc-stone)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)] text-white">
|
||||||
<h2 className={`${sectionTitleClass} mb-6 flex items-center gap-2 border-b-2 border-[color:var(--mc-stone)]/30 pb-2 font-minecraft`}>
|
<h2 className={`${sectionTitleClass} mb-6 flex items-center gap-2 border-b-2 border-[color:var(--mc-stone)]/30 pb-2 font-minecraft`}>
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户