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:
co-authored by
Cursor
parent
e457339847
commit
5e866d08f4
@@ -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?))
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
mod handlers;
|
||||
pub mod service;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
handlers::router()
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
Reference in New Issue
Block a user