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