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:
@@ -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?))
|
||||
}
|
||||
@@ -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()))
|
||||
|
||||
Reference in New Issue
Block a user