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.
129 lines
3.9 KiB
Rust
129 lines
3.9 KiB
Rust
use crate::{
|
|
db::Pool,
|
|
error::{AppError, AppResult},
|
|
models::club::Club,
|
|
};
|
|
use chrono::Utc;
|
|
|
|
const CLUB_SELECT: &str =
|
|
"SELECT id, profile_id, name, coins, level, created_at, updated_at, manager_name \
|
|
FROM clubs";
|
|
|
|
pub async fn get_club_by_profile(pool: &Pool, profile_id: &str) -> AppResult<Club> {
|
|
sqlx::query_as::<_, Club>(&format!("{CLUB_SELECT} WHERE profile_id = ? LIMIT 1"))
|
|
.bind(profile_id)
|
|
.fetch_optional(pool)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("club not found".into()))
|
|
}
|
|
|
|
pub async fn get_club_by_id(pool: &Pool, club_id: &str) -> AppResult<Club> {
|
|
sqlx::query_as::<_, Club>(&format!("{CLUB_SELECT} WHERE id = ? LIMIT 1"))
|
|
.bind(club_id)
|
|
.fetch_optional(pool)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("club not found".into()))
|
|
}
|
|
|
|
pub async fn create_club(pool: &Pool, club: &Club) -> AppResult<()> {
|
|
sqlx::query(
|
|
"INSERT INTO clubs (id, profile_id, name, coins, level, created_at, updated_at, manager_name) \
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.bind(&club.id)
|
|
.bind(&club.profile_id)
|
|
.bind(&club.name)
|
|
.bind(club.coins)
|
|
.bind(club.level)
|
|
.bind(club.created_at)
|
|
.bind(club.updated_at)
|
|
.bind(&club.manager_name)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn update_club(
|
|
pool: &Pool,
|
|
club_id: &str,
|
|
name: Option<&str>,
|
|
manager_name: Option<&str>,
|
|
) -> AppResult<Club> {
|
|
let now = Utc::now();
|
|
if let Some(n) = name {
|
|
sqlx::query("UPDATE clubs SET name = ?, updated_at = ? WHERE id = ?")
|
|
.bind(n)
|
|
.bind(now)
|
|
.bind(club_id)
|
|
.execute(pool)
|
|
.await?;
|
|
}
|
|
if let Some(m) = manager_name {
|
|
sqlx::query("UPDATE clubs SET manager_name = ?, updated_at = ? WHERE id = ?")
|
|
.bind(m)
|
|
.bind(now)
|
|
.bind(club_id)
|
|
.execute(pool)
|
|
.await?;
|
|
}
|
|
get_club_by_id(pool, club_id).await
|
|
}
|
|
|
|
pub async fn add_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> {
|
|
let now = Utc::now();
|
|
sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?")
|
|
.bind(amount)
|
|
.bind(now)
|
|
.bind(club_id)
|
|
.execute(pool)
|
|
.await?;
|
|
|
|
let new_balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
|
|
.bind(club_id)
|
|
.fetch_one(pool)
|
|
.await?;
|
|
Ok(new_balance)
|
|
}
|
|
|
|
pub async fn spend_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> {
|
|
if amount < 0 {
|
|
return Err(AppError::BadRequest(format!(
|
|
"cannot spend a negative amount: {amount}"
|
|
)));
|
|
}
|
|
|
|
let now = Utc::now();
|
|
// Atomic compare-and-swap: the `coins >= ?` guard makes the debit conditional in a
|
|
// single statement, so two concurrent spends can never both pass a stale balance
|
|
// check and drive coins negative (the old SELECT-then-UPDATE was a TOCTOU race).
|
|
let affected = sqlx::query(
|
|
"UPDATE clubs SET coins = coins - ?, updated_at = ? WHERE id = ? AND coins >= ?",
|
|
)
|
|
.bind(amount)
|
|
.bind(now)
|
|
.bind(club_id)
|
|
.bind(amount)
|
|
.execute(pool)
|
|
.await?
|
|
.rows_affected();
|
|
|
|
if affected == 0 {
|
|
// No row updated: the club is missing, or it could not afford the debit.
|
|
// Disambiguate so callers keep the NotFound vs BadRequest distinction.
|
|
let balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
|
|
.bind(club_id)
|
|
.fetch_optional(pool)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound(format!("club not found: {club_id}")))?;
|
|
return Err(AppError::BadRequest(format!(
|
|
"insufficient coins: have {balance}, need {amount}"
|
|
)));
|
|
}
|
|
|
|
let new_balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
|
|
.bind(club_id)
|
|
.fetch_one(pool)
|
|
.await?;
|
|
Ok(new_balance)
|
|
}
|