feat(reviews): order-line reviews with merchant reply and platform moderation (add-product-reviews)

This commit is contained in:
Chengdong Zhang
2026-09-24 16:38:05 +08:00
parent 473d19d089
commit c532f87b03
34 changed files with 2055 additions and 36 deletions
+2
View File
@@ -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<AppState> {
.merge(order::router())
.merge(points::router())
.merge(shop::router())
.merge(review::router())
.merge(fulfillment::router())
.merge(billing::router())
}
+113
View File
@@ -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<AppState> {
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<AppState>,
Path(id): Path<Uuid>,
Query(q): Query<PageQuery>,
) -> ApiResult<Json<Paged<ReviewRow>>> {
Ok(Json(
service::list_public(&state, id, q.page, q.per_page).await?,
))
}
async fn public_summary(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<ReviewSummary>> {
Ok(Json(service::summary(&state, id).await?))
}
async fn my_reviewable(
State(state): State<AppState>,
auth: AuthUser,
) -> ApiResult<Json<Vec<ReviewableItem>>> {
auth.require(&[UserRole::Customer])?;
Ok(Json(service::reviewable(&state, auth.id).await?))
}
async fn create_review(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<ReviewBody>,
) -> ApiResult<(StatusCode, Json<ReviewRow>)> {
auth.require(&[UserRole::Customer])?;
Ok((
StatusCode::CREATED,
Json(service::create(&state, auth.id, body).await?),
))
}
async fn shop_list(
State(state): State<AppState>,
auth: AuthUser,
Query(q): Query<PageQuery>,
) -> ApiResult<Json<Paged<ReviewRow>>> {
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<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<ReplyBody>,
) -> ApiResult<Json<ReviewRow>> {
let shop_id = auth.require_shop()?;
Ok(Json(service::reply(&state, shop_id, id, body).await?))
}
async fn admin_list(
State(state): State<AppState>,
auth: AuthUser,
Query(q): Query<PageQuery>,
) -> ApiResult<Json<Paged<ReviewRow>>> {
auth.require_admin()?;
Ok(Json(service::list_all(&state, q.page, q.per_page).await?))
}
async fn admin_hide(State(state): State<AppState>, auth: AuthUser, Path(id): Path<Uuid>) -> ApiResult<Json<ReviewRow>> {
auth.require_admin()?;
Ok(Json(service::hide(&state, id).await?))
}
async fn admin_delete(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<StatusCode> {
auth.require_admin()?;
service::remove(&state, id).await?;
Ok(StatusCode::NO_CONTENT)
}
+4
View File
@@ -0,0 +1,4 @@
pub mod handlers;
pub mod service;
pub use handlers::router;
+321
View File
@@ -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<Value>,
pub reply_at: Option<DateTime<Utc>>,
pub status: String,
pub created_at: DateTime<Utc>,
/// 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<i32, i64>,
}
#[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<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct ReviewBody {
pub order_item_id: Uuid,
pub rating: i32,
pub content: Value,
pub images: Option<Vec<String>>,
}
#[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<ReviewRow> {
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<i64>,
per_page: Option<i64>,
) -> ApiResult<Paged<ReviewRow>> {
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<ReviewSummary> {
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<ReviewRow> {
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<Vec<ReviewableItem>> {
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<i64>,
per_page: Option<i64>,
) -> ApiResult<Paged<ReviewRow>> {
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<ReviewRow> {
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<i64>,
per_page: Option<i64>,
) -> ApiResult<Paged<ReviewRow>> {
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<ReviewRow> {
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(())
}