feat(mall): restore real brand and sales facets
Wave 6, the last substantive piece of the mock-API migration. Wave 1 removed the brand facet and the sales/comments sorts for want of a model; sales turn out to be derivable from order_items and a brand model is a table plus a column. - a `brands` table with a nullable `products.brand_id` and an ordered admin replace, mirroring categories and storefront content; a public `GET /api/brands` and a `brand_id` filter on the catalog, which the search page's facet uses - `sold_count` per product, computed from `order_items` joined to orders that reached payment, so an abandoned or cancelled checkout cannot count as a sale. It is computed per read rather than stored, so it cannot drift from the orders that produced it - `sort=sales` alongside `sort=price`; anything else is still a 400 - merchants can set a product's brand through the existing product upsert - the review UI is gone: the card's review figure and the product detail page's reviews tab, summary and replies. There is no reviews model, and the mall attributed invented comments to named shoppers and showed a "good rate". The now-unreferenced fabrication helpers went with it (`salesOf`, `commentCountOf`, `commentsFor`, `commentStats`, `salesRankFor`, `productDetail`, `storeDetail`) Two bugs found by checking rather than trusting: the fixed-data `listProducts` had silently ignored `brand_id`, `sort` and `order`, so the restored facet rendered but filtered nothing until the rollback check caught it; and the seed's brand lookup read back through the shared `r` variable the product loop reassigns, working once and then throwing. Verified: 29 backend tests green including a new brand-and-sales case; all three frontends build; searching filters by brand (24 to 6) and sorts by sales with counts matching the API; a product page offers detail and after-sale tabs only, with a real sold count; the fixed-data rollback filters by brand too. OpenSpec change: openspec/changes/replace-mock-api-wave-6
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
-- Product brands: reference data like categories, administered centrally and
|
||||
-- referenced by products. A brand can be retired without touching its products.
|
||||
|
||||
CREATE TABLE brands (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name JSONB NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE products
|
||||
ADD COLUMN brand_id UUID REFERENCES brands (id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX products_brand_idx ON products (brand_id);
|
||||
@@ -67,11 +67,21 @@ pub struct Category {
|
||||
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,
|
||||
pub shop_id: Uuid,
|
||||
pub category_id: Option<Uuid>,
|
||||
pub brand_id: Option<Uuid>,
|
||||
pub slug: String,
|
||||
pub name: serde_json::Value,
|
||||
pub description: serde_json::Value,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
routing::{get, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::models::{Brand, UserRole};
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router(_state: AppState) -> Router<AppState> {
|
||||
Router::new().route("/brands", get(list_brands))
|
||||
}
|
||||
|
||||
pub fn admin_router(_state: AppState) -> Router<AppState> {
|
||||
Router::new().route("/admin/brands", put(replace_brands))
|
||||
}
|
||||
|
||||
async fn list_brands(State(state): State<AppState>) -> ApiResult<Json<Vec<Brand>>> {
|
||||
let brands = sqlx::query_as::<_, Brand>(
|
||||
"SELECT * FROM brands WHERE active = TRUE ORDER BY position, created_at",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(Json(brands))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BrandInput {
|
||||
slug: String,
|
||||
name: Value,
|
||||
#[serde(default = "default_active")]
|
||||
active: bool,
|
||||
}
|
||||
|
||||
fn default_active() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Replaces the whole ordered list, as storefront content does: small lists are
|
||||
/// edited whole, and positions are reindexed from the submitted order.
|
||||
async fn replace_brands(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(body): Json<Vec<BrandInput>>,
|
||||
) -> ApiResult<Json<Vec<Brand>>> {
|
||||
auth.require(&[UserRole::PlatformAdmin])?;
|
||||
|
||||
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(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| match e {
|
||||
sqlx::Error::Database(d) if d.is_unique_violation() => {
|
||||
ApiError::BadRequest(format!("duplicate brand slug: {}", brand.slug))
|
||||
}
|
||||
other => ApiError::from(other),
|
||||
})?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
list_brands(State(state)).await
|
||||
}
|
||||
@@ -16,6 +16,27 @@ pub struct ProductWithSkus {
|
||||
#[serde(flatten)]
|
||||
pub product: Product,
|
||||
pub skus: Vec<Sku>,
|
||||
/// Units sold across orders that reached payment. Zero when nothing sold.
|
||||
pub sold_count: i64,
|
||||
}
|
||||
|
||||
/// Orders that count as a sale: an abandoned or cancelled checkout does not.
|
||||
const PAID_STATUSES: &str =
|
||||
"('paid', 'fulfilling', 'shipped', 'completed')";
|
||||
|
||||
/// Units sold for one product, as a correlated subquery so it can also drive
|
||||
/// the sales sort without a second round trip.
|
||||
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'))";
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct SoldRow {
|
||||
product_id: Uuid,
|
||||
sold: i64,
|
||||
}
|
||||
|
||||
pub async fn attach_skus(
|
||||
@@ -41,10 +62,31 @@ pub async fn attach_skus(
|
||||
.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?
|
||||
};
|
||||
Ok(products
|
||||
.into_iter()
|
||||
.map(|p| ProductWithSkus {
|
||||
skus: skus.iter().filter(|s| s.product_id == p.id).cloned().collect(),
|
||||
sold_count: sold
|
||||
.iter()
|
||||
.find(|row| row.product_id == p.id)
|
||||
.map(|row| row.sold)
|
||||
.unwrap_or(0),
|
||||
product: p,
|
||||
})
|
||||
.collect())
|
||||
@@ -62,6 +104,7 @@ struct ListQuery {
|
||||
page: Option<i64>,
|
||||
per_page: Option<i64>,
|
||||
category_id: Option<Uuid>,
|
||||
brand_id: Option<Uuid>,
|
||||
shop_id: Option<Uuid>,
|
||||
q: Option<String>,
|
||||
sort: Option<String>,
|
||||
@@ -72,15 +115,18 @@ struct ListQuery {
|
||||
enum SortBy {
|
||||
Newest,
|
||||
Price,
|
||||
Sales,
|
||||
}
|
||||
|
||||
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.
|
||||
/// Only `price` and `sales` are supported sorts; 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("sales") => Ok(SortBy::Sales),
|
||||
Some(other) => Err(ApiError::BadRequest(format!("unsupported sort: {other}"))),
|
||||
}
|
||||
}
|
||||
@@ -125,11 +171,13 @@ async fn list_products(
|
||||
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 ($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?;
|
||||
|
||||
@@ -138,6 +186,8 @@ async fn list_products(
|
||||
(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}
|
||||
@@ -146,12 +196,14 @@ async fn list_products(
|
||||
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 $4 OFFSET $5"
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod brands;
|
||||
pub mod cart;
|
||||
pub mod catalog;
|
||||
pub mod content;
|
||||
@@ -23,6 +24,8 @@ pub fn api_router(state: AppState) -> Router<AppState> {
|
||||
.merge(auth::router(state.clone()))
|
||||
.merge(currency::router(state.clone()))
|
||||
.merge(catalog::router(state.clone()))
|
||||
.merge(brands::router(state.clone()))
|
||||
.merge(brands::admin_router(state.clone()))
|
||||
.merge(content::router(state.clone()))
|
||||
.merge(content::admin_router(state.clone()))
|
||||
.merge(cart::router(state.clone()))
|
||||
|
||||
@@ -100,6 +100,7 @@ async fn get_product(
|
||||
#[derive(Deserialize)]
|
||||
pub struct ProductBody {
|
||||
category_id: Option<Uuid>,
|
||||
brand_id: Option<Uuid>,
|
||||
slug: String,
|
||||
name: serde_json::Value,
|
||||
description: Option<serde_json::Value>,
|
||||
@@ -132,11 +133,12 @@ async fn create_product(
|
||||
let shop_id = require_shop(&auth)?;
|
||||
validate_product_body(&body)?;
|
||||
let product = sqlx::query_as::<_, Product>(
|
||||
"INSERT INTO products (shop_id, category_id, slug, name, description, images)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *",
|
||||
"INSERT INTO products (shop_id, category_id, brand_id, slug, name, description, images)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *",
|
||||
)
|
||||
.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!({})))
|
||||
@@ -164,11 +166,13 @@ async fn update_product(
|
||||
validate_product_body(&body)?;
|
||||
let product = sqlx::query_as::<_, Product>(
|
||||
"UPDATE products
|
||||
SET category_id = $2, slug = $3, name = $4, description = $5, images = $6, updated_at = now()
|
||||
SET category_id = $2, brand_id = $3, slug = $4, name = $5, description = $6,
|
||||
images = $7, updated_at = now()
|
||||
WHERE id = $1 RETURNING *",
|
||||
)
|
||||
.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!({})))
|
||||
|
||||
+117
-2
@@ -1,8 +1,9 @@
|
||||
mod common;
|
||||
|
||||
use common::{
|
||||
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,
|
||||
add_to_cart, category_id_by_slug, checkout, client, create_product_full, create_product_with_sku,
|
||||
create_product_with_sku_in_category, create_shop, login_admin, make_shop_owner, pay,
|
||||
publish_product, register_customer, spawn_app,
|
||||
};
|
||||
use serial_test::serial;
|
||||
|
||||
@@ -274,6 +275,120 @@ async fn category_subtree_listing_and_price_sort() {
|
||||
assert_eq!(body["error"]["code"], "BAD_REQUEST");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn brand_filter_and_real_sales() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
|
||||
// Brands are admin-managed reference data; replacing the list is idempotent.
|
||||
let brands = serde_json::json!([
|
||||
{"slug": "alpha", "name": {"en": "Alpha", "zh": "阿尔法"}},
|
||||
{"slug": "beta", "name": {"en": "Beta", "zh": "贝塔"}}
|
||||
]);
|
||||
let res = client()
|
||||
.put(app.url("/api/admin/brands"))
|
||||
.bearer_auth(&admin)
|
||||
.json(&brands)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200, "{:?}", res.text().await);
|
||||
let list: serde_json::Value = res.json().await.unwrap();
|
||||
let alpha = list[0]["id"].as_str().unwrap().to_string();
|
||||
let beta = list[1]["id"].as_str().unwrap().to_string();
|
||||
|
||||
let shop_id = create_shop(&app, &admin, "shop-brands").await;
|
||||
let owner = make_shop_owner(&app, &admin, &shop_id).await;
|
||||
let (p_alpha, _) =
|
||||
create_product_full(&app, &owner, "branded-a", 1000, 10, None, Some(&alpha)).await;
|
||||
let (p_beta, _) =
|
||||
create_product_full(&app, &owner, "branded-b", 2000, 10, None, Some(&beta)).await;
|
||||
publish_product(&app, &owner, &p_alpha).await;
|
||||
publish_product(&app, &owner, &p_beta).await;
|
||||
|
||||
let ids = |body: &serde_json::Value| -> Vec<String> {
|
||||
body["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| p["id"].as_str().unwrap().to_string())
|
||||
.collect()
|
||||
};
|
||||
let sold = |body: &serde_json::Value, id: &str| -> i64 {
|
||||
body["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|p| p["id"] == id)
|
||||
.unwrap()["sold_count"]
|
||||
.as_i64()
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// The brand filter narrows within the shop.
|
||||
let res = client()
|
||||
.get(app.url(&format!(
|
||||
"/api/products?shop_id={shop_id}&brand_id={alpha}&per_page=50"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let body: serde_json::Value = res.json().await.unwrap();
|
||||
assert_eq!(ids(&body), vec![p_alpha.clone()]);
|
||||
assert_eq!(body["total"], 1);
|
||||
|
||||
// An unpaid order is not a sale.
|
||||
let sku_alpha: String =
|
||||
sqlx::query_scalar("SELECT id::text FROM skus WHERE product_id = $1::uuid")
|
||||
.bind(&p_alpha)
|
||||
.fetch_one(&app.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let (buyer, _) = register_customer(&app, "brand-buyer").await;
|
||||
add_to_cart(&app, &buyer, &sku_alpha, 3).await;
|
||||
let orders = checkout(&app, &buyer).await;
|
||||
|
||||
let res = client()
|
||||
.get(app.url(&format!("/api/products?shop_id={shop_id}&per_page=50")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let body: serde_json::Value = res.json().await.unwrap();
|
||||
assert_eq!(sold(&body, &p_alpha), 0, "pending payment must not count as sold");
|
||||
assert_eq!(sold(&body, &p_beta), 0);
|
||||
|
||||
// Paying makes the units count, and the sales sort follows them.
|
||||
for order in &orders {
|
||||
pay(&app, &buyer, order["id"].as_str().unwrap()).await;
|
||||
}
|
||||
let res = client()
|
||||
.get(app.url(&format!("/api/products?shop_id={shop_id}&per_page=50")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let body: serde_json::Value = res.json().await.unwrap();
|
||||
assert_eq!(sold(&body, &p_alpha), 3);
|
||||
|
||||
let res = client()
|
||||
.get(app.url(&format!(
|
||||
"/api/products?shop_id={shop_id}&sort=sales&order=desc&per_page=50"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let body: serde_json::Value = res.json().await.unwrap();
|
||||
assert_eq!(ids(&body)[0], p_alpha, "sales sort puts the sold product first");
|
||||
|
||||
// Comments still have no model, so that sort stays refused.
|
||||
let res = client()
|
||||
.get(app.url("/api/products?sort=comments"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 400);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn suspended_shop_hidden_from_public_catalog() {
|
||||
|
||||
@@ -150,7 +150,7 @@ pub async fn create_product_with_sku(
|
||||
price_minor: i64,
|
||||
stock: i32,
|
||||
) -> (String, String) {
|
||||
create_product_with_sku_in_category(app, owner_token, slug, price_minor, stock, None).await
|
||||
create_product_full(app, owner_token, slug, price_minor, stock, None, None).await
|
||||
}
|
||||
|
||||
/// Same, but placed in `category_id` so category filtering can be exercised.
|
||||
@@ -161,6 +161,19 @@ pub async fn create_product_with_sku_in_category(
|
||||
price_minor: i64,
|
||||
stock: i32,
|
||||
category_id: Option<&str>,
|
||||
) -> (String, String) {
|
||||
create_product_full(app, owner_token, slug, price_minor, stock, category_id, None).await
|
||||
}
|
||||
|
||||
/// Same, with a category and a brand so both filters can be exercised.
|
||||
pub async fn create_product_full(
|
||||
app: &TestApp,
|
||||
owner_token: &str,
|
||||
slug: &str,
|
||||
price_minor: i64,
|
||||
stock: i32,
|
||||
category_id: Option<&str>,
|
||||
brand_id: Option<&str>,
|
||||
) -> (String, String) {
|
||||
let slug = format!("{slug}-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
|
||||
let res = client()
|
||||
@@ -171,6 +184,7 @@ pub async fn create_product_with_sku_in_category(
|
||||
"name": {"en": format!("Product {slug}"), "zh": format!("商品 {slug}")},
|
||||
"description": {"en": "desc en", "zh": "描述"},
|
||||
"category_id": category_id,
|
||||
"brand_id": brand_id,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
|
||||
Reference in New Issue
Block a user