refactor(api): split catalog module into product, category, brand
Rearranges the Rust backend by domain: `modules/catalog` bundled four distinct concepts (Product, SKU, Category, Brand) behind one router and one 392-line service file, unlike every other module in the codebase which owns exactly one bounded aggregate. Splits into `modules/product` (Product/SKU, publish lifecycle, shop-scoped CRUD), `modules/category` (category tree, subtree query), and `modules/brand` (brand list, admin replace-all). `Category`/`Brand` move out of the shared `models.rs` into their owning modules; `Product`/`Sku` stay since `favorite`/`flash_sale`/`group_buying` reference them across modules. Purely internal restructuring — no route, schema, or behavior changes. Implements openspec change split-catalog-into-product-category-brand. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0b594c0147
commit
8446650baf
@@ -86,24 +86,6 @@ pub enum ProductStatus {
|
||||
Unpublished,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct Category {
|
||||
pub id: Uuid,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub name: serde_json::Value,
|
||||
pub slug: String,
|
||||
pub position: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct Brand {
|
||||
pub id: Uuid,
|
||||
pub name: serde_json::Value,
|
||||
pub slug: String,
|
||||
pub position: i32,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct Product {
|
||||
pub id: Uuid,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct Brand {
|
||||
pub id: Uuid,
|
||||
pub name: serde_json::Value,
|
||||
pub slug: String,
|
||||
pub position: i32,
|
||||
pub active: bool,
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use axum::{routing::{get, put}, extract::State, Json, Router};
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::error::ApiResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::dto::Brand;
|
||||
use super::service::{self, BrandInput};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/brands", get(list_brands))
|
||||
.route("/admin/brands", put(replace_brands))
|
||||
}
|
||||
|
||||
async fn list_brands(State(state): State<AppState>) -> ApiResult<Json<Vec<Brand>>> {
|
||||
Ok(Json(service::list_brands(&state).await?))
|
||||
}
|
||||
|
||||
async fn replace_brands(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(body): Json<Vec<BrandInput>>,
|
||||
) -> ApiResult<Json<Vec<Brand>>> {
|
||||
auth.require_admin()?;
|
||||
Ok(Json(service::replace_brands(&state, body).await?))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
mod dto;
|
||||
mod handlers;
|
||||
pub mod service;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
handlers::router()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use crate::error::{unique_conflict, ApiError, ApiResult};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::dto::Brand;
|
||||
|
||||
pub async fn list_brands(state: &AppState) -> ApiResult<Vec<Brand>> {
|
||||
Ok(sqlx::query_as::<_, Brand>(
|
||||
"SELECT id, name, slug, position, active FROM brands WHERE active = TRUE ORDER BY position, created_at",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct BrandInput {
|
||||
pub slug: String,
|
||||
pub name: serde_json::Value,
|
||||
#[serde(default = "default_active")]
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
fn default_active() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn replace_brands(state: &AppState, body: Vec<BrandInput>) -> ApiResult<Vec<Brand>> {
|
||||
for brand in &body {
|
||||
if brand.slug.trim().is_empty()
|
||||
|| !brand
|
||||
.slug
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||
{
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"invalid brand slug: {}",
|
||||
brand.slug
|
||||
)));
|
||||
}
|
||||
let ok = ["en", "zh"].iter().all(|code| {
|
||||
brand
|
||||
.name
|
||||
.get(code)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|s| !s.trim().is_empty())
|
||||
});
|
||||
if !ok {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"brand {} needs non-empty en and zh names",
|
||||
brand.slug
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let mut tx = state.db.begin().await?;
|
||||
sqlx::query("DELETE FROM brands").execute(&mut *tx).await?;
|
||||
for (i, brand) in body.iter().enumerate() {
|
||||
sqlx::query("INSERT INTO brands (name, slug, position, active) VALUES ($1, $2, $3, $4)")
|
||||
.bind(&brand.name)
|
||||
.bind(brand.slug.trim())
|
||||
.bind(i as i32)
|
||||
.bind(brand.active)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| unique_conflict(e, format!("duplicate brand slug: {}", brand.slug)))?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
list_brands(state).await
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct Category {
|
||||
pub id: Uuid,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub name: serde_json::Value,
|
||||
pub slug: String,
|
||||
pub position: i32,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use axum::{extract::State, routing::get, Json, Router};
|
||||
|
||||
use crate::error::ApiResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::dto::Category;
|
||||
use super::service;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/categories", get(list_categories))
|
||||
}
|
||||
|
||||
async fn list_categories(State(state): State<AppState>) -> ApiResult<Json<Vec<Category>>> {
|
||||
Ok(Json(service::list_categories(&state).await?))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
mod dto;
|
||||
mod handlers;
|
||||
pub mod service;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
handlers::router()
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
use crate::error::ApiResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::dto::Category;
|
||||
|
||||
pub(crate) const SUBTREE_CTE: &str = "WITH RECURSIVE subtree AS (
|
||||
SELECT id FROM categories WHERE id = $1::uuid
|
||||
UNION ALL
|
||||
SELECT c.id FROM categories c JOIN subtree st ON c.parent_id = st.id
|
||||
) ";
|
||||
|
||||
pub async fn list_categories(state: &AppState) -> ApiResult<Vec<Category>> {
|
||||
Ok(sqlx::query_as::<_, Category>(
|
||||
"SELECT id, parent_id, name, slug, position FROM categories ORDER BY position, slug",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
pub mod account;
|
||||
pub mod address;
|
||||
pub mod billing;
|
||||
pub mod brand;
|
||||
pub mod cart;
|
||||
pub mod catalog;
|
||||
pub mod category;
|
||||
pub mod content;
|
||||
pub mod coupon;
|
||||
pub mod currency;
|
||||
@@ -14,6 +15,7 @@ pub mod health;
|
||||
pub mod identity;
|
||||
pub mod order;
|
||||
pub mod points;
|
||||
pub mod product;
|
||||
pub mod shop;
|
||||
|
||||
use axum::Router;
|
||||
@@ -27,7 +29,9 @@ pub fn api_router() -> Router<AppState> {
|
||||
.merge(address::router())
|
||||
.merge(identity::router())
|
||||
.merge(currency::router())
|
||||
.merge(catalog::router())
|
||||
.merge(product::router())
|
||||
.merge(category::router())
|
||||
.merge(brand::router())
|
||||
.merge(content::router())
|
||||
.merge(cart::router())
|
||||
.merge(coupon::router())
|
||||
|
||||
+3
-23
@@ -1,7 +1,7 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post, put},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
@@ -10,19 +10,16 @@ use uuid::Uuid;
|
||||
use crate::auth::AuthUser;
|
||||
use crate::error::ApiResult;
|
||||
use crate::http::Paged;
|
||||
use crate::models::{Brand, Category, ProductStatus, Sku};
|
||||
use crate::models::{ProductStatus, Sku};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::dto::{ProductBody, ProductWithSkus, PublicListQuery, SkuBody};
|
||||
use super::service::{self, BrandInput};
|
||||
use super::service;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/products", get(list_products))
|
||||
.route("/products/{id_or_slug}", get(get_product))
|
||||
.route("/categories", get(list_categories))
|
||||
.route("/brands", get(list_brands))
|
||||
.route("/admin/brands", put(replace_brands))
|
||||
.route("/shop/products", get(shop_list).post(create_product))
|
||||
.route("/shop/products/{id}", get(shop_get).put(update_product))
|
||||
.route("/shop/products/{id}/publish", post(publish))
|
||||
@@ -44,23 +41,6 @@ async fn get_product(
|
||||
Ok(Json(service::get_public(&state, &id_or_slug).await?))
|
||||
}
|
||||
|
||||
async fn list_categories(State(state): State<AppState>) -> ApiResult<Json<Vec<Category>>> {
|
||||
Ok(Json(service::list_categories(&state).await?))
|
||||
}
|
||||
|
||||
async fn list_brands(State(state): State<AppState>) -> ApiResult<Json<Vec<Brand>>> {
|
||||
Ok(Json(service::list_brands(&state).await?))
|
||||
}
|
||||
|
||||
async fn replace_brands(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(body): Json<Vec<BrandInput>>,
|
||||
) -> ApiResult<Json<Vec<Brand>>> {
|
||||
auth.require_admin()?;
|
||||
Ok(Json(service::replace_brands(&state, body).await?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ShopListQuery {
|
||||
page: Option<i64>,
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::error::{unique_conflict, ApiError, ApiResult};
|
||||
use crate::http::{clamp_page, clamp_per_page, Paged};
|
||||
use crate::models::{Brand, Category, Product, ProductStatus, Sku};
|
||||
use crate::models::{Product, ProductStatus, Sku};
|
||||
use crate::modules::category::service::SUBTREE_CTE;
|
||||
use crate::state::AppState;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -14,12 +15,6 @@ const SOLD_UNITS: &str = "(SELECT COALESCE(SUM(oi.qty), 0)
|
||||
WHERE sk.product_id = p.id
|
||||
AND o.status IN ('paid', 'fulfilling', 'shipped', 'completed'))";
|
||||
|
||||
const SUBTREE_CTE: &str = "WITH RECURSIVE subtree AS (
|
||||
SELECT id FROM categories WHERE id = $1::uuid
|
||||
UNION ALL
|
||||
SELECT c.id FROM categories c JOIN subtree st ON c.parent_id = st.id
|
||||
) ";
|
||||
|
||||
const MIN_PRICE: &str =
|
||||
"(SELECT MIN(price_minor) FROM skus WHERE product_id = p.id AND active = TRUE)";
|
||||
|
||||
@@ -111,78 +106,6 @@ pub async fn get_public(state: &AppState, id_or_slug: &str) -> ApiResult<Product
|
||||
Ok(items.remove(0))
|
||||
}
|
||||
|
||||
pub async fn list_categories(state: &AppState) -> ApiResult<Vec<Category>> {
|
||||
Ok(sqlx::query_as::<_, Category>(
|
||||
"SELECT id, parent_id, name, slug, position FROM categories ORDER BY position, slug",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn list_brands(state: &AppState) -> ApiResult<Vec<Brand>> {
|
||||
Ok(sqlx::query_as::<_, Brand>(
|
||||
"SELECT id, name, slug, position, active FROM brands WHERE active = TRUE ORDER BY position, created_at",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct BrandInput {
|
||||
pub slug: String,
|
||||
pub name: serde_json::Value,
|
||||
#[serde(default = "default_active")]
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
fn default_active() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn replace_brands(state: &AppState, body: Vec<BrandInput>) -> ApiResult<Vec<Brand>> {
|
||||
for brand in &body {
|
||||
if brand.slug.trim().is_empty()
|
||||
|| !brand
|
||||
.slug
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||
{
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"invalid brand slug: {}",
|
||||
brand.slug
|
||||
)));
|
||||
}
|
||||
let ok = ["en", "zh"].iter().all(|code| {
|
||||
brand
|
||||
.name
|
||||
.get(code)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|s| !s.trim().is_empty())
|
||||
});
|
||||
if !ok {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"brand {} needs non-empty en and zh names",
|
||||
brand.slug
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let mut tx = state.db.begin().await?;
|
||||
sqlx::query("DELETE FROM brands").execute(&mut *tx).await?;
|
||||
for (i, brand) in body.iter().enumerate() {
|
||||
sqlx::query("INSERT INTO brands (name, slug, position, active) VALUES ($1, $2, $3, $4)")
|
||||
.bind(&brand.name)
|
||||
.bind(brand.slug.trim())
|
||||
.bind(i as i32)
|
||||
.bind(brand.active)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| unique_conflict(e, format!("duplicate brand slug: {}", brand.slug)))?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
list_brands(state).await
|
||||
}
|
||||
|
||||
async fn load_own_product(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult<Product> {
|
||||
sqlx::query_as::<_, Product>(
|
||||
"SELECT id, shop_id, category_id, brand_id, slug, name, description, images, status, created_at, updated_at
|
||||
Reference in New Issue
Block a user