0

[Series Thực Chiến E-commerce] Bài 19: Khách hàng "cào phím" - Xây dựng tính năng Đánh giá & Bình luận (Rating/Comment)

Chào anh em!

Sau màn "hack não" với các thuật toán lọc, sắp xếp và phân trang ở Bài 18, khách hàng của chúng ta cuối cùng cũng tìm được món đồ ưng ý. Thường thì mua xong, tâm lý chung là phải quay lại trang sản phẩm để "khen nức nở" hoặc "bóc phốt" xem hàng có chuẩn không đúng không?

Hôm nay, chúng ta sẽ xây dựng tính năng Đánh giá và Bình luận (Rating).

Logic của tính năng này có một điểm rất "khoai" mà anh em cần lưu ý: Mỗi người dùng chỉ được đánh giá 1 sản phẩm đúng 1 lần. Nếu họ đã đánh giá 5 sao, hôm sau xài thấy lỗi quay lại đánh giá 1 sao, thì hệ thống phải cập nhật cái đánh giá cũ, chứ không phải đẻ ra thêm một cái đánh giá mới.

Cùng xem đoạn code xử lý pha "quay xe" này của khách hàng nhé!

1. Phẫu thuật não bộ (Controller)

Anh em mở file controllers/product.js ra và nhét cái hàm ratings này vào:

const mongoose = require('mongoose'); // Nhớ import mongoose vào đầu file nhé

const ratings = asyncHandler(async (req, res) => {
  // 1. Nhận diện người đánh giá (Lấy _id từ token)
  const { _id } = req.user;  
  
  // 2. Lấy nội dung đánh giá từ Frontend gửi lên
  const { star, comment, pid } = req.body;

  // Kiểm tra đầu vào cơ bản
  if (!star || !pid) throw new Error('Missing inputs - Thiếu số sao hoặc ID sản phẩm');

  // 💡 ĐIỂM SÁNG PHÒNG THỦ: Kiểm tra pid có phải định dạng ObjectId hợp lệ không
  // Rất nhiều trường hợp Frontend gửi lên 1 chuỗi linh tinh làm app bị crash khi query
  if (!mongoose.Types.ObjectId.isValid(pid)) {
    return res.status(400).json({ success: false, message: 'Invalid product ID' });
  }

  // Chuyển đổi an toàn sang dạng ObjectId để so sánh chuẩn xác
  const userId = mongoose.Types.ObjectId(_id);  
  const productId = mongoose.Types.ObjectId(pid); 

  // Lấy sản phẩm lên để thao tác
  const ratingProduct = await Product.findById(productId);
  if (!ratingProduct) {
    return res.status(404).json({ success: false, message: 'Product not found' });
  }

  // 3. LOGIC CỐT LÕI: Kiểm tra xem user này đã từng rate sản phẩm này chưa?
  // Tìm trong mảng ratings xem có cái object nào mà postedBy trùng với ID của thằng đang gửi request không
  const alreadyRatingIndex = ratingProduct.ratings.findIndex(
    el => el.postedBy.toString() === userId.toString()  
  );

  if (alreadyRatingIndex !== -1) {
    // TRƯỜNG HỢP 1: Đã đánh giá rồi -> CẬP NHẬT LẠI
    // Tìm thấy vị trí (index) rồi thì cứ chui thẳng vào đó mà gán lại giá trị mới
    ratingProduct.ratings[alreadyRatingIndex].star = star;
    ratingProduct.ratings[alreadyRatingIndex].comment = comment;

    await ratingProduct.save(); 
  } else {
    // TRƯỜNG HỢP 2: Lần đầu đánh giá -> THÊM MỚI
    // Dùng push() để nhét 1 object mới toanh vào cuối mảng ratings
    ratingProduct.ratings.push({ star, comment, postedBy: userId });

    await ratingProduct.save(); 
  }

  return res.status(200).json({
    status: true,
    message: 'Rating successfully added or updated - Ghi nhận đánh giá thành công!'
  });
});

module.exports = {
  // ... các hàm cũ
  ratings,
};

Kinh nghiệm xương máu ở đoạn này: Việc anh em dùng findIndex kết hợp với .save() là một kỹ thuật thao tác với Sub-document (tài liệu con) rất an toàn và trực quan trong Mongoose. Khác với dùng các operator phức tạp như $elemMatch hay $set trong câu lệnh update, việc bốc mảng lên, tìm index, gán lại giá trị bằng Javascript thuần rồi save() giúp code dễ đọc, dễ bảo trì và dễ debug hơn rất nhiều.

2. Dựng chốt chặn ở Router

Đánh giá sản phẩm là đặc quyền của người dùng đã mua hàng (hoặc chí ít là đã tạo tài khoản). Mấy "anh hùng bàn phím" ẩn danh (chưa đăng nhập) thì kiên quyết không cho cào phím nhé!

Anh em mở file routers/product.js và thêm tuyến đường này:

const express = require('express');
const router = express.Router();
const ctrls = require('../controllers/product');
const { verifyAccessToken, isAdmin } = require('../middlewares/verifyToken');

// ... các route cũ

// Route gửi đánh giá: CHỈ CẦN ĐĂNG NHẬP LÀ ĐƯỢC (không cần Admin)
router.put('/ratings', [verifyAccessToken], ctrls.ratings);

module.exports = router;

Cách test trên Postman:

Đăng nhập lấy thẻ accessToken.

Tạo request PUT vào http://localhost:5000/api/product/ratings, dán token vào.

Trong tab Body (JSON), gửi lên:

{
  "pid": "điền-id-sản-phẩm-vào-đây",
  "star": 5,
  "comment": "Máy đẹp, giao hàng nhanh, shipper dth 10 điểm không có nhưng!"
}

Bấm Send. Sau đó gọi API Get Product ra xem, anh em sẽ thấy trong mảng ratings của sản phẩm đã chễm chệ cái comment vừa rồi.

Cảnh báo: Bom nổ chậm!

Code chạy ngon rồi, nhưng anh em nhìn lại cái models/product.js hồi trước xem. Sản phẩm của chúng ta có một trường cực kỳ quan trọng là totalRatings (Tổng số sao trung bình của sản phẩm).

Đoạn code Bài 19 hôm nay mới chỉ ném comment vào mảng ratings, chứ chưa hề cập nhật lại cái số điểm trung bình này! Chẳng lẽ bắt Frontend lúc render danh sách sản phẩm phải tải cả cục mảng 1000 comments về, rồi tự dùng vòng lặp cộng trừ nhân chia ra điểm trung bình? Mấy ông Frontend vác dao sang chém anh em Backend ngay! Việc tính toán này BẮT BUỘC phải nằm ở Server.

Cách xử lý cái trường totalRatings sao cho mượt mà và tự động nhất chính là nội dung của bài tiếp theo: Lession 20: Total Ratings.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí