initial commit

This commit is contained in:
2025-07-04 11:56:23 +08:00
commit 0cf47db3e8
9 changed files with 361 additions and 0 deletions

19
src/db.rs Executable file
View File

@@ -0,0 +1,19 @@
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
use sqlx::{PgPool, Postgres, Transaction};
use tracing::info;
pub type DbPool = PgPool;
pub type DbTransaction<'a> = Transaction<'a, Postgres>;
pub async fn create_pool() -> Result<DbPool, sqlx::Error> {
info!("Creating database connection pool...");
PgPoolOptions::new()
.max_connections(5)
.connect_with(PgConnectOptions::new())
.await
}
pub async fn run_migrations(pool: &DbPool) -> Result<(), sqlx::migrate::MigrateError> {
info!("Running database migrations...");
sqlx::migrate!("./migrations").run(pool).await
}

34
src/errors.rs Normal file
View File

@@ -0,0 +1,34 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use sqlx::Error as SqlxError;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Account not found: {0}")]
AccountNotFound(i64),
#[error("Account already exists")]
AccountAlreadyExists,
#[error("Insufficient funds")]
InsufficientFunds,
#[error("Database error: {0}")]
DatabaseError(#[from] SqlxError),
#[error("Invalid input: {0}")]
InvalidInput(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = match self {
AppError::AccountNotFound(_) => StatusCode::NOT_FOUND,
AppError::InsufficientFunds => StatusCode::BAD_REQUEST,
AppError::AccountAlreadyExists => StatusCode::BAD_REQUEST,
AppError::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR,
AppError::InvalidInput(_) => StatusCode::BAD_REQUEST,
};
(status, self.to_string()).into_response()
}
}

167
src/handlers.rs Normal file
View File

@@ -0,0 +1,167 @@
use std::str::FromStr;
use axum::{
Json,
extract::{Path, State},
};
use bigdecimal::BigDecimal;
use sqlx::types::BigDecimal as SqlxBigDecimal;
use tracing::info;
use crate::{
db::DbPool,
errors::AppError,
models::{Account, CreateAccount, TransactionRequest},
};
pub async fn create_account(
State(pool): State<DbPool>,
Json(payload): Json<CreateAccount>,
) -> Result<(), AppError> {
if payload.account_id < 1 {
return Err(AppError::InvalidInput(String::from(
"account_id is invalid",
)));
}
if payload.initial_balance < BigDecimal::from(0) {
return Err(AppError::InvalidInput(String::from(
"initial_balance is negative",
)));
}
info!("Creating account: {:?}", payload.account_id);
sqlx::query(
r#"
INSERT INTO accounts (account_id, balance)
VALUES ($1, $2)
"#,
)
.bind(payload.account_id)
.bind(SqlxBigDecimal::from(payload.initial_balance))
.execute(&pool)
.await
.map_err(|err| match err {
sqlx::Error::Database(_) => AppError::AccountAlreadyExists,
_ => AppError::from(err),
})?;
Ok(())
}
pub async fn get_account(
State(pool): State<DbPool>,
Path(account_id): Path<i64>,
) -> Result<Json<Account>, AppError> {
if account_id < 1 {
return Err(AppError::InvalidInput(String::from(
"account_id is invalid",
)));
}
info!("Fetching account: {}", account_id);
let account = sqlx::query_as::<_, Account>(
r#"
SELECT account_id, balance
FROM accounts
WHERE account_id = $1
"#,
)
.bind(account_id)
.fetch_one(&pool)
.await
.map_err(|_| AppError::AccountNotFound(account_id))?;
Ok(Json(account))
}
pub async fn process_transaction(
State(pool): State<DbPool>,
Json(payload): Json<TransactionRequest>,
) -> Result<(), AppError> {
if payload.source_account_id < 1 {
return Err(AppError::InvalidInput(String::from(
"source_account_id is invalid",
)));
}
if payload.destination_account_id < 1 {
return Err(AppError::InvalidInput(String::from(
"destination_account_id is invalid",
)));
}
if payload.amount < BigDecimal::from_str("0.01").unwrap() {
return Err(AppError::InvalidInput(String::from(
"Minimum amount is 0.01",
)));
}
info!(
"Processing transaction from {} to {} for amount {}",
payload.source_account_id, payload.destination_account_id, payload.amount
);
let mut tx = pool.begin().await.map_err(AppError::from)?;
// Check source account balance
let source_balance: SqlxBigDecimal = sqlx::query_scalar(
r#"
SELECT balance FROM accounts WHERE account_id = $1 FOR UPDATE
"#,
)
.bind(payload.source_account_id)
.fetch_one(&mut *tx)
.await
.map_err(|_| AppError::AccountNotFound(payload.source_account_id))?;
if source_balance < SqlxBigDecimal::from(payload.amount.clone()) {
return Err(AppError::InsufficientFunds);
}
// Update source account
sqlx::query(
r#"
UPDATE accounts
SET balance = balance - $1
WHERE account_id = $2
"#,
)
.bind(SqlxBigDecimal::from(payload.amount.clone()))
.bind(payload.source_account_id)
.execute(&mut *tx)
.await
.map_err(AppError::from)?;
// Update destination account
let update_result = sqlx::query(
r#"
UPDATE accounts
SET balance = balance + $1
WHERE account_id = $2
"#,
)
.bind(SqlxBigDecimal::from(payload.amount.clone()))
.bind(payload.destination_account_id)
.execute(&mut *tx)
.await
.map_err(AppError::from)?;
if update_result.rows_affected() == 0 {
return Err(AppError::AccountNotFound(payload.destination_account_id));
}
// Record transaction
sqlx::query(
r#"
INSERT INTO transactions (source_account_id, destination_account_id, amount)
VALUES ($1, $2, $3)
"#,
)
.bind(payload.source_account_id)
.bind(payload.destination_account_id)
.bind(SqlxBigDecimal::from(payload.amount.clone()))
.execute(&mut *tx)
.await
.map_err(AppError::from)?;
tx.commit().await.map_err(AppError::from)?;
Ok(())
}

41
src/main.rs Normal file
View File

@@ -0,0 +1,41 @@
use axum::{
Router,
routing::{get, post},
};
use dotenv::dotenv;
use tracing::Level;
use tracing_subscriber::{FmtSubscriber, layer::SubscriberExt, util::SubscriberInitExt};
mod db;
mod errors;
mod handlers;
mod models;
#[tokio::main]
async fn main() {
let subscriber = FmtSubscriber::builder()
.with_max_level(Level::TRACE)
.finish();
tracing::subscriber::set_global_default(subscriber)
.expect("Setting default tracing subscriber failed");
dotenv().ok();
let pool = db::create_pool().await.expect("Failed to create pool");
// Run migrations
db::run_migrations(&pool)
.await
.expect("Failed to run migrations");
// Build our application's routes
let app = Router::new()
.route("/accounts", post(handlers::create_account))
.route("/accounts/{account_id}", get(handlers::get_account))
.route("/transactions", post(handlers::process_transaction))
.with_state(pool);
// Run our app
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
tracing::debug!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}

62
src/models.rs Normal file
View File

@@ -0,0 +1,62 @@
use std::str::FromStr;
use bigdecimal::{BigDecimal, ParseBigDecimalError};
use serde::{Deserialize, Serialize};
use sqlx::{Database, Decode, Encode, FromRow, Type, error::BoxDynError};
use std::cmp::PartialOrd;
use std::error::Error;
use std::fmt::Display;
// use validator::{Validate, ValidateRange};
// #[derive(Debug, Serialize, Deserialize, PartialEq, PartialOrd, Clone, Type)]
// #[sqlx(transparent)]
// pub struct Amount(BigDecimal);
// impl Amount {
// fn new(text: &str) -> Result<Amount, ParseBigDecimalError> {
// Ok(Amount(BigDecimal::from_str(text)?))
// }
// }
// impl Display for Amount {
// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// self.0.fmt(f)
// }
// }
// impl ValidateRange<Amount> for Amount {
// fn greater_than(&self, max: Amount) -> Option<bool> {
// Some(self > &max)
// }
// fn less_than(&self, min: Amount) -> Option<bool> {
// Some(self < &min)
// }
// }
// impl From<Amount> for BigDecimal {
// fn from(value: Amount) -> Self {
// value.0
// }
// }
type Amount = BigDecimal;
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Account {
pub account_id: i64,
pub balance: Amount,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateAccount {
pub account_id: i64,
pub initial_balance: Amount,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TransactionRequest {
pub source_account_id: i64,
pub destination_account_id: i64,
pub amount: Amount,
}