feat: add streak reward and 100-achievement milestone system
这个提交包含在:
@@ -5,6 +5,130 @@
|
||||
#include "csp/services/daily_task_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") {
|
||||
auto db = csp::db::SqliteDb::OpenMemory();
|
||||
csp::db::ApplyMigrations(db);
|
||||
@@ -30,5 +154,50 @@ TEST_CASE("daily task reward only once per day") {
|
||||
REQUIRE(after->rating == 2);
|
||||
|
||||
const auto tasks = daily.ListTodayTasks(user.user_id);
|
||||
REQUIRE(tasks.size() == 4);
|
||||
REQUIRE(tasks.size() == 5);
|
||||
}
|
||||
|
||||
TEST_CASE("learning streak reward grants +1 when streak >= 3 days") {
|
||||
auto db = csp::db::SqliteDb::OpenMemory();
|
||||
csp::db::ApplyMigrations(db);
|
||||
|
||||
csp::services::AuthService auth(db);
|
||||
const auto user = auth.Register("daily_task_streak_user", "password123");
|
||||
|
||||
csp::services::UserService users(db);
|
||||
csp::services::DailyTaskService daily(db);
|
||||
sqlite3* raw = db.raw();
|
||||
|
||||
const std::string today = daily.CurrentDayKey();
|
||||
const std::string yesterday = ShiftDayKey(today, -1);
|
||||
const std::string two_days_ago = ShiftDayKey(today, -2);
|
||||
|
||||
// Register inserts today's login_checkin automatically; remove it so this test
|
||||
// can call login_checkin and verify reward delta in one step.
|
||||
DeleteDailyTaskLog(raw, user.user_id,
|
||||
csp::services::DailyTaskService::kTaskLoginCheckin, today);
|
||||
|
||||
InsertDailyTaskLog(raw, user.user_id,
|
||||
csp::services::DailyTaskService::kTaskLoginCheckin,
|
||||
yesterday, 1, 0);
|
||||
InsertDailyTaskLog(raw, user.user_id,
|
||||
csp::services::DailyTaskService::kTaskLoginCheckin,
|
||||
two_days_ago, 1, 0);
|
||||
|
||||
const auto before = users.GetById(user.user_id);
|
||||
REQUIRE(before.has_value());
|
||||
|
||||
REQUIRE(daily.CompleteTaskIfFirstToday(
|
||||
user.user_id, csp::services::DailyTaskService::kTaskLoginCheckin));
|
||||
REQUIRE_FALSE(daily.CompleteTaskIfFirstToday(
|
||||
user.user_id, csp::services::DailyTaskService::kTaskLoginCheckin));
|
||||
|
||||
const auto after = users.GetById(user.user_id);
|
||||
REQUIRE(after.has_value());
|
||||
// login_checkin + streak bonus
|
||||
REQUIRE(after->rating == before->rating + 2);
|
||||
|
||||
REQUIRE(CountTaskLog(raw, user.user_id,
|
||||
csp::services::DailyTaskService::kTaskLearningStreak3Days,
|
||||
today) == 1);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户