chore(api): apply rustfmt across modules and tests

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Chengdong Zhang
2026-09-21 18:52:52 +08:00
co-authored by Cursor
parent 6c1357ec4d
commit a4ef6ca49e
46 changed files with 495 additions and 334 deletions
+5 -1
View File
@@ -25,7 +25,11 @@ pub fn hash_password(password: &str) -> Result<String, ApiError> {
pub fn verify_password(password: &str, hash: &str) -> bool {
PasswordHash::new(hash)
.map(|h| Argon2::default().verify_password(password.as_bytes(), &h).is_ok())
.map(|h| {
Argon2::default()
.verify_password(password.as_bytes(), &h)
.is_ok()
})
.unwrap_or(false)
}
+1 -2
View File
@@ -39,7 +39,6 @@ impl Config {
}
pub fn database_url_for(db: &str) -> anyhow::Result<String> {
let base =
std::env::var("DATABASE_URL").context("DATABASE_URL must be set for tests")?;
let base = std::env::var("DATABASE_URL").context("DATABASE_URL must be set for tests")?;
Ok(format!("{base}/{db}"))
}
+10 -2
View File
@@ -1,4 +1,8 @@
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
#[derive(Debug, thiserror::Error)]
@@ -40,7 +44,11 @@ impl IntoResponse for ApiError {
)
}
};
(status, Json(json!({ "error": { "code": code, "message": message } }))).into_response()
(
status,
Json(json!({ "error": { "code": code, "message": message } })),
)
.into_response()
}
}
+1 -1
View File
@@ -3,8 +3,8 @@ pub mod config;
pub mod error;
pub mod http;
pub mod models;
pub mod money;
pub mod modules;
pub mod money;
pub mod seed;
pub mod state;
+3 -3
View File
@@ -294,7 +294,8 @@ pub struct CustomerAccountEntry {
pub created_at: DateTime<Utc>,
}
pub const CUSTOMER_ACCOUNT_ENTRY_COLUMNS: &str = "id, account_id, delta_minor, balance_minor, reason, reference_type, reference_id, created_at";
pub const CUSTOMER_ACCOUNT_ENTRY_COLUMNS: &str =
"id, account_id, delta_minor, balance_minor, reason, reference_type, reference_id, created_at";
/// Lifecycle of a customer-owned coupon snapshot.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -503,8 +504,7 @@ pub struct CollectiveGroupMember {
pub joined_at: DateTime<Utc>,
}
pub const COLLECTIVE_GROUP_MEMBER_COLUMNS: &str =
"id, group_id, order_id, user_id, joined_at";
pub const COLLECTIVE_GROUP_MEMBER_COLUMNS: &str = "id, group_id, order_id, user_id, joined_at";
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct AddressBookEntry {
+1 -2
View File
@@ -3,8 +3,7 @@ use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::models::{
CustomerAccount, CustomerAccountEntry, CUSTOMER_ACCOUNT_COLUMNS,
CUSTOMER_ACCOUNT_ENTRY_COLUMNS,
CustomerAccount, CustomerAccountEntry, CUSTOMER_ACCOUNT_COLUMNS, CUSTOMER_ACCOUNT_ENTRY_COLUMNS,
};
/// Monetary accounts are opened in the platform base currency. Multi-currency
+29 -9
View File
@@ -43,7 +43,15 @@ pub async fn credit(
let amount = positive(amount_minor)?;
let account = account_for(tx, user_id, kind, currency).await?;
let updated = repo::credit_atomic(tx, account.id, amount).await?;
append(tx, account.id, amount, updated.balance_minor, reason, reference).await
append(
tx,
account.id,
amount,
updated.balance_minor,
reason,
reference,
)
.await
}
/// Subtract `amount_minor` only when the account can cover it; 409 otherwise.
@@ -59,7 +67,15 @@ pub async fn debit(
let amount = positive(amount_minor)?;
let account = account_for(tx, user_id, kind, currency).await?;
let updated = repo::debit_guarded(tx, account.id, amount).await?;
append(tx, account.id, -amount, updated.balance_minor, reason, reference).await
append(
tx,
account.id,
-amount,
updated.balance_minor,
reason,
reference,
)
.await
}
/// Credit once per `reason`, in its own transaction. Demo seeding uses this so
@@ -142,7 +158,15 @@ async fn transfer(
let from_updated = repo::debit_guarded(tx, from, amount).await?;
let to_updated = repo::credit_atomic(tx, to, amount).await?;
let out = append(tx, from, -amount, from_updated.balance_minor, reason, reference).await?;
let out = append(
tx,
from,
-amount,
from_updated.balance_minor,
reason,
reference,
)
.await?;
let into = append(tx, to, amount, to_updated.balance_minor, reason, reference).await?;
Ok((out, into))
}
@@ -206,9 +230,7 @@ fn summarize(accounts: &[CustomerAccount]) -> ApiResult<AccountSummary> {
let currency = available
.currency
.clone()
.ok_or_else(|| {
ApiError::Internal(anyhow::anyhow!("available account has no currency"))
})?;
.ok_or_else(|| ApiError::Internal(anyhow::anyhow!("available account has no currency")))?;
Ok(AccountSummary {
balance_minor: available.balance_minor,
frozen_minor: frozen.balance_minor,
@@ -226,9 +248,7 @@ fn find(accounts: &[CustomerAccount], kind: AccountKind) -> ApiResult<&CustomerA
fn positive(amount_minor: i64) -> ApiResult<i64> {
if amount_minor <= 0 {
return Err(ApiError::BadRequest(
"amount_minor must be positive".into(),
));
return Err(ApiError::BadRequest("amount_minor must be positive".into()));
}
Ok(amount_minor)
}
+1 -5
View File
@@ -21,11 +21,7 @@ pub async fn list_for_user<'e, E: PgExecutor<'e>>(
.await?)
}
pub async fn get_own(
db: &sqlx::PgPool,
user_id: Uuid,
id: Uuid,
) -> ApiResult<AddressBookEntry> {
pub async fn get_own(db: &sqlx::PgPool, user_id: Uuid, id: Uuid) -> ApiResult<AddressBookEntry> {
sqlx::query_as::<_, AddressBookEntry>(&format!(
"SELECT {ADDR_COLS}
FROM addresses WHERE id = $1 AND user_id = $2"
+2 -10
View File
@@ -52,11 +52,7 @@ pub async fn update(
Ok(row)
}
pub async fn set_default(
state: &AppState,
user_id: Uuid,
id: Uuid,
) -> ApiResult<AddressBookEntry> {
pub async fn set_default(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult<AddressBookEntry> {
repo::get_own(&state.db, user_id, id).await?;
let mut tx = state.db.begin().await?;
repo::clear_defaults(&mut tx, user_id).await?;
@@ -65,11 +61,7 @@ pub async fn set_default(
Ok(row)
}
pub async fn delete(
state: &AppState,
user_id: Uuid,
id: Uuid,
) -> ApiResult<Vec<AddressBookEntry>> {
pub async fn delete(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult<Vec<AddressBookEntry>> {
let existing = repo::get_own(&state.db, user_id, id).await?;
let mut tx = state.db.begin().await?;
repo::delete(&mut tx, id).await?;
+5 -1
View File
@@ -90,7 +90,11 @@ pub async fn request(
return Err(ApiError::BadRequest("title is required".into()));
}
if body.kind == InvoiceKind::Company
&& body.tax_no.as_ref().map(|t| t.trim().is_empty()).unwrap_or(true)
&& body
.tax_no
.as_ref()
.map(|t| t.trim().is_empty())
.unwrap_or(true)
{
return Err(ApiError::BadRequest(
"tax_no is required for company invoices".into(),
+15 -3
View File
@@ -9,7 +9,12 @@ pub async fn get(state: &AppState, user_id: Uuid) -> ApiResult<CartView> {
store::cart_view(state, user_id).await
}
pub async fn add_item(state: &AppState, user_id: Uuid, sku_id: Uuid, qty: i32) -> ApiResult<CartView> {
pub async fn add_item(
state: &AppState,
user_id: Uuid,
sku_id: Uuid,
qty: i32,
) -> ApiResult<CartView> {
if qty <= 0 {
return Err(ApiError::BadRequest("qty must be > 0".into()));
}
@@ -26,9 +31,16 @@ pub async fn add_item(state: &AppState, user_id: Uuid, sku_id: Uuid, qty: i32) -
store::cart_view(state, user_id).await
}
pub async fn set_item(state: &AppState, user_id: Uuid, sku_id: Uuid, qty: i32) -> ApiResult<CartView> {
pub async fn set_item(
state: &AppState,
user_id: Uuid,
sku_id: Uuid,
qty: i32,
) -> ApiResult<CartView> {
if qty <= 0 {
return Err(ApiError::BadRequest("qty must be > 0; use DELETE to remove".into()));
return Err(ApiError::BadRequest(
"qty must be > 0; use DELETE to remove".into(),
));
}
if !store::is_purchasable(state, sku_id).await? {
return Err(ApiError::BadRequest("sku is not purchasable".into()));
+3 -1
View File
@@ -35,7 +35,9 @@ pub async fn set_qty(state: &AppState, user_id: Uuid, sku_id: Uuid, qty: i32) ->
pub async fn clear_cart(state: &AppState, user_id: Uuid) -> ApiResult<()> {
let mut conn = state.redis.clone();
conn.del::<_, ()>(key(user_id)).await.map_err(ApiError::from)?;
conn.del::<_, ()>(key(user_id))
.await
.map_err(ApiError::from)?;
Ok(())
}
+3 -1
View File
@@ -369,7 +369,9 @@ pub async fn upsert_sku(
.fetch_one(&state.db)
.await?;
if !currency_ok {
return Err(ApiError::BadRequest(format!("unknown currency: {currency}")));
return Err(ApiError::BadRequest(format!(
"unknown currency: {currency}"
)));
}
Ok(sqlx::query_as::<_, Sku>(
"INSERT INTO skus (product_id, sku_code, attributes, price_minor, currency, stock, active)
+14 -7
View File
@@ -50,7 +50,11 @@ pub struct ContentView {
}
pub async fn load_content(state: &AppState, active_only: bool) -> ApiResult<ContentView> {
let filter = if active_only { " WHERE active = TRUE" } else { "" };
let filter = if active_only {
" WHERE active = TRUE"
} else {
""
};
let banners = sqlx::query_as::<_, Banner>(&format!(
"SELECT id, image, url, position, active FROM banners{filter} ORDER BY position, created_at"
))
@@ -130,11 +134,10 @@ fn default_active() -> bool {
}
pub async fn replace_kind(state: &AppState, kind: &str, body: Value) -> ApiResult<ContentView> {
if !matches!(
kind,
"banners" | "promos" | "quick-links" | "floor-adverts"
) {
return Err(ApiError::BadRequest(format!("unknown content kind: {kind}")));
if !matches!(kind, "banners" | "promos" | "quick-links" | "floor-adverts") {
return Err(ApiError::BadRequest(format!(
"unknown content kind: {kind}"
)));
}
let mut tx = state.db.begin().await?;
@@ -146,7 +149,11 @@ pub async fn replace_kind(state: &AppState, kind: &str, body: Value) -> ApiResul
non_empty(&item.image, "image")?;
non_empty(&item.url, "url")?;
}
let table = if kind == "banners" { "banners" } else { "promos" };
let table = if kind == "banners" {
"banners"
} else {
"promos"
};
sqlx::query(&format!("DELETE FROM {table}"))
.execute(&mut *tx)
.await?;
+1 -4
View File
@@ -34,10 +34,7 @@ async fn list_claimable(
Ok(Json(service::list_claimable(&state, shop_id).await?))
}
async fn list_mine(
State(state): State<AppState>,
auth: AuthUser,
) -> ApiResult<Json<Vec<Coupon>>> {
async fn list_mine(State(state): State<AppState>, auth: AuthUser) -> ApiResult<Json<Vec<Coupon>>> {
auth.require_customer()?;
Ok(Json(service::list_mine(&state, auth.id).await?))
}
+2 -9
View File
@@ -112,10 +112,7 @@ pub async fn delete(tx: &mut PgConnection, shop_id: Uuid, id: Uuid) -> ApiResult
}
/// Lock the template row for a claim so two customers cannot both take the last one.
pub async fn lock_template(
tx: &mut PgConnection,
id: Uuid,
) -> ApiResult<CouponTemplate> {
pub async fn lock_template(tx: &mut PgConnection, id: Uuid) -> ApiResult<CouponTemplate> {
sqlx::query_as::<_, CouponTemplate>(&format!(
"SELECT {COUPON_TEMPLATE_COLUMNS} FROM coupon_templates WHERE id = $1 FOR UPDATE"
))
@@ -212,11 +209,7 @@ pub async fn lock_owned_for_update(
.await?)
}
pub async fn redeem(
tx: &mut PgConnection,
coupon_id: Uuid,
order_id: Uuid,
) -> ApiResult<Coupon> {
pub async fn redeem(tx: &mut PgConnection, coupon_id: Uuid, order_id: Uuid) -> ApiResult<Coupon> {
sqlx::query_as::<_, Coupon>(&format!(
"UPDATE coupons SET status = 'redeemed', order_id = $2, redeemed_at = now()
WHERE id = $1 AND status = 'claimed'
+5 -9
View File
@@ -113,14 +113,14 @@ pub fn checkout_discount(
}
let now = Utc::now();
if now < coupon.starts_at || now > coupon.ends_at {
return Err(ApiError::Conflict("coupon is outside its active window".into()));
return Err(ApiError::Conflict(
"coupon is outside its active window".into(),
));
}
let from = currencies
.iter()
.find(|c| c.code == coupon.currency)
.ok_or_else(|| {
ApiError::BadRequest(format!("currency {} is disabled", coupon.currency))
})?;
.ok_or_else(|| ApiError::BadRequest(format!("currency {} is disabled", coupon.currency)))?;
let amount = convert_minor(coupon.amount_minor, from, target)?;
let threshold = convert_minor(coupon.threshold_minor, from, target)?;
if subtotal_minor < threshold {
@@ -132,11 +132,7 @@ pub fn checkout_discount(
Ok(amount.min(subtotal_minor))
}
pub async fn redeem(
tx: &mut PgConnection,
coupon_id: Uuid,
order_id: Uuid,
) -> ApiResult<Coupon> {
pub async fn redeem(tx: &mut PgConnection, coupon_id: Uuid, order_id: Uuid) -> ApiResult<Coupon> {
repo::redeem(tx, coupon_id, order_id).await
}
+4 -3
View File
@@ -39,9 +39,10 @@ async fn convert(
State(state): State<AppState>,
Query(q): Query<ConvertQuery>,
) -> ApiResult<Json<Value>> {
let (amount_minor, currency) =
service::convert(&state, q.amount_minor, &q.from, &q.to).await?;
Ok(Json(json!({ "amount_minor": amount_minor, "currency": currency })))
let (amount_minor, currency) = service::convert(&state, q.amount_minor, &q.from, &q.to).await?;
Ok(Json(
json!({ "amount_minor": amount_minor, "currency": currency }),
))
}
async fn list_all_currencies(
+8 -2
View File
@@ -57,7 +57,9 @@ pub async fn upsert(
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()));
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()));
@@ -82,7 +84,11 @@ pub async fn upsert(
.await?)
}
pub async fn set_rate(state: &AppState, code: &str, rate: rust_decimal::Decimal) -> ApiResult<Currency> {
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()));
+2 -6
View File
@@ -30,9 +30,7 @@ pub fn router() -> Router<AppState> {
)
}
async fn public_active(
State(state): State<AppState>,
) -> ApiResult<Json<Vec<PublicSessionView>>> {
async fn public_active(State(state): State<AppState>) -> ApiResult<Json<Vec<PublicSessionView>>> {
Ok(Json(service::public_active(&state).await?))
}
@@ -98,9 +96,7 @@ async fn shop_update_item(
Json(body): Json<ItemInput>,
) -> ApiResult<Json<FlashSaleItem>> {
let shop_id = auth.require_shop()?;
Ok(Json(
service::update_item(&state, shop_id, id, body).await?,
))
Ok(Json(service::update_item(&state, shop_id, id, body).await?))
}
async fn shop_delete_item(
+6 -8
View File
@@ -258,9 +258,11 @@ pub async fn overlapping_sale_exists<'e, E: PgExecutor<'e>>(
/// The group-buying capability lands after this one, so its overlap check is
/// dormant until its table exists.
pub async fn group_buying_table_exists<'e, E: PgExecutor<'e>>(exec: E) -> ApiResult<bool> {
Ok(sqlx::query_scalar("SELECT to_regclass('public.group_buying_activities') IS NOT NULL")
.fetch_one(exec)
.await?)
Ok(
sqlx::query_scalar("SELECT to_regclass('public.group_buying_activities') IS NOT NULL")
.fetch_one(exec)
.await?,
)
}
pub async fn overlapping_group_buying_exists(
@@ -356,11 +358,7 @@ pub async fn lock_active_items(
}
/// Activity units this customer already holds on non-cancelled orders.
pub async fn purchased_qty(
tx: &mut PgConnection,
user_id: Uuid,
item_id: Uuid,
) -> ApiResult<i64> {
pub async fn purchased_qty(tx: &mut PgConnection, user_id: Uuid, item_id: Uuid) -> ApiResult<i64> {
Ok(sqlx::query_scalar(
"SELECT COALESCE(SUM(oi.qty), 0)
FROM order_items oi
+1 -3
View File
@@ -33,9 +33,7 @@ async fn confirm_delivered(
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<ShipmentView>> {
Ok(Json(
service::confirm_delivered(&state, auth.id, id).await?,
))
Ok(Json(service::confirm_delivered(&state, auth.id, id).await?))
}
async fn create_shipment(
+6 -2
View File
@@ -125,10 +125,14 @@ pub async fn create(
body: ShipmentBody,
) -> ApiResult<ShipmentView> {
if body.carrier.trim().is_empty() || body.tracking_no.trim().is_empty() {
return Err(ApiError::BadRequest("carrier and tracking_no are required".into()));
return Err(ApiError::BadRequest(
"carrier and tracking_no are required".into(),
));
}
if body.items.is_empty() || body.items.iter().any(|i| i.qty <= 0) {
return Err(ApiError::BadRequest("items must be non-empty with qty > 0".into()));
return Err(ApiError::BadRequest(
"items must be non-empty with qty > 0".into(),
));
}
let mut tx = state.db.begin().await?;
let order = order_repo::lock_for_shop(&mut tx, shop_id, order_id).await?;
+20 -23
View File
@@ -2,9 +2,7 @@ use sqlx::{PgConnection, PgExecutor};
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::models::{
CollectiveGroup, GroupBuyingActivity, COLLECTIVE_GROUP_COLUMNS,
};
use crate::models::{CollectiveGroup, GroupBuyingActivity, COLLECTIVE_GROUP_COLUMNS};
use super::dto::{ActivityInput, ActivityView, OpenGroupView};
@@ -89,10 +87,7 @@ pub async fn get_own<'e, E: PgExecutor<'e>>(
.ok_or_else(|| ApiError::NotFound("group-buying activity".into()))
}
pub async fn get_by_id<'e, E: PgExecutor<'e>>(
exec: E,
id: Uuid,
) -> ApiResult<GroupBuyingActivity> {
pub async fn get_by_id<'e, E: PgExecutor<'e>>(exec: E, id: Uuid) -> ApiResult<GroupBuyingActivity> {
sqlx::query_as::<_, GroupBuyingActivity>(
"SELECT id, shop_id, sku_id, name, description, image, group_price_minor, currency,
required_members, starts_at, ends_at, group_lifetime_hours, enabled,
@@ -170,12 +165,11 @@ pub async fn update(
}
pub async fn delete(tx: &mut PgConnection, shop_id: Uuid, id: Uuid) -> ApiResult<()> {
let result =
sqlx::query("DELETE FROM group_buying_activities WHERE id = $1 AND shop_id = $2")
.bind(id)
.bind(shop_id)
.execute(&mut *tx)
.await?;
let result = sqlx::query("DELETE FROM group_buying_activities WHERE id = $1 AND shop_id = $2")
.bind(id)
.bind(shop_id)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::NotFound("group-buying activity".into()));
}
@@ -232,10 +226,7 @@ pub async fn open_groups_for_activities(
}
/// Joining only reads the group: a seat is claimed at payment, not checkout.
pub async fn get_group<'e, E: PgExecutor<'e>>(
exec: E,
id: Uuid,
) -> ApiResult<CollectiveGroup> {
pub async fn get_group<'e, E: PgExecutor<'e>>(exec: E, id: Uuid) -> ApiResult<CollectiveGroup> {
sqlx::query_as::<_, CollectiveGroup>(&format!(
"SELECT {COLLECTIVE_GROUP_COLUMNS} FROM collective_groups WHERE id = $1"
))
@@ -262,11 +253,13 @@ pub async fn insert_group(
/// Record which pending order opened the group.
pub async fn set_leader(tx: &mut PgConnection, group_id: Uuid, order_id: Uuid) -> ApiResult<()> {
sqlx::query("UPDATE collective_groups SET leader_order_id = $2, updated_at = now() WHERE id = $1")
.bind(group_id)
.bind(order_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"UPDATE collective_groups SET leader_order_id = $2, updated_at = now() WHERE id = $1",
)
.bind(group_id)
.bind(order_id)
.execute(&mut *tx)
.await?;
Ok(())
}
@@ -300,7 +293,11 @@ pub async fn insert_member(
}
/// One paid seat: increment and become successful exactly at capacity.
pub async fn claim_seat(tx: &mut PgConnection, group_id: Uuid, required: i32) -> ApiResult<CollectiveGroup> {
pub async fn claim_seat(
tx: &mut PgConnection,
group_id: Uuid,
required: i32,
) -> ApiResult<CollectiveGroup> {
Ok(sqlx::query_as::<_, CollectiveGroup>(&format!(
"UPDATE collective_groups
SET paid_member_count = paid_member_count + 1,
+1 -4
View File
@@ -19,9 +19,6 @@ pub async fn ready(State(state): State<AppState>) -> ApiResult<Json<Value>> {
.await
.is_ok();
let mut redis = state.redis.clone();
let redis_ok = redis
.set::<_, _, ()>("vmall:ready:ping", "1")
.await
.is_ok();
let redis_ok = redis.set::<_, _, ()>("vmall:ready:ping", "1").await.is_ok();
Ok(Json(json!({ "db": db_ok, "redis": redis_ok })))
}
+9 -2
View File
@@ -1,4 +1,9 @@
use axum::{extract::State, http::StatusCode, routing::{get, post}, Json, Router};
use axum::{
extract::State,
http::StatusCode,
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::Value;
@@ -49,7 +54,9 @@ async fn login(
State(state): State<AppState>,
Json(body): Json<LoginBody>,
) -> ApiResult<Json<Value>> {
Ok(Json(service::login(&state, &body.email, &body.password).await?))
Ok(Json(
service::login(&state, &body.email, &body.password).await?,
))
}
async fn me(State(state): State<AppState>, auth: AuthUser) -> ApiResult<Json<UserPublic>> {
+9 -7
View File
@@ -38,15 +38,17 @@ pub async fn find_by_id<'e, E: PgExecutor<'e>>(
exec: E,
id: Uuid,
) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>(&format!(
"SELECT {USER_COLUMNS} FROM users WHERE id = $1"
))
.bind(id)
.fetch_optional(exec)
.await
sqlx::query_as::<_, User>(&format!("SELECT {USER_COLUMNS} FROM users WHERE id = $1"))
.bind(id)
.fetch_optional(exec)
.await
}
pub async fn list_users(db: &PgPool, page: Option<i64>, per_page: Option<i64>) -> ApiResult<Paged<User>> {
pub async fn list_users(
db: &PgPool,
page: Option<i64>,
per_page: Option<i64>,
) -> ApiResult<Paged<User>> {
let page = clamp_page(page);
let per_page = clamp_per_page(per_page);
let total: i64 = sqlx::query_scalar("SELECT count(*) FROM users")
+37 -39
View File
@@ -68,33 +68,39 @@ pub async fn list_page(
offset: i64,
) -> ApiResult<Vec<Order>> {
Ok(match scope {
OrderScope::User(user_id) => sqlx::query_as::<_, Order>(&format!(
"SELECT {ORDER_COLS} FROM orders WHERE user_id = $1
OrderScope::User(user_id) => {
sqlx::query_as::<_, Order>(&format!(
"SELECT {ORDER_COLS} FROM orders WHERE user_id = $1
ORDER BY created_at DESC LIMIT $2 OFFSET $3"
))
.bind(user_id)
.bind(per_page)
.bind(offset)
.fetch_all(db)
.await?,
OrderScope::Shop(shop_id) => sqlx::query_as::<_, Order>(&format!(
"SELECT {ORDER_COLS} FROM orders
))
.bind(user_id)
.bind(per_page)
.bind(offset)
.fetch_all(db)
.await?
}
OrderScope::Shop(shop_id) => {
sqlx::query_as::<_, Order>(&format!(
"SELECT {ORDER_COLS} FROM orders
WHERE shop_id = $1 AND ($2::order_status IS NULL OR status = $2)
ORDER BY created_at DESC LIMIT $3 OFFSET $4"
))
.bind(shop_id)
.bind(status)
.bind(per_page)
.bind(offset)
.fetch_all(db)
.await?,
OrderScope::Admin => sqlx::query_as::<_, Order>(&format!(
"SELECT {ORDER_COLS} FROM orders ORDER BY created_at DESC LIMIT $1 OFFSET $2"
))
.bind(per_page)
.bind(offset)
.fetch_all(db)
.await?,
))
.bind(shop_id)
.bind(status)
.bind(per_page)
.bind(offset)
.fetch_all(db)
.await?
}
OrderScope::Admin => {
sqlx::query_as::<_, Order>(&format!(
"SELECT {ORDER_COLS} FROM orders ORDER BY created_at DESC LIMIT $1 OFFSET $2"
))
.bind(per_page)
.bind(offset)
.fetch_all(db)
.await?
}
})
}
@@ -120,11 +126,7 @@ pub async fn get_for_shop(db: &PgPool, shop_id: Uuid, id: Uuid) -> ApiResult<Ord
.ok_or_else(|| ApiError::NotFound("order".into()))
}
pub async fn lock_for_shop(
tx: &mut PgConnection,
shop_id: Uuid,
id: Uuid,
) -> ApiResult<Order> {
pub async fn lock_for_shop(tx: &mut PgConnection, shop_id: Uuid, id: Uuid) -> ApiResult<Order> {
sqlx::query_as::<_, Order>(&format!(
"SELECT {ORDER_COLS} FROM orders WHERE id = $1 AND shop_id = $2 FOR UPDATE"
))
@@ -198,13 +200,11 @@ pub async fn insert_item(
}
pub async fn decrement_stock(tx: &mut PgConnection, sku_id: Uuid, qty: i32) -> ApiResult<()> {
let result = sqlx::query(
"UPDATE skus SET stock = stock - $2 WHERE id = $1 AND stock >= $2",
)
.bind(sku_id)
.bind(qty)
.execute(&mut *tx)
.await?;
let result = sqlx::query("UPDATE skus SET stock = stock - $2 WHERE id = $1 AND stock >= $2")
.bind(sku_id)
.bind(qty)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::Conflict("insufficient stock".into()));
}
@@ -233,9 +233,7 @@ pub async fn mark_paid(tx: &mut PgConnection, id: Uuid) -> ApiResult<Order> {
.bind(id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| {
ApiError::Conflict("order not payable (missing or wrong status)".into())
})
.ok_or_else(|| ApiError::Conflict("order not payable (missing or wrong status)".into()))
}
pub async fn cancel(tx: &mut PgConnection, user_id: Uuid, id: Uuid) -> ApiResult<Order> {
+12 -16
View File
@@ -5,9 +5,9 @@ use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::http::{clamp_page, clamp_per_page, Paged};
use crate::models::OrderStatus;
use crate::money::convert_minor;
use crate::modules::group_buying::GroupBuyIntent;
use crate::modules::{cart, coupon, flash_sale, group_buying};
use crate::money::convert_minor;
use crate::state::AppState;
use super::dto::{AddressBody, OrderScope, OrderView};
@@ -45,14 +45,7 @@ pub async fn list(
let page = clamp_page(page);
let per_page = clamp_per_page(per_page);
let total = repo::count(&state.db, scope, status).await?;
let orders = repo::list_page(
&state.db,
scope,
status,
per_page,
(page - 1) * per_page,
)
.await?;
let orders = repo::list_page(&state.db, scope, status, per_page, (page - 1) * per_page).await?;
let items = repo::attach_items(&state.db, orders).await?;
Ok(Paged {
items,
@@ -186,9 +179,7 @@ pub async fn checkout(
let index = lines
.iter()
.position(|line| line.sku_id == intent.sku_id)
.ok_or_else(|| {
ApiError::BadRequest("group-buying SKU is not in the cart".into())
})?;
.ok_or_else(|| ApiError::BadRequest("group-buying SKU is not in the cart".into()))?;
let qty = lines[index].normal_qty + lines[index].activity_qty;
if qty != 1 {
return Err(ApiError::BadRequest(
@@ -248,8 +239,10 @@ pub async fn checkout(
let address = serde_json::to_value(&shipping_address).map_err(ApiError::internal)?;
let mut created = Vec::new();
for shop_id in shop_order {
let shop_lines: Vec<&CheckoutLine> =
lines.iter().filter(|line| line.shop_id == shop_id).collect();
let shop_lines: Vec<&CheckoutLine> = lines
.iter()
.filter(|line| line.shop_id == shop_id)
.collect();
let subtotal = subtotal_by_shop.get(&shop_id).copied().unwrap_or(0);
let selected_coupon = coupon_by_shop.get(&shop_id).copied();
// Eligibility and the discount are resolved server-side; the client
@@ -326,7 +319,8 @@ pub async fn checkout(
}
// The whole ordered quantity leaves SKU stock; only the activity
// portion also leaves reserved activity stock.
repo::decrement_stock(&mut tx, line.sku_id, line.normal_qty + line.activity_qty).await?;
repo::decrement_stock(&mut tx, line.sku_id, line.normal_qty + line.activity_qty)
.await?;
}
created.push(order);
}
@@ -340,7 +334,9 @@ pub async fn pay(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult<OrderVi
let mut tx = state.db.begin().await?;
let order = repo::lock_for_user(&mut tx, user_id, id).await?;
if order.status != OrderStatus::PendingPayment {
return Err(ApiError::Conflict("order not payable (wrong status)".into()));
return Err(ApiError::Conflict(
"order not payable (wrong status)".into(),
));
}
// A group order claims its paid seat in the same transaction as payment.
if let Some(group_id) = order.group_id {
+6 -8
View File
@@ -21,21 +21,19 @@ pub fn router() -> Router<AppState> {
// Public catalog; redemption and history are customer-scoped.
.route("/points/products", get(list_published))
.route("/points/redemptions", get(list_mine).post(redeem))
.route(
"/admin/points/products",
get(admin_list).post(admin_create),
)
.route("/admin/points/products", get(admin_list).post(admin_create))
.route("/admin/points/products/{id}", put(admin_update))
.route("/admin/points/products/{id}/publish", post(admin_publish))
.route("/admin/points/products/{id}/unpublish", post(admin_unpublish))
.route(
"/admin/points/products/{id}/unpublish",
post(admin_unpublish),
)
.route("/admin/points/orders", get(admin_orders))
.route("/admin/points/orders/{id}/fulfill", post(admin_fulfill))
.route("/admin/points/orders/{id}/cancel", post(admin_cancel))
}
async fn list_published(
State(state): State<AppState>,
) -> ApiResult<Json<Vec<IntegralProduct>>> {
async fn list_published(State(state): State<AppState>) -> ApiResult<Json<Vec<IntegralProduct>>> {
Ok(Json(service::list_published(&state).await?))
}
+7 -7
View File
@@ -6,8 +6,8 @@ use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::http::{clamp_page, clamp_per_page, Paged};
use crate::models::{
IntegralOrder, IntegralOrderItem, IntegralOrderStatus, IntegralProduct,
INTEGRAL_ORDER_COLUMNS, INTEGRAL_ORDER_ITEM_COLUMNS, INTEGRAL_PRODUCT_COLUMNS,
IntegralOrder, IntegralOrderItem, IntegralOrderStatus, IntegralProduct, INTEGRAL_ORDER_COLUMNS,
INTEGRAL_ORDER_ITEM_COLUMNS, INTEGRAL_PRODUCT_COLUMNS,
};
use super::dto::{IntegralProductInput, RedemptionView};
@@ -35,10 +35,7 @@ pub async fn list_all<'e, E: PgExecutor<'e>>(exec: E) -> ApiResult<Vec<IntegralP
/// Lock a published product for redemption. An unpublished or missing product
/// is a 404 so customers cannot probe the draft catalog.
pub async fn lock_published(
tx: &mut PgConnection,
id: Uuid,
) -> ApiResult<IntegralProduct> {
pub async fn lock_published(tx: &mut PgConnection, id: Uuid) -> ApiResult<IntegralProduct> {
sqlx::query_as::<_, IntegralProduct>(&format!(
"SELECT {INTEGRAL_PRODUCT_COLUMNS} FROM integral_products
WHERE id = $1 AND published = TRUE
@@ -242,7 +239,10 @@ pub async fn transition(
.ok_or_else(|| ApiError::Conflict("redemption order is not in the expected state".into()))
}
pub async fn attach_items(db: &PgPool, orders: Vec<IntegralOrder>) -> ApiResult<Vec<RedemptionView>> {
pub async fn attach_items(
db: &PgPool,
orders: Vec<IntegralOrder>,
) -> ApiResult<Vec<RedemptionView>> {
let ids: Vec<Uuid> = orders.iter().map(|o| o.id).collect();
let items = if ids.is_empty() {
Vec::new()
+1 -3
View File
@@ -163,9 +163,7 @@ async fn transition(
fn validate_product(body: &IntegralProductInput) -> ApiResult<()> {
bilingual(&body.name, "name")?;
if body.points_price <= 0 {
return Err(ApiError::BadRequest(
"points_price must be positive".into(),
));
return Err(ApiError::BadRequest("points_price must be positive".into()));
}
if body.stock < 0 {
return Err(ApiError::BadRequest("stock must not be negative".into()));
+5 -7
View File
@@ -34,13 +34,11 @@ const SELECT_PROFILE: &str = "SELECT s.id, s.slug, s.name,
const SHOP_COLS: &str = "id, name, slug, status, created_at";
pub async fn get_by_id(state: &AppState, id: Uuid) -> ApiResult<Shop> {
sqlx::query_as::<_, Shop>(&format!(
"SELECT {SHOP_COLS} FROM shops WHERE id = $1"
))
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| ApiError::NotFound("shop".into()))
sqlx::query_as::<_, Shop>(&format!("SELECT {SHOP_COLS} FROM shops WHERE id = $1"))
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| ApiError::NotFound("shop".into()))
}
pub async fn list_admin(state: &AppState) -> ApiResult<Vec<Shop>> {
+4 -5
View File
@@ -8,11 +8,10 @@ use crate::state::AppState;
/// Idempotent dev seed: platform admin account.
pub async fn ensure_platform_admin(state: &AppState) -> anyhow::Result<()> {
let email = "admin@vmall.local";
let exists: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)")
.bind(email)
.fetch_one(&state.db)
.await?;
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)")
.bind(email)
.fetch_one(&state.db)
.await?;
if exists {
return Ok(());
}