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:
+119
-81
@@ -2,7 +2,12 @@
|
||||
/**
|
||||
* Seed demo data into a running vmall-api (dev).
|
||||
* Usage: node scripts/seed-demo.mjs [baseUrl]
|
||||
* Idempotent: existing slugs/emails are reused, products are re-published.
|
||||
* Idempotent: existing slugs/emails are reused, products are re-published,
|
||||
* and an owner's role is only reassigned when it does not already point at
|
||||
* the right shop.
|
||||
*
|
||||
* Categories are NOT created here - they are reference data seeded by the
|
||||
* migrations, because the API exposes no category write route.
|
||||
*/
|
||||
const base = process.argv[2] ?? "http://localhost:8080/api";
|
||||
|
||||
@@ -37,117 +42,149 @@ let r = await call("POST", "/auth/login", {
|
||||
if (r.status !== 200) fail("admin login", r);
|
||||
const admin = r.data.token;
|
||||
|
||||
// 2. demo shop
|
||||
let shopId;
|
||||
r = await call("POST", "/admin/shops", {
|
||||
token: admin,
|
||||
body: {
|
||||
name: { en: "Demo Store", zh: "演示店铺" },
|
||||
// 2. shops with their own owner accounts
|
||||
const SHOPS = [
|
||||
{
|
||||
slug: "demo-store",
|
||||
name: { en: "Demo Store", zh: "演示店铺" },
|
||||
owner: { email: "shop@vmall.local", password: "shop12345", displayName: "Demo Owner" },
|
||||
},
|
||||
});
|
||||
if (r.status === 201) shopId = r.data.id;
|
||||
else if (r.status === 409) {
|
||||
const shops = await call("GET", "/admin/shops", { token: admin });
|
||||
shopId = shops.data.find((s) => s.slug === "demo-store").id;
|
||||
} else fail("create shop", r);
|
||||
{
|
||||
slug: "aurora-digital",
|
||||
name: { en: "Aurora Digital", zh: "极光数码" },
|
||||
owner: { email: "aurora@vmall.local", password: "shop12345", displayName: "Aurora Owner" },
|
||||
},
|
||||
{
|
||||
slug: "nordwind-home",
|
||||
name: { en: "Nordwind Home", zh: "北风家居" },
|
||||
owner: { email: "nordwind@vmall.local", password: "shop12345", displayName: "Nordwind Owner" },
|
||||
},
|
||||
{
|
||||
slug: "terra-grocery",
|
||||
name: { en: "Terra Grocery", zh: "大地生鲜" },
|
||||
owner: { email: "terra@vmall.local", password: "shop12345", displayName: "Terra Owner" },
|
||||
},
|
||||
];
|
||||
|
||||
// 3. demo shop owner (register may 409 if exists)
|
||||
const ownerEmail = "shop@vmall.local";
|
||||
await call("POST", "/auth/register", {
|
||||
body: { email: ownerEmail, password: "shop12345", display_name: "Demo Owner" },
|
||||
});
|
||||
r = await call("POST", "/auth/login", {
|
||||
body: { email: ownerEmail, password: "shop12345" },
|
||||
});
|
||||
if (r.status !== 200) fail("owner login", r);
|
||||
let owner = r.data.token;
|
||||
if (r.data.user.role !== "shop_owner") {
|
||||
r = await call("PUT", `/admin/users/${r.data.user.id}/role`, {
|
||||
/** Create the shop if missing and return a token for its owner. */
|
||||
async function ensureShop(def) {
|
||||
let shopId;
|
||||
r = await call("POST", "/admin/shops", {
|
||||
token: admin,
|
||||
body: { role: "shop_owner", shop_id: shopId },
|
||||
body: { name: def.name, slug: def.slug },
|
||||
});
|
||||
if (r.status === 201) shopId = r.data.id;
|
||||
else if (r.status === 409) {
|
||||
const shops = await call("GET", "/admin/shops", { token: admin });
|
||||
shopId = shops.data.find((s) => s.slug === def.slug).id;
|
||||
} else fail(`create shop ${def.slug}`, r);
|
||||
|
||||
await call("POST", "/auth/register", {
|
||||
body: {
|
||||
email: def.owner.email,
|
||||
password: def.owner.password,
|
||||
display_name: def.owner.displayName,
|
||||
},
|
||||
});
|
||||
if (r.status !== 200) fail("assign owner", r);
|
||||
r = await call("POST", "/auth/login", {
|
||||
body: { email: ownerEmail, password: "shop12345" },
|
||||
body: { email: def.owner.email, password: def.owner.password },
|
||||
});
|
||||
owner = r.data.token;
|
||||
if (r.status !== 200) fail(`owner login ${def.owner.email}`, r);
|
||||
let token = r.data.token;
|
||||
|
||||
const ownsThisShop = r.data.user.role === "shop_owner" && r.data.user.shop_id === shopId;
|
||||
if (!ownsThisShop) {
|
||||
r = await call("PUT", `/admin/users/${r.data.user.id}/role`, {
|
||||
token: admin,
|
||||
body: { role: "shop_owner", shop_id: shopId },
|
||||
});
|
||||
if (r.status !== 200) fail(`assign owner ${def.slug}`, r);
|
||||
r = await call("POST", "/auth/login", {
|
||||
body: { email: def.owner.email, password: def.owner.password },
|
||||
});
|
||||
if (r.status !== 200) fail(`owner re-login ${def.owner.email}`, r);
|
||||
token = r.data.token;
|
||||
}
|
||||
console.log(`shop ready: ${def.slug}`);
|
||||
return token;
|
||||
}
|
||||
|
||||
// 4. demo customer
|
||||
const ownerTokens = new Map();
|
||||
for (const def of SHOPS) {
|
||||
ownerTokens.set(def.slug, await ensureShop(def));
|
||||
}
|
||||
|
||||
// 3. demo customer
|
||||
await call("POST", "/auth/register", {
|
||||
body: { email: "customer@vmall.local", password: "customer123", display_name: "Demo Customer" },
|
||||
});
|
||||
|
||||
// 5. categories
|
||||
// 4. categories (reference data from the migrations)
|
||||
const cats = (await call("GET", "/categories")).data;
|
||||
const catBy = (slug) => cats.find((c) => c.slug === slug)?.id ?? null;
|
||||
if (cats.length < 7) {
|
||||
console.error(`expected the seeded category tree, found ${cats.length} categories`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 6. products
|
||||
// 5. products: 24 across 4 shops, covering all six top-level categories so
|
||||
// every home floor is non-empty.
|
||||
const products = [
|
||||
{
|
||||
slug: "wireless-headphones",
|
||||
name: { en: "Wireless Headphones", zh: "无线耳机" },
|
||||
description: {
|
||||
en: "Noise-cancelling over-ear headphones with 40h battery.",
|
||||
zh: "降噪头戴式耳机,40 小时续航。",
|
||||
},
|
||||
category: "electronics",
|
||||
price: 7999,
|
||||
stock: 25,
|
||||
img: "https://picsum.photos/seed/headphones/600/600",
|
||||
},
|
||||
{
|
||||
slug: "mechanical-keyboard",
|
||||
name: { en: "Mechanical Keyboard", zh: "机械键盘" },
|
||||
description: {
|
||||
en: "Hot-swappable 75% keyboard, gasket mount.",
|
||||
zh: "热插拔 75% 配列机械键盘,Gasket 结构。",
|
||||
},
|
||||
category: "electronics",
|
||||
price: 12900,
|
||||
stock: 40,
|
||||
img: "https://picsum.photos/seed/keyboard/600/600",
|
||||
},
|
||||
{
|
||||
slug: "linen-shirt",
|
||||
name: { en: "Linen Shirt", zh: "亚麻衬衫" },
|
||||
description: { en: "Breathable 100% linen shirt.", zh: "透气纯亚麻衬衫。" },
|
||||
category: "fashion",
|
||||
price: 4599,
|
||||
stock: 60,
|
||||
img: "https://picsum.photos/seed/shirt/600/600",
|
||||
},
|
||||
{
|
||||
slug: "ceramic-mug",
|
||||
name: { en: "Ceramic Mug", zh: "陶瓷马克杯" },
|
||||
description: { en: "Hand-glazed 350ml mug.", zh: "手工上釉 350ml 马克杯。" },
|
||||
category: "home-living",
|
||||
price: 1999,
|
||||
stock: 100,
|
||||
img: "https://picsum.photos/seed/mug/600/600",
|
||||
},
|
||||
// demo-store
|
||||
{ shop: "demo-store", slug: "wireless-headphones", category: "electronics-audio", price: 7999, stock: 25, name: { en: "Wireless Headphones", zh: "无线耳机" }, description: { en: "Noise-cancelling over-ear headphones with 40h battery.", zh: "降噪头戴式耳机,40 小时续航。" }, img: "headphones" },
|
||||
{ shop: "demo-store", slug: "mechanical-keyboard", category: "computers-keyboards", price: 12900, stock: 40, name: { en: "Mechanical Keyboard", zh: "机械键盘" }, description: { en: "Hot-swappable 75% keyboard, gasket mount.", zh: "热插拔 75% 配列机械键盘,Gasket 结构。" }, img: "keyboard" },
|
||||
{ shop: "demo-store", slug: "linen-shirt", category: "fashion-shirts", price: 4599, stock: 60, name: { en: "Linen Shirt", zh: "亚麻衬衫" }, description: { en: "Breathable 100% linen shirt.", zh: "透气纯亚麻衬衫。" }, img: "shirt" },
|
||||
{ shop: "demo-store", slug: "ceramic-mug", category: "home-cookers", price: 1999, stock: 100, name: { en: "Ceramic Mug", zh: "陶瓷马克杯" }, description: { en: "Hand-glazed 350ml mug.", zh: "手工上釉 350ml 马克杯。" }, img: "mug" },
|
||||
{ shop: "demo-store", slug: "canvas-backpack", category: "fashion-backpacks", price: 3999, stock: 45, name: { en: "Canvas Backpack", zh: "帆布双肩包" }, description: { en: "Water-resistant 22L everyday backpack.", zh: "防泼水 22L 通勤双肩包。" }, img: "backpack" },
|
||||
{ shop: "demo-store", slug: "merino-sweater", category: "fashion-menswear", price: 6900, stock: 30, name: { en: "Merino Sweater", zh: "美利奴羊毛衫" }, description: { en: "Fine-gauge merino crew neck.", zh: "细针美利奴圆领毛衣。" }, img: "sweater" },
|
||||
|
||||
// aurora-digital
|
||||
{ shop: "aurora-digital", slug: "aurora-x1-pro", category: "electronics-flagship", price: 99900, stock: 12, name: { en: "Aurora X1 Pro", zh: "Aurora X1 Pro 旗舰手机" }, description: { en: "6.7in flagship with triple camera and 120Hz display.", zh: "6.7 英寸三摄旗舰,120Hz 屏幕。" }, img: "aurora-x1" },
|
||||
{ shop: "aurora-digital", slug: "aurora-a3", category: "electronics-budget", price: 19900, stock: 60, name: { en: "Aurora A3", zh: "Aurora A3 手机" }, description: { en: "Everyday 5G phone with 5000mAh battery.", zh: "日常 5G 手机,5000mAh 电池。" }, img: "aurora-a3" },
|
||||
{ shop: "aurora-digital", slug: "aurora-buds-air", category: "electronics-earbuds", price: 12900, stock: 80, name: { en: "Aurora Buds Air", zh: "Aurora Buds Air 耳机" }, description: { en: "Active noise cancelling true wireless earbuds.", zh: "主动降噪真无线耳机。" }, img: "aurora-buds" },
|
||||
{ shop: "aurora-digital", slug: "aurora-boom-speaker", category: "electronics-speakers", price: 8900, stock: 35, name: { en: "Aurora Boom Speaker", zh: "Aurora Boom 音箱" }, description: { en: "Portable IPX7 speaker with 20h playback.", zh: "IPX7 防水便携音箱,20 小时播放。" }, img: "aurora-boom" },
|
||||
{ shop: "aurora-digital", slug: "aurora-ultrabook-14", category: "computers-ultrabooks", price: 129900, stock: 18, name: { en: "Aurora Ultrabook 14", zh: "Aurora 轻薄本 14" }, description: { en: "1.1kg magnesium chassis with 14in OLED panel.", zh: "1.1kg 镁合金机身,14 英寸 OLED 屏。" }, img: "aurora-ultrabook" },
|
||||
{ shop: "aurora-digital", slug: "aurora-27-monitor", category: "computers-monitors", price: 34900, stock: 22, name: { en: "Aurora 27 Monitor", zh: "Aurora 27 英寸显示器" }, description: { en: "27in 4K USB-C monitor with 90W power delivery.", zh: "27 英寸 4K USB-C 显示器,90W 反向供电。" }, img: "aurora-monitor" },
|
||||
|
||||
// nordwind-home
|
||||
{ shop: "nordwind-home", slug: "nordwind-rice-cooker", category: "home-cookers", price: 8900, stock: 50, name: { en: "Nordwind Rice Cooker", zh: "北风智能电饭煲" }, description: { en: "IH rice cooker with 12 presets.", zh: "IH 电磁加热,12 种预设菜单。" }, img: "nordwind-cooker" },
|
||||
{ shop: "nordwind-home", slug: "nordwind-blender-pro", category: "home-blenders", price: 15900, stock: 28, name: { en: "Nordwind Blender Pro", zh: "北风破壁机 Pro" }, description: { en: "1400W high-speed blender with vacuum jar.", zh: "1400W 高速破壁机,配真空杯。" }, img: "nordwind-blender" },
|
||||
{ shop: "nordwind-home", slug: "nordwind-stick-vacuum", category: "home-vacuums", price: 24900, stock: 24, name: { en: "Nordwind Stick Vacuum", zh: "北风无线吸尘器" }, description: { en: "Cordless vacuum with 60min runtime.", zh: "无线手持吸尘器,续航 60 分钟。" }, img: "nordwind-vacuum" },
|
||||
{ shop: "nordwind-home", slug: "nordwind-air-purifier", category: "home-purifiers", price: 32900, stock: 20, name: { en: "Nordwind Air Purifier", zh: "北风空气净化器" }, description: { en: "HEPA 13 purifier covering 60 square metres.", zh: "HEPA 13 滤网,适用 60 平方米。" }, img: "nordwind-purifier" },
|
||||
{ shop: "nordwind-home", slug: "nordwind-serum-c", category: "beauty-serums", price: 4900, stock: 70, name: { en: "Nordwind Vitamin C Serum", zh: "北风维C精华" }, description: { en: "15% vitamin C brightening serum.", zh: "15% 维C 提亮精华。" }, img: "nordwind-serum" },
|
||||
{ shop: "nordwind-home", slug: "nordwind-shave-9000", category: "beauty-shavers", price: 7900, stock: 40, name: { en: "Nordwind Shaver 9000", zh: "北风剃须刀 9000" }, description: { en: "Wet and dry rotary shaver with travel case.", zh: "干湿两用旋转剃须刀,含旅行盒。" }, img: "nordwind-shaver" },
|
||||
|
||||
// terra-grocery
|
||||
{ shop: "terra-grocery", slug: "terra-mixed-nuts", category: "grocery-nuts", price: 1599, stock: 200, name: { en: "Terra Mixed Nuts", zh: "大地混合坚果" }, description: { en: "Roasted unsalted nut mix, 500g.", zh: "烘烤无盐混合坚果,500g。" }, img: "terra-nuts" },
|
||||
{ shop: "terra-grocery", slug: "terra-dark-chocolate", category: "grocery-chocolate", price: 1299, stock: 180, name: { en: "Terra Dark Chocolate", zh: "大地黑巧克力" }, description: { en: "72% single-origin dark chocolate.", zh: "72% 单一产地黑巧克力。" }, img: "terra-chocolate" },
|
||||
{ shop: "terra-grocery", slug: "terra-orchard-apples", category: "grocery-fruit", price: 899, stock: 300, name: { en: "Terra Orchard Apples", zh: "大地果园苹果" }, description: { en: "Crisp orchard apples, 1kg box.", zh: "脆甜果园苹果,1kg 装。" }, img: "terra-apples" },
|
||||
{ shop: "terra-grocery", slug: "terra-organic-kale", category: "grocery-vegetables", price: 699, stock: 260, name: { en: "Terra Organic Kale", zh: "大地有机羽衣甘蓝" }, description: { en: "Certified organic kale, 400g.", zh: "有机认证羽衣甘蓝,400g。" }, img: "terra-kale" },
|
||||
{ shop: "terra-grocery", slug: "terra-carry-on", category: "fashion-luggage", price: 42900, stock: 16, name: { en: "Terra Carry-On", zh: "大地登机箱" }, description: { en: "Polycarbonate carry-on with TSA lock.", zh: "聚碳酸酯登机箱,TSA 密码锁。" }, img: "terra-carryon" },
|
||||
{ shop: "terra-grocery", slug: "terra-leather-tote", category: "fashion-bags", price: 21900, stock: 26, name: { en: "Terra Leather Tote", zh: "大地真皮托特包" }, description: { en: "Full-grain leather tote with laptop sleeve.", zh: "头层牛皮托特包,含电脑隔层。" }, img: "terra-tote" },
|
||||
];
|
||||
|
||||
for (const p of products) {
|
||||
const token = ownerTokens.get(p.shop);
|
||||
r = await call("POST", "/shop/products", {
|
||||
token: owner,
|
||||
token,
|
||||
body: {
|
||||
slug: p.slug,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
category_id: catBy(p.category),
|
||||
images: [p.img],
|
||||
images: [`https://picsum.photos/seed/${p.img}/600/600`],
|
||||
},
|
||||
});
|
||||
let id;
|
||||
if (r.status === 201) id = r.data.id;
|
||||
else if (r.status === 409) {
|
||||
const list = await call("GET", "/shop/products?per_page=100", { token: owner });
|
||||
id = list.data.items.find((x) => x.slug === p.slug).id;
|
||||
const list = await call("GET", "/shop/products?per_page=100", { token });
|
||||
id = list.data.items.find((x) => x.slug === p.slug)?.id;
|
||||
if (!id) fail(`lookup product ${p.slug}`, r);
|
||||
} else fail(`create product ${p.slug}`, r);
|
||||
|
||||
r = await call("POST", `/shop/products/${id}/skus`, {
|
||||
token: owner,
|
||||
token,
|
||||
body: {
|
||||
sku_code: `${p.slug}-std`,
|
||||
price_minor: p.price,
|
||||
@@ -156,12 +193,13 @@ for (const p of products) {
|
||||
},
|
||||
});
|
||||
if (r.status !== 200) fail(`sku ${p.slug}`, r);
|
||||
r = await call("POST", `/shop/products/${id}/publish`, { token: owner });
|
||||
r = await call("POST", `/shop/products/${id}/publish`, { token });
|
||||
if (r.status !== 200) fail(`publish ${p.slug}`, r);
|
||||
console.log(`product ready: ${p.slug}`);
|
||||
}
|
||||
console.log(`products ready: ${products.length} across ${SHOPS.length} shops`);
|
||||
|
||||
console.log("\nDemo data seeded.");
|
||||
console.log(" platform admin : admin@vmall.local / admin1234 (apps/admin :3002)");
|
||||
console.log(" shop owner : shop@vmall.local / shop12345 (apps/shop-admin :3001)");
|
||||
console.log(" customer : customer@vmall.local / customer123 (apps/mall :3000)");
|
||||
console.log(" extra owners : aurora@ / nordwind@ / terra@vmall.local (password shop12345)");
|
||||
|
||||
Reference in New Issue
Block a user