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
+93
View File
@@ -0,0 +1,93 @@
use axum::{
extract::{Path, State},
http::StatusCode,
routing::{get, put},
Json, Router,
};
use serde::Deserialize;
use serde_json::Value;
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::error::ApiResult;
use crate::models::{Shop, ShopStatus};
use crate::state::AppState;
use super::service::{self, ProfileBody, ShopProfileView};
pub fn router() -> Router<AppState> {
Router::new()
.route("/shop/profile", get(my_shop))
.route("/shops", get(list_shops))
.route("/shops/{slug}", get(get_shop))
.route("/admin/shops", get(admin_list_shops).post(create_shop))
.route("/admin/shops/{id}/status", put(set_shop_status))
.route("/admin/shops/{id}/profile", put(set_shop_profile))
}
async fn my_shop(State(state): State<AppState>, auth: AuthUser) -> ApiResult<Json<Shop>> {
let shop_id = auth.require_shop()?;
Ok(Json(service::get_by_id(&state, shop_id).await?))
}
async fn list_shops(State(state): State<AppState>) -> ApiResult<Json<Vec<ShopProfileView>>> {
Ok(Json(service::list_active_profiles(&state).await?))
}
async fn get_shop(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> ApiResult<Json<ShopProfileView>> {
Ok(Json(service::get_active_by_slug(&state, &slug).await?))
}
async fn admin_list_shops(
State(state): State<AppState>,
auth: AuthUser,
) -> ApiResult<Json<Vec<Shop>>> {
auth.require_admin()?;
Ok(Json(service::list_admin(&state).await?))
}
#[derive(Deserialize)]
struct CreateShopBody {
name: Value,
slug: String,
}
async fn create_shop(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<CreateShopBody>,
) -> ApiResult<(StatusCode, Json<Shop>)> {
auth.require_admin()?;
Ok((
StatusCode::CREATED,
Json(service::create(&state, body.name, body.slug).await?),
))
}
#[derive(Deserialize)]
struct SetStatusBody {
status: ShopStatus,
}
async fn set_shop_status(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<SetStatusBody>,
) -> ApiResult<Json<Shop>> {
auth.require_admin()?;
Ok(Json(service::set_status(&state, id, body.status).await?))
}
async fn set_shop_profile(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<ProfileBody>,
) -> ApiResult<Json<ShopProfileView>> {
auth.require_admin()?;
Ok(Json(service::set_profile(&state, id, body).await?))
}
+10
View File
@@ -0,0 +1,10 @@
mod handlers;
pub mod service;
use axum::Router;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
handlers::router()
}
+193
View File
@@ -0,0 +1,193 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use crate::error::{unique_conflict, ApiError, ApiResult};
use crate::models::{Shop, ShopStatus};
use crate::state::AppState;
/// A shop plus whatever profile it has.
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct ShopProfileView {
pub id: Uuid,
pub slug: String,
pub name: Value,
pub company: Option<String>,
pub region: Option<String>,
pub address: Option<Value>,
pub logo: Option<String>,
pub banner: Option<String>,
pub notice: Option<Value>,
pub after_sale: Option<Value>,
pub score_rating: Option<f64>,
pub score_agreement: Option<f64>,
pub score_service: Option<f64>,
pub score_speed: Option<f64>,
}
const SELECT_PROFILE: &str = "SELECT s.id, s.slug, s.name,
p.company, p.region, p.address, p.logo, p.banner, p.notice, p.after_sale,
p.score_rating, p.score_agreement, p.score_service, p.score_speed
FROM shops s
LEFT JOIN shop_profiles p ON p.shop_id = s.id";
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()))
}
pub async fn list_admin(state: &AppState) -> ApiResult<Vec<Shop>> {
Ok(sqlx::query_as::<_, Shop>(&format!(
"SELECT {SHOP_COLS} FROM shops ORDER BY created_at"
))
.fetch_all(&state.db)
.await?)
}
pub async fn list_active_profiles(state: &AppState) -> ApiResult<Vec<ShopProfileView>> {
Ok(sqlx::query_as::<_, ShopProfileView>(&format!(
"{SELECT_PROFILE} WHERE s.status = 'active' ORDER BY s.created_at, s.slug"
))
.fetch_all(&state.db)
.await?)
}
pub async fn get_active_by_slug(state: &AppState, slug: &str) -> ApiResult<ShopProfileView> {
sqlx::query_as::<_, ShopProfileView>(&format!(
"{SELECT_PROFILE} WHERE s.slug = $1 AND s.status = 'active'"
))
.bind(slug)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| ApiError::NotFound("shop".into()))
}
pub async fn create(state: &AppState, name: Value, slug: String) -> ApiResult<Shop> {
let name_en = name.get("en").and_then(|v| v.as_str()).unwrap_or("");
if name_en.trim().is_empty() {
return Err(ApiError::BadRequest("name.en is required".into()));
}
if slug.trim().is_empty() || !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return Err(ApiError::BadRequest("invalid slug".into()));
}
sqlx::query_as::<_, Shop>(&format!(
"INSERT INTO shops (name, slug) VALUES ($1, $2) RETURNING {SHOP_COLS}"
))
.bind(&name)
.bind(slug.trim())
.fetch_one(&state.db)
.await
.map_err(|e| unique_conflict(e, "slug already exists"))
}
pub async fn set_status(state: &AppState, id: Uuid, status: ShopStatus) -> ApiResult<Shop> {
sqlx::query_as::<_, Shop>(&format!(
"UPDATE shops SET status = $2 WHERE id = $1 RETURNING {SHOP_COLS}"
))
.bind(id)
.bind(status)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| ApiError::NotFound("shop".into()))
}
#[derive(Debug, Deserialize)]
pub struct ProfileBody {
pub logo: Option<String>,
pub banner: Option<String>,
pub company: Option<String>,
pub region: Option<String>,
pub address: Option<Value>,
pub notice: Option<Value>,
pub after_sale: Option<Value>,
pub score_rating: Option<f64>,
pub score_agreement: Option<f64>,
pub score_service: Option<f64>,
pub score_speed: Option<f64>,
}
fn bilingual(label: &Value, field: &str) -> ApiResult<()> {
let ok = ["en", "zh"].iter().all(|code| {
label
.get(code)
.and_then(Value::as_str)
.is_some_and(|s| !s.trim().is_empty())
});
if !ok {
return Err(ApiError::BadRequest(format!(
"{field} needs non-empty en and zh"
)));
}
Ok(())
}
pub async fn set_profile(
state: &AppState,
id: Uuid,
body: ProfileBody,
) -> ApiResult<ShopProfileView> {
for (value, field) in [
(&body.address, "address"),
(&body.notice, "notice"),
(&body.after_sale, "after_sale"),
] {
if let Some(label) = value {
bilingual(label, field)?;
}
}
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM shops WHERE id = $1)")
.bind(id)
.fetch_one(&state.db)
.await?;
if !exists {
return Err(ApiError::NotFound("shop".into()));
}
sqlx::query(
"INSERT INTO shop_profiles (shop_id, logo, banner, company, region, address, notice,
after_sale, score_rating, score_agreement, score_service,
score_speed, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now())
ON CONFLICT (shop_id) DO UPDATE SET
logo = EXCLUDED.logo,
banner = EXCLUDED.banner,
company = EXCLUDED.company,
region = EXCLUDED.region,
address = EXCLUDED.address,
notice = EXCLUDED.notice,
after_sale = EXCLUDED.after_sale,
score_rating = EXCLUDED.score_rating,
score_agreement = EXCLUDED.score_agreement,
score_service = EXCLUDED.score_service,
score_speed = EXCLUDED.score_speed,
updated_at = now()",
)
.bind(id)
.bind(&body.logo)
.bind(&body.banner)
.bind(&body.company)
.bind(&body.region)
.bind(&body.address)
.bind(&body.notice)
.bind(&body.after_sale)
.bind(body.score_rating)
.bind(body.score_agreement)
.bind(body.score_service)
.bind(body.score_speed)
.execute(&state.db)
.await?;
sqlx::query_as::<_, ShopProfileView>(&format!("{SELECT_PROFILE} WHERE s.id = $1"))
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| ApiError::NotFound("shop".into()))
}