68d10658c7
Correctness fixes from docs/CORE_CORRECTNESS_ISSUES.md: - Issue 3 (HIGH, exploit): submit_sbc dedups owned_card_ids (HashSet) and bounds the list (MAX_SBC_CARDS=30) before resolution. A repeated id resolved the same card N times, passed validation, and granted the reward while only one card was consumed -> any SBC satisfiable with one duplicated card = free reward. Now rejected with BadRequest. Regression test added. - Issue 2 (HIGH): TOCTOU economy mutations closed with single-statement compare-and-swap (no transaction plumbing): club::spend_coins conditional debit (WHERE coins >= ?) + rows_affected, also rejects negative amounts; pack::open_pack claims the pack before minting; market::buy_listing claims the listing before charging and releases on debit failure; market::sell_card guards the DELETE with owner + rows_affected; checkin::claim uses a conditional INSERT ... WHERE NOT EXISTS (today) before paying out. - Issue 4 (LOW): season.rs .expect() on missing rows -> graceful AppError; checkin index (streak-1) % 7 -> .rem_euclid(7) (guards negative index panic). - Issue 1 (LOW): migration 0019 adds sbc_submissions.club_id + backfill; submit_sbc binds it so the MY CLUB milestone query stops silently reading 0. Core suite 179 green + clippy clean.
150 lines
4.8 KiB
Rust
150 lines
4.8 KiB
Rust
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 (1–7 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
|
||
}
|
||
}
|