feat: persist customer product and shop favorites through the live API

Replace mall fixture favorites with customer-scoped endpoints, and send signed-out shoppers back to the page they left after sign-in.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Chengdong Zhang
2026-09-21 18:51:45 +08:00
co-authored by Cursor
parent 94a64ec712
commit 6c1357ec4d
34 changed files with 1783 additions and 119 deletions
+5 -2
View File
@@ -73,9 +73,12 @@ openspec validate --all --strict # 规范校验
## 商城的 mock 边界
`apps/mall` 通过 `apps/mall/plugins/api.ts` 的 `liveDomains` 按域选择适配器。已有 API 的域全部走真实后端:catalog、currency、content、brands、shops、auth、cart、orders、shipments、invoices、addresses。
`apps/mall` 通过 `apps/mall/plugins/api.ts` 的 `liveDomains` 按域选择适配器。当前真实后端域包括
catalog、currency、content、brands、shops、auth、account、cart、orders、shipments、invoices、
addresses、coupons、points、flashSales、groupBuying、favorites。
仍来自 `~/mock/data` 的部分都是没有后端能力的营销/账户域,逐项记录在 `docs/TBD-marketing.md`(优惠券、收藏、账户统计、秒杀、拼团、积分商城、评价等)。每一项都是一次新的能力建设,不是适配层切换。
仍由页面直接读取 `~/mock/data` 的能力记录在 `docs/TBD-marketing.md`。营销 fixture 仍由
fixed-data 适配器使用,作为每个已迁移域的回滚实现。
- **fixed-data 适配器本身**:`Mock API adapter` 规范要求它仍能服务每个域,因此它是回滚路径,删除它会破坏回滚。
+25
View File
@@ -0,0 +1,25 @@
-- Customer favorites: one row per (user, product) or (user, shop).
-- Two nullable FKs plus a check keep the target shape honest; partial unique
-- indexes enforce uniqueness without letting NULL shop/product repeat.
CREATE TABLE favorites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
product_id UUID REFERENCES products (id) ON DELETE CASCADE,
shop_id UUID REFERENCES shops (id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT favorites_exactly_one_target CHECK (
(product_id IS NOT NULL AND shop_id IS NULL)
OR (product_id IS NULL AND shop_id IS NOT NULL)
)
);
CREATE INDEX favorites_user_created_idx ON favorites (user_id, created_at DESC);
CREATE UNIQUE INDEX favorites_user_product_idx
ON favorites (user_id, product_id)
WHERE product_id IS NOT NULL;
CREATE UNIQUE INDEX favorites_user_shop_idx
ON favorites (user_id, shop_id)
WHERE shop_id IS NOT NULL;
+116
View File
@@ -0,0 +1,116 @@
use chrono::{DateTime, Utc};
use serde::Serialize;
use serde_json::Value;
use uuid::Uuid;
use crate::modules::shop::service::ShopProfileView;
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct FavoriteProductSummary {
pub id: Uuid,
pub shop_id: Uuid,
pub slug: String,
pub name: Value,
pub image: Option<String>,
pub price_minor: Option<i64>,
pub currency: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(tag = "kind")]
pub enum Favorite {
#[serde(rename = "product")]
Product {
id: Uuid,
user_id: Uuid,
created_at: DateTime<Utc>,
product: FavoriteProductSummary,
},
#[serde(rename = "shop")]
Shop {
id: Uuid,
user_id: Uuid,
created_at: DateTime<Utc>,
shop: ShopProfileView,
},
}
#[derive(Debug, sqlx::FromRow)]
pub struct ProductFavoriteRow {
pub id: Uuid,
pub user_id: Uuid,
pub created_at: DateTime<Utc>,
pub product_id: Uuid,
pub shop_id: Uuid,
pub slug: String,
pub name: Value,
pub image: Option<String>,
pub price_minor: Option<i64>,
pub currency: Option<String>,
}
impl ProductFavoriteRow {
pub fn into_favorite(self) -> Favorite {
Favorite::Product {
id: self.id,
user_id: self.user_id,
created_at: self.created_at,
product: FavoriteProductSummary {
id: self.product_id,
shop_id: self.shop_id,
slug: self.slug,
name: self.name,
image: self.image,
price_minor: self.price_minor,
currency: self.currency,
},
}
}
}
#[derive(Debug, sqlx::FromRow)]
pub struct ShopFavoriteRow {
pub id: Uuid,
pub user_id: Uuid,
pub created_at: DateTime<Utc>,
pub shop_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>,
}
impl ShopFavoriteRow {
pub fn into_favorite(self) -> Favorite {
Favorite::Shop {
id: self.id,
user_id: self.user_id,
created_at: self.created_at,
shop: ShopProfileView {
id: self.shop_id,
slug: self.slug,
name: self.name,
company: self.company,
region: self.region,
address: self.address,
logo: self.logo,
banner: self.banner,
notice: self.notice,
after_sale: self.after_sale,
score_rating: self.score_rating,
score_agreement: self.score_agreement,
score_service: self.score_service,
score_speed: self.score_speed,
},
}
}
}
+88
View File
@@ -0,0 +1,88 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
routing::{get, put},
Json, Router,
};
use serde::Deserialize;
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::error::ApiResult;
use crate::http::Paged;
use crate::state::AppState;
use super::dto::Favorite;
use super::service;
#[derive(Debug, Deserialize)]
struct FavoriteListQuery {
kind: String,
target_id: Option<Uuid>,
page: Option<i64>,
per_page: Option<i64>,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/favorites", get(list_favorites))
.route(
"/favorites/products/{product_id}",
put(add_product).delete(remove_product),
)
.route(
"/favorites/shops/{shop_id}",
put(add_shop).delete(remove_shop),
)
}
async fn list_favorites(
State(state): State<AppState>,
auth: AuthUser,
Query(q): Query<FavoriteListQuery>,
) -> ApiResult<Json<Paged<Favorite>>> {
auth.require_customer()?;
Ok(Json(
service::list(&state, auth.id, &q.kind, q.target_id, q.page, q.per_page).await?,
))
}
async fn add_product(
State(state): State<AppState>,
auth: AuthUser,
Path(product_id): Path<Uuid>,
) -> ApiResult<Json<Favorite>> {
auth.require_customer()?;
Ok(Json(
service::add_product(&state, auth.id, product_id).await?,
))
}
async fn remove_product(
State(state): State<AppState>,
auth: AuthUser,
Path(product_id): Path<Uuid>,
) -> ApiResult<StatusCode> {
auth.require_customer()?;
service::remove_product(&state, auth.id, product_id).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn add_shop(
State(state): State<AppState>,
auth: AuthUser,
Path(shop_id): Path<Uuid>,
) -> ApiResult<Json<Favorite>> {
auth.require_customer()?;
Ok(Json(service::add_shop(&state, auth.id, shop_id).await?))
}
async fn remove_shop(
State(state): State<AppState>,
auth: AuthUser,
Path(shop_id): Path<Uuid>,
) -> ApiResult<StatusCode> {
auth.require_customer()?;
service::remove_shop(&state, auth.id, shop_id).await?;
Ok(StatusCode::NO_CONTENT)
}
+12
View File
@@ -0,0 +1,12 @@
mod dto;
mod handlers;
mod repo;
pub mod service;
use axum::Router;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
handlers::router()
}
+243
View File
@@ -0,0 +1,243 @@
use sqlx::{PgConnection, PgPool};
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use super::dto::{Favorite, ProductFavoriteRow, ShopFavoriteRow};
const PRODUCT_VISIBLE: &str = "p.status = 'published' AND sh.status = 'active'";
const PRODUCT_FROM: &str = "FROM favorites f
JOIN products p ON p.id = f.product_id
JOIN shops sh ON sh.id = p.shop_id
LEFT JOIN LATERAL (
SELECT s.price_minor, s.currency
FROM skus s
WHERE s.product_id = p.id AND s.active = TRUE
ORDER BY s.price_minor ASC, s.sku_code ASC
LIMIT 1
) sku ON TRUE";
const PRODUCT_SELECT: &str = "SELECT f.id, f.user_id, f.created_at,
p.id AS product_id, p.shop_id, p.slug, p.name,
CASE WHEN jsonb_typeof(p.images) = 'array' AND jsonb_array_length(p.images) > 0
THEN p.images->>0 ELSE NULL END AS image,
sku.price_minor, sku.currency";
const SHOP_FROM: &str = "FROM favorites f
JOIN shops s ON s.id = f.shop_id
LEFT JOIN shop_profiles p ON p.shop_id = s.id";
const SHOP_SELECT: &str = "SELECT f.id, f.user_id, f.created_at,
s.id AS shop_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";
pub async fn lock_visible_product(tx: &mut PgConnection, product_id: Uuid) -> ApiResult<bool> {
Ok(sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM products p
JOIN shops sh ON sh.id = p.shop_id
WHERE p.id = $1 AND p.status = 'published' AND sh.status = 'active'
FOR SHARE OF p, sh
)",
)
.bind(product_id)
.fetch_one(&mut *tx)
.await?)
}
pub async fn lock_visible_shop(tx: &mut PgConnection, shop_id: Uuid) -> ApiResult<bool> {
Ok(sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM shops
WHERE id = $1 AND status = 'active'
FOR SHARE
)",
)
.bind(shop_id)
.fetch_one(&mut *tx)
.await?)
}
pub async fn upsert_product(
tx: &mut PgConnection,
user_id: Uuid,
product_id: Uuid,
) -> ApiResult<Uuid> {
let inserted: Option<Uuid> = sqlx::query_scalar(
"INSERT INTO favorites (user_id, product_id)
VALUES ($1, $2)
ON CONFLICT (user_id, product_id) WHERE product_id IS NOT NULL
DO NOTHING
RETURNING id",
)
.bind(user_id)
.bind(product_id)
.fetch_optional(&mut *tx)
.await?;
if let Some(id) = inserted {
return Ok(id);
}
sqlx::query_scalar("SELECT id FROM favorites WHERE user_id = $1 AND product_id = $2")
.bind(user_id)
.bind(product_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| ApiError::NotFound("favorite".into()))
}
pub async fn upsert_shop(tx: &mut PgConnection, user_id: Uuid, shop_id: Uuid) -> ApiResult<Uuid> {
let inserted: Option<Uuid> = sqlx::query_scalar(
"INSERT INTO favorites (user_id, shop_id)
VALUES ($1, $2)
ON CONFLICT (user_id, shop_id) WHERE shop_id IS NOT NULL
DO NOTHING
RETURNING id",
)
.bind(user_id)
.bind(shop_id)
.fetch_optional(&mut *tx)
.await?;
if let Some(id) = inserted {
return Ok(id);
}
sqlx::query_scalar("SELECT id FROM favorites WHERE user_id = $1 AND shop_id = $2")
.bind(user_id)
.bind(shop_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| ApiError::NotFound("favorite".into()))
}
pub async fn delete_product(db: &PgPool, user_id: Uuid, product_id: Uuid) -> ApiResult<()> {
sqlx::query("DELETE FROM favorites WHERE user_id = $1 AND product_id = $2")
.bind(user_id)
.bind(product_id)
.execute(db)
.await?;
Ok(())
}
pub async fn delete_shop(db: &PgPool, user_id: Uuid, shop_id: Uuid) -> ApiResult<()> {
sqlx::query("DELETE FROM favorites WHERE user_id = $1 AND shop_id = $2")
.bind(user_id)
.bind(shop_id)
.execute(db)
.await?;
Ok(())
}
pub async fn count_products(db: &PgPool, user_id: Uuid, target_id: Option<Uuid>) -> ApiResult<i64> {
Ok(sqlx::query_scalar(&format!(
"SELECT count(*) {PRODUCT_FROM}
WHERE f.user_id = $1 AND f.product_id IS NOT NULL
AND {PRODUCT_VISIBLE}
AND ($2::uuid IS NULL OR f.product_id = $2)"
))
.bind(user_id)
.bind(target_id)
.fetch_one(db)
.await?)
}
pub async fn count_shops(db: &PgPool, user_id: Uuid, target_id: Option<Uuid>) -> ApiResult<i64> {
Ok(sqlx::query_scalar(&format!(
"SELECT count(*) {SHOP_FROM}
WHERE f.user_id = $1 AND f.shop_id IS NOT NULL
AND s.status = 'active'
AND ($2::uuid IS NULL OR f.shop_id = $2)"
))
.bind(user_id)
.bind(target_id)
.fetch_one(db)
.await?)
}
pub async fn list_products(
db: &PgPool,
user_id: Uuid,
target_id: Option<Uuid>,
limit: i64,
offset: i64,
) -> ApiResult<Vec<Favorite>> {
let rows = sqlx::query_as::<_, ProductFavoriteRow>(&format!(
"{PRODUCT_SELECT} {PRODUCT_FROM}
WHERE f.user_id = $1 AND f.product_id IS NOT NULL
AND {PRODUCT_VISIBLE}
AND ($2::uuid IS NULL OR f.product_id = $2)
ORDER BY f.created_at DESC
LIMIT $3 OFFSET $4"
))
.bind(user_id)
.bind(target_id)
.bind(limit)
.bind(offset)
.fetch_all(db)
.await?;
Ok(rows
.into_iter()
.map(ProductFavoriteRow::into_favorite)
.collect())
}
pub async fn list_shops(
db: &PgPool,
user_id: Uuid,
target_id: Option<Uuid>,
limit: i64,
offset: i64,
) -> ApiResult<Vec<Favorite>> {
let rows = sqlx::query_as::<_, ShopFavoriteRow>(&format!(
"{SHOP_SELECT} {SHOP_FROM}
WHERE f.user_id = $1 AND f.shop_id IS NOT NULL
AND s.status = 'active'
AND ($2::uuid IS NULL OR f.shop_id = $2)
ORDER BY f.created_at DESC
LIMIT $3 OFFSET $4"
))
.bind(user_id)
.bind(target_id)
.bind(limit)
.bind(offset)
.fetch_all(db)
.await?;
Ok(rows
.into_iter()
.map(ShopFavoriteRow::into_favorite)
.collect())
}
pub async fn get_product_favorite(
tx: &mut PgConnection,
user_id: Uuid,
product_id: Uuid,
) -> ApiResult<Favorite> {
let row = sqlx::query_as::<_, ProductFavoriteRow>(&format!(
"{PRODUCT_SELECT} {PRODUCT_FROM}
WHERE f.user_id = $1 AND f.product_id = $2 AND {PRODUCT_VISIBLE}"
))
.bind(user_id)
.bind(product_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| ApiError::NotFound("favorite".into()))?;
Ok(row.into_favorite())
}
pub async fn get_shop_favorite(
tx: &mut PgConnection,
user_id: Uuid,
shop_id: Uuid,
) -> ApiResult<Favorite> {
let row = sqlx::query_as::<_, ShopFavoriteRow>(&format!(
"{SHOP_SELECT} {SHOP_FROM}
WHERE f.user_id = $1 AND f.shop_id = $2 AND s.status = 'active'"
))
.bind(user_id)
.bind(shop_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| ApiError::NotFound("favorite".into()))?;
Ok(row.into_favorite())
}
+72
View File
@@ -0,0 +1,72 @@
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::http::{clamp_page, clamp_per_page, Paged};
use crate::state::AppState;
use super::dto::Favorite;
use super::repo;
pub async fn add_product(state: &AppState, user_id: Uuid, product_id: Uuid) -> ApiResult<Favorite> {
let mut tx = state.db.begin().await?;
if !repo::lock_visible_product(&mut tx, product_id).await? {
return Err(ApiError::NotFound("product".into()));
}
repo::upsert_product(&mut tx, user_id, product_id).await?;
let favorite = repo::get_product_favorite(&mut tx, user_id, product_id).await?;
tx.commit().await?;
Ok(favorite)
}
pub async fn add_shop(state: &AppState, user_id: Uuid, shop_id: Uuid) -> ApiResult<Favorite> {
let mut tx = state.db.begin().await?;
if !repo::lock_visible_shop(&mut tx, shop_id).await? {
return Err(ApiError::NotFound("shop".into()));
}
repo::upsert_shop(&mut tx, user_id, shop_id).await?;
let favorite = repo::get_shop_favorite(&mut tx, user_id, shop_id).await?;
tx.commit().await?;
Ok(favorite)
}
pub async fn remove_product(state: &AppState, user_id: Uuid, product_id: Uuid) -> ApiResult<()> {
repo::delete_product(&state.db, user_id, product_id).await
}
pub async fn remove_shop(state: &AppState, user_id: Uuid, shop_id: Uuid) -> ApiResult<()> {
repo::delete_shop(&state.db, user_id, shop_id).await
}
pub async fn list(
state: &AppState,
user_id: Uuid,
kind: &str,
target_id: Option<Uuid>,
page: Option<i64>,
per_page: Option<i64>,
) -> ApiResult<Paged<Favorite>> {
let page = clamp_page(page);
let per_page = clamp_per_page(per_page);
let offset = (page - 1) * per_page;
let (total, items) = match kind {
"product" => (
repo::count_products(&state.db, user_id, target_id).await?,
repo::list_products(&state.db, user_id, target_id, per_page, offset).await?,
),
"shop" => (
repo::count_shops(&state.db, user_id, target_id).await?,
repo::list_shops(&state.db, user_id, target_id, per_page, offset).await?,
),
other => {
return Err(ApiError::BadRequest(format!(
"kind must be product or shop, got {other}"
)))
}
};
Ok(Paged {
items,
total,
page,
per_page,
})
}
+2
View File
@@ -6,6 +6,7 @@ pub mod catalog;
pub mod content;
pub mod coupon;
pub mod currency;
pub mod favorite;
pub mod flash_sale;
pub mod fulfillment;
pub mod group_buying;
@@ -30,6 +31,7 @@ pub fn api_router() -> Router<AppState> {
.merge(content::router())
.merge(cart::router())
.merge(coupon::router())
.merge(favorite::router())
.merge(flash_sale::router())
.merge(group_buying::router())
.merge(order::router())
+464
View File
@@ -0,0 +1,464 @@
mod common;
use common::{
client, create_product_with_sku, create_shop, login_admin, make_shop_owner, publish_product,
register_customer, spawn_app,
};
use serial_test::serial;
use std::time::Duration;
use uuid::Uuid;
async fn put_product(app: &common::TestApp, token: &str, product_id: &str) -> reqwest::Response {
client()
.put(app.url(&format!("/api/favorites/products/{product_id}")))
.bearer_auth(token)
.send()
.await
.unwrap()
}
async fn put_shop(app: &common::TestApp, token: &str, shop_id: &str) -> reqwest::Response {
client()
.put(app.url(&format!("/api/favorites/shops/{shop_id}")))
.bearer_auth(token)
.send()
.await
.unwrap()
}
async fn delete_product(app: &common::TestApp, token: &str, product_id: &str) -> reqwest::Response {
client()
.delete(app.url(&format!("/api/favorites/products/{product_id}")))
.bearer_auth(token)
.send()
.await
.unwrap()
}
async fn delete_shop(app: &common::TestApp, token: &str, shop_id: &str) -> reqwest::Response {
client()
.delete(app.url(&format!("/api/favorites/shops/{shop_id}")))
.bearer_auth(token)
.send()
.await
.unwrap()
}
async fn list(
app: &common::TestApp,
token: &str,
kind: &str,
target_id: Option<&str>,
page: Option<i64>,
per_page: Option<i64>,
) -> serde_json::Value {
let mut req = client()
.get(app.url("/api/favorites"))
.query(&[("kind", kind)]);
if let Some(id) = target_id {
req = req.query(&[("target_id", id)]);
}
if let Some(p) = page {
req = req.query(&[("page", p)]);
}
if let Some(n) = per_page {
req = req.query(&[("per_page", n)]);
}
let res = req.bearer_auth(token).send().await.unwrap();
assert_eq!(res.status(), 200, "list: {:?}", res.text().await);
res.json().await.unwrap()
}
async fn sellable(
app: &common::TestApp,
admin: &str,
slug: &str,
price: i64,
) -> (String, String, String) {
let shop_id = create_shop(app, admin, slug).await;
let owner = make_shop_owner(app, admin, &shop_id).await;
let (product_id, _) = create_product_with_sku(app, &owner, slug, price, 10).await;
publish_product(app, &owner, &product_id).await;
(owner, shop_id, product_id)
}
#[tokio::test]
#[serial]
async fn check_constraint_rejects_invalid_target_shape() {
let app = spawn_app().await;
let (_token, user_id) = register_customer(&app, "fav-shape").await;
let admin = login_admin(&app).await;
let (_owner, shop_id, product_id) = sellable(&app, &admin, "fav-shape", 1000).await;
let both = sqlx::query(
"INSERT INTO favorites (user_id, product_id, shop_id)
VALUES ($1::uuid, $2::uuid, $3::uuid)",
)
.bind(&user_id)
.bind(&product_id)
.bind(&shop_id)
.execute(&app.db)
.await;
assert!(both.is_err(), "both targets must be rejected");
let neither = sqlx::query("INSERT INTO favorites (user_id) VALUES ($1::uuid)")
.bind(&user_id)
.execute(&app.db)
.await;
assert!(neither.is_err(), "neither target must be rejected");
}
#[tokio::test]
#[serial]
async fn uniqueness_is_per_customer_and_target() {
let app = spawn_app().await;
let (_token, user_id) = register_customer(&app, "fav-uniq").await;
let admin = login_admin(&app).await;
let (_owner, shop_id, product_id) = sellable(&app, &admin, "fav-uniq", 1000).await;
sqlx::query("INSERT INTO favorites (user_id, product_id) VALUES ($1::uuid, $2::uuid)")
.bind(&user_id)
.bind(&product_id)
.execute(&app.db)
.await
.unwrap();
let dup_product =
sqlx::query("INSERT INTO favorites (user_id, product_id) VALUES ($1::uuid, $2::uuid)")
.bind(&user_id)
.bind(&product_id)
.execute(&app.db)
.await;
assert!(dup_product.is_err());
sqlx::query("INSERT INTO favorites (user_id, shop_id) VALUES ($1::uuid, $2::uuid)")
.bind(&user_id)
.bind(&shop_id)
.execute(&app.db)
.await
.unwrap();
let dup_shop =
sqlx::query("INSERT INTO favorites (user_id, shop_id) VALUES ($1::uuid, $2::uuid)")
.bind(&user_id)
.bind(&shop_id)
.execute(&app.db)
.await;
assert!(dup_shop.is_err());
}
#[tokio::test]
#[serial]
async fn ownership_filters_list_and_remove() {
let app = spawn_app().await;
let (alice, _) = register_customer(&app, "fav-alice").await;
let (bob, _) = register_customer(&app, "fav-bob").await;
let admin = login_admin(&app).await;
let (_owner, shop_id, product_id) = sellable(&app, &admin, "fav-own", 2500).await;
assert_eq!(put_product(&app, &alice, &product_id).await.status(), 200);
assert_eq!(put_shop(&app, &alice, &shop_id).await.status(), 200);
let bob_products = list(&app, &bob, "product", None, None, None).await;
assert_eq!(bob_products["total"], 0);
assert_eq!(bob_products["items"].as_array().unwrap().len(), 0);
assert_eq!(delete_product(&app, &bob, &product_id).await.status(), 204);
let alice_products = list(&app, &alice, "product", None, None, None).await;
assert_eq!(alice_products["total"], 1);
assert_eq!(alice_products["items"][0]["product"]["id"], product_id);
}
#[tokio::test]
#[serial]
async fn missing_or_unavailable_targets_are_not_found_on_add() {
let app = spawn_app().await;
let (token, _) = register_customer(&app, "fav-miss").await;
let admin = login_admin(&app).await;
let (owner, shop_id, product_id) = sellable(&app, &admin, "fav-miss", 1000).await;
let missing = Uuid::new_v4();
assert_eq!(
put_product(&app, &token, &missing.to_string())
.await
.status(),
404
);
assert_eq!(
put_shop(&app, &token, &missing.to_string()).await.status(),
404
);
let res = client()
.post(app.url(&format!("/api/shop/products/{product_id}/unpublish")))
.bearer_auth(&owner)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
assert_eq!(put_product(&app, &token, &product_id).await.status(), 404);
let res = client()
.put(app.url(&format!("/api/admin/shops/{shop_id}/status")))
.bearer_auth(&admin)
.json(&serde_json::json!({ "status": "suspended" }))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
assert_eq!(put_shop(&app, &token, &shop_id).await.status(), 404);
}
#[tokio::test]
#[serial]
async fn repeated_add_and_remove_are_idempotent() {
let app = spawn_app().await;
let (token, _) = register_customer(&app, "fav-idem").await;
let admin = login_admin(&app).await;
let (_owner, shop_id, product_id) = sellable(&app, &admin, "fav-idem", 1800).await;
let first = put_product(&app, &token, &product_id).await;
assert_eq!(first.status(), 200);
let first_id = first.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let second = put_product(&app, &token, &product_id).await;
assert_eq!(second.status(), 200);
let second_body: serde_json::Value = second.json().await.unwrap();
assert_eq!(second_body["id"], first_id);
assert_eq!(
list(&app, &token, "product", None, None, None).await["total"],
1
);
assert_eq!(put_shop(&app, &token, &shop_id).await.status(), 200);
assert_eq!(put_shop(&app, &token, &shop_id).await.status(), 200);
assert_eq!(
list(&app, &token, "shop", None, None, None).await["total"],
1
);
assert_eq!(delete_shop(&app, &token, &shop_id).await.status(), 204);
assert_eq!(delete_shop(&app, &token, &shop_id).await.status(), 204);
assert_eq!(
list(&app, &token, "shop", None, None, None).await["total"],
0
);
assert_eq!(
delete_product(&app, &token, &product_id).await.status(),
204
);
}
#[tokio::test]
#[serial]
async fn listing_hydrates_filters_and_paginates_visible_targets() {
let app = spawn_app().await;
let (token, _) = register_customer(&app, "fav-list").await;
let admin = login_admin(&app).await;
let (owner, shop_a, product_a) = sellable(&app, &admin, "fav-lista", 500).await;
let (_owner_b, shop_b, product_b) = sellable(&app, &admin, "fav-listb", 1500).await;
let (_owner_c, _shop_c, product_c) = sellable(&app, &admin, "fav-listc", 900).await;
assert_eq!(put_product(&app, &token, &product_a).await.status(), 200);
assert_eq!(put_product(&app, &token, &product_b).await.status(), 200);
assert_eq!(put_product(&app, &token, &product_c).await.status(), 200);
assert_eq!(put_shop(&app, &token, &shop_a).await.status(), 200);
assert_eq!(put_shop(&app, &token, &shop_b).await.status(), 200);
let products = list(&app, &token, "product", None, None, None).await;
assert_eq!(products["total"], 3);
let items = products["items"].as_array().unwrap();
assert_eq!(items.len(), 3);
assert_eq!(items[0]["kind"], "product");
assert!(items
.iter()
.any(|row| { row["product"]["id"] == product_b && row["product"]["price_minor"] == 1500 }));
let filtered = list(&app, &token, "product", Some(&product_a), None, None).await;
assert_eq!(filtered["total"], 1);
assert_eq!(filtered["items"][0]["product"]["id"], product_a);
let shops = list(&app, &token, "shop", None, None, None).await;
assert_eq!(shops["total"], 2);
assert_eq!(shops["items"][0]["kind"], "shop");
let shop_filter = list(&app, &token, "shop", Some(&shop_b), None, None).await;
assert_eq!(shop_filter["total"], 1);
assert_eq!(shop_filter["items"][0]["shop"]["id"], shop_b);
let page1 = list(&app, &token, "product", None, Some(1), Some(1)).await;
let page2 = list(&app, &token, "product", None, Some(2), Some(1)).await;
assert_eq!(page1["total"], 3);
assert_eq!(page1["items"].as_array().unwrap().len(), 1);
assert_eq!(page2["items"].as_array().unwrap().len(), 1);
assert_ne!(page1["items"][0]["id"], page2["items"][0]["id"]);
let res = client()
.post(app.url(&format!("/api/shop/products/{product_a}/unpublish")))
.bearer_auth(&owner)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let after = list(&app, &token, "product", None, None, None).await;
assert_eq!(after["total"], 2);
assert!(after["items"]
.as_array()
.unwrap()
.iter()
.all(|row| row["product"]["id"] != product_a));
let res = client()
.put(app.url(&format!("/api/admin/shops/{shop_a}/status")))
.bearer_auth(&admin)
.json(&serde_json::json!({ "status": "suspended" }))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let shops_after = list(&app, &token, "shop", None, None, None).await;
assert_eq!(shops_after["total"], 1);
assert_eq!(shops_after["items"][0]["shop"]["id"], shop_b);
}
#[tokio::test]
#[serial]
async fn non_customers_cannot_use_favorite_routes() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let res = client()
.get(app.url("/api/favorites"))
.query(&[("kind", "product")])
.bearer_auth(&admin)
.send()
.await
.unwrap();
assert_eq!(res.status(), 403);
}
#[tokio::test]
#[serial]
async fn add_holds_target_visible_until_favorite_commits() {
let app = spawn_app().await;
let (token, _) = register_customer(&app, "fav-atomic").await;
let admin = login_admin(&app).await;
let (_owner, shop_id, _product_id) = sellable(&app, &admin, "fav-atomic", 1000).await;
sqlx::query("DROP TRIGGER IF EXISTS favorites_atomic_delay ON favorites")
.execute(&app.db)
.await
.unwrap();
sqlx::query("DROP FUNCTION IF EXISTS favorites_atomic_delay()")
.execute(&app.db)
.await
.unwrap();
sqlx::query(
"CREATE FUNCTION favorites_atomic_delay() RETURNS trigger AS $$
BEGIN
PERFORM pg_advisory_xact_lock(915001);
RETURN NEW;
END
$$ LANGUAGE plpgsql",
)
.execute(&app.db)
.await
.unwrap();
sqlx::query(
"CREATE TRIGGER favorites_atomic_delay BEFORE INSERT ON favorites
FOR EACH ROW EXECUTE FUNCTION favorites_atomic_delay()",
)
.execute(&app.db)
.await
.unwrap();
let mut lock_conn = app.db.acquire().await.unwrap();
sqlx::query("SELECT pg_advisory_lock(915001)")
.execute(&mut *lock_conn)
.await
.unwrap();
let base = app.base.clone();
let add_token = token.clone();
let add_shop_id = shop_id.clone();
let add = tokio::spawn(async move {
client()
.put(format!("{base}/api/favorites/shops/{add_shop_id}"))
.bearer_auth(add_token)
.send()
.await
.unwrap()
});
let insert_waiting = tokio::time::timeout(Duration::from_secs(2), async {
loop {
let waiting: bool = sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
AND query LIKE 'INSERT INTO favorites%'
)",
)
.fetch_one(&app.db)
.await
.unwrap();
if waiting {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.is_ok();
let suspend_base = app.base.clone();
let suspend_admin = admin.clone();
let suspend_shop_id = shop_id.clone();
let mut suspend = tokio::spawn(async move {
client()
.put(format!(
"{suspend_base}/api/admin/shops/{suspend_shop_id}/status"
))
.bearer_auth(suspend_admin)
.json(&serde_json::json!({ "status": "suspended" }))
.send()
.await
.unwrap()
});
let status_changed_before_insert =
tokio::time::timeout(Duration::from_millis(150), &mut suspend)
.await
.is_ok();
sqlx::query("SELECT pg_advisory_unlock(915001)")
.execute(&mut *lock_conn)
.await
.unwrap();
let add_response = add.await.unwrap();
let suspend_response = if status_changed_before_insert {
None
} else {
Some(suspend.await.unwrap())
};
sqlx::query("DROP TRIGGER favorites_atomic_delay ON favorites")
.execute(&app.db)
.await
.unwrap();
sqlx::query("DROP FUNCTION favorites_atomic_delay()")
.execute(&app.db)
.await
.unwrap();
assert!(insert_waiting, "favorite insert never reached the trigger");
assert!(
!status_changed_before_insert,
"shop status changed while favorite creation was in flight"
);
assert_eq!(
add_response.status(),
200,
"add: {:?}",
add_response.text().await
);
assert_eq!(suspend_response.unwrap().status(), 200);
}
@@ -0,0 +1,10 @@
import { signInPath } from "~/utils/auth";
/** Send a signed-out shopper to sign in and back to the page they were on. */
export function useSignInRedirect() {
const route = useRoute();
const router = useRouter();
return async function signInThenReturn(): Promise<void> {
await router.push(signInPath(route.fullPath));
};
}
+4
View File
@@ -24,6 +24,8 @@ export default {
addedCart: "Added to cart",
favorite: "Favorite",
unfavorite: "Unfavorite",
favoriteFailed: "Unable to update favorite",
favoriteLoadFailed: "Unable to load favorite state",
noStock: "Out of stock",
unavailable: "This combination is unavailable",
store: "Store",
@@ -72,6 +74,8 @@ export default {
addedCart: "已加入购物车",
favorite: "收藏",
unfavorite: "取消收藏",
favoriteFailed: "收藏更新失败",
favoriteLoadFailed: "无法加载收藏状态",
noStock: "暂时缺货",
unavailable: "该规格暂不可用",
store: "店铺",
+8
View File
@@ -18,6 +18,10 @@ export default {
speed: "Delivery",
favorite: "Follow store",
favorited: "Following",
favoriteFailed: "Unable to update store favorite",
favoriteLoadFailed: "Unable to load store favorite",
notice: "Store notice",
afterSale: "After-sales policy",
salesRank: "Top sellers",
storeProducts: "Store products",
sortPrice: "Price",
@@ -47,6 +51,10 @@ export default {
speed: "发货速度",
favorite: "收藏店铺",
favorited: "已收藏",
favoriteFailed: "店铺收藏更新失败",
favoriteLoadFailed: "无法加载店铺收藏",
notice: "店铺公告",
afterSale: "售后服务",
salesRank: "本店销量排行",
storeProducts: "店内商品",
sortPrice: "价格",
+4
View File
@@ -77,6 +77,8 @@ export default {
removeFavorite: "Remove favorite",
enterStore: "Enter store",
noFavoriteStores: "No favorite stores yet.",
favoriteLoadFailed: "Unable to load favorites",
favoriteRemoveFailed: "Unable to remove favorite",
couponsTitle: "My coupons",
couponTitle: "Coupon",
amount: "Amount",
@@ -174,6 +176,8 @@ export default {
removeFavorite: "取消收藏",
enterStore: "进入店铺",
noFavoriteStores: "暂无关注店铺。",
favoriteLoadFailed: "收藏加载失败",
favoriteRemoveFailed: "取消收藏失败",
couponsTitle: "我的优惠券",
couponTitle: "优惠券",
amount: "优惠金额",
+9 -4
View File
@@ -1,10 +1,15 @@
export default defineNuxtRouteMiddleware(async () => {
import { signInPath } from "~/utils/auth";
export default defineNuxtRouteMiddleware(async (to) => {
if (import.meta.server) return;
// The requested page, not the current one, is where the shopper should land
// after signing in — deep links into the buyer centre must survive.
const signIn = signInPath(to.fullPath);
const session = useSessionStore();
if (!session.token) session.hydrate();
if (!session.token) return navigateTo("/login");
if (!session.token) return navigateTo(signIn);
// Trust the auth API's answer rather than whatever localStorage claims; a
// rejected token clears the session inside validate().
if (!(await session.validate())) return navigateTo("/login");
if (session.user?.role !== "customer") return navigateTo("/login");
if (!(await session.validate())) return navigateTo(signIn);
if (session.user?.role !== "customer") return navigateTo(signIn);
});
+154 -3
View File
@@ -15,6 +15,9 @@ import type {
Coupon,
CouponTemplate,
CouponTemplateInput,
Favorite,
FavoriteListQuery,
FavoriteProductSummary,
FlashSaleItem,
FlashSaleItemInput,
FlashSaleSession,
@@ -45,6 +48,7 @@ import {
MOCK_CATEGORIES,
MOCK_COUPONS,
MOCK_CURRENCIES,
MOCK_FAVORITES,
INTEGRAL_PRODUCTS,
MOCK_PROMOS,
MOCK_QUICK_LINKS,
@@ -71,19 +75,26 @@ interface MockState {
addresses: AddressBookEntry[];
/** In-memory only: claims made during this browser session. */
coupons: Coupon[];
/** Persisted customer favorites for fixed-adapter reload parity. */
favorites: Favorite[];
/** In-memory points catalog and redemptions for the fixed-data path. */
pointsProducts: IntegralProduct[];
redemptions: IntegralOrder[];
addressSeq: number;
favoriteSeq: number;
orderSeq: number;
invoiceSeq: number;
redemptionSeq: number;
}
// v3: address book joined the persisted state.
const STORAGE_KEY = "vmall.mock.state.v3";
// v4: customer favorites joined the persisted rollback state.
const STORAGE_KEY = "vmall.mock.state.v4";
type PersistedState = Pick<MockState, "cart" | "orders" | "shipments" | "invoices" | "addresses" | "orderSeq" | "invoiceSeq" | "addressSeq">;
type PersistedState = Pick<
MockState,
"cart" | "orders" | "shipments" | "invoices" | "addresses" | "favorites" |
"orderSeq" | "invoiceSeq" | "addressSeq" | "favoriteSeq"
>;
// Load cart/order session state persisted by a previous page load (client only).
function loadPersisted(): PersistedState | null {
@@ -98,6 +109,7 @@ function loadPersisted(): PersistedState | null {
if (!Array.isArray(p.shipments) || !Array.isArray(p.invoices)) return null;
if (typeof p.orderSeq !== "number" || typeof p.invoiceSeq !== "number") return null;
if (!Array.isArray(p.addresses) || typeof p.addressSeq !== "number") return null;
if (!Array.isArray(p.favorites) || typeof p.favoriteSeq !== "number") return null;
return p as PersistedState;
} catch {
return null;
@@ -145,6 +157,64 @@ function seedCoupons(): Coupon[] {
return MOCK_COUPONS.map(mockOwnedCoupon);
}
function clampPage(page?: number): number {
return Math.max(1, page ?? 1);
}
function clampPerPage(perPage?: number): number {
return Math.min(100, Math.max(1, perPage ?? 20));
}
function productSummary(product: Product): FavoriteProductSummary {
const sku = lowestSku(product);
return {
id: product.id,
shop_id: product.shop_id,
slug: product.slug,
name: product.name,
image: product.images[0] ?? null,
price_minor: sku?.price_minor ?? null,
currency: sku?.currency ?? null,
};
}
function hydrateProductFavorite(id: string, createdAt: string, product: Product): Favorite {
return {
kind: "product",
id,
user_id: MOCK_USER.id,
created_at: createdAt,
product: productSummary(product),
};
}
function hydrateShopFavorite(id: string, createdAt: string, store: MockStoreRecord): Favorite {
return {
kind: "shop",
id,
user_id: MOCK_USER.id,
created_at: createdAt,
shop: toShopProfile(store),
};
}
function seedFavorites(): Favorite[] {
const rows: Favorite[] = [];
for (const item of MOCK_FAVORITES) {
const created = `${item.createdAt}T09:00:00.000Z`;
if (item.kind === "product") {
const product = productById(item.refId);
if (product && product.status === "published") {
rows.push(hydrateProductFavorite(item.id, created, product));
}
} else {
const store = storeById(item.refId);
if (store) rows.push(hydrateShopFavorite(item.id, created, store));
}
}
return rows;
}
function seedPointsProducts(): IntegralProduct[] {
return INTEGRAL_PRODUCTS.map((p) => ({
id: p.id,
@@ -277,9 +347,11 @@ function initialState(): MockState {
invoices: seed.invoices,
addresses: seededAddresses,
coupons: seedCoupons(),
favorites: seedFavorites(),
pointsProducts: seedPointsProducts(),
redemptions: [],
addressSeq: 100,
favoriteSeq: 200,
orderSeq: 100,
invoiceSeq: 100,
redemptionSeq: 0,
@@ -335,6 +407,8 @@ export function createMockApi(): ApiClient {
invoiceSeq: state.invoiceSeq,
addresses: state.addresses,
addressSeq: state.addressSeq,
favorites: state.favorites,
favoriteSeq: state.favoriteSeq,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch {
@@ -722,6 +796,83 @@ export function createMockApi(): ApiClient {
return Promise.resolve({ ...entry });
},
listFavorites: (q: FavoriteListQuery) => {
const page = clampPage(q.page);
const perPage = clampPerPage(q.per_page);
const visible = state.favorites
.map((row) => {
if (row.kind === "product") {
const product = productById(row.product.id);
if (!product || product.status !== "published") return null;
return hydrateProductFavorite(row.id, row.created_at, product);
}
const store = storeById(row.shop.id);
if (!store) return null;
return hydrateShopFavorite(row.id, row.created_at, store);
})
.filter((row): row is Favorite => row !== null)
.filter((row) => row.kind === q.kind)
.filter((row) => {
if (!q.target_id) return true;
return row.kind === "product" ? row.product.id === q.target_id : row.shop.id === q.target_id;
})
.sort((a, b) => b.created_at.localeCompare(a.created_at));
const total = visible.length;
const start = (page - 1) * perPage;
return Promise.resolve({
items: visible.slice(start, start + perPage),
total,
page,
per_page: perPage,
});
},
addProductFavorite: (productId: string) => {
const product = productById(productId);
if (!product || product.status !== "published") {
return Promise.reject(new ApiError(404, "NOT_FOUND", "product"));
}
const existing = state.favorites.find(
(row) => row.kind === "product" && row.product.id === productId,
);
if (existing) {
return Promise.resolve(hydrateProductFavorite(existing.id, existing.created_at, product));
}
state.favoriteSeq += 1;
const row = hydrateProductFavorite(`f-${state.favoriteSeq}`, new Date().toISOString(), product);
state.favorites.push(row);
persist();
return Promise.resolve(row);
},
removeProductFavorite: (productId: string) => {
state.favorites = state.favorites.filter(
(row) => !(row.kind === "product" && row.product.id === productId),
);
persist();
return Promise.resolve();
},
addShopFavorite: (shopId: string) => {
const store = storeById(shopId);
if (!store) return Promise.reject(new ApiError(404, "NOT_FOUND", "shop"));
const existing = state.favorites.find((row) => row.kind === "shop" && row.shop.id === shopId);
if (existing) {
return Promise.resolve(hydrateShopFavorite(existing.id, existing.created_at, store));
}
state.favoriteSeq += 1;
const row = hydrateShopFavorite(`f-${state.favoriteSeq}`, new Date().toISOString(), store);
state.favorites.push(row);
persist();
return Promise.resolve(row);
},
removeShopFavorite: (shopId: string) => {
state.favorites = state.favorites.filter((row) => !(row.kind === "shop" && row.shop.id === shopId));
persist();
return Promise.resolve();
},
listShopCouponTemplates: (shopId: string) =>
Promise.resolve(MOCK_COUPONS.map((t) => mockTemplate(t, shopId))),
+1 -1
View File
@@ -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", "shops", "brands", "auth", "account", "cart", "orders", "shipments", "invoices", "addresses", "coupons", "points", "flashSales", "groupBuying"],
liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "account", "cart", "orders", "shipments", "invoices", "addresses", "coupons", "points", "flashSales", "groupBuying", "favorites"],
appName: "mall",
},
},
+2 -1
View File
@@ -7,6 +7,7 @@ const { $api } = useNuxtApp();
const session = useSessionStore();
const cart = useCartStore();
const router = useRouter();
const signInThenReturn = useSignInRedirect();
const activities = ref<GroupBuyingActivityView[]>([]);
const loading = ref(true);
const error = ref("");
@@ -42,7 +43,7 @@ function joined(activity: GroupBuyingActivityView, groupId: string): string {
// intent; the server prices and validates it at checkout.
async function start(activity: GroupBuyingActivityView, open: boolean): Promise<void> {
if (!session.isLoggedIn) {
await navigateTo("/login");
await signInThenReturn();
return;
}
error.value = "";
+76 -5
View File
@@ -14,6 +14,7 @@ const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const cart = useCartStore();
const session = useSessionStore();
const signInThenReturn = useSignInRedirect();
const routeId = computed(() => {
const value = route.params.id;
@@ -72,7 +73,7 @@ const claimedIds = ref<Set<string>>(new Set());
async function claim(coupon: CouponTemplate): Promise<void> {
if (!session.isLoggedIn) {
await navigateTo("/login");
await signInThenReturn();
return;
}
claimingId.value = coupon.id;
@@ -92,6 +93,10 @@ const selectedAttributes = reactive<Record<string, string>>({});
const quantity = ref(1);
const galleryIndex = ref(0);
const favorite = ref(false);
const favoriteLoading = ref(false);
const favoriteBusy = ref(false);
const favoriteError = ref("");
let favoriteRequestVersion = 0;
const cartSuccess = ref(false);
const activeTab = ref("detail");
@@ -116,7 +121,6 @@ watch(
if (initial) Object.assign(selectedAttributes, initial.attributes);
quantity.value = 1;
galleryIndex.value = 0;
favorite.value = false;
cartSuccess.value = false;
activeTab.value = "detail";
},
@@ -176,7 +180,7 @@ const addToCart = async (): Promise<boolean> => {
} catch (error) {
// A live cart needs a token; send a signed-out shopper to sign in and back.
if (error instanceof ApiError && error.status === 401) {
await router.push(`/login?redirect=${encodeURIComponent(route.fullPath)}`);
await signInThenReturn();
return false;
}
throw error;
@@ -198,6 +202,66 @@ const addCart = async (): Promise<void> => {
}
};
watch(
() => [product.value?.id, session.isLoggedIn] as const,
async ([id, loggedIn]) => {
const version = ++favoriteRequestVersion;
favorite.value = false;
favoriteLoading.value = Boolean(id && loggedIn);
favoriteBusy.value = false;
favoriteError.value = "";
if (!id || !loggedIn) return;
try {
const page = await $api.listFavorites({ kind: "product", target_id: id, per_page: 1 });
if (version === favoriteRequestVersion && product.value?.id === id) {
favorite.value = page.items.length > 0;
}
} catch {
if (version === favoriteRequestVersion && product.value?.id === id) {
favoriteError.value = t("product.favoriteLoadFailed");
}
} finally {
if (version === favoriteRequestVersion) favoriteLoading.value = false;
}
},
{ immediate: true },
);
const toggleFavorite = async (): Promise<void> => {
const current = product.value;
if (!current || favoriteLoading.value || favoriteBusy.value) return;
if (!session.isLoggedIn) {
await signInThenReturn();
return;
}
const version = ++favoriteRequestVersion;
favoriteBusy.value = true;
favoriteError.value = "";
try {
if (favorite.value) {
await $api.removeProductFavorite(current.id);
if (version === favoriteRequestVersion && product.value?.id === current.id) {
favorite.value = false;
}
} else {
await $api.addProductFavorite(current.id);
if (version === favoriteRequestVersion && product.value?.id === current.id) {
favorite.value = true;
}
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
await signInThenReturn();
return;
}
if (version === favoriteRequestVersion && product.value?.id === current.id) {
favoriteError.value = t("product.favoriteFailed");
}
} finally {
if (version === favoriteRequestVersion) favoriteBusy.value = false;
}
};
// No reviews tab: there is no reviews capability, and the mall will not present
// invented reviewers and ratings as fact. See the wave-6 design.
const tabs = computed(() => [
@@ -256,10 +320,12 @@ const detailImages = computed(() => product.value?.images ?? []);
type="button"
class="favorite"
:class="{ active: favorite }"
:disabled="favoriteLoading || favoriteBusy"
:aria-label="favorite ? t('product.unfavorite') : t('product.favorite')"
@click="favorite = !favorite"
@click="toggleFavorite"
>{{ favorite ? "♥" : "♡" }}</button>
</div>
<p v-if="favoriteError" class="favorite-error">{{ favoriteError }}</p>
<div class="price-panel">
<span class="price-label">{{ t("product.currentPrice") }}</span>
<strong v-if="matchedSku" class="price"><PriceText :amount-minor="matchedSku.price_minor" :currency="matchedSku.currency" /></strong>
@@ -489,9 +555,14 @@ h1 {
padding: 13px 0;
}
.summary-meta .soldout,
.stock-warning {
.stock-warning,
.favorite-error {
color: var(--mall-red);
}
.favorite-error {
margin: 6px 0 0;
font-size: 12px;
}
.coupon-row,
.attribute-row,
.quantity-row {
+2 -1
View File
@@ -11,6 +11,7 @@ import { ApiError, t as pick } from "@vmall/shared";
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const session = useSessionStore();
const signInThenReturn = useSignInRedirect();
const stats = ref<AccountSummary | null>(null);
const products = ref<IntegralProduct[]>([]);
const redemptions = ref<IntegralOrder[]>([]);
@@ -85,7 +86,7 @@ function addressForRedemption(): Address | null {
async function redeem(product: IntegralProduct): Promise<void> {
if (!session.isLoggedIn) {
await navigateTo("/login");
await signInThenReturn();
return;
}
const address = addressForRedemption();
+82 -1
View File
@@ -1,11 +1,15 @@
<script setup lang="ts">
import { ApiError } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
import type { Paged, Product } from "@vmall/shared";
import { lowestSku } from "~/utils/product";
import { useSessionStore } from "~/stores/session";
const route = useRoute();
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const session = useSessionStore();
const signInThenReturn = useSignInRedirect();
const slug = computed(() => {
const raw = route.params.id;
@@ -21,6 +25,10 @@ const { data: shop } = await useAsyncData("shop-profile", () => $api.getShop(slu
const store = computed(() => shop.value);
const favorite = ref(false);
const favoriteLoading = ref(false);
const favoriteBusy = ref(false);
const favoriteError = ref("");
let favoriteRequestVersion = 0;
// Only sorts the catalog model can answer; sales and comments have no model.
const sortMode = ref<"default" | "price">("default");
const sortOrder = ref<"asc" | "desc">("desc");
@@ -80,6 +88,67 @@ function chooseSort(mode: "default" | "price"): void {
}
page.value = 1;
}
watch(
() => [store.value?.id, session.isLoggedIn] as const,
async ([id, loggedIn]) => {
const version = ++favoriteRequestVersion;
favorite.value = false;
favoriteLoading.value = Boolean(id && loggedIn);
favoriteBusy.value = false;
favoriteError.value = "";
if (!id || !loggedIn) return;
try {
const result = await $api.listFavorites({ kind: "shop", target_id: id, per_page: 1 });
if (version === favoriteRequestVersion && store.value?.id === id) {
favorite.value = result.items.length > 0;
}
} catch {
if (version === favoriteRequestVersion && store.value?.id === id) {
favoriteError.value = t("stores.favoriteLoadFailed");
}
} finally {
if (version === favoriteRequestVersion) favoriteLoading.value = false;
}
},
{ immediate: true },
);
const toggleFavorite = async (): Promise<void> => {
const current = store.value;
if (!current || favoriteLoading.value || favoriteBusy.value) return;
if (!session.isLoggedIn) {
await signInThenReturn();
return;
}
const version = ++favoriteRequestVersion;
favoriteBusy.value = true;
favoriteError.value = "";
try {
if (favorite.value) {
await $api.removeShopFavorite(current.id);
if (version === favoriteRequestVersion && store.value?.id === current.id) {
favorite.value = false;
}
} else {
await $api.addShopFavorite(current.id);
if (version === favoriteRequestVersion && store.value?.id === current.id) {
favorite.value = true;
}
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
await signInThenReturn();
return;
}
if (version === favoriteRequestVersion && store.value?.id === current.id) {
favoriteError.value = t("stores.favoriteFailed");
}
} finally {
if (version === favoriteRequestVersion) favoriteBusy.value = false;
}
};
</script>
<template>
@@ -112,9 +181,16 @@ function chooseSort(mode: "default" | "price"): void {
<div v-if="store.region"><dt>{{ t("stores.region") }}</dt><dd>{{ store.region }}</dd></div>
<div v-if="store.address"><dt>{{ t("stores.address") }}</dt><dd>{{ pick(store.address, locale) }}</dd></div>
</dl>
<button type="button" class="mbtn favorite-button" :class="{ selected: favorite }" @click="favorite = !favorite">
<button
type="button"
class="mbtn favorite-button"
:class="{ selected: favorite }"
:disabled="favoriteLoading || favoriteBusy"
@click="toggleFavorite"
>
{{ favorite ? t("stores.favorited") : t("stores.favorite") }}
</button>
<p v-if="favoriteError" class="favorite-error">{{ favoriteError }}</p>
</section>
<section v-if="store.notice || store.after_sale" class="mpanel notice-card">
@@ -253,6 +329,11 @@ function chooseSort(mode: "default" | "price"): void {
border-color: var(--mall-red);
color: var(--mall-red);
}
.favorite-error {
margin: 6px 0 0;
color: var(--mall-red);
font-size: 12px;
}
.rail-title {
margin: 0 0 12px;
border-left: 3px solid var(--mall-red);
+104 -39
View File
@@ -1,69 +1,117 @@
<script setup lang="ts">
import type { Product } from "@vmall/shared";
import type { Favorite, Paged, ProductFavorite, ShopFavorite } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
import { MOCK_FAVORITES, lowestSku, productById, storeById } from "~/mock/data";
definePageMeta({ middleware: "auth" });
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const activeTab = ref<"product" | "store">("product");
const favorites = ref(MOCK_FAVORITES.map((item) => ({ ...item })));
const activeTab = ref<"product" | "shop">("product");
const page = ref(1);
const perPage = 12;
const loading = ref(true);
const error = ref("");
const removingId = ref("");
const emptyPage: Paged<Favorite> = { items: [], total: 0, page: 1, per_page: perPage };
const result = ref<Paged<Favorite>>({ ...emptyPage });
let loadVersion = 0;
const tabs = computed(() => [
{ key: "product", label: t("user.favoriteProductsTab") },
{ key: "store", label: t("user.favoriteStoresTab") },
{ key: "shop", label: t("user.favoriteStoresTab") },
]);
const productRows = computed(() =>
favorites.value
.filter((favorite) => favorite.kind === "product")
.map((favorite) => productById(favorite.refId))
.filter((product): product is Product => product !== null)
.map((product) => {
const sku = lowestSku(product);
return {
product,
amountMinor: sku?.price_minor ?? null,
currency: sku?.currency ?? null,
};
}),
result.value.items.filter((row): row is ProductFavorite => row.kind === "product"),
);
const storeRows = computed(() =>
favorites.value
.filter((favorite) => favorite.kind === "store")
.map((favorite) => storeById(favorite.refId))
.filter((store): store is NonNullable<ReturnType<typeof storeById>> => store !== null),
result.value.items.filter((row): row is ShopFavorite => row.kind === "shop"),
);
function removeProductFavorite(productId: string): void {
favorites.value = favorites.value.filter((item) => !(item.kind === "product" && item.refId === productId));
async function load(): Promise<Paged<Favorite> | null> {
const version = ++loadVersion;
const requestedKind = activeTab.value;
const requestedPage = page.value;
loading.value = true;
error.value = "";
try {
const next = await $api.listFavorites({
kind: requestedKind,
page: requestedPage,
per_page: perPage,
});
if (version !== loadVersion || activeTab.value !== requestedKind || page.value !== requestedPage) {
return null;
}
result.value = next;
return next;
} catch {
if (version === loadVersion && activeTab.value === requestedKind && page.value === requestedPage) {
error.value = t("user.favoriteLoadFailed");
result.value = { ...emptyPage };
}
return null;
} finally {
if (version === loadVersion) loading.value = false;
}
}
function removeStoreFavorite(storeId: string): void {
favorites.value = favorites.value.filter((item) => !(item.kind === "store" && item.refId === storeId));
watch(activeTab, () => {
page.value = 1;
});
watch([activeTab, page], () => {
void load();
}, { immediate: true });
async function removeProductFavorite(productId: string): Promise<void> {
if (removingId.value) return;
removingId.value = productId;
try {
await $api.removeProductFavorite(productId);
const next = await load();
if (next) page.value = Math.min(page.value, Math.max(1, Math.ceil(next.total / next.per_page)));
} catch {
error.value = t("user.favoriteRemoveFailed");
} finally {
removingId.value = "";
}
}
async function removeStoreFavorite(storeId: string): Promise<void> {
if (removingId.value) return;
removingId.value = storeId;
try {
await $api.removeShopFavorite(storeId);
const next = await load();
if (next) page.value = Math.min(page.value, Math.max(1, Math.ceil(next.total / next.per_page)));
} catch {
error.value = t("user.favoriteRemoveFailed");
} finally {
removingId.value = "";
}
}
</script>
<template>
<section class="mpanel favorites-panel">
<h1 class="mpanel-title">{{ t("user.favoritesTitle") }}</h1>
<p v-if="error" class="muted">{{ error }}</p>
<UiTabs v-model="activeTab" :tabs="tabs">
<template #default>
<div v-if="activeTab === 'product'">
<div v-if="loading" class="muted">{{ t("common.loading") }}</div>
<div v-else-if="activeTab === 'product'">
<UiEmptyState v-if="productRows.length === 0" :text="t('user.noFavorites')" />
<div v-else class="product-grid">
<article v-for="item in productRows" :key="item.product.id" class="product-card hover-lift">
<article v-for="item in productRows" :key="item.id" class="product-card hover-lift">
<NuxtLink :to="`/goods/${item.product.slug}`" class="cover">
<img :src="item.product.images[0] || '/mock/product-1.svg'" :alt="pick(item.product.name, locale)" />
<img :src="item.product.image || '/mock/product-1.svg'" :alt="pick(item.product.name, locale)" />
</NuxtLink>
<div class="content">
<NuxtLink :to="`/goods/${item.product.slug}`" class="name">{{ pick(item.product.name, locale) }}</NuxtLink>
<PriceText v-if="item.amountMinor !== null && item.currency" :amount-minor="item.amountMinor" :currency="item.currency" />
<button class="mbtn" type="button" @click="removeProductFavorite(item.product.id)">{{ t("user.removeFavorite") }}</button>
<PriceText v-if="item.product.price_minor !== null && item.product.currency" :amount-minor="item.product.price_minor" :currency="item.product.currency" />
<button class="mbtn" type="button" :disabled="removingId === item.product.id" @click="removeProductFavorite(item.product.id)">{{ t("user.removeFavorite") }}</button>
</div>
</article>
</div>
@@ -72,19 +120,27 @@ function removeStoreFavorite(storeId: string): void {
<div v-else>
<UiEmptyState v-if="storeRows.length === 0" :text="t('user.noFavoriteStores')" />
<div v-else class="store-list">
<article v-for="store in storeRows" :key="store.id" class="store-row">
<img :src="store.logo" :alt="pick(store.name, locale)" />
<article v-for="item in storeRows" :key="item.id" class="store-row">
<img v-if="item.shop.logo" :src="item.shop.logo" :alt="pick(item.shop.name, locale)" />
<span v-else class="store-logo-placeholder">{{ pick(item.shop.name, locale).slice(0, 1) }}</span>
<div class="store-main">
<strong>{{ pick(store.name, locale) }}</strong>
<span>{{ store.company }}</span>
<strong>{{ pick(item.shop.name, locale) }}</strong>
<span>{{ item.shop.company }}</span>
</div>
<div class="store-actions">
<NuxtLink class="mbtn" :to="`/stores/${store.slug}`">{{ t("user.enterStore") }}</NuxtLink>
<button class="mbtn" type="button" @click="removeStoreFavorite(store.id)">{{ t("user.removeFavorite") }}</button>
<NuxtLink class="mbtn" :to="`/stores/${item.shop.slug}`">{{ t("user.enterStore") }}</NuxtLink>
<button class="mbtn" type="button" :disabled="removingId === item.shop.id" @click="removeStoreFavorite(item.shop.id)">{{ t("user.removeFavorite") }}</button>
</div>
</article>
</div>
</div>
<UiPagination
v-if="!loading && result.total > result.per_page"
:page="result.page"
:total="result.total"
:per-page="result.per_page"
@change="page = $event"
/>
</template>
</UiTabs>
</section>
@@ -147,13 +203,22 @@ function removeStoreFavorite(storeId: string): void {
.store-row:last-child {
margin-bottom: 0;
}
.store-row img {
.store-row img,
.store-logo-placeholder {
width: 56px;
height: 56px;
border: 1px solid var(--mall-line);
border-radius: 4px;
object-fit: cover;
}
.store-logo-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
color: var(--mall-muted);
font-size: 18px;
}
.store-main {
display: flex;
flex: 1;
+21 -20
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import type { AccountSummary, Order, Product } from "@vmall/shared";
import type { AccountSummary, Order, ProductFavorite } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
import { MOCK_FAVORITES, lowestSku, productById } from "~/mock/data";
definePageMeta({ middleware: "auth" });
@@ -9,6 +8,8 @@ const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const orders = ref<Order[]>([]);
const stats = ref<AccountSummary | null>(null);
const favoriteProducts = ref<ProductFavorite[]>([]);
const favoriteProductTotal = ref(0);
const loading = ref(true);
async function loadOrders(): Promise<void> {
@@ -30,9 +31,23 @@ async function loadStats(): Promise<void> {
}
}
async function loadFavorites(): Promise<void> {
try {
const page = await $api.listFavorites({ kind: "product", page: 1, per_page: 8 });
favoriteProducts.value = page.items.filter(
(row): row is ProductFavorite => row.kind === "product",
);
favoriteProductTotal.value = page.total;
} catch {
favoriteProducts.value = [];
favoriteProductTotal.value = 0;
}
}
onMounted(() => {
void loadOrders();
void loadStats();
void loadFavorites();
});
const counts = computed(() => ({
@@ -50,20 +65,6 @@ const statusLinks = computed(() => [
{ key: "completed", label: t("user.completed"), count: counts.value.completed, to: "/user/orders?status=completed" },
{ key: "afterSale", label: t("user.afterSale"), count: counts.value.afterSale, to: "/user/orders?status=after-sale" },
]);
const favoriteProducts = computed(() =>
MOCK_FAVORITES.filter((favorite) => favorite.kind === "product")
.map((favorite) => productById(favorite.refId))
.filter((product): product is Product => product !== null)
.map((product) => {
const sku = lowestSku(product);
return {
product,
amountMinor: sku?.price_minor ?? null,
currency: sku?.currency ?? null,
};
}),
);
</script>
<template>
@@ -123,13 +124,13 @@ const favoriteProducts = computed(() =>
</section>
<section class="mpanel">
<h2 class="mpanel-title">{{ t("user.favoriteProducts") }}</h2>
<h2 class="mpanel-title">{{ t("user.favoriteProducts") }} ({{ favoriteProductTotal }})</h2>
<UiEmptyState v-if="favoriteProducts.length === 0" :text="t('user.noFavorites')" />
<div v-else class="favorite-grid">
<NuxtLink v-for="item in favoriteProducts" :key="item.product.id" :to="`/goods/${item.product.slug}`" class="favorite-card hover-lift">
<img :src="item.product.images[0] || '/mock/product-1.svg'" :alt="pick(item.product.name, locale)" />
<NuxtLink v-for="item in favoriteProducts" :key="item.id" :to="`/goods/${item.product.slug}`" class="favorite-card hover-lift">
<img :src="item.product.image || '/mock/product-1.svg'" :alt="pick(item.product.name, locale)" />
<span>{{ pick(item.product.name, locale) }}</span>
<PriceText v-if="item.amountMinor !== null && item.currency" :amount-minor="item.amountMinor" :currency="item.currency" />
<PriceText v-if="item.product.price_minor !== null && item.product.currency" :amount-minor="item.product.price_minor" :currency="item.product.currency" />
</NuxtLink>
</div>
</section>
+10 -1
View File
@@ -23,7 +23,8 @@ type LiveDomain =
| "coupons"
| "points"
| "flashSales"
| "groupBuying";
| "groupBuying"
| "favorites";
/**
* Explicit per-domain method picks rather than a string allowlist: indexing
@@ -84,6 +85,13 @@ const LIVE_PICKS = {
groupBuying: (a: ApiClient) => ({
listGroupBuyingActivities: a.listGroupBuyingActivities,
}),
favorites: (a: ApiClient) => ({
listFavorites: a.listFavorites,
addProductFavorite: a.addProductFavorite,
removeProductFavorite: a.removeProductFavorite,
addShopFavorite: a.addShopFavorite,
removeShopFavorite: a.removeShopFavorite,
}),
} satisfies Record<LiveDomain, (a: ApiClient) => Partial<ApiClient>>;
const KNOWN_DOMAINS = Object.keys(LIVE_PICKS) as LiveDomain[];
@@ -106,6 +114,7 @@ const DEFAULT_LIVE_DOMAINS: LiveDomain[] = [
"points",
"flashSales",
"groupBuying",
"favorites",
];
export default defineNuxtPlugin(() => {
+11
View File
@@ -0,0 +1,11 @@
/**
* Sign-in URL that returns the shopper to `fullPath` afterwards. `login.vue`
* only honours a same-origin absolute path, and a redirect back to the sign-in
* page itself would loop, so that case drops the parameter.
*/
export function signInPath(fullPath: string): string {
if (!fullPath.startsWith("/") || fullPath.startsWith("//") || fullPath.startsWith("/login")) {
return "/login";
}
return `/login?redirect=${encodeURIComponent(fullPath)}`;
}
+33 -41
View File
@@ -1,10 +1,9 @@
# TBD — marketing capabilities with no backend (mall mock holdouts)
The mock→live migration finished at `replace-mock-api-wave-7` (address book, 2026-09-18).
Every domain that has an API is live; what follows is what still renders from
`apps/mall/mock/data.ts` because **no backend capability exists for it**. Each entry is a
new capability (schema + routes + contract + pages), not a domain flip — pick one up by
opening an OpenSpec change, the same way waves 1–7 did.
The mock→live migration and the first marketing implementation wave are complete. Every domain
with an API is live; only the unchecked items below still render from `apps/mall/mock/data.ts`
because no backend capability exists for them. Each remaining entry is a new capability, not a
domain flip, and requires its own OpenSpec change.
**How to use:** check a box only once the behaviour is implemented *and* verified against
the live backend (`cargo run -p vmall-api`, `node scripts/seed-demo.mjs`), then remove the
@@ -15,9 +14,9 @@ mock data and the page's `~/mock/data` import in the same change.
---
## Planning report — 2026-09-18
## Implementation report — 2026-09-21
Five independent, implementation-ready OpenSpec changes were created and validated strictly:
Five independent OpenSpec changes were implemented, verified, archived, and synced to main specs:
| Change | Business boundary | Prerequisite |
| --- | --- | --- |
@@ -27,10 +26,10 @@ Five independent, implementation-ready OpenSpec changes were created and validat
| `add-flash-sales` | Timed sessions, SKU activity inventory, limits, and checkout pricing | None |
| `add-group-buying` | Activities, group lifecycle, paid membership, and payment-time capacity | None |
Each change has `proposal.md`, `design.md`, `specs/`, and `tasks.md` in
`openspec/changes/<change>/`; all five passed `openspec change validate <change> --strict`.
Their planning documents use generic B2B2C storefront terminology rather than a source-project
name.
Each change is archived under `openspec/changes/archive/2026-09-18-<change>/`; its requirements
are present in the main OpenSpec specs. Backend migrations `0010` through `0014`, API modules,
shared contracts, and live frontend domain picks are present. Source-project names are absent from
the planning artifacts.
**Captured decisions:** coupons are template plus customer-owned snapshot and one per shop order;
activity-priced shop orders (flash or group) reject coupons; a SKU cannot be in overlapping flash
@@ -40,38 +39,32 @@ flash sales use activity-reserved stock and server-calculated prices; group seat
payment rather than checkout; an unpaid opener cancel closes an empty group; catalog product-detail
does not show flash or group prices in these changes.
**Recommended implementation order:** start `add-customer-accounts` and/or
`add-shop-coupons`; archive accounts; then `add-points-mall`; implement `add-flash-sales` after
the coupon checkout shape is settled; implement `add-group-buying` last because it expands the
payment state machine. **Favorites** can be a separate OpenSpec change at any time (no order
coupling). The deferred designs below intentionally have no change yet.
**Implemented order:** `add-customer-accounts`, `add-shop-coupons`, `add-points-mall`,
`add-flash-sales`, then `add-group-buying`. Favorites is implemented as `add-favorites`. The
deferred designs below intentionally have no change yet.
## Mock holdouts with a mall UI today
- [ ] **Coupons** — covered by in-progress `add-shop-coupons` (not yet applied). Mall still
reads `MOCK_COUPONS` until that change is implemented.
- [ ] **Favorites** — `user/favorites.vue` lists `MOCK_FAVORITES` (products tab +
stores tab); `user/index.vue` derives counts from it; `goods/[id].vue` heart is local
`ref(false)`. Ready for its own OpenSpec change whenever convenient: no checkout
coupling. Schema: `favorites(user_id, product_id | shop_id)` with a partial unique
index per target kind (see deferred design). APIs: `GET/POST/DELETE /api/favorites`.
- [ ] **Account stats** — covered by in-progress `add-customer-accounts` (not yet applied).
Mall still reads `USER_STATS` until that change lands. The change already includes
append-only ledgers and guarded mutation; public entry listing stays out of scope.
Frozen balance is a reserved kind and stays zero until a freeze flow exists.
- [x] **Coupons** — implemented and archived as `2026-09-18-add-shop-coupons`; mall product,
customer coupon, and checkout surfaces use the live coupon API.
- [x] **Favorites** — implemented as `add-favorites`; customer product/shop favorites persist
through the live API. Mall product/store detail, buyer-center list, and dashboard preview
use the selected adapter instead of `MOCK_FAVORITES` or local heart state.
- [x] **Account stats** — implemented and archived as `2026-09-18-add-customer-accounts`.
The mall reads the live summary; append-only ledgers and guarded mutation back later flows.
Frozen balance remains zero until a freeze flow exists.
## Marketing pages that are display-only mock
## Implemented marketing pages
These exist as full pages (`seckill.vue`, `collective.vue`, `integral.vue`) linked from
the home navigation; all three read fixtures directly.
The former display-only pages now use live backend domains; their fixtures remain only inside the
fixed-data adapter as the required rollback implementation.
- [ ] **Seckill (秒杀)** — covered by in-progress `add-flash-sales`. Page still reads
`SECKILL_SESSIONS`. Catalog product-detail does not show flash prices in that change.
- [ ] **Collective / 拼团** — covered by in-progress `add-group-buying`. Page still reads
fixture counts. Paid seats at payment; unpaid opener cancel closes an empty group.
- [ ] **Integral mall / 积分商城** — covered by in-progress `add-points-mall` after
`add-customer-accounts` archives. Demo spendable points are seed-credited through the
ledger; earning campaigns stay out of that change.
- [x] **Seckill (秒杀)** — implemented and archived as `2026-09-18-add-flash-sales`.
Catalog product detail intentionally does not show activity prices.
- [x] **Collective / 拼团** — implemented and archived as `2026-09-18-add-group-buying`.
Paid seats are claimed at payment; unpaid opener cancellation closes an empty group.
- [x] **Integral mall / 积分商城** — implemented and archived as
`2026-09-18-add-points-mall`; demo points are ledger credits and earning campaigns remain out.
## Domains typical B2B2C storefronts have and this MVP does not (no UI here)
@@ -102,10 +95,9 @@ transaction rules before implementation.
threshold and reduction in one currency, active window, and an explicit best-eligible rule.
Coupon vs flash/group is already exclusive (reject coupon on activity-priced shop orders).
Keep 满减 independent from coupons until a stacking policy with those shop coupons is specified.
- [ ] **Favorites** — do not use a polymorphic `target_id`/type pair. Keep the existing
product-or-shop design with separate nullable foreign keys, a target-kind check, and one
partial unique index per target type so duplicate claims are impossible. This can be a
dedicated OpenSpec change; it is the smallest remaining mall mock with a UI.
- [x] **Favorites** — implemented as `add-favorites` with explicit nullable product/shop
foreign keys, an exactly-one-target check, and one partial unique index per kind.
Do not revisit a polymorphic `target_id`/type pair.
- [ ] **Reviews / 评价** — a future review belongs to a fulfilled order item, not just a
product. Preserve an immutable rating/content snapshot, allow one shop reply, and make
public visibility and platform moderation explicit lifecycle states.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-21
+43
View File
@@ -0,0 +1,43 @@
## Context
The buyer center renders product and shop favorites from `MOCK_FAVORITES`; product and store detail pages toggle a local boolean that resets on navigation or reload. Catalog and store data are already live, authentication is customer-scoped, and the fixed-data adapter must continue implementing the full shared client surface.
## Goals / Non-Goals
**Goals:**
- Persist one customer favorite for a product or shop and enforce ownership and uniqueness in Postgres.
- Support efficient buyer-center lists, previews/counts, and detail-page favorite state without browser joins or N+1 API calls.
- Make add/remove safe to retry and keep live and fixed adapters behaviorally compatible.
**Non-Goals:**
- Folders, notes, sharing, alerts, recommendation signals, merchant analytics, or fixture-data migration.
## Decisions
### Model two explicit nullable targets in one table
`favorites` contains `id`, `user_id`, nullable `product_id`, nullable `shop_id`, and `created_at`. A check constraint requires exactly one target. Two partial unique indexes enforce `(user_id, product_id)` and `(user_id, shop_id)` uniqueness; foreign keys cascade on target or user deletion.
This is preferred to a polymorphic `target_type/target_id`, which cannot enforce both target foreign keys. Separate product/shop tables would duplicate ownership, timestamps, repositories, and list composition for no stronger invariant.
### Use idempotent target-resource mutations
Authenticated customer routes are:
- `GET /api/favorites?kind=product|shop&target_id=<optional>&page=&per_page=`
- `PUT /api/favorites/products/{product_id}` and matching `DELETE`
- `PUT /api/favorites/shops/{shop_id}` and matching `DELETE`
`PUT` returns the existing favorite when already present. `DELETE` succeeds with no body even when absent. Target-specific paths prevent malformed mixed-target request bodies and make detail-page retry behavior deterministic.
### Return paginated, hydrated discriminated unions
The shared `Favorite` type is a `product` or `shop` discriminated union containing favorite metadata and a current public target summary. Product summaries include IDs/slug/localized name/image and lowest active SKU price/currency; shop summaries include the public shop profile fields required by the current card. SQL aggregates the product price in the list query, avoiding one API call per favorite.
Only published products belonging to active shops and active shops themselves appear in lists or can be added. An unavailable target's row remains dormant and reappears if the target is republished; physical target deletion cascades the favorite. `total` counts only visible rows.
### Treat favorite state as authenticated UI state
Product and store detail load a filtered favorite query when signed in, preserve the return URL when sign-in is required, and disable the control while a mutation is in flight. Buyer-center pages use paginated favorite responses for tabs, previews, and counts. The `favorites` domain is added to live picks and the fixed adapter implements identical methods over its fixture-backed state.
## Risks / Trade-offs
- Dormant rows are invisible while a target is unavailable. This preserves user intent across temporary unpublishing but means stored-row count can exceed API `total`.
- Hydrated summaries couple this read model to catalog/shop presentation fields; the benefit is bounded query count and a stable buyer-center contract.
- Idempotent delete cannot tell the UI whether a row previously existed; the UI only needs the resulting unfavorited state.
@@ -0,0 +1,27 @@
## Why
Favorites are the last existing mall UI capability that still reads fixed fixtures directly. Persisting product and shop favorites completes the buyer-center journey and makes the product-detail heart survive reloads without touching checkout or payment.
## What Changes
- Add customer-owned product and shop favorites with database-enforced target shape and per-target uniqueness.
- Add authenticated APIs to list, add, and remove each favorite kind; repeated add/remove operations are idempotent.
- Return current target data with each favorite so the Mall does not issue one request per saved item.
- Replace fixture-derived favorites, buyer-center previews/counts, and the product-detail local heart state with the shared live API contract.
- Keep equivalent fixed-data adapter behavior as the rollback implementation.
## Capabilities
### New Capabilities
- `favorites`: Customer ownership, product/shop target integrity, idempotent mutation, and target-aware listing.
### Modified Capabilities
- `frontend-mall`: Product detail and buyer-center favorites use the selected API adapter instead of local state and `MOCK_FAVORITES`.
## Non-goals
Favorite folders, notes, sharing, notifications, ranking, merchant analytics, bulk mutation, and automatic migration of fixture favorites are excluded.
## Impact
Adds one Postgres migration, a Rust favorites module and customer routes, shared types/API methods, a `favorites` live-domain pick, fixed-adapter parity, and Mall updates for product detail and buyer-center pages.
@@ -0,0 +1,34 @@
## ADDED Requirements
### Requirement: Customer-owned favorite targets
A favorite SHALL belong to one authenticated customer and reference exactly one product or one shop. The database SHALL enforce target foreign keys and no more than one favorite per customer and target. Customers SHALL never read or mutate another customer's favorites.
#### Scenario: target shape is enforced
- **WHEN** a favorite row would reference both a product and a shop or neither target
- **THEN** the database rejects the row
#### Scenario: ownership filters every operation
- **WHEN** one customer lists or removes favorites
- **THEN** only that customer's rows are read or changed
### Requirement: Idempotent favorite mutation
An authenticated customer SHALL add or remove a published product belonging to an active shop or an active shop as a favorite. Repeating the same add SHALL return the existing favorite without creating a duplicate, and repeating the same remove SHALL succeed with the target still unfavorited. A missing or unavailable target SHALL return 404 on add.
#### Scenario: repeated product add
- **WHEN** a customer adds the same published product twice
- **THEN** both requests succeed and exactly one favorite row exists
#### Scenario: repeated shop removal
- **WHEN** a customer removes the same shop favorite twice
- **THEN** both requests succeed and no favorite row remains
### Requirement: Paginated target-aware favorite listing
An authenticated customer SHALL list favorites filtered by product or shop kind, with optional target ID and pagination. Each result SHALL be a discriminated favorite containing current public target summary data; product summaries SHALL include the current lowest active SKU price and currency. Unpublished products, products of inactive shops, and inactive shops SHALL be absent, and `total` SHALL count only visible results.
#### Scenario: buyer center loads product favorites
- **WHEN** a customer lists product favorites
- **THEN** each visible row contains product card data without additional per-product requests
#### Scenario: unavailable target is hidden
- **WHEN** a favorited product becomes unpublished
- **THEN** it is absent from the favorite list and total until it becomes publicly available again
@@ -0,0 +1,20 @@
## ADDED Requirements
### Requirement: Live customer favorites
The mall SHALL use the shared selected API adapter for product and shop favorite state. Product and store detail controls SHALL load persisted state, require customer authentication, prevent duplicate in-flight mutations, and survive reloads. The buyer-center favorites page and dashboard preview/counts SHALL render paginated live favorite results and remove targets through the API instead of reading `MOCK_FAVORITES` or mutating local-only state.
#### Scenario: product favorite survives reload
- **WHEN** a signed-in shopper favorites a product and reloads its detail page
- **THEN** the favorite control remains selected from backend state
#### Scenario: anonymous favorite requires sign-in
- **WHEN** a signed-out shopper uses a product or store favorite control
- **THEN** the mall sends the shopper to sign in with the current detail URL as the return destination
#### Scenario: remove from buyer center
- **WHEN** a shopper removes a product or shop from the favorites page
- **THEN** the API state, visible list, dashboard preview, and visible count reflect the removal without fixture mutation
#### Scenario: fixed adapter remains functional
- **WHEN** the favorites domain is configured to fixed data
- **THEN** detail controls and buyer-center favorite flows behave deterministically through the same shared client methods
+30
View File
@@ -0,0 +1,30 @@
## 1. Persistence and backend contract
- [x] 1.1 Add migration `0015_favorites.sql` with explicit product/shop foreign keys, exactly-one-target check, cascading deletes, customer indexes, and partial unique indexes for each target kind.
- [x] 1.2 Add shared discriminated favorite summary/query types and `listFavorites`, `addProductFavorite`, `removeProductFavorite`, `addShopFavorite`, and `removeShopFavorite` methods to `@vmall/shared`.
- [x] 1.3 Implement `apps/api/src/modules/favorite/` repository, service, DTO, handlers, and module registration with customer-only target-resource routes.
- [x] 1.4 Implement visible-target validation, idempotent upserts/deletes, ownership filtering, pagination totals, optional target filtering, and SQL-hydrated product/shop summaries.
## 2. Backend behavioral proof
- [x] 2.1 Add isolated API integration coverage for exactly-one-target and uniqueness constraints, customer ownership, missing/unavailable targets, repeated add/remove, product/shop listing, target filtering, pagination, and unavailable-target hiding.
- [x] 2.2 Run the focused favorites integration tests and then run `cargo test -p vmall-api` twice to prove list tests remain green against the non-truncated shared test database.
## 3. Adapter and live-domain wiring
- [x] 3.1 Implement the five favorite client methods in `apps/mall/mock/api.ts` with per-session mutable fixture state and the same idempotent/filter/pagination behavior.
- [x] 3.2 Add the `favorites` domain and exact shared-client method picks to Mall API selection and enable it in the default/live runtime configuration.
- [x] 3.3 Add or adjust bilingual favorite loading, mutation, and failure strings through the existing Mall locale source without introducing per-page hard-coded copy.
## 4. Mall favorite surfaces
- [x] 4.1 Replace product-detail local heart state with authenticated filtered lookup and idempotent live add/remove, preserving the detail URL through sign-in and disabling concurrent clicks.
- [x] 4.2 Replace store-detail local favorite state with the same persisted authenticated behavior for shop targets.
- [x] 4.3 Replace `apps/mall/pages/user/favorites.vue` fixture joins and local deletion with paginated product/shop API results and persisted removal.
- [x] 4.4 Replace buyer-dashboard `MOCK_FAVORITES` preview and count derivation with a bounded live product-favorites query and remove all page-level favorites fixture imports.
## 5. Verification and tracker cleanup
- [x] 5.1 Seed or create a deterministic customer product and shop favorite, run the API plus Mall, and browser-smoke add, reload persistence, buyer-center listing/removal, store favorite, and anonymous sign-in redirect.
- [x] 5.2 Build all three frontends because the shared API contract changes: `pnpm --filter @vmall/mall build`, `pnpm --filter @vmall/shop-admin build`, and `pnpm --filter @vmall/admin build`.
- [x] 5.3 Mark Favorites implemented in `docs/TBD-marketing.md`, update the README mock boundary, check every OpenSpec task, and run `openspec change validate add-favorites --strict` plus `openspec validate --all --strict`.
+19
View File
@@ -13,6 +13,8 @@ import type {
CouponTemplate,
CouponTemplateInput,
Currency,
Favorite,
FavoriteKind,
FlashSaleItem,
FlashSaleItemInput,
FlashSaleSession,
@@ -126,6 +128,13 @@ export interface ConvertResult {
currency: string;
}
export interface FavoriteListQuery {
kind: FavoriteKind;
target_id?: string;
page?: number;
per_page?: number;
}
type Query = Record<string, string | number | boolean | undefined>;
async function request<T>(
@@ -212,6 +221,11 @@ export interface ApiClient {
updateAddress(id: string, address: AddressInput): Promise<AddressBookEntry>;
deleteAddress(id: string): Promise<AddressBookEntry[]>;
setDefaultAddress(id: string): Promise<AddressBookEntry>;
listFavorites(q: FavoriteListQuery): Promise<Paged<Favorite>>;
addProductFavorite(productId: string): Promise<Favorite>;
removeProductFavorite(productId: string): Promise<void>;
addShopFavorite(shopId: string): Promise<Favorite>;
removeShopFavorite(shopId: string): Promise<void>;
/** Public: coupons a shopper could claim from this shop right now. */
listShopCouponTemplates(shopId: string): Promise<CouponTemplate[]>;
listMyCoupons(): Promise<Coupon[]>;
@@ -328,6 +342,11 @@ export function createApi(opts: ApiClientOptions): ApiClient {
updateAddress: (id, address) => r("PUT", `/addresses/${id}`, address),
deleteAddress: (id) => r("DELETE", `/addresses/${id}`),
setDefaultAddress: (id) => r("POST", `/addresses/${id}/default`),
listFavorites: (q) => r("GET", "/favorites", undefined, { ...q }),
addProductFavorite: (productId) => r("PUT", `/favorites/products/${productId}`),
removeProductFavorite: (productId) => r("DELETE", `/favorites/products/${productId}`),
addShopFavorite: (shopId) => r("PUT", `/favorites/shops/${shopId}`),
removeShopFavorite: (shopId) => r("DELETE", `/favorites/shops/${shopId}`),
listShopCouponTemplates: (shopId) => r("GET", `/shops/${shopId}/coupon-templates`),
listMyCoupons: () => r("GET", "/me/coupons"),
claimCoupon: (templateId) => r("POST", "/me/coupons", { template_id: templateId }),
+31
View File
@@ -567,6 +567,37 @@ export interface ShopProfile {
score_speed: number | null;
}
/** Current public product card data nested in a product favorite. */
export interface FavoriteProductSummary {
id: string;
shop_id: string;
slug: string;
name: LocalizedText;
image: string | null;
price_minor: number | null;
currency: string | null;
}
export type FavoriteKind = "product" | "shop";
export interface ProductFavorite {
kind: "product";
id: string;
user_id: string;
created_at: string;
product: FavoriteProductSummary;
}
export interface ShopFavorite {
kind: "shop";
id: string;
user_id: string;
created_at: string;
shop: ShopProfile;
}
export type Favorite = ProductFavorite | ShopFavorite;
export interface ShopProfileInput {
logo?: string | null;
banner?: string | null;
+19
View File
@@ -342,6 +342,25 @@ for (const p of products) {
}
console.log(`products ready: ${products.length} across ${SHOPS.length} shops`);
// 5c. deterministic customer favorites (idempotent PUTs).
{
r = await call("POST", "/auth/login", {
body: { email: "customer@vmall.local", password: "customer123" },
});
if (r.status !== 200) fail("customer login for favorites", r);
const customerToken = r.data.token;
const listed = await call("GET", `/products?shop_id=${demoShopId}&per_page=100`);
const headphones = Array.isArray(listed.data?.items)
? listed.data.items.find((p) => p.slug === "wireless-headphones")
: null;
if (!headphones?.id) fail("lookup wireless-headphones", listed);
r = await call("PUT", `/favorites/products/${headphones.id}`, { token: customerToken });
if (r.status !== 200) fail("seed product favorite", r);
r = await call("PUT", `/favorites/shops/${demoShopId}`, { token: customerToken });
if (r.status !== 200) fail("seed shop favorite", r);
console.log("favorites ready: wireless-headphones + demo-store");
}
// 6. points mall products (idempotent by English name). The demo customer's
// spendable points are credited by the API through the account ledger.
const POINTS_PRODUCTS = [