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
+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(