Files
SCOPENET-MC/panel/server/src/routes/economy.rs
T

549 lines
19 KiB
Rust

//! Per-server economy system, marketplace, transactions, and baltop.
use crate::auth::AuthUser;
use crate::error::{AppError, AppResult};
use crate::routes::servers::GameServer;
use crate::state::AppState;
use axum::extract::{Path, State};
use axum::Json;
use scopenet_shared::{BaltopEntry, EconomyTransaction, MarketListing, ServerEconomyBalance};
use serde::Deserialize;
use serde_json::Value;
async fn begin_operation(state: &AppState, server_id: i64, operation_id: &str) -> AppResult<(sqlx::Transaction<'static, sqlx::Sqlite>, Option<Value>)> {
if operation_id.is_empty() || operation_id.len() > 100 { return Err(AppError::bad_request("Invalid operation ID")); }
let mut tx = state.db.begin().await?;
// The first statement obtains the write lock before reading any balances.
let inserted = sqlx::query("INSERT OR IGNORE INTO economy_operations(server_id, operation_id, response) VALUES (?, ?, '')")
.bind(server_id).bind(operation_id).execute(&mut *tx).await?.rows_affected();
let previous = if inserted == 0 {
let raw: String = sqlx::query_scalar("SELECT response FROM economy_operations WHERE server_id = ? AND operation_id = ?")
.bind(server_id).bind(operation_id).fetch_one(&mut *tx).await?;
Some(serde_json::from_str(&raw)?)
} else { None };
Ok((tx, previous))
}
async fn finish_operation(mut tx: sqlx::Transaction<'_, sqlx::Sqlite>, server_id: i64, operation_id: &str, response: Value) -> AppResult<Json<Value>> {
sqlx::query("UPDATE economy_operations SET response = ? WHERE server_id = ? AND operation_id = ?")
.bind(response.to_string()).bind(server_id).bind(operation_id).execute(&mut *tx).await?;
tx.commit().await?;
Ok(Json(response))
}
async fn ensure_balance(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, server_id: i64, uuid: &str, name: &str) -> AppResult<()> {
sqlx::query("INSERT INTO server_economy(server_id, uuid, username, balance, updated_at) VALUES (?, ?, ?, 1000, ?) ON CONFLICT(server_id, uuid) DO NOTHING")
.bind(server_id).bind(uuid).bind(name).bind(chrono::Utc::now().to_rfc3339()).execute(&mut **tx).await?;
Ok(())
}
#[derive(Deserialize)]
pub struct AdjustBalancePayload {
pub uuid: String,
pub username: String,
pub delta: f64,
pub operation_id: String,
pub description: String,
}
pub async fn server_adjust_balance(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<AdjustBalancePayload>) -> AppResult<Json<Value>> {
if !p.delta.is_finite() || p.delta == 0.0 || p.delta.abs() > 1e12 { return Err(AppError::bad_request("Invalid amount")); }
let (mut tx, previous) = begin_operation(&state, server.id, &p.operation_id).await?;
if let Some(previous) = previous { return Ok(Json(previous)); }
ensure_balance(&mut tx, server.id, &p.uuid, &p.username).await?;
let balance: Option<f64> = sqlx::query_scalar("UPDATE server_economy SET balance = balance + ?, updated_at = ? WHERE server_id = ? AND uuid = ? AND balance + ? >= 0 RETURNING balance")
.bind(p.delta).bind(chrono::Utc::now().to_rfc3339()).bind(server.id).bind(&p.uuid).bind(p.delta).fetch_optional(&mut *tx).await?;
let Some(balance) = balance else { return Err(AppError::bad_request("Insufficient funds")); };
let (from, from_name, to, to_name) = if p.delta > 0.0 { ("server", "Server Shop", p.uuid.as_str(), p.username.as_str()) } else { (p.uuid.as_str(), p.username.as_str(), "server", "Server Shop") };
sqlx::query("INSERT INTO economy_transactions(server_id, from_uuid, from_name, to_uuid, to_name, amount, description, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
.bind(server.id).bind(from).bind(from_name).bind(to).bind(to_name).bind(p.delta.abs()).bind(&p.description).bind(chrono::Utc::now().to_rfc3339()).execute(&mut *tx).await?;
finish_operation(tx, server.id, &p.operation_id, serde_json::json!({"ok": true, "balance": balance})).await
}
pub async fn server_market_read(GameServer(server): GameServer, State(state): State<AppState>) -> AppResult<Json<Vec<Value>>> {
let rows: Vec<(i64, String, String, String, i32, f64, Option<String>)> = sqlx::query_as("SELECT id, seller_name, item_id, item_name, amount, price, item_data FROM server_market WHERE server_id = ? ORDER BY id DESC LIMIT 45")
.bind(server.id).fetch_all(&state.db).await?;
Ok(Json(rows.into_iter().map(|(id, seller, item_id, item_name, amount, price, data)| serde_json::json!({"id":id,"seller_name":seller,"item_id":item_id,"item_name":item_name,"amount":amount,"price":price,"item_data":data})).collect()))
}
// ---------------------------------------------------------------------------
// Launcher & Public Economy API
// ---------------------------------------------------------------------------
/// Get current user's economy balances across all connected game servers.
pub async fn get_my_balances(
auth: AuthUser,
State(state): State<AppState>,
) -> AppResult<Json<Vec<ServerEconomyBalance>>> {
let rows: Vec<(i64, String, f64)> = sqlx::query_as(
"SELECT se.server_id, gs.name, se.balance
FROM server_economy se
JOIN game_servers gs ON gs.id = se.server_id
WHERE se.uuid = ?
ORDER BY se.balance DESC",
)
.bind(&auth.uuid)
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.map(|(sid, sname, bal)| ServerEconomyBalance {
server_id: sid,
server_name: sname,
balance: bal,
currency_symbol: "$".into(),
})
.collect();
Ok(Json(list))
}
/// Get current user's recent transactions across all servers.
pub async fn get_my_transactions(
auth: AuthUser,
State(state): State<AppState>,
) -> AppResult<Json<Vec<EconomyTransaction>>> {
let rows: Vec<(i64, i64, String, String, String, String, String, f64, String, String)> = sqlx::query_as(
"SELECT et.id, et.server_id, gs.name, et.from_uuid, et.from_name, et.to_uuid, et.to_name, et.amount, et.description, et.created_at
FROM economy_transactions et
JOIN game_servers gs ON gs.id = et.server_id
WHERE et.from_uuid = ? OR et.to_uuid = ?
ORDER BY et.id DESC
LIMIT 50",
)
.bind(&auth.uuid)
.bind(&auth.uuid)
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.map(|(id, sid, sname, fuuid, fname, tuuid, tname, amount, desc, created)| EconomyTransaction {
id,
server_id: sid,
server_name: Some(sname),
from_uuid: fuuid,
from_name: fname,
to_uuid: tuuid,
to_name: tname,
amount,
description: desc,
created_at: created,
})
.collect();
Ok(Json(list))
}
/// Get top richest players for a specific server.
pub async fn get_server_baltop(
Path(server_id): Path<i64>,
State(state): State<AppState>,
) -> AppResult<Json<Vec<BaltopEntry>>> {
let rows: Vec<(String, String, f64)> = sqlx::query_as(
"SELECT uuid, username, balance
FROM server_economy
WHERE server_id = ?
ORDER BY balance DESC
LIMIT 25",
)
.bind(server_id)
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.enumerate()
.map(|(i, (uuid, username, balance))| BaltopEntry {
rank: i + 1,
uuid,
username,
balance,
})
.collect();
Ok(Json(list))
}
/// Get active marketplace listings for a server.
pub async fn get_server_market(
Path(server_id): Path<i64>,
State(state): State<AppState>,
) -> AppResult<Json<Vec<MarketListing>>> {
let rows: Vec<(i64, i64, String, String, String, String, i32, f64, String)> = sqlx::query_as(
"SELECT id, server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at
FROM server_market
WHERE server_id = ?
ORDER BY id DESC
LIMIT 100",
)
.bind(server_id)
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.map(|(id, sid, suuid, sname, iid, iname, amt, price, created)| MarketListing {
id,
server_id: sid,
seller_uuid: suuid,
seller_name: sname,
item_id: iid,
item_name: iname,
amount: amt,
price,
created_at: created,
})
.collect();
Ok(Json(list))
}
// ---------------------------------------------------------------------------
// Server Integration Relay Endpoints
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct ServerBalanceQuery {
pub uuid: String,
pub username: Option<String>,
}
pub async fn server_get_balance(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(payload): Json<ServerBalanceQuery>,
) -> AppResult<Json<Value>> {
let now = chrono::Utc::now().to_rfc3339();
let username = payload.username.unwrap_or_else(|| "Player".into());
let bal_opt: Option<f64> = sqlx::query_scalar(
"SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?",
)
.bind(server.id)
.bind(&payload.uuid)
.fetch_optional(&state.db)
.await?;
let balance = match bal_opt {
Some(b) => b,
None => {
let starting_balance = 1000.0;
sqlx::query(
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(server_id, uuid) DO UPDATE SET username = excluded.username",
)
.bind(server.id)
.bind(&payload.uuid)
.bind(&username)
.bind(starting_balance)
.bind(&now)
.execute(&state.db)
.await?;
starting_balance
}
};
Ok(Json(serde_json::json!({
"server_id": server.id,
"uuid": payload.uuid,
"balance": balance,
"currency_symbol": "$"
})))
}
#[derive(Deserialize)]
pub struct ServerTransferPayload {
pub from_uuid: String,
pub from_name: String,
pub to_uuid: String,
pub to_name: String,
pub amount: f64,
pub description: Option<String>,
}
pub async fn server_transfer(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(payload): Json<ServerTransferPayload>,
) -> AppResult<Json<Value>> {
if !payload.amount.is_finite() || payload.amount <= 0.0 {
return Err(AppError::bad_request("Transfer amount must be positive"));
}
let mut tx = state.db.begin().await?;
let now = chrono::Utc::now().to_rfc3339();
// Check sender balance
let from_bal: Option<f64> = sqlx::query_scalar(
"SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?",
)
.bind(server.id)
.bind(&payload.from_uuid)
.fetch_optional(&mut *tx)
.await?;
let current_from_bal = from_bal.unwrap_or(0.0);
if current_from_bal < payload.amount {
return Err(AppError::bad_request("Insufficient funds"));
}
let new_from_bal = current_from_bal - payload.amount;
sqlx::query("UPDATE server_economy SET balance = ?, updated_at = ? WHERE server_id = ? AND uuid = ?")
.bind(new_from_bal)
.bind(&now)
.bind(server.id)
.bind(&payload.from_uuid)
.execute(&mut *tx)
.await?;
// Update receiver
sqlx::query(
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (server_id, uuid) DO UPDATE SET
balance = balance + excluded.balance,
username = excluded.username,
updated_at = excluded.updated_at",
)
.bind(server.id)
.bind(&payload.to_uuid)
.bind(&payload.to_name)
.bind(payload.amount)
.bind(&now)
.execute(&mut *tx)
.await?;
let desc = payload.description.unwrap_or_else(|| format!("Payment to {}", payload.to_name));
sqlx::query(
"INSERT INTO economy_transactions (server_id, from_uuid, from_name, to_uuid, to_name, amount, description, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(server.id)
.bind(&payload.from_uuid)
.bind(&payload.from_name)
.bind(&payload.to_uuid)
.bind(&payload.to_name)
.bind(payload.amount)
.bind(&desc)
.bind(&now)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(serde_json::json!({
"ok": true,
"from_balance": new_from_bal
})))
}
#[derive(Deserialize)]
pub struct ServerSyncBalancePayload {
pub uuid: String,
pub username: String,
pub new_balance: f64,
}
pub async fn server_sync_balance(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(payload): Json<ServerSyncBalancePayload>,
) -> AppResult<Json<Value>> {
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(server_id, uuid) DO UPDATE SET
balance = excluded.balance,
username = excluded.username,
updated_at = excluded.updated_at",
)
.bind(server.id)
.bind(&payload.uuid)
.bind(&payload.username)
.bind(payload.new_balance)
.bind(&now)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
pub async fn server_baltop(
GameServer(server): GameServer,
State(state): State<AppState>,
) -> AppResult<Json<Vec<BaltopEntry>>> {
let rows: Vec<(String, String, f64)> = sqlx::query_as(
"SELECT uuid, username, balance
FROM server_economy
WHERE server_id = ?
ORDER BY balance DESC
LIMIT 20",
)
.bind(server.id)
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.enumerate()
.map(|(i, (uuid, username, balance))| BaltopEntry {
rank: i + 1,
uuid,
username,
balance,
})
.collect();
Ok(Json(list))
}
#[derive(Deserialize)]
pub struct MarketListPayload {
pub operation_id: String,
pub item_data: Option<String>,
pub seller_uuid: String,
pub seller_name: String,
pub item_id: String,
pub item_name: String,
pub amount: i32,
pub price: f64,
}
pub async fn server_market_list(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(payload): Json<MarketListPayload>,
) -> AppResult<Json<Value>> {
if !payload.price.is_finite() || payload.price <= 0.0 || payload.amount <= 0 || payload.amount > 64 { return Err(AppError::bad_request("Invalid listing")); }
let (mut tx, previous) = begin_operation(&state, server.id, &payload.operation_id).await?;
if let Some(previous) = previous { return Ok(Json(previous)); }
let now = chrono::Utc::now().to_rfc3339();
let id: i64 = sqlx::query_scalar(
"INSERT INTO server_market (server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at, item_data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id",
)
.bind(server.id)
.bind(payload.seller_uuid)
.bind(payload.seller_name)
.bind(payload.item_id)
.bind(payload.item_name)
.bind(payload.amount)
.bind(payload.price)
.bind(&now)
.bind(&payload.item_data)
.fetch_one(&mut *tx)
.await?;
finish_operation(tx, server.id, &payload.operation_id, serde_json::json!({ "id": id, "ok": true })).await
}
#[derive(Deserialize)]
pub struct MarketBuyPayload {
pub operation_id: String,
pub listing_id: i64,
pub buyer_uuid: String,
pub buyer_name: String,
}
pub async fn server_market_buy(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(payload): Json<MarketBuyPayload>,
) -> AppResult<Json<Value>> {
let (mut tx, previous) = begin_operation(&state, server.id, &payload.operation_id).await?;
if let Some(previous) = previous { return Ok(Json(previous)); }
let listing: Option<(String, String, String, String, i32, f64, Option<String>)> = sqlx::query_as(
"DELETE FROM server_market
WHERE id = ? AND server_id = ?
RETURNING seller_uuid, seller_name, item_id, item_name, amount, price, item_data
",
)
.bind(payload.listing_id)
.bind(server.id)
.fetch_optional(&mut *tx)
.await?;
let Some((seller_uuid, seller_name, item_id, item_name, amount, price, item_data)) = listing else {
return Err(AppError::not_found("Listing not found"));
};
ensure_balance(&mut tx, server.id, &payload.buyer_uuid, &payload.buyer_name).await?;
let now = chrono::Utc::now().to_rfc3339();
// Deduct buyer
let buyer_bal: f64 = sqlx::query_scalar(
"SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?",
)
.bind(server.id)
.bind(&payload.buyer_uuid)
.fetch_optional(&mut *tx)
.await?
.unwrap_or(0.0);
if buyer_bal < price {
return Err(AppError::bad_request("Insufficient funds"));
}
sqlx::query("UPDATE server_economy SET balance = balance - ?, updated_at = ? WHERE server_id = ? AND uuid = ?")
.bind(price)
.bind(&now)
.bind(server.id)
.bind(&payload.buyer_uuid)
.execute(&mut *tx)
.await?;
// Pay seller
sqlx::query(
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (server_id, uuid) DO UPDATE SET
balance = balance + excluded.balance,
username = excluded.username,
updated_at = excluded.updated_at",
)
.bind(server.id)
.bind(&seller_uuid)
.bind(&seller_name)
.bind(price)
.bind(&now)
.execute(&mut *tx)
.await?;
// Delete listing
sqlx::query("DELETE FROM server_market WHERE id = ?")
.bind(payload.listing_id)
.execute(&mut *tx)
.await?;
// Log transaction
let desc = format!("Market purchase: {}x {} from {}", amount, item_name, seller_name);
sqlx::query(
"INSERT INTO economy_transactions (server_id, from_uuid, from_name, to_uuid, to_name, amount, description, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(server.id)
.bind(&payload.buyer_uuid)
.bind(&payload.buyer_name)
.bind(&seller_uuid)
.bind(&seller_name)
.bind(price)
.bind(&desc)
.bind(&now)
.execute(&mut *tx)
.await?;
finish_operation(tx, server.id, &payload.operation_id, serde_json::json!({
"ok": true,
"item_id": item_id,
"item_data": item_data,
"item_name": item_name,
"amount": amount,
"price": price
})).await
}