Files
OpenFUT-Core/src/services/checkin.rs
T
funman300 a034e74c16
CI / Build, lint & test (push) Successful in 2m19s
style(core): apply cargo fmt across routes, services, models, tests
Pure rustfmt reflow (import grouping, array/match-arm/call-arg wrapping,
alphabetized module decls, comment realignment). No semantic change:
full-diff and `git diff -w` both confirm logic byte-identical to 271c363;
workspace builds and all 74 core tests pass. Retained pre-existing WIP
brought forward after verification.
2026-08-20 16:05:41 +00:00

153 lines
4.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::{
db::Pool,
error::AppResult,
services::{club, pack},
};
use uuid::Uuid;
const STREAK_COINS: [i64; 7] = [500, 650, 800, 1000, 1200, 1500, 2000];
// Day 7 (index 6) also grants a pack:
const STREAK_7_PACK: &str = "silver_pack";
#[derive(Debug, serde::Serialize)]
pub struct CheckinStatus {
pub available: bool,
pub streak_day: i64, // current streak (17 cycle, 0 if never checked in)
pub next_reward_coins: i64,
pub next_reward_pack: Option<&'static str>,
pub last_checked_in: Option<String>,
}
#[derive(Debug, serde::Serialize)]
pub struct CheckinResult {
pub coins_awarded: i64,
pub pack_awarded: Option<String>,
pub new_streak: i64,
pub already_claimed: bool,
}
pub async fn get_status(pool: &Pool, profile_id: &str) -> AppResult<CheckinStatus> {
let row: Option<(i64, String)> = sqlx::query_as(
"SELECT streak_day, checked_in_at FROM daily_checkins \
WHERE profile_id = ? ORDER BY checked_in_at DESC LIMIT 1",
)
.bind(profile_id)
.fetch_optional(pool)
.await?;
let today = today_date();
match row {
None => Ok(CheckinStatus {
available: true,
streak_day: 0,
next_reward_coins: STREAK_COINS[0],
next_reward_pack: None,
last_checked_in: None,
}),
Some((last_streak, last_at)) => {
let last_day = &last_at[..10]; // YYYY-MM-DD
let available = last_day != today.as_str();
let next_streak = compute_next_streak(last_streak, &last_at);
let idx = (next_streak - 1).rem_euclid(7) as usize;
Ok(CheckinStatus {
available,
streak_day: if available { next_streak } else { last_streak },
next_reward_coins: STREAK_COINS[idx],
next_reward_pack: if idx == 6 { Some(STREAK_7_PACK) } else { None },
last_checked_in: Some(last_at),
})
}
}
}
pub async fn claim(pool: &Pool, profile_id: &str, club_id: &str) -> AppResult<CheckinResult> {
let row: Option<(i64, String)> = sqlx::query_as(
"SELECT streak_day, checked_in_at FROM daily_checkins \
WHERE profile_id = ? ORDER BY checked_in_at DESC LIMIT 1",
)
.bind(profile_id)
.fetch_optional(pool)
.await?;
let today = today_date();
if let Some((_, ref last_at)) = row {
if &last_at[..10] == today.as_str() {
let last_streak = row.as_ref().map(|(s, _)| *s).unwrap_or(1);
return Ok(CheckinResult {
coins_awarded: 0,
pack_awarded: None,
new_streak: last_streak,
already_claimed: true,
});
}
}
let last_streak = row
.as_ref()
.map(|(s, last_at)| compute_next_streak(*s, last_at))
.unwrap_or(1);
let idx = (last_streak - 1).rem_euclid(7) as usize;
let coins = STREAK_COINS[idx];
let pack_def = if idx == 6 { Some(STREAK_7_PACK) } else { None };
// Atomically claim today's check-in: the INSERT lands only if no row exists for
// today, so two concurrent claims cannot both pay out (was a check-then-act race).
let now = chrono::Utc::now().to_rfc3339();
let inserted = sqlx::query(
"INSERT INTO daily_checkins (id, profile_id, club_id, streak_day, coins_awarded, pack_granted, checked_in_at) \
SELECT ?, ?, ?, ?, ?, ?, ? \
WHERE NOT EXISTS (SELECT 1 FROM daily_checkins WHERE profile_id = ? AND substr(checked_in_at, 1, 10) = ?)",
)
.bind(Uuid::new_v4().to_string())
.bind(profile_id)
.bind(club_id)
.bind(last_streak)
.bind(coins)
.bind(pack_def)
.bind(&now)
.bind(profile_id)
.bind(&today)
.execute(pool)
.await?
.rows_affected();
if inserted == 0 {
// A concurrent claim already recorded today's check-in — do not pay out again.
return Ok(CheckinResult {
coins_awarded: 0,
pack_awarded: None,
new_streak: last_streak,
already_claimed: true,
});
}
club::add_coins(pool, club_id, coins).await?;
if let Some(def) = pack_def {
let _ = pack::grant_pack(pool, club_id, def).await;
}
Ok(CheckinResult {
coins_awarded: coins,
pack_awarded: pack_def.map(String::from),
new_streak: last_streak,
already_claimed: false,
})
}
fn today_date() -> String {
chrono::Utc::now().format("%Y-%m-%d").to_string()
}
/// If last check-in was yesterday or today → continue streak; otherwise reset to 1.
fn compute_next_streak(last_streak: i64, last_at: &str) -> i64 {
let last_day = &last_at[..10];
let today = chrono::Utc::now().date_naive();
let yesterday = (today - chrono::Days::new(1)).to_string();
let today_str = today.to_string();
if last_day == yesterday || last_day == today_str {
(last_streak % 7) + 1
} else {
1
}
}