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:
Chengdong Zhang
2026-09-22 16:07:44 +08:00
co-authored by Claude Sonnet 5
parent 0b594c0147
commit 8446650baf
22 changed files with 450 additions and 122 deletions
+71
View File
@@ -0,0 +1,71 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::models::{Product, Sku};
#[derive(Debug, Serialize)]
pub struct ProductWithSkus {
#[serde(flatten)]
pub product: Product,
pub skus: Vec<Sku>,
pub sold_count: i64,
}
#[derive(Deserialize)]
pub struct PublicListQuery {
pub page: Option<i64>,
pub per_page: Option<i64>,
pub category_id: Option<Uuid>,
pub brand_id: Option<Uuid>,
pub shop_id: Option<Uuid>,
pub q: Option<String>,
pub sort: Option<String>,
pub order: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortBy {
Newest,
Price,
Sales,
}
impl PublicListQuery {
pub fn sort_by(&self) -> ApiResult<SortBy> {
match self.sort.as_deref() {
None => Ok(SortBy::Newest),
Some("price") => Ok(SortBy::Price),
Some("sales") => Ok(SortBy::Sales),
Some(other) => Err(ApiError::BadRequest(format!("unsupported sort: {other}"))),
}
}
pub fn ascending(&self) -> ApiResult<bool> {
match self.order.as_deref() {
None | Some("asc") => Ok(true),
Some("desc") => Ok(false),
Some(other) => Err(ApiError::BadRequest(format!("unsupported order: {other}"))),
}
}
}
#[derive(Deserialize)]
pub struct ProductBody {
pub category_id: Option<Uuid>,
pub brand_id: Option<Uuid>,
pub slug: String,
pub name: serde_json::Value,
pub description: Option<serde_json::Value>,
pub images: Option<serde_json::Value>,
}
#[derive(Deserialize)]
pub struct SkuBody {
pub sku_code: String,
pub attributes: Option<serde_json::Value>,
pub price_minor: i64,
pub currency: String,
pub stock: i32,
pub active: Option<bool>,
}
+125
View File
@@ -0,0 +1,125 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::error::ApiResult;
use crate::http::Paged;
use crate::models::{ProductStatus, Sku};
use crate::state::AppState;
use super::dto::{ProductBody, ProductWithSkus, PublicListQuery, SkuBody};
use super::service;
pub fn router() -> Router<AppState> {
Router::new()
.route("/products", get(list_products))
.route("/products/{id_or_slug}", get(get_product))
.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))
.route("/shop/products/{id}/unpublish", post(unpublish))
.route("/shop/products/{id}/skus", post(upsert_sku))
}
async fn list_products(
State(state): State<AppState>,
Query(q): Query<PublicListQuery>,
) -> ApiResult<Json<Paged<ProductWithSkus>>> {
Ok(Json(service::list_public(&state, q).await?))
}
async fn get_product(
State(state): State<AppState>,
Path(id_or_slug): Path<String>,
) -> ApiResult<Json<ProductWithSkus>> {
Ok(Json(service::get_public(&state, &id_or_slug).await?))
}
#[derive(Deserialize)]
struct ShopListQuery {
page: Option<i64>,
per_page: Option<i64>,
status: Option<ProductStatus>,
}
async fn shop_list(
State(state): State<AppState>,
auth: AuthUser,
Query(q): Query<ShopListQuery>,
) -> ApiResult<Json<Paged<ProductWithSkus>>> {
let shop_id = auth.require_shop()?;
Ok(Json(
service::list_shop_products(&state, shop_id, q.page, q.per_page, q.status).await?,
))
}
async fn shop_get(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<ProductWithSkus>> {
let shop_id = auth.require_shop()?;
Ok(Json(service::get_shop_product(&state, shop_id, id).await?))
}
async fn create_product(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<ProductBody>,
) -> ApiResult<(StatusCode, Json<ProductWithSkus>)> {
let shop_id = auth.require_shop()?;
Ok((
StatusCode::CREATED,
Json(service::create_product(&state, shop_id, body).await?),
))
}
async fn update_product(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<ProductBody>,
) -> ApiResult<Json<ProductWithSkus>> {
let shop_id = auth.require_shop()?;
Ok(Json(
service::update_product(&state, shop_id, id, body).await?,
))
}
async fn publish(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<ProductWithSkus>> {
let shop_id = auth.require_shop()?;
Ok(Json(
service::transition(&state, shop_id, id, ProductStatus::Published).await?,
))
}
async fn unpublish(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<ProductWithSkus>> {
let shop_id = auth.require_shop()?;
Ok(Json(
service::transition(&state, shop_id, id, ProductStatus::Unpublished).await?,
))
}
async fn upsert_sku(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<SkuBody>,
) -> ApiResult<Json<Sku>> {
let shop_id = auth.require_shop()?;
Ok(Json(service::upsert_sku(&state, shop_id, id, body).await?))
}
+12
View File
@@ -0,0 +1,12 @@
mod dto;
mod handlers;
mod repo;
pub mod service;
use axum::Router;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
handlers::router()
}
+73
View File
@@ -0,0 +1,73 @@
use std::collections::HashMap;
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::ApiResult;
use crate::models::{Product, Sku};
use super::dto::ProductWithSkus;
const PAID_STATUSES: &str = "('paid', 'fulfilling', 'shipped', 'completed')";
#[derive(sqlx::FromRow)]
struct SoldRow {
product_id: Uuid,
sold: i64,
}
pub async fn attach_skus(
db: &PgPool,
products: Vec<Product>,
public_only: bool,
) -> ApiResult<Vec<ProductWithSkus>> {
let ids: Vec<Uuid> = products.iter().map(|p| p.id).collect();
let skus = if ids.is_empty() {
Vec::new()
} else if public_only {
sqlx::query_as::<_, Sku>(
"SELECT id, product_id, sku_code, attributes, price_minor, currency, stock, active
FROM skus WHERE product_id = ANY($1) AND active = TRUE ORDER BY sku_code",
)
.bind(&ids)
.fetch_all(db)
.await?
} else {
sqlx::query_as::<_, Sku>(
"SELECT id, product_id, sku_code, attributes, price_minor, currency, stock, active
FROM skus WHERE product_id = ANY($1) ORDER BY sku_code",
)
.bind(&ids)
.fetch_all(db)
.await?
};
let sold: Vec<SoldRow> = if ids.is_empty() {
Vec::new()
} else {
sqlx::query_as::<_, SoldRow>(&format!(
"SELECT sk.product_id, COALESCE(SUM(oi.qty), 0)::bigint AS sold
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN skus sk ON sk.id = oi.sku_id
WHERE sk.product_id = ANY($1)
AND o.status IN {PAID_STATUSES}
GROUP BY sk.product_id"
))
.bind(&ids)
.fetch_all(db)
.await?
};
let mut skus_by: HashMap<Uuid, Vec<Sku>> = HashMap::new();
for sku in skus {
skus_by.entry(sku.product_id).or_default().push(sku);
}
let sold_by: HashMap<Uuid, i64> = sold.into_iter().map(|r| (r.product_id, r.sold)).collect();
Ok(products
.into_iter()
.map(|p| ProductWithSkus {
skus: skus_by.remove(&p.id).unwrap_or_default(),
sold_count: sold_by.get(&p.id).copied().unwrap_or(0),
product: p,
})
.collect())
}
+315
View File
@@ -0,0 +1,315 @@
use crate::error::{unique_conflict, ApiError, ApiResult};
use crate::http::{clamp_page, clamp_per_page, Paged};
use crate::models::{Product, ProductStatus, Sku};
use crate::modules::category::service::SUBTREE_CTE;
use crate::state::AppState;
use uuid::Uuid;
use super::dto::{ProductBody, ProductWithSkus, PublicListQuery, SkuBody, SortBy};
use super::repo;
const SOLD_UNITS: &str = "(SELECT COALESCE(SUM(oi.qty), 0)
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN skus sk ON sk.id = oi.sku_id
WHERE sk.product_id = p.id
AND o.status IN ('paid', 'fulfilling', 'shipped', 'completed'))";
const MIN_PRICE: &str =
"(SELECT MIN(price_minor) FROM skus WHERE product_id = p.id AND active = TRUE)";
const PRODUCT_COLS: &str = "p.id, p.shop_id, p.category_id, p.brand_id, p.slug, p.name,
p.description, p.images, p.status, p.created_at, p.updated_at";
pub async fn list_public(
state: &AppState,
q: PublicListQuery,
) -> ApiResult<Paged<ProductWithSkus>> {
let page = clamp_page(q.page);
let per_page = clamp_per_page(q.per_page);
let pattern = q.q.as_ref().map(|s| format!("%{s}%"));
let sort_by = q.sort_by()?;
let ascending = q.ascending()?;
let total: i64 = sqlx::query_scalar(&format!(
"{SUBTREE_CTE}
SELECT count(*) FROM products p JOIN shops s ON s.id = p.shop_id
WHERE p.status = 'published' AND s.status = 'active'
AND ($1::uuid IS NULL OR p.category_id IN (SELECT id FROM subtree))
AND ($2::uuid IS NULL OR p.shop_id = $2)
AND ($3::text IS NULL OR p.name::text ILIKE $3)
AND ($4::uuid IS NULL OR p.brand_id = $4)"
))
.bind(q.category_id)
.bind(q.shop_id)
.bind(&pattern)
.bind(q.brand_id)
.fetch_one(&state.db)
.await?;
let order_clause = match (sort_by, ascending) {
(SortBy::Newest, _) => "p.created_at DESC".to_string(),
(SortBy::Price, true) => format!("{MIN_PRICE} ASC NULLS LAST, p.created_at DESC"),
(SortBy::Price, false) => format!("{MIN_PRICE} DESC NULLS LAST, p.created_at DESC"),
(SortBy::Sales, true) => format!("{SOLD_UNITS} ASC, p.created_at DESC"),
(SortBy::Sales, false) => format!("{SOLD_UNITS} DESC, p.created_at DESC"),
};
let products = sqlx::query_as::<_, Product>(&format!(
"{SUBTREE_CTE}
SELECT {PRODUCT_COLS} FROM products p JOIN shops s ON s.id = p.shop_id
WHERE p.status = 'published' AND s.status = 'active'
AND ($1::uuid IS NULL OR p.category_id IN (SELECT id FROM subtree))
AND ($2::uuid IS NULL OR p.shop_id = $2)
AND ($3::text IS NULL OR p.name::text ILIKE $3)
AND ($4::uuid IS NULL OR p.brand_id = $4)
ORDER BY {order_clause}
LIMIT $5 OFFSET $6"
))
.bind(q.category_id)
.bind(q.shop_id)
.bind(&pattern)
.bind(q.brand_id)
.bind(per_page)
.bind((page - 1) * per_page)
.fetch_all(&state.db)
.await?;
let items = repo::attach_skus(&state.db, products, true).await?;
Ok(Paged {
items,
total,
page,
per_page,
})
}
pub async fn get_public(state: &AppState, id_or_slug: &str) -> ApiResult<ProductWithSkus> {
let product = if let Ok(id) = Uuid::parse_str(id_or_slug) {
sqlx::query_as::<_, Product>(&format!(
"SELECT {PRODUCT_COLS} FROM products p JOIN shops s ON s.id = p.shop_id
WHERE p.id = $1 AND p.status = 'published' AND s.status = 'active'"
))
.bind(id)
.fetch_optional(&state.db)
.await?
} else {
sqlx::query_as::<_, Product>(&format!(
"SELECT {PRODUCT_COLS} FROM products p JOIN shops s ON s.id = p.shop_id
WHERE p.slug = $1 AND p.status = 'published' AND s.status = 'active'"
))
.bind(id_or_slug)
.fetch_optional(&state.db)
.await?
}
.ok_or_else(|| ApiError::NotFound("product".into()))?;
let mut items = repo::attach_skus(&state.db, vec![product], true).await?;
Ok(items.remove(0))
}
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
FROM products WHERE id = $1 AND shop_id = $2",
)
.bind(id)
.bind(shop_id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| ApiError::NotFound("product".into()))
}
pub async fn list_shop_products(
state: &AppState,
shop_id: Uuid,
page: Option<i64>,
per_page: Option<i64>,
status: Option<ProductStatus>,
) -> ApiResult<Paged<ProductWithSkus>> {
let page = clamp_page(page);
let per_page = clamp_per_page(per_page);
let total: i64 = sqlx::query_scalar(
"SELECT count(*) FROM products WHERE shop_id = $1 AND ($2::product_status IS NULL OR status = $2)",
)
.bind(shop_id)
.bind(status)
.fetch_one(&state.db)
.await?;
let products = sqlx::query_as::<_, Product>(
"SELECT id, shop_id, category_id, brand_id, slug, name, description, images, status, created_at, updated_at
FROM products
WHERE shop_id = $1 AND ($2::product_status IS NULL OR status = $2)
ORDER BY created_at DESC LIMIT $3 OFFSET $4",
)
.bind(shop_id)
.bind(status)
.bind(per_page)
.bind((page - 1) * per_page)
.fetch_all(&state.db)
.await?;
let items = repo::attach_skus(&state.db, products, false).await?;
Ok(Paged {
items,
total,
page,
per_page,
})
}
pub async fn get_shop_product(
state: &AppState,
shop_id: Uuid,
id: Uuid,
) -> ApiResult<ProductWithSkus> {
let product = load_own_product(state, shop_id, id).await?;
let mut items = repo::attach_skus(&state.db, vec![product], false).await?;
Ok(items.remove(0))
}
fn validate_product_body(body: &ProductBody) -> ApiResult<()> {
if body.slug.trim().is_empty()
|| !body
.slug
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-')
{
return Err(ApiError::BadRequest(
"slug must be non-empty alphanumeric with dashes".into(),
));
}
let name_en = body.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()));
}
Ok(())
}
pub async fn create_product(
state: &AppState,
shop_id: Uuid,
body: ProductBody,
) -> ApiResult<ProductWithSkus> {
validate_product_body(&body)?;
let product = sqlx::query_as::<_, Product>(
"INSERT INTO products (shop_id, category_id, brand_id, slug, name, description, images)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, shop_id, category_id, brand_id, slug, name, description, images, status, created_at, updated_at",
)
.bind(shop_id)
.bind(body.category_id)
.bind(body.brand_id)
.bind(body.slug.trim())
.bind(&body.name)
.bind(body.description.unwrap_or_else(|| serde_json::json!({})))
.bind(body.images.unwrap_or_else(|| serde_json::json!([])))
.fetch_one(&state.db)
.await
.map_err(|e| unique_conflict(e, "slug already exists in this shop"))?;
let mut items = repo::attach_skus(&state.db, vec![product], false).await?;
Ok(items.remove(0))
}
pub async fn update_product(
state: &AppState,
shop_id: Uuid,
id: Uuid,
body: ProductBody,
) -> ApiResult<ProductWithSkus> {
load_own_product(state, shop_id, id).await?;
validate_product_body(&body)?;
let product = sqlx::query_as::<_, Product>(
"UPDATE products
SET category_id = $2, brand_id = $3, slug = $4, name = $5, description = $6,
images = $7, updated_at = now()
WHERE id = $1
RETURNING id, shop_id, category_id, brand_id, slug, name, description, images, status, created_at, updated_at",
)
.bind(id)
.bind(body.category_id)
.bind(body.brand_id)
.bind(body.slug.trim())
.bind(&body.name)
.bind(body.description.unwrap_or_else(|| serde_json::json!({})))
.bind(body.images.unwrap_or_else(|| serde_json::json!([])))
.fetch_one(&state.db)
.await
.map_err(|e| unique_conflict(e, "slug already exists in this shop"))?;
let mut items = repo::attach_skus(&state.db, vec![product], false).await?;
Ok(items.remove(0))
}
pub async fn transition(
state: &AppState,
shop_id: Uuid,
id: Uuid,
target: ProductStatus,
) -> ApiResult<ProductWithSkus> {
let product = load_own_product(state, shop_id, id).await?;
if target == ProductStatus::Published {
let sellable: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM skus WHERE product_id = $1 AND active = TRUE AND price_minor > 0)",
)
.bind(product.id)
.fetch_one(&state.db)
.await?;
if !sellable {
return Err(ApiError::BadRequest(
"product needs at least one active SKU with price > 0 to publish".into(),
));
}
}
let updated = sqlx::query_as::<_, Product>(
"UPDATE products SET status = $2, updated_at = now() WHERE id = $1
RETURNING id, shop_id, category_id, brand_id, slug, name, description, images, status, created_at, updated_at",
)
.bind(product.id)
.bind(target)
.fetch_one(&state.db)
.await?;
let mut items = repo::attach_skus(&state.db, vec![updated], false).await?;
Ok(items.remove(0))
}
pub async fn upsert_sku(
state: &AppState,
shop_id: Uuid,
product_id: Uuid,
body: SkuBody,
) -> ApiResult<Sku> {
load_own_product(state, shop_id, product_id).await?;
if body.sku_code.trim().is_empty() {
return Err(ApiError::BadRequest("sku_code is required".into()));
}
if body.price_minor < 0 {
return Err(ApiError::BadRequest("price_minor must be >= 0".into()));
}
if body.stock < 0 {
return Err(ApiError::BadRequest("stock must be >= 0".into()));
}
let currency = body.currency.to_uppercase();
let currency_ok: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM currencies WHERE code = $1 AND enabled)")
.bind(&currency)
.fetch_one(&state.db)
.await?;
if !currency_ok {
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)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (product_id, sku_code)
DO UPDATE SET attributes = $3, price_minor = $4, currency = $5, stock = $6, active = $7
RETURNING id, product_id, sku_code, attributes, price_minor, currency, stock, active",
)
.bind(product_id)
.bind(body.sku_code.trim())
.bind(body.attributes.unwrap_or_else(|| serde_json::json!({})))
.bind(body.price_minor)
.bind(&currency)
.bind(body.stock)
.bind(body.active.unwrap_or(true))
.fetch_one(&state.db)
.await?)
}