feat(mall): serve home marketing content from the API

Wave 4 of replacing the fixed-data mock adapter, and the first capability the
mall never had a backend for: the home page's banners, promo tiles, quick links
and floor advert art move out of local arrays.

- four explicit tables (`banners`, `promos`, `quick_links`, `floor_adverts`)
  rather than one JSONB payload table, so Postgres enforces each shape
- a migration seeds them from the assets the page already rendered, so the flip
  is visually a no-op. Destinations are real routes now: the mock's promo links
  pointed at dangling `?category=c1` ids and its first banner used `sort=sales`,
  which the catalog API rejects
- `GET /api/content/home` is public and returns the four active, ordered lists,
  always including a key so a page can render a missing block
- `GET /api/admin/content` and `PUT /api/admin/content/{kind}` let a platform
  admin read everything and replace one kind transactionally, with positions
  reindexed from the submitted order and a rejected list changing nothing
- the mall's fixed-data adapter learns `getHomeContent`, and a `content` domain
  joins the per-domain switch so the rollback path still renders the page

Verified: 23 backend tests green including six new content tests; all three
frontends build; the home page renders the same four blocks as before, an admin
reorder and deactivation change the rendered carousel, and the fixed-data
rollback renders every block with the backend stopped.

OpenSpec change: openspec/changes/replace-mock-api-wave-4
This commit is contained in:
2026-09-17 16:48:10 +00:00
parent 2136a48fbe
commit 104737e4e1
17 changed files with 866 additions and 15 deletions
@@ -0,0 +1,81 @@
-- Storefront marketing content for the home page.
--
-- Four kinds live in explicit tables rather than one JSONB payload table, so
-- Postgres enforces each shape instead of the API hand-validating opaque blobs.
-- Seeded from the assets the page already rendered, so moving this content into
-- the database is visually a no-op.
CREATE TABLE banners (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
image TEXT NOT NULL,
url TEXT NOT NULL,
position INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE promos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
image TEXT NOT NULL,
url TEXT NOT NULL,
position INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE quick_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
label JSONB NOT NULL,
url TEXT NOT NULL,
-- inline SVG path data, 24x24 viewBox
glyph TEXT NOT NULL,
position INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- An ordered pool: the home page assigns these to floors in order and wraps,
-- so the destination stays the floor's own category.
CREATE TABLE floor_adverts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
image TEXT NOT NULL,
position INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO banners (image, url, position)
VALUES
('/mock/banner-1.svg', '/search?sort=price&order=desc', 0),
('/mock/banner-2.svg', '/seckill', 1),
('/mock/banner-3.svg', '/collective', 2);
INSERT INTO promos (image, url, position)
VALUES
('/mock/promo-1.svg', '/seckill', 0),
('/mock/promo-2.svg', '/collective', 1),
('/mock/promo-3.svg', '/integral', 2);
INSERT INTO quick_links (label, url, glyph, position)
VALUES
('{"en": "Verification", "zh": "实名认证"}', '/user',
'M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6l8-4z', 0),
('{"en": "Points Mall", "zh": "积分商城"}', '/integral',
'M12 2a10 10 0 100 20 10 10 0 000-20zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z', 1),
('{"en": "Group Buy", "zh": "优惠团购"}', '/collective',
'M20 7h-3.2A3 3 0 0012 3.5 3 3 0 007.2 7H4v4h7V9h2v2h7V7zM4 13v8h7v-8H4zm9 8h7v-8h-7v8z', 2),
('{"en": "Flash Sale", "zh": "秒杀活动"}', '/seckill',
'M13 2L4 14h6l-1 8 9-12h-6l1-8z', 3),
('{"en": "Notice", "zh": "商城公告"}', '/user',
'M12 22a2.5 2.5 0 002.5-2.5h-5A2.5 2.5 0 0012 22zm7-6v-5a7 7 0 00-5-6.7V4a2 2 0 00-4 0v.3A7 7 0 005 11v5l-2 2v1h18v-1l-2-2z', 4),
('{"en": "Become a Seller", "zh": "入驻商家"}', '/stores',
'M4 4h16l2 5v2a3 3 0 01-3 3 3 3 0 01-3-3 3 3 0 01-3 3 3 3 0 01-3-3 3 3 0 01-3 3 3 3 0 01-3-3V9l2-5zm0 10.7V20h7v-5h2v5h7v-5.3a4.98 4.98 0 01-2 .4V20H6v-5.7a4.98 4.98 0 01-2 .4z', 5);
INSERT INTO floor_adverts (image, position)
VALUES
('/mock/floor-adv-1.svg', 0),
('/mock/floor-adv-2.svg', 1),
('/mock/floor-adv-3.svg', 2),
('/mock/floor-adv-4.svg', 3),
('/mock/floor-adv-5.svg', 4),
('/mock/floor-adv-6.svg', 5);
+257
View File
@@ -0,0 +1,257 @@
use axum::{
extract::{Path, State},
routing::{get, put},
Json, Router,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::error::{ApiError, ApiResult};
use crate::models::UserRole;
use crate::state::AppState;
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Banner {
pub id: Uuid,
pub image: String,
pub url: String,
pub position: i32,
pub active: bool,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Promo {
pub id: Uuid,
pub image: String,
pub url: String,
pub position: i32,
pub active: bool,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct QuickLink {
pub id: Uuid,
pub label: Value,
pub url: String,
pub glyph: String,
pub position: i32,
pub active: bool,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct FloorAdvert {
pub id: Uuid,
pub image: String,
pub position: i32,
pub active: bool,
}
/// Every content kind. The public read carries active rows only; the admin read
/// carries everything, so a disabled block stays editable.
#[derive(Debug, Serialize)]
pub struct ContentView {
pub banners: Vec<Banner>,
pub promos: Vec<Promo>,
pub quick_links: Vec<QuickLink>,
pub floor_adverts: Vec<FloorAdvert>,
}
pub fn router(_state: AppState) -> Router<AppState> {
Router::new().route("/content/home", get(home_content))
}
pub fn admin_router(_state: AppState) -> Router<AppState> {
Router::new()
.route("/admin/content", get(admin_content))
.route("/admin/content/{kind}", put(replace_content))
}
/// Only the string literal below decides filtering; no input reaches it.
async fn load_content(state: &AppState, active_only: bool) -> ApiResult<ContentView> {
let filter = if active_only { " WHERE active = TRUE" } else { "" };
let banners = sqlx::query_as::<_, Banner>(&format!(
"SELECT * FROM banners{filter} ORDER BY position, created_at"
))
.fetch_all(&state.db)
.await?;
let promos = sqlx::query_as::<_, Promo>(&format!(
"SELECT * FROM promos{filter} ORDER BY position, created_at"
))
.fetch_all(&state.db)
.await?;
let quick_links = sqlx::query_as::<_, QuickLink>(&format!(
"SELECT * FROM quick_links{filter} ORDER BY position, created_at"
))
.fetch_all(&state.db)
.await?;
let floor_adverts = sqlx::query_as::<_, FloorAdvert>(&format!(
"SELECT * FROM floor_adverts{filter} ORDER BY position, created_at"
))
.fetch_all(&state.db)
.await?;
Ok(ContentView {
banners,
promos,
quick_links,
floor_adverts,
})
}
async fn home_content(State(state): State<AppState>) -> ApiResult<Json<ContentView>> {
Ok(Json(load_content(&state, true).await?))
}
async fn admin_content(
State(state): State<AppState>,
auth: AuthUser,
) -> ApiResult<Json<ContentView>> {
auth.require(&[UserRole::PlatformAdmin])?;
Ok(Json(load_content(&state, false).await?))
}
fn non_empty(value: &str, field: &str) -> ApiResult<()> {
if value.trim().is_empty() {
return Err(ApiError::BadRequest(format!("{field} must not be empty")));
}
Ok(())
}
fn localized(label: &Value) -> ApiResult<()> {
let ok = ["en", "zh"].iter().all(|code| {
label
.get(code)
.and_then(Value::as_str)
.is_some_and(|s| !s.trim().is_empty())
});
if !ok {
return Err(ApiError::BadRequest(
"quick-link label needs non-empty en and zh".into(),
));
}
Ok(())
}
#[derive(Deserialize)]
struct LinkInput {
image: String,
url: String,
#[serde(default = "default_active")]
active: bool,
}
#[derive(Deserialize)]
struct QuickLinkInput {
label: Value,
url: String,
glyph: String,
#[serde(default = "default_active")]
active: bool,
}
#[derive(Deserialize)]
struct AdvertInput {
image: String,
#[serde(default = "default_active")]
active: bool,
}
fn default_active() -> bool {
true
}
/// Replaces one kind from an ordered list. Validation happens before the
/// transaction, and positions are reindexed from the submitted order, so a
/// rejected list leaves the stored content untouched.
async fn replace_content(
State(state): State<AppState>,
auth: AuthUser,
Path(kind): Path<String>,
Json(body): Json<Value>,
) -> ApiResult<Json<ContentView>> {
auth.require(&[UserRole::PlatformAdmin])?;
if !matches!(
kind.as_str(),
"banners" | "promos" | "quick-links" | "floor-adverts"
) {
return Err(ApiError::BadRequest(format!("unknown content kind: {kind}")));
}
let mut tx = state.db.begin().await?;
match kind.as_str() {
"banners" | "promos" => {
let items: Vec<LinkInput> =
serde_json::from_value(body).map_err(|e| ApiError::BadRequest(e.to_string()))?;
for item in &items {
non_empty(&item.image, "image")?;
non_empty(&item.url, "url")?;
}
let table = if kind == "banners" { "banners" } else { "promos" };
sqlx::query(&format!("DELETE FROM {table}"))
.execute(&mut *tx)
.await?;
for (i, item) in items.iter().enumerate() {
sqlx::query(&format!(
"INSERT INTO {table} (image, url, position, active) VALUES ($1, $2, $3, $4)"
))
.bind(&item.image)
.bind(&item.url)
.bind(i as i32)
.bind(item.active)
.execute(&mut *tx)
.await?;
}
}
"quick-links" => {
let items: Vec<QuickLinkInput> =
serde_json::from_value(body).map_err(|e| ApiError::BadRequest(e.to_string()))?;
for item in &items {
non_empty(&item.url, "url")?;
non_empty(&item.glyph, "glyph")?;
localized(&item.label)?;
}
sqlx::query("DELETE FROM quick_links")
.execute(&mut *tx)
.await?;
for (i, item) in items.iter().enumerate() {
sqlx::query(
"INSERT INTO quick_links (label, url, glyph, position, active)
VALUES ($1, $2, $3, $4, $5)",
)
.bind(&item.label)
.bind(&item.url)
.bind(&item.glyph)
.bind(i as i32)
.bind(item.active)
.execute(&mut *tx)
.await?;
}
}
"floor-adverts" => {
let items: Vec<AdvertInput> =
serde_json::from_value(body).map_err(|e| ApiError::BadRequest(e.to_string()))?;
for item in &items {
non_empty(&item.image, "image")?;
}
sqlx::query("DELETE FROM floor_adverts")
.execute(&mut *tx)
.await?;
for (i, item) in items.iter().enumerate() {
sqlx::query(
"INSERT INTO floor_adverts (image, position, active) VALUES ($1, $2, $3)",
)
.bind(&item.image)
.bind(i as i32)
.bind(item.active)
.execute(&mut *tx)
.await?;
}
}
_ => unreachable!("kind validated above"),
}
tx.commit().await?;
Ok(Json(load_content(&state, false).await?))
}
+3
View File
@@ -2,6 +2,7 @@ pub mod admin;
pub mod auth;
pub mod cart;
pub mod catalog;
pub mod content;
pub mod currency;
pub mod health;
pub mod order_common;
@@ -21,6 +22,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(content::router(state.clone()))
.merge(content::admin_router(state.clone()))
.merge(cart::router(state.clone()))
.merge(orders::router(state.clone()))
.merge(shop::router(state.clone()))
+194
View File
@@ -0,0 +1,194 @@
mod common;
use common::{client, create_shop, login_admin, make_shop_owner, register_customer, spawn_app};
use serial_test::serial;
/// The suite shares one database and `replace_content` rewrites a kind, so every
/// test here writes the state it asserts instead of relying on the seed, and
/// none of them submits an empty list.
async fn replace(
app: &common::TestApp,
token: &str,
kind: &str,
items: serde_json::Value,
) -> reqwest::Response {
client()
.put(app.url(&format!("/api/admin/content/{kind}")))
.bearer_auth(token)
.json(&items)
.send()
.await
.unwrap()
}
async fn public_content(app: &common::TestApp) -> serde_json::Value {
let res = client()
.get(app.url("/api/content/home"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "public content read must be unauthenticated");
res.json().await.unwrap()
}
#[tokio::test]
#[serial]
async fn home_content_is_public_and_ordered() {
let app = spawn_app().await;
let content = public_content(&app).await;
for kind in ["banners", "promos", "quick_links", "floor_adverts"] {
assert!(
content[kind].is_array(),
"{kind} must always be present, even when empty"
);
}
// No test in this file empties a kind, so these stay populated.
for kind in ["banners", "promos", "quick_links"] {
assert!(
!content[kind].as_array().unwrap().is_empty(),
"{kind} should carry content"
);
}
let positions: Vec<i64> = content["banners"]
.as_array()
.unwrap()
.iter()
.map(|b| b["position"].as_i64().unwrap())
.collect();
let mut sorted = positions.clone();
sorted.sort_unstable();
assert_eq!(positions, sorted, "content must come back in position order");
}
#[tokio::test]
#[serial]
async fn admin_replace_round_trips_and_reorders() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let first = serde_json::json!([
{"image": "/mock/a.svg", "url": "/seckill"},
{"image": "/mock/b.svg", "url": "/collective"}
]);
let res = replace(&app, &admin, "banners", first).await;
assert_eq!(res.status(), 200, "{:?}", res.text().await);
let images = |v: &serde_json::Value| -> Vec<String> {
v["banners"]
.as_array()
.unwrap()
.iter()
.map(|b| b["image"].as_str().unwrap().to_string())
.collect()
};
assert_eq!(images(&public_content(&app).await), vec!["/mock/a.svg", "/mock/b.svg"]);
// The submitted order decides the stored order and the positions.
let flipped = serde_json::json!([
{"image": "/mock/b.svg", "url": "/collective"},
{"image": "/mock/a.svg", "url": "/seckill"}
]);
assert_eq!(replace(&app, &admin, "banners", flipped).await.status(), 200);
let content = public_content(&app).await;
assert_eq!(images(&content), vec!["/mock/b.svg", "/mock/a.svg"]);
assert_eq!(content["banners"][0]["position"], 0);
assert_eq!(content["banners"][1]["position"], 1);
}
#[tokio::test]
#[serial]
async fn inactive_rows_are_hidden_from_the_public_read() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let items = serde_json::json!([
{"image": "/mock/on.svg", "url": "/seckill"},
{"image": "/mock/off.svg", "url": "/collective", "active": false}
]);
assert_eq!(replace(&app, &admin, "banners", items).await.status(), 200);
let public = public_content(&app).await;
let visible: Vec<&str> = public["banners"]
.as_array()
.unwrap()
.iter()
.map(|b| b["image"].as_str().unwrap())
.collect();
assert_eq!(visible, vec!["/mock/on.svg"], "inactive rows must not be public");
// The admin read keeps it, so a disabled block stays editable.
let res = client()
.get(app.url("/api/admin/content"))
.bearer_auth(&admin)
.send()
.await
.unwrap();
let all: serde_json::Value = res.json().await.unwrap();
assert_eq!(all["banners"].as_array().unwrap().len(), 2);
}
#[tokio::test]
#[serial]
async fn invalid_entry_is_rejected_without_touching_stored_content() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let good = serde_json::json!([{"image": "/mock/keep.svg", "url": "/seckill"}]);
assert_eq!(replace(&app, &admin, "banners", good).await.status(), 200);
// Second entry is missing its image.
let bad = serde_json::json!([
{"image": "/mock/ok.svg", "url": "/seckill"},
{"url": "/collective"}
]);
let res = replace(&app, &admin, "banners", bad).await;
assert_eq!(res.status(), 400);
let content = public_content(&app).await;
let images: Vec<&str> = content["banners"]
.as_array()
.unwrap()
.iter()
.map(|b| b["image"].as_str().unwrap())
.collect();
assert_eq!(images, vec!["/mock/keep.svg"], "a rejected list must change nothing");
}
#[tokio::test]
#[serial]
async fn quick_link_labels_must_be_bilingual() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let one_sided = serde_json::json!([
{"label": {"en": "Only English"}, "url": "/user", "glyph": "M12 2l8 4v6z"}
]);
let res = replace(&app, &admin, "quick-links", one_sided).await;
assert_eq!(res.status(), 400, "a label missing zh must be refused");
}
#[tokio::test]
#[serial]
async fn content_writes_require_a_platform_admin() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (customer, _) = register_customer(&app, "content-cust").await;
let shop_id = create_shop(&app, &admin, "shop-content").await;
let owner = make_shop_owner(&app, &admin, &shop_id).await;
let items = serde_json::json!([{"image": "/mock/x.svg", "url": "/seckill"}]);
for token in [&customer, &owner] {
let res = replace(&app, token, "banners", items.clone()).await;
assert_eq!(res.status(), 403, "only platform admins may write content");
}
let res = client()
.put(app.url("/api/admin/content/not-a-kind"))
.bearer_auth(&admin)
.json(&items)
.send()
.await
.unwrap();
assert_eq!(res.status(), 400, "an unknown kind is a client error");
}