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
+133
View File
@@ -0,0 +1,133 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::{unique_conflict, ApiError, ApiResult};
use crate::models::{Invoice, InvoiceKind, OrderStatus};
use crate::modules::order::repo as order_repo;
use crate::state::AppState;
#[derive(Debug, Serialize)]
pub struct InvoiceView {
#[serde(flatten)]
pub invoice: Invoice,
pub order_no: String,
}
#[derive(Deserialize)]
pub struct InvoiceBody {
pub title: String,
pub tax_no: Option<String>,
pub kind: InvoiceKind,
}
const INVOICE_COLS: &str = "id, invoice_no, order_id, user_id, title, tax_no, kind,
amount_minor, currency, status, issued_at, created_at";
const INVOICE_I: &str = "i.id, i.invoice_no, i.order_id, i.user_id, i.title, i.tax_no, i.kind,
i.amount_minor, i.currency, i.status, i.issued_at, i.created_at";
async fn views(db: &PgPool, invoices: Vec<Invoice>) -> ApiResult<Vec<InvoiceView>> {
let order_ids: Vec<Uuid> = invoices.iter().map(|i| i.order_id).collect();
let order_nos: Vec<(Uuid, String)> = if order_ids.is_empty() {
Vec::new()
} else {
sqlx::query_as("SELECT id, order_no FROM orders WHERE id = ANY($1)")
.bind(&order_ids)
.fetch_all(db)
.await?
};
let nos: HashMap<Uuid, String> = order_nos.into_iter().collect();
Ok(invoices
.into_iter()
.map(|i| InvoiceView {
order_no: nos.get(&i.order_id).cloned().unwrap_or_default(),
invoice: i,
})
.collect())
}
pub async fn list_for_user(state: &AppState, user_id: Uuid) -> ApiResult<Vec<InvoiceView>> {
let invoices = sqlx::query_as::<_, Invoice>(&format!(
"SELECT {INVOICE_COLS} FROM invoices WHERE user_id = $1 ORDER BY created_at DESC"
))
.bind(user_id)
.fetch_all(&state.db)
.await?;
views(&state.db, invoices).await
}
pub async fn list_for_shop(state: &AppState, shop_id: Uuid) -> ApiResult<Vec<InvoiceView>> {
let invoices = sqlx::query_as::<_, Invoice>(&format!(
"SELECT {INVOICE_I} FROM invoices i
JOIN orders o ON o.id = i.order_id
WHERE o.shop_id = $1
ORDER BY i.created_at DESC"
))
.bind(shop_id)
.fetch_all(&state.db)
.await?;
views(&state.db, invoices).await
}
pub async fn request(
state: &AppState,
user_id: Uuid,
order_id: Uuid,
body: InvoiceBody,
) -> ApiResult<InvoiceView> {
let order = order_repo::get_for_user(&state.db, user_id, order_id).await?;
if matches!(
order.status,
OrderStatus::PendingPayment | OrderStatus::Cancelled
) {
return Err(ApiError::BadRequest(
"invoices can only be requested for paid orders".into(),
));
}
if body.title.trim().is_empty() {
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)
{
return Err(ApiError::BadRequest(
"tax_no is required for company invoices".into(),
));
}
let invoice = sqlx::query_as::<_, Invoice>(&format!(
"INSERT INTO invoices (order_id, user_id, title, tax_no, kind, amount_minor, currency)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING {INVOICE_COLS}"
))
.bind(order.id)
.bind(user_id)
.bind(body.title.trim())
.bind(body.tax_no.as_deref().map(str::trim))
.bind(body.kind)
.bind(order.total_minor)
.bind(&order.currency)
.fetch_one(&state.db)
.await
.map_err(|e| unique_conflict(e, "order already has an open invoice"))?;
let mut out = views(&state.db, vec![invoice]).await?;
Ok(out.remove(0))
}
pub async fn issue(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult<InvoiceView> {
let invoice = sqlx::query_as::<_, Invoice>(&format!(
"UPDATE invoices i
SET status = 'issued', issued_at = now(),
invoice_no = 'INV' || to_char(now(), 'YYMMDD') || lpad(nextval('invoice_no_seq')::text, 6, '0')
FROM orders o
WHERE i.id = $1 AND o.id = i.order_id AND o.shop_id = $2 AND i.status = 'requested'
RETURNING {INVOICE_I}"
))
.bind(id)
.bind(shop_id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| ApiError::Conflict("invoice not found or not in requested status".into()))?;
let mut out = views(&state.db, vec![invoice]).await?;
Ok(out.remove(0))
}