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:
2026-09-17 17:35:28 +00:00
parent 65cea42da6
commit ce3e8db5b1
20 changed files with 487 additions and 193 deletions
+8 -2
View File
@@ -21,6 +21,7 @@ import type {
import {
BASE_CURRENCY,
MOCK_BANNERS,
MOCK_BRANDS,
MOCK_CATEGORIES,
MOCK_CURRENCIES,
MOCK_PROMOS,
@@ -162,7 +163,10 @@ export function createMockApi(): ApiClient {
searchMockProducts({
q: q.q,
categoryId: q.category_id,
brandId: q.brand_id,
shopId: q.shop_id,
sort: q.sort,
order: q.order,
page: q.page,
perPage: q.per_page,
}),
@@ -175,6 +179,7 @@ export function createMockApi(): ApiClient {
},
listCategories: () => Promise.resolve(MOCK_CATEGORIES),
listBrands: () => Promise.resolve(MOCK_BRANDS),
listCurrencies: () => Promise.resolve(MOCK_CURRENCIES),
convert: (amountMinor, from, to) =>
Promise.resolve({ amount_minor: mockConvertMinor(amountMinor, from, to), currency: to }),
@@ -375,8 +380,7 @@ export function createMockApi(): ApiClient {
})),
}),
listShops: (): Promise<ShopProfile[]> => Promise.resolve(MOCK_STORES.map(toShopProfile)),
getShop: (slug) => {
listShops: (): Promise<ShopProfile[]> => Promise.resolve(MOCK_STORES.map(toShopProfile)), getShop: (slug) => {
const store = storeById(slug);
if (!store) return Promise.reject(new ApiError(404, "NOT_FOUND", "Shop not found"));
return Promise.resolve(toShopProfile(store));
@@ -412,6 +416,8 @@ export function createMockApi(): ApiClient {
getContent: () => unsupported(),
replaceContent: () => unsupported(),
setShopProfile: () => unsupported(),
getBrands: () => unsupported(),
replaceBrands: () => unsupported(),
},
};
}
+20 -115
View File
@@ -3,6 +3,7 @@
import type {
Address,
Brand,
Category,
Invoice,
LocalizedText,
@@ -50,18 +51,6 @@ export interface MockPromo {
url: string;
}
export interface MockComment {
id: string;
productId: string;
author: string;
avatar: string;
rating: number; // 1-5
content: LocalizedText;
images: string[];
reply: LocalizedText | null;
createdAt: string;
}
export interface MockCoupon {
id: string;
title: LocalizedText;
@@ -105,21 +94,6 @@ export interface IntegralProduct {
stock: number;
}
export interface ProductDetail {
product: Product;
store: MockStore;
comments: MockComment[];
commentStats: { all: number; good: number; medium: number; bad: number; goodRate: number };
coupons: MockCoupon[];
salesRank: Product[];
}
export interface StoreDetail {
store: MockStore;
products: Product[];
salesRank: Product[];
}
// ---------- currencies ----------
export const MOCK_CURRENCIES = [
@@ -332,7 +306,15 @@ export function categorySubtreeIds(rootId: string): Set<string> {
// ---------- brands ----------
const PRODUCT_BRAND: Record<string, string> = {};
/** Mirrors the seeded `brands` table so the rollback path can still filter. */
export const MOCK_BRANDS: Brand[] = [
{ id: "b1", slug: "aurora", name: L("Aurora", "极光"), position: 0, active: true },
{ id: "b2", slug: "nordwind", name: L("Nordwind", "北风"), position: 1, active: true },
{ id: "b3", slug: "hexon", name: L("Hexon", "赫克森"), position: 2, active: true },
{ id: "b4", slug: "mikado", name: L("Mikado", "御门"), position: 3, active: true },
{ id: "b5", slug: "solace", name: L("Solace", "索莱斯"), position: 4, active: true },
{ id: "b6", slug: "terra", name: L("Terra", "大地"), position: 5, active: true },
];
// ---------- stores ----------
@@ -442,7 +424,6 @@ function buildProducts(): Product[] {
const now = "2026-09-01T00:00:00.000Z";
return PRODUCT_SPECS.map((spec, i) => {
const id = `p${i + 1}`;
PRODUCT_BRAND[id] = spec.brand;
const img = `/mock/product-${spec.n}.svg`;
const skus: Sku[] = [];
const combos = spec.attrs.reduce<string[][]>(
@@ -470,12 +451,16 @@ function buildProducts(): Product[] {
id,
shop_id: spec.store,
category_id: spec.cat,
brand_id: spec.brand,
slug: spec.slug,
name: spec.name,
description: spec.sub,
images: [img, img, img],
status: "published",
created_at: now,
// The fixed-data path has no order data to count, so it reports none
// rather than inventing a figure.
sold_count: 0,
skus,
} satisfies Product;
});
@@ -501,28 +486,19 @@ export interface MockSearchQuery {
categoryId?: string;
brandId?: string;
shopId?: string;
sort?: "default" | "price" | "sales" | "comments";
sort?: "default" | "price" | "sales";
order?: "asc" | "desc";
page?: number;
perPage?: number;
}
// Deterministic pseudo stats so sort orders are stable. Seeded by hashing the
// id, not by parsing digits out of it: mock ids are "p1" but live ids are UUIDs,
// and `Number("f8a1...")` is NaN, which rendered as "NaN sold".
/** Stable ordering for the fixed-data catalogue; not a claim about sales. */
function seedOf(id: string): number {
let hash = 0;
for (const ch of id) hash = (hash * 31 + ch.charCodeAt(0)) % 100000;
return hash;
}
export function salesOf(p: Product): number {
return 50 + ((seedOf(p.id) * 137) % 950);
}
export function commentCountOf(p: Product): number {
return 5 + ((seedOf(p.id) * 61) % 240);
}
export function searchMockProducts(query: MockSearchQuery): { items: Product[]; total: number; page: number; per_page: number } {
const page = query.page && query.page > 0 ? query.page : 1;
const perPage = query.perPage && query.perPage > 0 ? query.perPage : 20;
@@ -532,7 +508,7 @@ export function searchMockProducts(query: MockSearchQuery): { items: Product[];
const ids = categorySubtreeIds(query.categoryId);
list = list.filter((p) => p.category_id !== null && ids.has(p.category_id));
}
if (query.brandId) list = list.filter((p) => PRODUCT_BRAND[p.id] === query.brandId);
if (query.brandId) list = list.filter((p) => p.brand_id === query.brandId);
const kw = query.q?.trim().toLowerCase();
if (kw) {
list = list.filter((p) =>
@@ -547,10 +523,9 @@ export function searchMockProducts(query: MockSearchQuery): { items: Product[];
list = [...list].sort((a, b) => dir * ((lowestSku(a)?.price_minor ?? 0) - (lowestSku(b)?.price_minor ?? 0)));
break;
case "sales":
list = [...list].sort((a, b) => dir * (salesOf(a) - salesOf(b)));
break;
case "comments":
list = [...list].sort((a, b) => dir * (commentCountOf(a) - commentCountOf(b)));
// The fixed-data catalogue has no orders, so every product reports zero
// sold and this keeps the incoming order rather than inventing one.
list = [...list].sort((a, b) => dir * (a.sold_count - b.sold_count));
break;
default:
list = [...list].sort((a, b) => seedOf(a.id) - seedOf(b.id));
@@ -600,76 +575,6 @@ export const MOCK_COUPONS: MockCoupon[] = [
{ id: "cp3", title: L("$40 off over $499", "满 499 减 40"), amountMinor: 4000, thresholdMinor: 49900, currency: BASE_CURRENCY, expiresAt: "2026-10-31" },
];
export function commentsFor(productId: string): MockComment[] {
const seed = Number(productId.replace(/\D/g, "")) || 1;
const pool: { author: string; text: LocalizedText; rating: number }[] = [
{ author: "A***a", text: L("Great quality, exactly as described. Fast shipping!", "质量很好,和描述一致,发货快!"), rating: 5 },
{ author: "M***e", text: L("Good value for the price. Would buy again.", "性价比不错,会回购。"), rating: 5 },
{ author: "J***n", text: L("Decent, but packaging could be better.", "还行,包装可以更好。"), rating: 4 },
{ author: "S***y", text: L("Average experience overall.", "整体一般。"), rating: 3 },
];
const count = 2 + (seed % 3);
const out: MockComment[] = [];
for (let i = 0; i < count; i++) {
const p = pool[(seed + i) % pool.length];
out.push({
id: `${productId}-cm${i + 1}`,
productId,
author: p.author,
avatar: "/mock/avatar.svg",
rating: p.rating,
content: p.text,
images: [],
reply:
p.rating <= 3
? L("Sorry for the inconvenience, please contact support.", "很抱歉带来不便,请联系在线客服处理。")
: null,
createdAt: `2026-0${(seed % 8) + 1}-1${i}`,
});
}
return out;
}
export function commentStats(productId: string): { all: number; good: number; medium: number; bad: number; goodRate: number } {
// Derived from the id itself so it matches commentCountOf for live UUID ids
// too, instead of falling back to an arbitrary mock product.
const all = 5 + ((seedOf(productId) * 61) % 240);
const good = Math.round(all * 0.92);
const medium = Math.round(all * 0.06);
const bad = all - good - medium;
return { all, good, medium, bad, goodRate: Math.round((good / Math.max(1, all)) * 100) };
}
export function salesRankFor(shopId: string): Product[] {
return MOCK_PRODUCTS.filter((p) => p.shop_id === shopId)
.sort((a, b) => salesOf(b) - salesOf(a))
.slice(0, 5);
}
export function productDetail(idOrSlug: string): ProductDetail | null {
const product = productById(idOrSlug);
if (!product) return null;
const store = storeById(product.shop_id) ?? MOCK_STORES[0];
return {
product,
store,
comments: commentsFor(product.id),
commentStats: commentStats(product.id),
coupons: MOCK_COUPONS,
salesRank: salesRankFor(product.shop_id),
};
}
export function storeDetail(idOrSlug: string): StoreDetail | null {
const store = storeById(idOrSlug);
if (!store) return null;
return {
store,
products: MOCK_PRODUCTS.filter((p) => p.shop_id === store.id),
salesRank: salesRankFor(store.id),
};
}
// ---------- marketing ----------
export const SECKILL_SESSIONS: SeckillSession[] = [