diff --git a/apps/api/migrations/0007_shop_profiles.sql b/apps/api/migrations/0007_shop_profiles.sql new file mode 100644 index 0000000..4a10cbe --- /dev/null +++ b/apps/api/migrations/0007_shop_profiles.sql @@ -0,0 +1,22 @@ +-- Buyer-facing shop profile, kept beside `shops` rather than widening it, so the +-- identity/status model both consoles already consume stays untouched. +-- +-- Schema only: a profile hangs off a shop, and the demo shops are created by +-- scripts/seed-demo.mjs, so the demo profile content lives there. + +CREATE TABLE shop_profiles ( + shop_id UUID PRIMARY KEY REFERENCES shops (id) ON DELETE CASCADE, + logo TEXT, + banner TEXT, + company TEXT, + region TEXT, + address JSONB, + notice JSONB, + after_sale JSONB, + -- Platform-set profile scores. There is no review model behind them. + score_rating DOUBLE PRECISION, + score_agreement DOUBLE PRECISION, + score_service DOUBLE PRECISION, + score_speed DOUBLE PRECISION, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/apps/api/src/routes/mod.rs b/apps/api/src/routes/mod.rs index d25a073..99b6dbe 100644 --- a/apps/api/src/routes/mod.rs +++ b/apps/api/src/routes/mod.rs @@ -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 { .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)) diff --git a/apps/api/src/routes/shops.rs b/apps/api/src/routes/shops.rs new file mode 100644 index 0000000..d44e3b2 --- /dev/null +++ b/apps/api/src/routes/shops.rs @@ -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, + pub region: Option, + pub address: Option, + pub logo: Option, + pub banner: Option, + pub notice: Option, + pub after_sale: Option, + pub score_rating: Option, + pub score_agreement: Option, + pub score_service: Option, + pub score_speed: Option, +} + +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 { + Router::new() + .route("/shops", get(list_shops)) + .route("/shops/{slug}", get(get_shop)) +} + +pub fn admin_router(_state: AppState) -> Router { + Router::new().route("/admin/shops/{id}/profile", put(set_shop_profile)) +} + +async fn list_shops(State(state): State) -> ApiResult>> { + 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, + Path(slug): Path, +) -> ApiResult> { + 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, + banner: Option, + company: Option, + region: Option, + address: Option, + notice: Option, + after_sale: Option, + score_rating: Option, + score_agreement: Option, + score_service: Option, + score_speed: Option, +} + +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, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + 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)) +} diff --git a/apps/api/tests/shops.rs b/apps/api/tests/shops.rs new file mode 100644 index 0000000..78e1e31 --- /dev/null +++ b/apps/api/tests/shops.rs @@ -0,0 +1,165 @@ +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 creating shops is additive, so each test +/// works on shops it creates itself and never asserts on a global count. + +async fn list_shops(app: &common::TestApp) -> serde_json::Value { + let res = client().get(app.url("/api/shops")).send().await.unwrap(); + assert_eq!(res.status(), 200, "the directory must be unauthenticated"); + res.json().await.unwrap() +} + +fn find<'a>(shops: &'a serde_json::Value, id: &str) -> Option<&'a serde_json::Value> { + shops + .as_array() + .unwrap() + .iter() + .find(|s| s["id"] == id) +} + +async fn set_profile( + app: &common::TestApp, + token: &str, + shop_id: &str, + body: serde_json::Value, +) -> reqwest::Response { + client() + .put(app.url(&format!("/api/admin/shops/{shop_id}/profile"))) + .bearer_auth(token) + .json(&body) + .send() + .await + .unwrap() +} + +#[tokio::test] +#[serial] +async fn shop_without_a_profile_is_still_listed() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let shop_id = create_shop(&app, &admin, "shop-noprofile").await; + + let shops = list_shops(&app).await; + let shop = find(&shops, &shop_id).expect("a shop with no profile must still be listed"); + assert!(shop["name"]["en"].is_string()); + assert!(shop["logo"].is_null(), "nothing may be invented for a missing profile"); + assert!(shop["score_rating"].is_null()); +} + +#[tokio::test] +#[serial] +async fn profile_upsert_round_trips_to_the_public_read() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let shop_id = create_shop(&app, &admin, "shop-profile").await; + + let body = serde_json::json!({ + "logo": "/mock/store-1.svg", + "company": "Profile Co.", + "region": "California", + "address": {"en": "1 Market Street", "zh": "市场街 1 号"}, + "notice": {"en": "Free shipping over $99.", "zh": "满 99 免运费。"}, + "after_sale": {"en": "7-day returns.", "zh": "7 天退货。"}, + "score_rating": 4.9, + "score_agreement": 4.8, + "score_service": 4.7, + "score_speed": 4.6 + }); + let res = set_profile(&app, &admin, &shop_id, body).await; + assert_eq!(res.status(), 200, "{:?}", res.text().await); + + let shop = find(&list_shops(&app).await, &shop_id).unwrap().clone(); + assert_eq!(shop["company"], "Profile Co."); + assert_eq!(shop["address"]["zh"], "市场街 1 号"); + assert_eq!(shop["score_rating"], 4.9); + + // Public read by slug returns the same composed profile. + let slug = shop["slug"].as_str().unwrap(); + let res = client() + .get(app.url(&format!("/api/shops/{slug}"))) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let by_slug: serde_json::Value = res.json().await.unwrap(); + assert_eq!(by_slug["id"], shop_id); + assert_eq!(by_slug["notice"]["en"], "Free shipping over $99."); +} + +#[tokio::test] +#[serial] +async fn suspended_or_unknown_shops_are_not_public() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let shop_id = create_shop(&app, &admin, "shop-suspended").await; + + let shops = list_shops(&app).await; + let slug = find(&shops, &shop_id).unwrap()["slug"].as_str().unwrap().to_string(); + + client() + .put(app.url(&format!("/api/admin/shops/{shop_id}/status"))) + .bearer_auth(&admin) + .json(&serde_json::json!({ "status": "suspended" })) + .send() + .await + .unwrap(); + + assert!(find(&list_shops(&app).await, &shop_id).is_none(), "suspended shops are hidden"); + let res = client() + .get(app.url(&format!("/api/shops/{slug}"))) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 404); + + let res = client() + .get(app.url("/api/shops/no-such-shop-at-all")) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 404, "an unknown slug is a 404, not an empty profile"); +} + +#[tokio::test] +#[serial] +async fn incomplete_bilingual_text_is_refused_without_writing() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let shop_id = create_shop(&app, &admin, "shop-bilingual").await; + + let good = serde_json::json!({ + "company": "Kept Co.", + "notice": {"en": "Original notice", "zh": "原始公告"} + }); + assert_eq!(set_profile(&app, &admin, &shop_id, good).await.status(), 200); + + let bad = serde_json::json!({ + "company": "Changed Co.", + "notice": {"en": "Only English"} + }); + let res = set_profile(&app, &admin, &shop_id, bad).await; + assert_eq!(res.status(), 400, "a label missing zh must be refused"); + + let shop = find(&list_shops(&app).await, &shop_id).unwrap().clone(); + assert_eq!(shop["company"], "Kept Co.", "the rejected write changed nothing"); + assert_eq!(shop["notice"]["zh"], "原始公告"); +} + +#[tokio::test] +#[serial] +async fn profile_writes_require_a_platform_admin() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let shop_id = create_shop(&app, &admin, "shop-profile-auth").await; + let owner = make_shop_owner(&app, &admin, &shop_id).await; + let (customer, _) = register_customer(&app, "shop-profile-cust").await; + + let body = serde_json::json!({ "company": "Nope" }); + for token in [&owner, &customer] { + let res = set_profile(&app, token, &shop_id, body.clone()).await; + assert_eq!(res.status(), 403, "only platform admins may write a profile"); + } +} diff --git a/apps/mall/mock/api.ts b/apps/mall/mock/api.ts index 6c8f871..716a80f 100644 --- a/apps/mall/mock/api.ts +++ b/apps/mall/mock/api.ts @@ -15,6 +15,7 @@ import type { Order, Product, Shipment, + ShopProfile, User, } from "@vmall/shared"; import { @@ -87,6 +88,28 @@ function unsupported(): never { throw new ApiError(501, "MOCK_UNSUPPORTED", "This admin/shop endpoint is not part of the mall mock."); } +type MockStoreRecord = NonNullable>; + +/** Mock stores carry a nested `rate`; the API exposes flat score fields. */ +function toShopProfile(store: MockStoreRecord): ShopProfile { + return { + id: store.id, + slug: store.slug, + name: store.name, + company: store.company, + region: store.region, + address: store.address, + logo: store.logo, + banner: store.banner, + notice: store.notice, + after_sale: store.afterSale, + score_rating: store.rate.score, + score_agreement: store.rate.agree, + score_service: store.rate.service, + score_speed: store.rate.speed, + }; +} + function skuIndex(): Record { const index: Record = {}; @@ -352,6 +375,13 @@ export function createMockApi(): ApiClient { })), }), + listShops: (): Promise => Promise.resolve(MOCK_STORES.map(toShopProfile)), + getShop: (slug) => { + const store = storeById(slug); + if (!store) return Promise.reject(new ApiError(404, "NOT_FOUND", "Shop not found")); + return Promise.resolve(toShopProfile(store)); + }, + shop: { getMyShop: () => unsupported(), listMyProducts: () => unsupported(), @@ -381,6 +411,7 @@ export function createMockApi(): ApiClient { setRate: () => unsupported(), getContent: () => unsupported(), replaceContent: () => unsupported(), + setShopProfile: () => unsupported(), }, }; } diff --git a/apps/mall/mock/data.ts b/apps/mall/mock/data.ts index ec686e5..046e705 100644 --- a/apps/mall/mock/data.ts +++ b/apps/mall/mock/data.ts @@ -491,15 +491,10 @@ export function storeById(idOrSlug: string): MockStore | null { return MOCK_STORES.find((s) => s.id === idOrSlug || s.slug === idOrSlug) ?? null; } -export function lowestSku(product: Product): Sku | null { - const active = (product.skus ?? []).filter((s) => s.active && s.stock > 0); - return ( - active.reduce( - (low, s) => (low === null || s.price_minor < low.price_minor ? s : low), - null, - ) ?? (product.skus ?? []).find((s) => s.active) ?? null - ); -} +import { lowestSku } from "~/utils/product"; + +/** Kept for the mock-era pages; live pages import it from `~/utils/product`. */ +export { lowestSku }; export interface MockSearchQuery { q?: string; diff --git a/apps/mall/nuxt.config.ts b/apps/mall/nuxt.config.ts index 730d837..924cfc7 100644 --- a/apps/mall/nuxt.config.ts +++ b/apps/mall/nuxt.config.ts @@ -9,7 +9,7 @@ export default defineNuxtConfig({ // Domains served by the live backend; every other domain stays on the // fixed-data adapter. Override with NUXT_PUBLIC_LIVE_DOMAINS='["catalog"]'. // See openspec/changes/replace-mock-api-wave-1/design.md and waves 2-3. - liveDomains: ["catalog", "currency", "content", "auth", "cart", "orders", "shipments", "invoices"], + liveDomains: ["catalog", "currency", "content", "shops", "auth", "cart", "orders", "shipments", "invoices"], appName: "mall", }, }, diff --git a/apps/mall/pages/checkout/pay.vue b/apps/mall/pages/checkout/pay.vue index 2c8847c..c1c83b1 100644 --- a/apps/mall/pages/checkout/pay.vue +++ b/apps/mall/pages/checkout/pay.vue @@ -9,6 +9,13 @@ const { locale, t } = useI18n(); const { currency } = usePrefs(); const router = useRouter(); const pendingOrderIds = useState("checkout-orders", () => []); +// Shared with the store directory; orders carry only a shop id. +const { data: shops } = await useAsyncData("shops", () => $api.listShops(), { default: () => [] }); + +function shopName(shopId: string): string { + const shop = shops.value.find((entry) => entry.id === shopId); + return shop ? pick(shop.name, locale.value) : t("checkout.shop"); +} const orders = ref([]); const paymentMethod = ref("balance"); @@ -85,8 +92,7 @@ onMounted(() => void loadOrders());
{{ t("checkout.orderNo") }}: {{ order.order_no }} - - {{ t("checkout.shop") }} + {{ shopName(order.shop_id) }}
diff --git a/apps/mall/pages/goods/[id].vue b/apps/mall/pages/goods/[id].vue index 6e7b110..9c5d648 100644 --- a/apps/mall/pages/goods/[id].vue +++ b/apps/mall/pages/goods/[id].vue @@ -2,15 +2,8 @@ import { t as pick } from "@vmall/shared"; import { ApiError } from "@vmall/shared"; import type { Category, Product, Sku } from "@vmall/shared"; -import { - MOCK_COUPONS, - MOCK_STORES, - commentStats, - commentsFor, - lowestSku, - salesOf, - storeById, -} from "~/mock/data"; +import { MOCK_COUPONS, commentStats, commentsFor, salesOf } from "~/mock/data"; +import { lowestSku } from "~/utils/product"; import { useCartStore } from "~/stores/cart"; type AttributeGroup = { key: string; values: string[] }; @@ -51,12 +44,16 @@ const { data: pageData } = await useAsyncData( const product = computed(() => pageData.value?.product ?? null); const related = computed(() => pageData.value?.related ?? []); +// Shared with the store directory and the order surfaces. +const { data: shops } = await useAsyncData("shops", () => $api.listShops(), { default: () => [] }); + const detail = computed(() => { const current = product.value; if (!current) return null; return { product: current, - store: storeById(current.shop_id) ?? MOCK_STORES[0], + // Null when the shop has no public profile; the card is then not rendered. + store: shops.value.find((shop) => shop.id === current.shop_id) ?? null, comments: commentsFor(current.id), commentStats: commentStats(current.id), coupons: MOCK_COUPONS, @@ -183,14 +180,19 @@ const tabs = computed(() => [ { key: "service", label: t("product.tabsAfterSale") }, ]); +/** Only the scores the platform actually set; nothing is defaulted. */ const rateRows = computed(() => { - const rate = store.value?.rate; - return [ - { key: "overall", value: rate?.score ?? 0, label: t("product.overall") }, - { key: "service", value: rate?.service ?? 0, label: t("product.service") }, - { key: "delivery", value: rate?.speed ?? 0, label: t("product.delivery") }, - { key: "agree", value: rate?.agree ?? 0, label: t("product.quality") }, + const profile = store.value; + if (!profile) return []; + const rows = [ + { key: "overall", value: profile.score_rating, label: t("product.overall") }, + { key: "service", value: profile.score_service, label: t("product.service") }, + { key: "delivery", value: profile.score_speed, label: t("product.delivery") }, + { key: "agree", value: profile.score_agreement, label: t("product.quality") }, ]; + return rows.filter( + (row): row is { key: string; value: number; label: string } => typeof row.value === "number", + ); }); const detailImages = computed(() => product.value?.images ?? []); @@ -283,20 +285,22 @@ const detailImages = computed(() => product.value?.images ?? []);