feat(mall): serve catalog and currency from the live API

Wave 1 of replacing the fixed-data mock adapter. The mall now selects its API
adapter per domain, with catalog and currency served live while auth, cart,
orders, shipments and invoices stay on fixed data.

Backend:
- seed the 6 x 2 x 2 category tree as reference data (migration 0005). The API
  exposes no category write route, so this cannot come from the seed script
- filter public product listing by the category subtree with a recursive CTE,
  matching the mock's existing behaviour instead of exact-match
- add sort=price with order=asc|desc, validated by hand so an unsupported value
  returns the project's ApiError 400 shape rather than axum's own rejection

Mall:
- replace the all-or-nothing mockApi boolean with a liveDomains list composed
  through a typed per-domain pick map
- source home floors, the category menu, search and product detail from the
  catalog API; banners, promos, quick links, store card and comment/coupon
  content stay local display-only content
- drop the brand facet and the sales/comments sorts: no backend model backs them
- fix salesOf/commentCountOf, which parsed digits out of the product id and so
  rendered "NaN sold" for live UUID ids; they now hash the id

Seed: 24 products across 4 shops, idempotent on re-run.

Note: the mall defaults to a live catalog, so pnpm dev:mall now expects the API
to be running; set NUXT_PUBLIC_LIVE_DOMAINS to an empty array for all-mock work.

OpenSpec change: openspec/changes/replace-mock-api-wave-1
This commit is contained in:
2026-09-17 15:15:25 +00:00
parent 44466e5e88
commit e0e833d0e5
20 changed files with 909 additions and 223 deletions
@@ -0,0 +1,95 @@
-- Reference taxonomy: extend the three top-level categories seeded in
-- 0003_catalog.sql into the full 6 x 2 x 2 tree the storefront is designed
-- around (six pinned sidebar rows, six home floors, two grandchildren per
-- child link).
--
-- Categories are reference data, not demo data: `GET /categories` is the only
-- category route (there is no write endpoint), so the seed script cannot create
-- them, and shop-admin needs a real tree for its category picker.
-- New top-level categories (positions 4-6 continue 0003's 1-3).
INSERT INTO categories (parent_id, name, slug, position)
VALUES
(NULL, '{"en": "Computers & Office", "zh": "电脑办公"}', 'computers-office', 4),
(NULL, '{"en": "Beauty & Care", "zh": "美妆个护"}', 'beauty-care', 5),
(NULL, '{"en": "Grocery & Fresh", "zh": "食品生鲜"}', 'grocery-fresh', 6);
-- Second level: two children under every top-level category.
INSERT INTO categories (parent_id, name, slug, position)
VALUES
((SELECT id FROM categories WHERE slug = 'electronics'),
'{"en": "Phones & Accessories", "zh": "手机与配件"}', 'electronics-phones', 1),
((SELECT id FROM categories WHERE slug = 'electronics'),
'{"en": "Audio", "zh": "影音娱乐"}', 'electronics-audio', 2),
((SELECT id FROM categories WHERE slug = 'fashion'),
'{"en": "Menswear", "zh": "男装"}', 'fashion-menswear', 1),
((SELECT id FROM categories WHERE slug = 'fashion'),
'{"en": "Bags & Luggage", "zh": "箱包"}', 'fashion-bags', 2),
((SELECT id FROM categories WHERE slug = 'home-living'),
'{"en": "Kitchen", "zh": "厨房电器"}', 'home-kitchen', 1),
((SELECT id FROM categories WHERE slug = 'home-living'),
'{"en": "Cleaning", "zh": "清洁电器"}', 'home-cleaning', 2),
((SELECT id FROM categories WHERE slug = 'computers-office'),
'{"en": "Laptops", "zh": "笔记本电脑"}', 'computers-laptops', 1),
((SELECT id FROM categories WHERE slug = 'computers-office'),
'{"en": "Peripherals", "zh": "外设产品"}', 'computers-peripherals', 2),
((SELECT id FROM categories WHERE slug = 'beauty-care'),
'{"en": "Skincare", "zh": "面部护肤"}', 'beauty-skincare', 1),
((SELECT id FROM categories WHERE slug = 'beauty-care'),
'{"en": "Grooming", "zh": "个人护理"}', 'beauty-grooming', 2),
((SELECT id FROM categories WHERE slug = 'grocery-fresh'),
'{"en": "Snacks", "zh": "休闲零食"}', 'grocery-snacks', 1),
((SELECT id FROM categories WHERE slug = 'grocery-fresh'),
'{"en": "Fresh Produce", "zh": "生鲜果蔬"}', 'grocery-produce', 2);
-- Third level: two grandchildren under every second-level category.
INSERT INTO categories (parent_id, name, slug, position)
VALUES
((SELECT id FROM categories WHERE slug = 'electronics-phones'),
'{"en": "Flagship Phones", "zh": "旗舰机型"}', 'electronics-flagship', 1),
((SELECT id FROM categories WHERE slug = 'electronics-phones'),
'{"en": "Budget Phones", "zh": "千元机"}', 'electronics-budget', 2),
((SELECT id FROM categories WHERE slug = 'electronics-audio'),
'{"en": "Earbuds", "zh": "真无线耳机"}', 'electronics-earbuds', 1),
((SELECT id FROM categories WHERE slug = 'electronics-audio'),
'{"en": "Speakers", "zh": "蓝牙音箱"}', 'electronics-speakers', 2),
((SELECT id FROM categories WHERE slug = 'fashion-menswear'),
'{"en": "Jackets", "zh": "夹克外套"}', 'fashion-jackets', 1),
((SELECT id FROM categories WHERE slug = 'fashion-menswear'),
'{"en": "Shirts", "zh": "衬衫"}', 'fashion-shirts', 2),
((SELECT id FROM categories WHERE slug = 'fashion-bags'),
'{"en": "Backpacks", "zh": "双肩包"}', 'fashion-backpacks', 1),
((SELECT id FROM categories WHERE slug = 'fashion-bags'),
'{"en": "Luggage", "zh": "旅行箱"}', 'fashion-luggage', 2),
((SELECT id FROM categories WHERE slug = 'home-kitchen'),
'{"en": "Cookers", "zh": "电饭煲"}', 'home-cookers', 1),
((SELECT id FROM categories WHERE slug = 'home-kitchen'),
'{"en": "Blenders", "zh": "破壁机"}', 'home-blenders', 2),
((SELECT id FROM categories WHERE slug = 'home-cleaning'),
'{"en": "Vacuums", "zh": "吸尘器"}', 'home-vacuums', 1),
((SELECT id FROM categories WHERE slug = 'home-cleaning'),
'{"en": "Air Purifiers", "zh": "空气净化器"}', 'home-purifiers', 2),
((SELECT id FROM categories WHERE slug = 'computers-laptops'),
'{"en": "Ultrabooks", "zh": "轻薄本"}', 'computers-ultrabooks', 1),
((SELECT id FROM categories WHERE slug = 'computers-laptops'),
'{"en": "Gaming Laptops", "zh": "游戏本"}', 'computers-gaming', 2),
((SELECT id FROM categories WHERE slug = 'computers-peripherals'),
'{"en": "Keyboards", "zh": "键盘"}', 'computers-keyboards', 1),
((SELECT id FROM categories WHERE slug = 'computers-peripherals'),
'{"en": "Monitors", "zh": "显示器"}', 'computers-monitors', 2),
((SELECT id FROM categories WHERE slug = 'beauty-skincare'),
'{"en": "Serums", "zh": "精华"}', 'beauty-serums', 1),
((SELECT id FROM categories WHERE slug = 'beauty-skincare'),
'{"en": "Creams", "zh": "面霜"}', 'beauty-creams', 2),
((SELECT id FROM categories WHERE slug = 'beauty-grooming'),
'{"en": "Shavers", "zh": "剃须刀"}', 'beauty-shavers', 1),
((SELECT id FROM categories WHERE slug = 'beauty-grooming'),
'{"en": "Hair Care", "zh": "洗发护发"}', 'beauty-haircare', 2),
((SELECT id FROM categories WHERE slug = 'grocery-snacks'),
'{"en": "Nuts", "zh": "坚果"}', 'grocery-nuts', 1),
((SELECT id FROM categories WHERE slug = 'grocery-snacks'),
'{"en": "Chocolate", "zh": "巧克力"}', 'grocery-chocolate', 2),
((SELECT id FROM categories WHERE slug = 'grocery-produce'),
'{"en": "Fruit", "zh": "水果"}', 'grocery-fruit', 1),
((SELECT id FROM categories WHERE slug = 'grocery-produce'),
'{"en": "Vegetables", "zh": "蔬菜"}', 'grocery-vegetables', 2);
+66 -11
View File
@@ -64,8 +64,50 @@ struct ListQuery {
category_id: Option<Uuid>,
shop_id: Option<Uuid>,
q: Option<String>,
sort: Option<String>,
order: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SortBy {
Newest,
Price,
}
impl ListQuery {
/// Only `price` is a supported sort; anything else is a client error rather
/// than being silently ignored. An absent `sort` keeps newest-first.
fn sort_by(&self) -> ApiResult<SortBy> {
match self.sort.as_deref() {
None => Ok(SortBy::Newest),
Some("price") => Ok(SortBy::Price),
Some(other) => Err(ApiError::BadRequest(format!("unsupported sort: {other}"))),
}
}
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}"))),
}
}
}
/// Resolves the requested category to itself plus every descendant, so a parent
/// category lists its children's and grandchildren's products too. Shared by the
/// count and page queries so `total` cannot drift from `items`.
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
) ";
/// Lowest active SKU price, used only when sorting by price. Products without a
/// sellable SKU sort last in both directions.
const MIN_PRICE: &str =
"(SELECT MIN(price_minor) FROM skus WHERE product_id = p.id AND active = TRUE)";
/// Public catalog: only published products of active shops.
async fn list_products(
State(state): State<AppState>,
@@ -74,27 +116,39 @@ async fn list_products(
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 total: i64 = sqlx::query_scalar(
"SELECT count(*) FROM products p JOIN shops s ON s.id = p.shop_id
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 = $1)
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 ($3::text IS NULL OR p.name::text ILIKE $3)"
))
.bind(q.category_id)
.bind(q.shop_id)
.bind(&pattern)
.fetch_one(&state.db)
.await?;
let products = sqlx::query_as::<_, Product>(
"SELECT p.* FROM products p JOIN shops s ON s.id = p.shop_id
// Interpolates only from the validated SortBy/order pair, never from input.
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"),
};
let products = sqlx::query_as::<_, Product>(&format!(
"{SUBTREE_CTE}
SELECT p.* 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 = $1)
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)
ORDER BY p.created_at DESC
LIMIT $4 OFFSET $5",
)
ORDER BY {order_clause}
LIMIT $4 OFFSET $5"
))
.bind(q.category_id)
.bind(q.shop_id)
.bind(&pattern)
@@ -102,6 +156,7 @@ async fn list_products(
.bind((page - 1) * per_page)
.fetch_all(&state.db)
.await?;
let items = attach_skus(&state.db, products, true).await?;
Ok(Json(Paged {
items,
+96 -2
View File
@@ -1,8 +1,8 @@
mod common;
use common::{
client, create_product_with_sku, create_shop, login_admin, make_shop_owner, register_customer,
spawn_app,
category_id_by_slug, client, create_product_with_sku, create_product_with_sku_in_category,
create_shop, login_admin, make_shop_owner, publish_product, register_customer, spawn_app,
};
use serial_test::serial;
@@ -180,6 +180,100 @@ async fn currency_conversion_math() {
assert_eq!(res.status(), 400);
}
#[tokio::test]
#[serial]
async fn category_subtree_listing_and_price_sort() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let shop_id = create_shop(&app, &admin, "shop-browse").await;
let owner = make_shop_owner(&app, &admin, &shop_id).await;
let electronics = category_id_by_slug(&app, "electronics").await;
let phones = category_id_by_slug(&app, "electronics-phones").await;
let flagship = category_id_by_slug(&app, "electronics-flagship").await;
let audio = category_id_by_slug(&app, "electronics-audio").await;
let fashion = category_id_by_slug(&app, "fashion").await;
// A grandchild, a sibling branch under the same root, and an unrelated root.
let (leaf, _) =
create_product_with_sku_in_category(&app, &owner, "leaf", 5000, 5, Some(&flagship)).await;
let (sibling, _) =
create_product_with_sku_in_category(&app, &owner, "sib", 1000, 5, Some(&audio)).await;
let (unrelated, _) =
create_product_with_sku_in_category(&app, &owner, "other", 3000, 5, Some(&fashion)).await;
for id in [&leaf, &sibling, &unrelated] {
publish_product(&app, &owner, id).await;
}
fn listed_ids(body: &serde_json::Value) -> Vec<String> {
body["items"]
.as_array()
.unwrap()
.iter()
.map(|p| p["id"].as_str().unwrap().to_string())
.collect()
}
// A root category must include its children's and grandchildren's products.
let res = client()
.get(app.url(&format!(
"/api/products?category_id={electronics}&shop_id={shop_id}&per_page=50"
)))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let body: serde_json::Value = res.json().await.unwrap();
let listed = listed_ids(&body);
assert!(listed.contains(&leaf), "grandchild product missing from root listing");
assert!(listed.contains(&sibling), "child product missing from root listing");
assert!(!listed.contains(&unrelated), "product from another root category leaked in");
assert_eq!(body["total"], 2, "total must count the subtree, not only the root");
// A mid-level category covers its own subtree and nothing else.
let res = client()
.get(app.url(&format!(
"/api/products?category_id={phones}&shop_id={shop_id}"
)))
.send()
.await
.unwrap();
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(listed_ids(&body), vec![leaf.clone()]);
// Price sort orders by the product's lowest active SKU price.
let res = client()
.get(app.url(&format!(
"/api/products?category_id={electronics}&shop_id={shop_id}&per_page=50&sort=price&order=asc"
)))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(listed_ids(&body), vec![sibling.clone(), leaf.clone()]);
let res = client()
.get(app.url(&format!(
"/api/products?category_id={electronics}&shop_id={shop_id}&per_page=50&sort=price&order=desc"
)))
.send()
.await
.unwrap();
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(listed_ids(&body), vec![leaf.clone(), sibling.clone()]);
// An unsupported sort is a client error rather than being silently ignored.
let res = client()
.get(app.url("/api/products?sort=bogus"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 400);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(body["error"]["code"], "BAD_REQUEST");
}
#[tokio::test]
#[serial]
async fn suspended_shop_hidden_from_public_catalog() {
+43
View File
@@ -149,6 +149,18 @@ pub async fn create_product_with_sku(
slug: &str,
price_minor: i64,
stock: i32,
) -> (String, String) {
create_product_with_sku_in_category(app, owner_token, slug, price_minor, stock, None).await
}
/// Same, but placed in `category_id` so category filtering can be exercised.
pub async fn create_product_with_sku_in_category(
app: &TestApp,
owner_token: &str,
slug: &str,
price_minor: i64,
stock: i32,
category_id: Option<&str>,
) -> (String, String) {
let slug = format!("{slug}-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
let res = client()
@@ -158,6 +170,7 @@ pub async fn create_product_with_sku(
"slug": slug,
"name": {"en": format!("Product {slug}"), "zh": format!("商品 {slug}")},
"description": {"en": "desc en", "zh": "描述"},
"category_id": category_id,
}))
.send()
.await
@@ -183,6 +196,36 @@ pub async fn create_product_with_sku(
(product_id, slug)
}
/// Publish a draft product so it appears in the public catalog.
pub async fn publish_product(app: &TestApp, owner_token: &str, product_id: &str) {
let res = client()
.post(app.url(&format!("/api/shop/products/{product_id}/publish")))
.bearer_auth(owner_token)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "publish: {:?}", res.text().await);
}
/// Look up a seeded reference category id by slug.
pub async fn category_id_by_slug(app: &TestApp, slug: &str) -> String {
let res = client()
.get(app.url("/api/categories"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let cats: serde_json::Value = res.json().await.unwrap();
cats.as_array()
.unwrap()
.iter()
.find(|c| c["slug"] == slug)
.unwrap_or_else(|| panic!("seeded category {slug} missing"))["id"]
.as_str()
.unwrap()
.to_string()
}
/// Full sellable fixture: shop + owner + published product with one SKU.
/// Returns (owner_token, shop_id, product_id, sku_id).
pub async fn setup_sellable(