diff --git a/apps/admin/app.vue b/apps/admin/app.vue index 5e0c33e..8df6fe9 100644 --- a/apps/admin/app.vue +++ b/apps/admin/app.vue @@ -16,6 +16,7 @@ const navItems = [ { to: "/shops", label: "nav.shops" }, { to: "/orders", label: "nav.orders" }, { to: "/aftersales", label: "nav.aftersales" }, + { to: "/reviews", label: "nav.reviews" }, { to: "/content", label: "nav.content" }, { to: "/brands", label: "nav.brands" }, { to: "/currencies", label: "nav.currencies" }, diff --git a/apps/admin/locales-extra.ts b/apps/admin/locales-extra.ts index a330e3c..827cbc7 100644 --- a/apps/admin/locales-extra.ts +++ b/apps/admin/locales-extra.ts @@ -11,6 +11,7 @@ export const enExtra = { content: "Content", brands: "Brands", aftersales: "After-sales", + reviews: "Reviews", }, admin: { dashboardTitle: "Platform overview", @@ -148,6 +149,24 @@ export const enExtra = { merchant: "Merchant", platform: "Platform", }, + reviewProduct: "Product", + reviewShop: "Shop", + reviewBuyer: "Buyer", + reviewRating: "Rating", + reviewContent: "Content", + reviewImages: "Images", + reviewReply: "Merchant reply", + reviewHide: "Hide", + reviewDelete: "Delete", + reviewConfirmHide: "Hide this review? It disappears from the storefront list and rating summary.", + reviewConfirmDelete: "Permanently delete this review? This cannot be undone.", + reviewHiddenNotice: "Review hidden.", + reviewDeletedNotice: "Review deleted.", + reviewConflict: "The review state changed; refresh and try again", + reviewStatuses: { + visible: "Visible", + hidden: "Hidden", + }, }, } as const; @@ -162,6 +181,7 @@ export const zhExtra = { content: "内容", brands: "品牌", aftersales: "售后仲裁", + reviews: "评价管理", }, admin: { dashboardTitle: "平台概览", @@ -296,5 +316,23 @@ export const zhExtra = { merchant: "商家", platform: "平台", }, + reviewProduct: "商品", + reviewShop: "店铺", + reviewBuyer: "买家", + reviewRating: "星级", + reviewContent: "内容", + reviewImages: "图片", + reviewReply: "商家回复", + reviewHide: "隐藏", + reviewDelete: "删除", + reviewConfirmHide: "确定隐藏该评价吗?隐藏后将从商品评价列表与评分汇总中移除。", + reviewConfirmDelete: "确定永久删除该评价吗?删除后不可恢复。", + reviewHiddenNotice: "评价已隐藏。", + reviewDeletedNotice: "评价已删除。", + reviewConflict: "评价状态已变化,请刷新后重试", + reviewStatuses: { + visible: "可见", + hidden: "已隐藏", + }, }, } as const; diff --git a/apps/admin/pages/reviews.vue b/apps/admin/pages/reviews.vue new file mode 100644 index 0000000..3e9d7ed --- /dev/null +++ b/apps/admin/pages/reviews.vue @@ -0,0 +1,260 @@ + + + diff --git a/apps/api/migrations/0018_product_reviews.sql b/apps/api/migrations/0018_product_reviews.sql new file mode 100644 index 0000000..63e14b3 --- /dev/null +++ b/apps/api/migrations/0018_product_reviews.sql @@ -0,0 +1,22 @@ +CREATE TABLE product_reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_item_id UUID NOT NULL REFERENCES order_items (id), + order_id UUID NOT NULL REFERENCES orders (id), + product_id UUID NOT NULL REFERENCES products (id), + shop_id UUID NOT NULL REFERENCES shops (id), + user_id UUID NOT NULL REFERENCES users (id), + rating INT NOT NULL CHECK (rating BETWEEN 1 AND 5), + content JSONB NOT NULL, + images JSONB NOT NULL DEFAULT '[]', + reply JSONB, + reply_at TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'visible' CHECK (status IN ('visible', 'hidden')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- At most one review per order line, forever. +CREATE UNIQUE INDEX product_reviews_one_per_line ON product_reviews (order_item_id); +CREATE INDEX idx_product_reviews_product ON product_reviews (product_id) WHERE status = 'visible'; +CREATE INDEX idx_product_reviews_shop ON product_reviews (shop_id); +CREATE INDEX idx_product_reviews_user ON product_reviews (user_id); diff --git a/apps/api/src/modules/mod.rs b/apps/api/src/modules/mod.rs index 72a5de4..e3d98ee 100644 --- a/apps/api/src/modules/mod.rs +++ b/apps/api/src/modules/mod.rs @@ -18,6 +18,7 @@ pub mod identity; pub mod order; pub mod points; pub mod product; +pub mod review; pub mod shop; use axum::Router; @@ -45,6 +46,7 @@ pub fn api_router() -> Router { .merge(order::router()) .merge(points::router()) .merge(shop::router()) + .merge(review::router()) .merge(fulfillment::router()) .merge(billing::router()) } diff --git a/apps/api/src/modules/review/handlers.rs b/apps/api/src/modules/review/handlers.rs new file mode 100644 index 0000000..ecfa148 --- /dev/null +++ b/apps/api/src/modules/review/handlers.rs @@ -0,0 +1,113 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + routing::get, + Json, Router, +}; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::error::ApiResult; +use crate::http::{PageQuery, Paged}; +use crate::models::UserRole; +use crate::state::AppState; + +use super::service::{ + self, ReplyBody, ReviewBody, ReviewRow, ReviewSummary, ReviewableItem, +}; + +pub fn router() -> Router { + Router::new() + .route("/products/{id}/reviews", get(public_list)) + .route("/products/{id}/review-summary", get(public_summary)) + .route("/me/reviewable", get(my_reviewable)) + .route("/reviews", axum::routing::post(create_review)) + .route("/shop/reviews", get(shop_list)) + .route("/shop/reviews/{id}/reply", axum::routing::post(shop_reply)) + .route("/admin/reviews", get(admin_list)) + .route( + "/admin/reviews/{id}", + axum::routing::delete(admin_delete), + ) + .route("/admin/reviews/{id}/hide", axum::routing::post(admin_hide)) +} + +async fn public_list( + State(state): State, + Path(id): Path, + Query(q): Query, +) -> ApiResult>> { + Ok(Json( + service::list_public(&state, id, q.page, q.per_page).await?, + )) +} + +async fn public_summary( + State(state): State, + Path(id): Path, +) -> ApiResult> { + Ok(Json(service::summary(&state, id).await?)) +} + +async fn my_reviewable( + State(state): State, + auth: AuthUser, +) -> ApiResult>> { + auth.require(&[UserRole::Customer])?; + Ok(Json(service::reviewable(&state, auth.id).await?)) +} + +async fn create_review( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + auth.require(&[UserRole::Customer])?; + Ok(( + StatusCode::CREATED, + Json(service::create(&state, auth.id, body).await?), + )) +} + +async fn shop_list( + State(state): State, + auth: AuthUser, + Query(q): Query, +) -> ApiResult>> { + let shop_id = auth.require_shop()?; + Ok(Json(service::list_for_shop(&state, shop_id, q.page, q.per_page).await?)) +} + +async fn shop_reply( + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + let shop_id = auth.require_shop()?; + Ok(Json(service::reply(&state, shop_id, id, body).await?)) +} + +async fn admin_list( + State(state): State, + auth: AuthUser, + Query(q): Query, +) -> ApiResult>> { + auth.require_admin()?; + Ok(Json(service::list_all(&state, q.page, q.per_page).await?)) +} + +async fn admin_hide(State(state): State, auth: AuthUser, Path(id): Path) -> ApiResult> { + auth.require_admin()?; + Ok(Json(service::hide(&state, id).await?)) +} + +async fn admin_delete( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult { + auth.require_admin()?; + service::remove(&state, id).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/apps/api/src/modules/review/mod.rs b/apps/api/src/modules/review/mod.rs new file mode 100644 index 0000000..755adf0 --- /dev/null +++ b/apps/api/src/modules/review/mod.rs @@ -0,0 +1,4 @@ +pub mod handlers; +pub mod service; + +pub use handlers::router; diff --git a/apps/api/src/modules/review/service.rs b/apps/api/src/modules/review/service.rs new file mode 100644 index 0000000..b398042 --- /dev/null +++ b/apps/api/src/modules/review/service.rs @@ -0,0 +1,321 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::PgConnection; +use uuid::Uuid; + +use crate::error::{unique_conflict, ApiError, ApiResult}; +use crate::http::{clamp_page, clamp_per_page, Paged}; +use crate::state::AppState; + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct ReviewRow { + pub id: Uuid, + pub order_item_id: Uuid, + pub order_id: Uuid, + pub product_id: Uuid, + pub shop_id: Uuid, + pub user_id: Uuid, + pub rating: i32, + pub content: Value, + pub images: Value, + pub reply: Option, + pub reply_at: Option>, + pub status: String, + pub created_at: DateTime, + /// Joined from users for storefront display. + pub reviewer_name: String, +} + +const COLS: &str = "r.id, r.order_item_id, r.order_id, r.product_id, r.shop_id, r.user_id, + r.rating, r.content, r.images, r.reply, r.reply_at, r.status, r.created_at, + u.display_name AS reviewer_name"; +const FROM: &str = "product_reviews r JOIN users u ON u.id = r.user_id"; + +#[derive(Debug, Serialize)] +pub struct ReviewSummary { + pub count: i64, + pub avg_rating: f64, + /// Star (1-5) -> number of visible reviews. + pub distribution: std::collections::BTreeMap, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct ReviewableItem { + pub order_item_id: Uuid, + pub order_id: Uuid, + pub order_no: String, + pub product_id: Uuid, + pub product_name: Value, + pub sku_code: String, + pub image: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct ReviewBody { + pub order_item_id: Uuid, + pub rating: i32, + pub content: Value, + pub images: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct ReplyBody { + pub content: Value, +} + +/// Review text needs at least one non-empty locale; display falls back. +fn some_locale(value: &Value, field: &str) -> ApiResult<()> { + let ok = ["en", "zh"].iter().any(|code| { + value + .get(code) + .and_then(Value::as_str) + .is_some_and(|s| !s.trim().is_empty()) + }); + if !ok { + return Err(ApiError::BadRequest(format!( + "{field} needs text in at least one locale" + ))); + } + Ok(()) +} + +async fn fetch(db: &mut PgConnection, id: Uuid) -> ApiResult { + sqlx::query_as::<_, ReviewRow>(&format!("SELECT {COLS} FROM {FROM} WHERE r.id = $1")) + .bind(id) + .fetch_optional(&mut *db) + .await? + .ok_or_else(|| ApiError::NotFound("review".into())) +} + +pub async fn list_public( + state: &AppState, + product_id: Uuid, + page: Option, + per_page: Option, +) -> ApiResult> { + let page = clamp_page(page); + let per_page = clamp_per_page(per_page); + let total: i64 = sqlx::query_scalar( + "SELECT count(*) FROM product_reviews WHERE product_id = $1 AND status = 'visible'", + ) + .bind(product_id) + .fetch_one(&state.db) + .await?; + let items = sqlx::query_as::<_, ReviewRow>(&format!( + "SELECT {COLS} FROM {FROM} WHERE r.product_id = $1 AND r.status = 'visible' + ORDER BY r.created_at DESC LIMIT $2 OFFSET $3" + )) + .bind(product_id) + .bind(per_page) + .bind((page - 1) * per_page) + .fetch_all(&state.db) + .await?; + Ok(Paged { + items, + total, + page, + per_page, + }) +} + +pub async fn summary(state: &AppState, product_id: Uuid) -> ApiResult { + let rows: Vec<(i32, i64)> = sqlx::query_as( + "SELECT rating, count(*)::bigint FROM product_reviews + WHERE product_id = $1 AND status = 'visible' GROUP BY rating", + ) + .bind(product_id) + .fetch_all(&state.db) + .await?; + let mut distribution = std::collections::BTreeMap::new(); + let mut count = 0i64; + let mut sum = 0i64; + for (rating, n) in rows { + distribution.insert(rating, n); + count += n; + sum += rating as i64 * n; + } + let avg_rating = if count > 0 { + (sum as f64 / count as f64 * 10.0).round() / 10.0 + } else { + 0.0 + }; + Ok(ReviewSummary { + count, + avg_rating, + distribution, + }) +} + +pub async fn create(state: &AppState, user_id: Uuid, body: ReviewBody) -> ApiResult { + if !(1..=5).contains(&body.rating) { + return Err(ApiError::BadRequest("rating must be between 1 and 5".into())); + } + some_locale(&body.content, "content")?; + let images = Value::from(body.images.clone().unwrap_or_default()); + + // The order line must belong to the customer's own completed order. + let line = sqlx::query_as::<_, (Uuid, Uuid, Uuid)>( + "SELECT oi.order_id, oi.sku_id, o.shop_id + FROM order_items oi JOIN orders o ON o.id = oi.order_id + WHERE oi.id = $1 AND o.user_id = $2 AND o.status = 'completed'", + ) + .bind(body.order_item_id) + .bind(user_id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::Conflict("order line is not reviewable".into()))?; + let product_id: Uuid = sqlx::query_scalar("SELECT product_id FROM skus WHERE id = $1") + .bind(line.1) + .fetch_one(&state.db) + .await?; + + let id: Uuid = sqlx::query_scalar( + "INSERT INTO product_reviews (order_item_id, order_id, product_id, shop_id, user_id, + rating, content, images) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id", + ) + .bind(body.order_item_id) + .bind(line.0) + .bind(product_id) + .bind(line.2) + .bind(user_id) + .bind(body.rating) + .bind(&body.content) + .bind(&images) + .fetch_one(&state.db) + .await + .map_err(|e| unique_conflict(e, "order line already reviewed"))?; + + let mut conn = state.db.acquire().await?; + fetch(&mut conn, id).await +} + +/// Completed order lines of mine that carry no review yet. +pub async fn reviewable(state: &AppState, user_id: Uuid) -> ApiResult> { + Ok(sqlx::query_as::<_, ReviewableItem>( + "SELECT oi.id AS order_item_id, o.id AS order_id, o.order_no, sk.product_id, + oi.product_name, oi.sku_code, oi.image, o.created_at + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + JOIN skus sk ON sk.id = oi.sku_id + WHERE o.user_id = $1 AND o.status = 'completed' + AND NOT EXISTS (SELECT 1 FROM product_reviews r WHERE r.order_item_id = oi.id) + ORDER BY o.created_at DESC", + ) + .bind(user_id) + .fetch_all(&state.db) + .await?) +} + +// --- merchant --- + +pub async fn list_for_shop( + state: &AppState, + shop_id: Uuid, + page: Option, + per_page: Option, +) -> ApiResult> { + let page = clamp_page(page); + let per_page = clamp_per_page(per_page); + let total: i64 = sqlx::query_scalar( + "SELECT count(*) FROM product_reviews WHERE shop_id = $1 AND status = 'visible'", + ) + .bind(shop_id) + .fetch_one(&state.db) + .await?; + let items = sqlx::query_as::<_, ReviewRow>(&format!( + "SELECT {COLS} FROM {FROM} WHERE r.shop_id = $1 AND r.status = 'visible' + ORDER BY r.created_at DESC LIMIT $2 OFFSET $3" + )) + .bind(shop_id) + .bind(per_page) + .bind((page - 1) * per_page) + .fetch_all(&state.db) + .await?; + Ok(Paged { + items, + total, + page, + per_page, + }) +} + +/// Guarded one-time reply: the update only lands while `reply IS NULL`. +pub async fn reply(state: &AppState, shop_id: Uuid, id: Uuid, body: ReplyBody) -> ApiResult { + some_locale(&body.content, "reply")?; + let mut tx = state.db.begin().await?; + let result = sqlx::query( + "UPDATE product_reviews SET reply = $2, reply_at = now(), updated_at = now() + WHERE id = $1 AND shop_id = $3 AND reply IS NULL", + ) + .bind(id) + .bind(&body.content) + .bind(shop_id) + .execute(&mut *tx) + .await?; + if result.rows_affected() == 0 { + return Err(ApiError::Conflict( + "review not found, not yours, or already replied".into(), + )); + } + let out = fetch(&mut tx, id).await?; + tx.commit().await?; + Ok(out) +} + +// --- platform admin --- + +pub async fn list_all( + state: &AppState, + page: Option, + per_page: Option, +) -> ApiResult> { + let page = clamp_page(page); + let per_page = clamp_per_page(per_page); + let total: i64 = sqlx::query_scalar("SELECT count(*) FROM product_reviews") + .fetch_one(&state.db) + .await?; + let items = sqlx::query_as::<_, ReviewRow>(&format!( + "SELECT {COLS} FROM {FROM} ORDER BY r.created_at DESC LIMIT $1 OFFSET $2" + )) + .bind(per_page) + .bind((page - 1) * per_page) + .fetch_all(&state.db) + .await?; + Ok(Paged { + items, + total, + page, + per_page, + }) +} + +pub async fn hide(state: &AppState, id: Uuid) -> ApiResult { + let mut tx = state.db.begin().await?; + let result = sqlx::query( + "UPDATE product_reviews SET status = 'hidden', updated_at = now() + WHERE id = $1 AND status = 'visible'", + ) + .bind(id) + .execute(&mut *tx) + .await?; + if result.rows_affected() == 0 { + return Err(ApiError::Conflict("review not found or already hidden".into())); + } + let out = fetch(&mut tx, id).await?; + tx.commit().await?; + Ok(out) +} + +pub async fn remove(state: &AppState, id: Uuid) -> ApiResult<()> { + let result = sqlx::query("DELETE FROM product_reviews WHERE id = $1") + .bind(id) + .execute(&state.db) + .await?; + if result.rows_affected() == 0 { + return Err(ApiError::NotFound("review".into())); + } + Ok(()) +} diff --git a/apps/api/tests/reviews.rs b/apps/api/tests/reviews.rs new file mode 100644 index 0000000..e5423ff --- /dev/null +++ b/apps/api/tests/reviews.rs @@ -0,0 +1,327 @@ +mod common; + +use common::{ + checkout, client, create_shop, login_admin, make_shop_owner, pay, register_customer, + setup_sellable, spawn_app, TestApp, +}; +use serial_test::serial; + +/// Review suite: one completed order line per test unless stated otherwise. + +/// A paid→shipped→completed order; returns (customer, order_id, order_item_id, product_id, shop_id). +async fn completed_line( + app: &TestApp, + label: &str, +) -> (String, String, String, String, String) { + let admin = login_admin(app).await; + let (owner, shop_id, _product, sku_id) = setup_sellable(app, &admin, label, 1000, 50).await; + let (customer, _) = register_customer(app, label).await; + common::add_to_cart(app, &customer, &sku_id, 1).await; + let orders = checkout(app, &customer).await; + let order = &orders[0]; + let order_id = order["id"].as_str().unwrap().to_string(); + pay(app, &customer, &order_id).await; + + // Ship and confirm to reach completed. + let detail: serde_json::Value = client() + .get(app.url(&format!("/api/shop/orders/{order_id}"))) + .bearer_auth(&owner) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let item_id = detail["items"][0]["id"].as_str().unwrap().to_string(); + let res = client() + .post(app.url(&format!("/api/shop/orders/{order_id}/shipments"))) + .bearer_auth(&owner) + .json(&serde_json::json!({ + "carrier": "SF", "tracking_no": "T1", + "items": [{"order_item_id": item_id, "qty": 1}] + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201, "{:?}", res.text().await); + let shipment: serde_json::Value = res.json().await.unwrap(); + let ship_id = shipment["id"].as_str().unwrap().to_string(); + assert_eq!( + client() + .post(app.url(&format!("/api/shop/shipments/{ship_id}/ship"))) + .bearer_auth(&owner) + .send() + .await + .unwrap() + .status(), + 200 + ); + let res = client() + .post(app.url(&format!("/api/shipments/{ship_id}/confirm-delivered"))) + .bearer_auth(&customer) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + + (customer, order_id, item_id, detail["items"][0]["sku_id"].as_str().unwrap().to_string(), shop_id) +} + +async fn submit(app: &TestApp, customer: &str, item_id: &str, rating: i64) -> reqwest::Response { + client() + .post(app.url("/api/reviews")) + .bearer_auth(customer) + .json(&serde_json::json!({ + "order_item_id": item_id, + "rating": rating, + "content": {"en": "Great product", "zh": "很棒的产品"}, + "images": ["https://example.com/r.png"] + })) + .send() + .await + .unwrap() +} + +#[tokio::test] +#[serial] +async fn review_completed_line_and_uniqueness() { + let app = spawn_app().await; + let (customer, _o, item_id, _sku, _shop) = completed_line(&app, "rv-basic").await; + + let res = submit(&app, &customer, &item_id, 5).await; + assert_eq!(res.status(), 201, "{:?}", res.text().await); + let review: serde_json::Value = res.json().await.unwrap(); + assert_eq!(review["rating"], 5); + assert_eq!(review["reviewer_name"].is_string(), true); + assert_eq!(review["status"], "visible"); + + // Second review of the same line conflicts; exactly one row exists. + let res = submit(&app, &customer, &item_id, 4).await; + assert_eq!(res.status(), 409); + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM product_reviews WHERE order_item_id = $1::uuid") + .bind(&item_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(count, 1); +} + +#[tokio::test] +#[serial] +async fn uncompleted_or_foreign_lines_are_rejected() { + let app = spawn_app().await; + let (customer, _o, item_id, _sku, _shop) = completed_line(&app, "rv-scope").await; + let (other, _) = register_customer(&app, "rv-scope-other").await; + + // Another customer's completed line is not reviewable by me. + let res = submit(&app, &other, &item_id, 3).await; + assert_eq!(res.status(), 409); + + // A line from an unpaid order is not reviewable. + let admin = login_admin(&app).await; + let (_owner, _s2, _p2, sku2) = setup_sellable(&app, &admin, "rv-scope-2", 500, 10).await; + common::add_to_cart(&app, &customer, &sku2, 1).await; + let orders = checkout(&app, &customer).await; + let unpaid = &orders[0]; + let detail: serde_json::Value = client() + .get(app.url(&format!("/api/orders/{}", unpaid["id"].as_str().unwrap()))) + .bearer_auth(&customer) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let unpaid_item = detail["items"][0]["id"].as_str().unwrap(); + let res = submit(&app, &customer, unpaid_item, 3).await; + assert_eq!(res.status(), 409); +} + +#[tokio::test] +#[serial] +async fn rating_bounds_and_content_validation() { + let app = spawn_app().await; + let (customer, _o, item_id, _sku, _shop) = completed_line(&app, "rv-valid").await; + + for rating in [0, 6] { + let res = submit(&app, &customer, &item_id, rating).await; + assert_eq!(res.status(), 400, "rating {rating} must be rejected"); + } + let res = client() + .post(app.url("/api/reviews")) + .bearer_auth(&customer) + .json(&serde_json::json!({ + "order_item_id": item_id, + "rating": 4, + "content": {"en": " ", "zh": ""} + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 400, "empty bilingual content must be rejected"); +} + +#[tokio::test] +#[serial] +async fn merchant_reply_once_and_scoped() { + let app = spawn_app().await; + let (customer, _o, item_id, _sku, shop_id) = completed_line(&app, "rv-reply").await; + let res = submit(&app, &customer, &item_id, 2).await; + let review_id = res.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + + let admin = login_admin(&app).await; + let owner = make_shop_owner(&app, &admin, &shop_id).await; + let other_shop = create_shop(&app, &admin, "rv-reply-other").await; + let other_owner = make_shop_owner(&app, &admin, &other_shop).await; + + let reply = |token: &str| { + let app_url = app.url(&format!("/api/shop/reviews/{review_id}/reply")); + let token = token.to_string(); + async move { + client() + .post(app_url) + .bearer_auth(token) + .json(&serde_json::json!({ "content": {"en": "Sorry, fixing it"} })) + .send() + .await + .unwrap() + } + }; + + assert_eq!(reply(&other_owner).await.status(), 409, "cross-shop reply rejected"); + assert_eq!(reply(&owner).await.status(), 200); + assert_eq!(reply(&owner).await.status(), 409, "second reply rejected"); + +} + +#[tokio::test] +#[serial] +async fn moderation_hides_and_deletes() { + let app = spawn_app().await; + let (customer, _o, item_id, sku_id, _shop) = completed_line(&app, "rv-mod").await; + let res = submit(&app, &customer, &item_id, 1).await; + let review: serde_json::Value = res.json().await.unwrap(); + let review_id = review["id"].as_str().unwrap().to_string(); + let product_id = review["product_id"].as_str().unwrap().to_string(); + let admin = login_admin(&app).await; + + // Visible in the public list and summary. + let summary: serde_json::Value = client() + .get(app.url(&format!("/api/products/{product_id}/review-summary"))) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(summary["count"], 1); + assert_eq!(summary["avg_rating"], 1.0); + assert_eq!(summary["distribution"]["1"], 1); + + // Hide: public list and summary exclude it; admin list still shows it. + let res = client() + .post(app.url(&format!("/api/admin/reviews/{review_id}/hide"))) + .bearer_auth(&admin) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + assert_eq!( + client() + .post(app.url(&format!("/api/admin/reviews/{review_id}/hide"))) + .bearer_auth(&admin) + .send() + .await + .unwrap() + .status(), + 409, + "hiding twice must conflict" + ); + + let summary: serde_json::Value = client() + .get(app.url(&format!("/api/products/{product_id}/review-summary"))) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(summary["count"], 0); + assert_eq!(summary["avg_rating"], 0.0); + + let public: serde_json::Value = client() + .get(app.url(&format!("/api/products/{product_id}/reviews"))) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(!public["items"].as_array().unwrap().iter().any(|r| r["id"] == review_id)); + let admin_list: serde_json::Value = client() + .get(app.url("/api/admin/reviews")) + .bearer_auth(&admin) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let row = admin_list["items"] + .as_array() + .unwrap() + .iter() + .find(|r| r["id"] == review_id) + .unwrap(); + assert_eq!(row["status"], "hidden"); + + // Delete removes the row entirely. + let res = client() + .delete(app.url(&format!("/api/admin/reviews/{review_id}"))) + .bearer_auth(&admin) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 204); + let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM product_reviews WHERE id = $1::uuid)") + .bind(&review_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert!(!exists); + let _ = sku_id; +} + +#[tokio::test] +#[serial] +async fn reviewable_listing_shrinks_after_submission() { + let app = spawn_app().await; + let (customer, _o, item_id, _sku, _shop) = completed_line(&app, "rv-pending").await; + + let list: serde_json::Value = client() + .get(app.url("/api/me/reviewable")) + .bearer_auth(&customer) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(list.as_array().unwrap().iter().any(|i| i["order_item_id"] == item_id)); + + assert_eq!(submit(&app, &customer, &item_id, 5).await.status(), 201); + + let list: serde_json::Value = client() + .get(app.url("/api/me/reviewable")) + .bearer_auth(&customer) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(!list.as_array().unwrap().iter().any(|i| i["order_item_id"] == item_id)); +} diff --git a/apps/mall/locales/product.ts b/apps/mall/locales/product.ts index f3b080b..989844b 100644 --- a/apps/mall/locales/product.ts +++ b/apps/mall/locales/product.ts @@ -45,6 +45,10 @@ export default { description: "Description", reviewSummary: "{rate}% positive reviews ({n} total)", reviewAll: "All reviews", + reviewStar: "{n} stars", + reviewEmpty: "No reviews yet.", + reviewLoadFailed: "Unable to load reviews.", + reviewImageAlt: "Review image {n}", reply: "Seller reply", expires: "Expires {date}", productNotFound: "Product not found", @@ -95,6 +99,10 @@ export default { description: "商品描述", reviewSummary: "好评率 {rate}%(共 {n} 条评价)", reviewAll: "全部评价", + reviewStar: "{n} 星", + reviewEmpty: "暂无评价。", + reviewLoadFailed: "评价加载失败。", + reviewImageAlt: "评价图片 {n}", reply: "商家回复", expires: "有效期至 {date}", productNotFound: "商品不存在", diff --git a/apps/mall/locales/user.ts b/apps/mall/locales/user.ts index 06e32e7..9902691 100644 --- a/apps/mall/locales/user.ts +++ b/apps/mall/locales/user.ts @@ -7,6 +7,27 @@ export default { memberCenter: "Membership", dashboard: "Account overview", myOrders: "My orders", + pendingReviews: "Pending reviews", + reviewsTitle: "Pending reviews", + reviewCount: "{n} items awaiting review", + reviewOrder: "Order no.", + reviewProduct: "Product", + reviewSku: "SKU", + reviewTime: "Ordered", + reviewNow: "Review now", + noPendingReviews: "No items awaiting review.", + reviewLoadFailed: "Unable to load pending reviews.", + reviewRating: "Rating", + reviewContent: "Review", + reviewContentHint: "Tell others what you think", + reviewImages: "Image URLs (optional)", + reviewImagePlaceholder: "https://example.com/photo.jpg", + reviewAddImage: "Add image URL", + reviewRemoveImage: "Remove", + reviewCancel: "Cancel", + reviewSubmit: "Submit review", + reviewSubmitFailed: "Unable to submit review.", + reviewSubmitSuccess: "Review submitted.", addresses: "Shipping addresses", coupons: "My coupons", favorites: "Favorites", @@ -151,6 +172,27 @@ export default { memberCenter: "会员中心", dashboard: "个人中心", myOrders: "我的订单", + pendingReviews: "待评价", + reviewsTitle: "待评价", + reviewCount: "还有 {n} 件待评价", + reviewOrder: "订单号", + reviewProduct: "商品", + reviewSku: "SKU", + reviewTime: "下单时间", + reviewNow: "去评价", + noPendingReviews: "暂无待评价商品。", + reviewLoadFailed: "待评价列表加载失败。", + reviewRating: "评分", + reviewContent: "评价内容", + reviewContentHint: "分享你的使用体验", + reviewImages: "图片 URL(选填)", + reviewImagePlaceholder: "https://example.com/photo.jpg", + reviewAddImage: "添加图片 URL", + reviewRemoveImage: "删除", + reviewCancel: "取消", + reviewSubmit: "提交评价", + reviewSubmitFailed: "评价提交失败。", + reviewSubmitSuccess: "评价已提交。", addresses: "收货地址", coupons: "我的优惠券", favorites: "收藏/关注", diff --git a/apps/mall/mock/api.ts b/apps/mall/mock/api.ts index cd82c6e..1b52186 100644 --- a/apps/mall/mock/api.ts +++ b/apps/mall/mock/api.ts @@ -43,6 +43,10 @@ import type { Product, PublicFlashSaleSession, RedeemPointsBody, + Review, + ReviewInput, + ReviewableItem, + ReviewSummary, Shipment, ShopProfile, User, @@ -87,6 +91,8 @@ interface MockState { /** Persisted customer aftersales for fixed-adapter reload parity. */ aftersales: Aftersale[]; aftersaleMessages: AftersaleMessage[]; + /** Persisted customer reviews for fixed-adapter reload parity. */ + reviews: Review[]; /** In-memory points catalog and redemptions for the fixed-data path. */ pointsProducts: IntegralProduct[]; redemptions: IntegralOrder[]; @@ -97,9 +103,10 @@ interface MockState { redemptionSeq: number; aftersaleSeq: number; aftersaleMessageSeq: number; + reviewSeq: number; } -// v5: customer aftersales joined the persisted rollback state. -const STORAGE_KEY = "vmall.mock.state.v5"; +// v6: customer reviews joined the persisted rollback state. +const STORAGE_KEY = "vmall.mock.state.v6"; type PersistedState = Pick< MockState, @@ -111,12 +118,14 @@ type PersistedState = Pick< | "favorites" | "aftersales" | "aftersaleMessages" + | "reviews" | "orderSeq" | "invoiceSeq" | "addressSeq" | "favoriteSeq" | "aftersaleSeq" | "aftersaleMessageSeq" + | "reviewSeq" >; // Load cart/order session state persisted by a previous page load (client only). @@ -136,6 +145,7 @@ function loadPersisted(): PersistedState | null { if (!Array.isArray(p.aftersales) || !Array.isArray(p.aftersaleMessages)) return null; if (typeof p.aftersaleSeq !== "number" || typeof p.aftersaleMessageSeq !== "number") return null; + if (!Array.isArray(p.reviews) || typeof p.reviewSeq !== "number") return null; return p as PersistedState; } catch { return null; @@ -445,6 +455,34 @@ function seedAftersaleMessages(aftersales: Aftersale[]): AftersaleMessage[] { : []; } +function seedReviews(orders: Order[]): Review[] { + const order = orders.find((entry) => entry.id === "o2"); + const item = order?.items.find((entry) => entry.id === "o2-it1"); + const product = item ? skuIndex()[item.sku_id]?.product : undefined; + if (!order || !item || !product) return []; + return [ + { + id: "rv-demo-1", + order_item_id: item.id, + order_id: order.id, + product_id: product.id, + shop_id: order.shop_id, + user_id: MOCK_USER.id, + rating: 5, + content: { + en: "Excellent quality and a smooth shopping experience.", + zh: "质量很好,购物体验很顺畅。", + }, + images: ["https://example.com/reviews/demo-product.jpg"], + reply: { en: "Thank you for your support!", zh: "感谢您的支持!" }, + reply_at: "2026-09-14T12:00:00.000Z", + status: "visible", + created_at: "2026-09-14T10:00:00.000Z", + reviewer_name: MOCK_USER.display_name, + }, + ]; +} + function initialState(): MockState { const persisted = loadPersisted(); // Coupons and points are session-only, so a restored snapshot re-seeds them. @@ -488,6 +526,7 @@ function initialState(): MockState { coupons: seedCoupons(), favorites: seedFavorites(), aftersales, + reviews: seedReviews(seed.orders), aftersaleMessages: seedAftersaleMessages(aftersales), pointsProducts: seedPointsProducts(), redemptions: [], @@ -497,6 +536,7 @@ function initialState(): MockState { invoiceSeq: 100, redemptionSeq: 0, aftersaleSeq: 100, + reviewSeq: 1, aftersaleMessageSeq: 100, }; } @@ -557,8 +597,10 @@ export function createMockApi(): ApiClient { favoriteSeq: state.favoriteSeq, aftersales: state.aftersales, aftersaleMessages: state.aftersaleMessages, + reviews: state.reviews, aftersaleSeq: state.aftersaleSeq, aftersaleMessageSeq: state.aftersaleMessageSeq, + reviewSeq: state.reviewSeq, }; localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); } catch { @@ -641,6 +683,44 @@ export function createMockApi(): ApiClient { }; } + function copyReview(row: Review): Review { + return { + ...row, + content: { ...row.content }, + images: [...row.images], + reply: row.reply ? { ...row.reply } : null, + }; + } + + function reviewProduct(found: { item: Order["items"][number] }): Product | null { + return skuIndex()[found.item.sku_id]?.product ?? null; + } + + function reviewableItems(): ReviewableItem[] { + return state.orders + .filter((order) => order.user_id === MOCK_USER.id && order.status === "completed") + .sort((a, b) => b.created_at.localeCompare(a.created_at)) + .flatMap((order) => + order.items.flatMap((item) => { + if (state.reviews.some((review) => review.order_item_id === item.id)) return []; + const product = reviewProduct({ item }); + if (!product) return []; + return [ + { + order_item_id: item.id, + order_id: order.id, + order_no: order.order_no, + product_id: product.id, + product_name: { ...item.product_name }, + sku_code: item.sku_code, + image: item.image, + created_at: order.created_at, + }, + ]; + }), + ); + } + return { register: () => Promise.resolve(tokens()), login: () => Promise.resolve(tokens()), @@ -1066,6 +1146,85 @@ export function createMockApi(): ApiClient { return { ...message, content: { ...message.content }, evidence: [...message.evidence] }; }, + listProductReviews: (productId: string, page = 1) => { + const visible = state.reviews + .filter((review) => review.product_id === productId && review.status === "visible") + .sort((a, b) => b.created_at.localeCompare(a.created_at)); + const currentPage = clampPage(page); + const perPage = clampPerPage(); + const start = (currentPage - 1) * perPage; + return Promise.resolve({ + items: visible.slice(start, start + perPage).map(copyReview), + total: visible.length, + page: currentPage, + per_page: perPage, + }); + }, + + getProductReviewSummary: (productId: string): Promise => { + const visible = state.reviews.filter( + (review) => review.product_id === productId && review.status === "visible", + ); + const distribution: Record = {}; + let total = 0; + for (const review of visible) { + const key = String(review.rating); + distribution[key] = (distribution[key] ?? 0) + 1; + total += review.rating; + } + return Promise.resolve({ + count: visible.length, + avg_rating: visible.length ? Math.round((total / visible.length) * 10) / 10 : 0, + distribution, + }); + }, + + listReviewableItems: (): Promise => + Promise.resolve(reviewableItems()), + + createReview: async (body: ReviewInput): Promise => { + if (!Number.isInteger(body.rating) || body.rating < 1 || body.rating > 5) { + throw new ApiError(400, "BAD_REQUEST", "rating must be between 1 and 5"); + } + const en = body.content.en?.trim() ?? ""; + const zh = body.content.zh?.trim() ?? ""; + if (!en && !zh) { + throw new ApiError(400, "BAD_REQUEST", "content needs text in at least one locale"); + } + const found = findOrderItem(body.order_item_id); + if (!found || found.order.user_id !== MOCK_USER.id || found.order.status !== "completed") { + throw new ApiError(409, "CONFLICT", "order line is not reviewable"); + } + if (state.reviews.some((review) => review.order_item_id === body.order_item_id)) { + throw new ApiError(409, "CONFLICT", "order line already reviewed"); + } + const product = reviewProduct(found); + if (!product) throw new ApiError(409, "CONFLICT", "order line is not reviewable"); + state.reviewSeq += 1; + const row: Review = { + id: `rv-${state.reviewSeq}`, + order_item_id: body.order_item_id, + order_id: found.order.id, + product_id: product.id, + shop_id: found.order.shop_id, + user_id: MOCK_USER.id, + rating: body.rating, + content: { + ...(body.content.en !== undefined ? { en: body.content.en } : {}), + ...(body.content.zh !== undefined ? { zh: body.content.zh } : {}), + }, + images: [...(body.images ?? [])], + reply: null, + reply_at: null, + status: "visible", + created_at: new Date().toISOString(), + reviewer_name: MOCK_USER.display_name, + }; + state.reviews = [row, ...state.reviews]; + persist(); + return copyReview(row); + }, + // Mirror of the seeded storefront-content rows, so the home page renders // identically when every domain is configured to fixed data. getHomeContent: (): Promise => @@ -1369,6 +1528,8 @@ export function createMockApi(): ApiClient { createFreightTemplate: () => unsupported(), updateFreightTemplate: () => unsupported(), deleteFreightTemplate: () => unsupported(), + listReviews: (_page?: number) => unsupported(), + replyReview: (_id: string, _content: Record) => unsupported(), }, admin: { listUsers: () => unsupported(), @@ -1395,6 +1556,9 @@ export function createMockApi(): ApiClient { listAftersales: (_status?: AftersaleStatus) => unsupported(), getAftersale: (_id: string) => unsupported(), arbitrateAftersale: (_id: string, _outcome: "refund" | "reject") => unsupported(), + listReviews: (_page?: number) => unsupported(), + hideReview: (_id: string) => unsupported(), + deleteReview: (_id: string) => unsupported(), }, }; } diff --git a/apps/mall/nuxt.config.ts b/apps/mall/nuxt.config.ts index d698e80..0ad57ab 100644 --- a/apps/mall/nuxt.config.ts +++ b/apps/mall/nuxt.config.ts @@ -31,6 +31,7 @@ export default defineNuxtConfig({ "groupBuying", "favorites", "aftersales", + "reviews", ], appName: "mall", }, diff --git a/apps/mall/pages/goods/[id].vue b/apps/mall/pages/goods/[id].vue index 25e9272..8ce8792 100644 --- a/apps/mall/pages/goods/[id].vue +++ b/apps/mall/pages/goods/[id].vue @@ -1,7 +1,16 @@ + +