feat(mall): read the store directory from the API

Wave 5: the store directory, store home and product-page store card stop reading
MOCK_STORES, and the payment and order surfaces name their shop.

- a `shop_profiles` table beside `shops`, so the identity model both consoles
  consume is untouched, with a public `GET /api/shops` and `GET /api/shops/{slug}`
  and an admin `PUT /api/admin/shops/{id}/profile`
- a shop with no profile is still listed, with the fields absent rather than
  invented; the pages guard every block, and a missing logo renders an
  initial-letter placeholder
- `scripts/seed-demo.mjs` upserts a profile per demo shop, since profiles hang
  off shops that script creates
- payment and order pages resolve shop ids to names from one cached shop read,
  retiring the generic "Shop" label
- three things went rather than being faked, following the wave-1 precedent:
  `distanceKm` and its sort (no geo model), the store home's sales/comments
  sorts, and its "best sellers" rail (no sales model)
- `lowestSku` moved out of the fixed-data module into `apps/mall/utils/product.ts`
  and re-exported, so live pages stop importing the mock module for a pure
  helper

Verified: 28 backend tests green including five new shop tests; all three
frontends build; the directory, store home, store card and order cards all render
real data with no distance or sales claims; the fixed-data rollback still renders
the store surfaces with the backend stopped.

Note: `nuxt build` does not typecheck in this repo (no `typescript.typeCheck`,
no `vue-tsc`), which AGENTS.md implies it does. A re-export used here created no
local binding and broke internal callers at runtime while the build stayed green;
`docs/TBD-migrate-wave.md` records the gap.

OpenSpec change: openspec/changes/replace-mock-api-wave-5
This commit is contained in:
2026-09-17 17:13:32 +00:00
parent 51f8bb7c1d
commit d0b6350d2d
19 changed files with 666 additions and 130 deletions
+3
View File
@@ -10,6 +10,7 @@ pub mod orders;
pub mod shop;
pub mod shop_catalog;
pub mod shop_orders;
pub mod shops;
use axum::Router;
@@ -27,6 +28,8 @@ pub fn api_router(state: AppState) -> Router<AppState> {
.merge(cart::router(state.clone()))
.merge(orders::router(state.clone()))
.merge(shop::router(state.clone()))
.merge(shops::router(state.clone()))
.merge(shops::admin_router(state.clone()))
.merge(shop_catalog::router(state.clone()))
.merge(shop_orders::router(state.clone()))
.merge(admin::router(state))
+170
View File
@@ -0,0 +1,170 @@
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;
/// A shop plus whatever profile it has. Every profile field is optional: a shop
/// without a `shop_profiles` row still renders, with nothing invented.
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct ShopProfileView {
pub id: Uuid,
pub slug: String,
pub name: Value,
pub company: Option<String>,
pub region: Option<String>,
pub address: Option<Value>,
pub logo: Option<String>,
pub banner: Option<String>,
pub notice: Option<Value>,
pub after_sale: Option<Value>,
pub score_rating: Option<f64>,
pub score_agreement: Option<f64>,
pub score_service: Option<f64>,
pub score_speed: Option<f64>,
}
const SELECT_PROFILE: &str = "SELECT s.id, s.slug, s.name,
p.company, p.region, p.address, p.logo, p.banner, p.notice, p.after_sale,
p.score_rating, p.score_agreement, p.score_service, p.score_speed
FROM shops s
LEFT JOIN shop_profiles p ON p.shop_id = s.id";
pub fn router(_state: AppState) -> Router<AppState> {
Router::new()
.route("/shops", get(list_shops))
.route("/shops/{slug}", get(get_shop))
}
pub fn admin_router(_state: AppState) -> Router<AppState> {
Router::new().route("/admin/shops/{id}/profile", put(set_shop_profile))
}
async fn list_shops(State(state): State<AppState>) -> ApiResult<Json<Vec<ShopProfileView>>> {
let shops = sqlx::query_as::<_, ShopProfileView>(&format!(
"{SELECT_PROFILE} WHERE s.status = 'active' ORDER BY s.created_at, s.slug"
))
.fetch_all(&state.db)
.await?;
Ok(Json(shops))
}
async fn get_shop(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> ApiResult<Json<ShopProfileView>> {
let shop = sqlx::query_as::<_, ShopProfileView>(&format!(
"{SELECT_PROFILE} WHERE s.slug = $1 AND s.status = 'active'"
))
.bind(&slug)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| ApiError::NotFound("shop".into()))?;
Ok(Json(shop))
}
#[derive(Debug, Deserialize)]
struct ProfileBody {
logo: Option<String>,
banner: Option<String>,
company: Option<String>,
region: Option<String>,
address: Option<Value>,
notice: Option<Value>,
after_sale: Option<Value>,
score_rating: Option<f64>,
score_agreement: Option<f64>,
score_service: Option<f64>,
score_speed: Option<f64>,
}
fn bilingual(label: &Value, field: &str) -> 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(format!(
"{field} needs non-empty en and zh"
)));
}
Ok(())
}
async fn set_shop_profile(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<ProfileBody>,
) -> ApiResult<Json<ShopProfileView>> {
auth.require(&[UserRole::PlatformAdmin])?;
for (value, field) in [
(&body.address, "address"),
(&body.notice, "notice"),
(&body.after_sale, "after_sale"),
] {
if let Some(label) = value {
bilingual(label, field)?;
}
}
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM shops WHERE id = $1)")
.bind(id)
.fetch_one(&state.db)
.await?;
if !exists {
return Err(ApiError::NotFound("shop".into()));
}
sqlx::query(
"INSERT INTO shop_profiles (shop_id, logo, banner, company, region, address, notice,
after_sale, score_rating, score_agreement, score_service,
score_speed, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now())
ON CONFLICT (shop_id) DO UPDATE SET
logo = EXCLUDED.logo,
banner = EXCLUDED.banner,
company = EXCLUDED.company,
region = EXCLUDED.region,
address = EXCLUDED.address,
notice = EXCLUDED.notice,
after_sale = EXCLUDED.after_sale,
score_rating = EXCLUDED.score_rating,
score_agreement = EXCLUDED.score_agreement,
score_service = EXCLUDED.score_service,
score_speed = EXCLUDED.score_speed,
updated_at = now()",
)
.bind(id)
.bind(&body.logo)
.bind(&body.banner)
.bind(&body.company)
.bind(&body.region)
.bind(&body.address)
.bind(&body.notice)
.bind(&body.after_sale)
.bind(body.score_rating)
.bind(body.score_agreement)
.bind(body.score_service)
.bind(body.score_speed)
.execute(&state.db)
.await?;
let shop = sqlx::query_as::<_, ShopProfileView>(&format!("{SELECT_PROFILE} WHERE s.id = $1"))
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| ApiError::NotFound("shop".into()))?;
Ok(Json(shop))
}