refactor(api): split Axum handlers into handler/service/repo modules

Keep the REST contract; move domain logic out of route files so checkout, fulfillment, and identity can be reused across customer, shop, and admin surfaces.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Chengdong Zhang
2026-09-18 14:59:36 +08:00
co-authored by Cursor
parent e457339847
commit 5e866d08f4
68 changed files with 3796 additions and 2304 deletions
+99
View File
@@ -0,0 +1,99 @@
use crate::error::ApiResult;
use crate::models::Currency;
use crate::money::convert_minor;
use crate::state::AppState;
pub async fn load_currencies(state: &AppState, enabled_only: bool) -> ApiResult<Vec<Currency>> {
let rows = if enabled_only {
sqlx::query_as::<_, Currency>(
"SELECT code, name, symbol, exponent, is_base, rate_to_base, enabled
FROM currencies WHERE enabled = TRUE ORDER BY code",
)
.fetch_all(&state.db)
.await?
} else {
sqlx::query_as::<_, Currency>(
"SELECT code, name, symbol, exponent, is_base, rate_to_base, enabled
FROM currencies ORDER BY code",
)
.fetch_all(&state.db)
.await?
};
Ok(rows)
}
pub async fn convert(
state: &AppState,
amount_minor: i64,
from: &str,
to: &str,
) -> ApiResult<(i64, String)> {
let currencies = load_currencies(state, true).await?;
let from_c = currencies
.iter()
.find(|c| c.code == from.to_uppercase())
.ok_or_else(|| {
crate::error::ApiError::BadRequest(format!("unknown or disabled currency: {from}"))
})?;
let to_c = currencies
.iter()
.find(|c| c.code == to.to_uppercase())
.ok_or_else(|| {
crate::error::ApiError::BadRequest(format!("unknown or disabled currency: {to}"))
})?;
let converted = convert_minor(amount_minor, from_c, to_c)?;
Ok((converted, to_c.code.clone()))
}
pub async fn upsert(
state: &AppState,
code: String,
name: serde_json::Value,
symbol: String,
exponent: i16,
rate_to_base: rust_decimal::Decimal,
enabled: bool,
) -> ApiResult<Currency> {
use crate::error::ApiError;
let code = code.to_uppercase();
if code.len() != 3 || !code.chars().all(|c| c.is_ascii_uppercase()) {
return Err(ApiError::BadRequest("code must be a 3-letter ISO code".into()));
}
if rate_to_base <= rust_decimal::Decimal::ZERO {
return Err(ApiError::BadRequest("rate_to_base must be > 0".into()));
}
if !(0..=6).contains(&exponent) {
return Err(ApiError::BadRequest("exponent must be 0..=6".into()));
}
Ok(sqlx::query_as::<_, Currency>(
"INSERT INTO currencies (code, name, symbol, exponent, rate_to_base, enabled)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (code) DO UPDATE
SET name = $2, symbol = $3, exponent = $4, rate_to_base = $5, enabled = $6
RETURNING code, name, symbol, exponent, is_base, rate_to_base, enabled",
)
.bind(&code)
.bind(&name)
.bind(&symbol)
.bind(exponent)
.bind(rate_to_base)
.bind(enabled)
.fetch_one(&state.db)
.await?)
}
pub async fn set_rate(state: &AppState, code: &str, rate: rust_decimal::Decimal) -> ApiResult<Currency> {
use crate::error::ApiError;
if rate <= rust_decimal::Decimal::ZERO {
return Err(ApiError::BadRequest("rate_to_base must be > 0".into()));
}
sqlx::query_as::<_, Currency>(
"UPDATE currencies SET rate_to_base = $2 WHERE code = $1
RETURNING code, name, symbol, exponent, is_base, rate_to_base, enabled",
)
.bind(code.to_uppercase())
.bind(rate)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| ApiError::NotFound("currency".into()))
}