0

# Bài 08 — API đơn hàng

⬅️ Bài trước | Mục lục | Bài tiếp theo ➡️


🎯 Mục tiêu

  • Thiết kế cấu trúc document đơn hàng
  • Viết POST /orders (tạo đơn) và GET /orders (danh sách đơn)
  • Hiểu vì sao đơn hàng phải sao chép thông tin sản phẩm
  • Nhận diện và loại bỏ code copy-paste vô nghĩa trong source gốc

Đây là bài cuối cùng của phần backend. Sau bài này bạn có API hoàn chỉnh.


📚 1. Thiết kế document đơn hàng

Đây là cấu trúc mà frontend sẽ gửi lên (xem cart/page.js ở bài 16):

{
  "_id": ObjectId("66c0a1b2c3d4e5f6a7b8c9d0"),
  "user": {
    "fullname": "Nguyễn Văn A",
    "phone": "0901234567",
    "address": "123 Lê Lợi, Quận 1, TP.HCM"
  },
  "detail": [
    {
      "_id": "6675905ecb0dded448a58bb0",
      "name": "Gucci Flora Gorgeous Magnolia",
      "image": "sp1.jpg",
      "price": 5050000,
      "rating": 4.5,
      "categoryId": "669a978e6e282b058a8e3f2c",
      "size": "s",
      "quantity": 2
    }
  ],
  "total_money": 10100000
}

Vì sao lồng user vào đơn hàng?

Dự án chưa có đăng nhập, nên không có userId để tham chiếu. Thông tin giao hàng được nhập trực tiếp ở modal đặt hàng và lưu luôn vào đơn.

Ngay cả khi có đăng nhập (bài 19), đây vẫn là cách làm đúng: địa chỉ giao hàng của đơn này phải giữ nguyên kể cả sau khi khách đổi địa chỉ trong hồ sơ.

Vì sao detail chứa bản sao sản phẩm?

Đây là nguyên tắc quan trọng trong thiết kế hệ thống bán hàng.

Cách làm Vấn đề
Chỉ lưu productId + quantity Nếu sau này đổi giá / đổi tên / xóa sản phẩm → hóa đơn cũ hiển thị sai hoặc lỗi
Lưu bản sao (snapshot) Hóa đơn "đóng băng" đúng thông tin tại thời điểm mua

Bạn mua áo giá 200k hôm nay. Tuần sau shop giảm còn 150k. Hóa đơn của bạn vẫn phải ghi 200k — đó là số tiền bạn đã trả.

💡 Trong thực tế người ta lưu cả hai: productId (để truy vết) và bản sao các trường quan trọng (name, price, image) tại thời điểm mua. Dự án này lưu nguyên object sản phẩm nên có sẵn cả hai.

Vì sao vẫn lưu total_money dù có thể tính lại?

Đây gọi là denormalization (phi chuẩn hóa) có chủ ý:

  • Hiển thị danh sách đơn hàng không cần duyệt qua detail để cộng
  • Thống kê doanh thu chỉ cần $sum: "$total_money"

Đánh đổi: nếu code frontend tính sai, số sai được lưu vĩnh viễn. → Backend nên tính lại và không tin số client gửi (mục 4 sẽ làm điều này).


⚠️ 2. Vấn đề của routes/oder.js trong source gốc

Mở file gốc, bạn sẽ thấy 5 route:

router.get("/", ...)                    // ✅ Hợp lý — danh sách đơn hàng
router.get("/id/:id", ...)              // ⚠️ Copy từ products, có bug
router.get("/byCategory/:id", ...)      // ❌ Vô nghĩa — đơn hàng làm gì có categoryId
router.get("/topRating", ...)           // ❌ Vô nghĩa — đơn hàng làm gì có rating
router.post("/", ...)                   // ✅ Hợp lý — tạo đơn hàng

Ba route giữa là copy nguyên từ products.js, chỉ đổi db.collection("products") thành db.collection("orders"). Người viết quên xóa đi.

Xem route /id/:id của orders:

router.get("/id/:id", async (req, res, next) => {
  const db = await connectDb();
  const productCollection = db.collection("orders");
  const products = await productCollection.findOne({ _id: new ObjectId(req.params.id) });

  const categoriesCollection = db.collection("categories");
  const categories = await categoriesCollection.findOne({
    _id: new ObjectId(products.categoryId),   // ← orders KHÔNG có categoryId!
  });
  ...
});

products.categoryIdundefinednew ObjectId(undefined)ném lỗi ngay lập tức. Route này luôn luôn crash.

Cả tên biến cũng sai: biến chứa đơn hàng lại đặt tên products, productCollection. Đây là dấu hiệu điển hình của copy-paste không đọc lại.

Bài học: copy-paste không sai, nhưng phải đọc lại và sửa cho phù hợp.


💻 3. Code bám sát source gốc (đã lược bỏ phần vô nghĩa)

Tạo file backend/routes/oder.js:

var express = require("express");
var router = express.Router();

// Import model
const connectDb = require("../model/db");
const { ObjectId } = require("mongodb");

// ============================================
// GET /orders — Lấy tất cả đơn hàng
// ============================================
router.get("/", async (req, res, next) => {
  const db = await connectDb();
  const orderCollection = db.collection("orders");
  const orders = await orderCollection.find().toArray();

  if (orders) {
    res.status(200).json(orders);
  } else {
    res.status(404).json({ message: "Không tìm thấy" });
  }
});

// ============================================
// POST /orders — Tạo đơn hàng mới
// ============================================
router.post("/", async (req, res, next) => {
  const db = await connectDb();
  const orderCollection = db.collection("orders");
  const data = req.body;
  const result = await orderCollection.insertOne(data);

  if (result.insertedId) {
    res.status(200).json(result);
  } else {
    res.status(404).json({ message: "Không tìm thấy" });
  }
});

module.exports = router;

Gắn vào backend/app.js:

var orderRouter = require('./routes/oder');
app.use('/orders', orderRouter);

🔍 Điểm cần chú ý

const data = req.body;
const result = await orderCollection.insertOne(data);

Backend nhận gì lưu nấy, không kiểm tra gì cả. Ngắn gọn nhưng cực kỳ nguy hiểm — mục 4 sẽ giải thích.

res.status(200).json(result);

result từ insertOne có dạng:

{ "acknowledged": true, "insertedId": "66c0a1b2c3d4e5f6a7b8c9d0" }

Frontend không thực sự dùng dữ liệu này — nó chỉ cần biết request thành công.


⚠️ 4. Vì sao "nhận gì lưu nấy" là lỗi nghiêm trọng

Với code trên, ai cũng có thể gửi:

curl -X POST http://localhost:5000/orders \
  -H "Content-Type: application/json" \
  -d '{"total_money": 1, "detail": [], "hack": "được luôn"}'

Và backend sẽ vui vẻ lưu vào database:

Rủi ro Hậu quả
Không kiểm tra total_money Khách sửa DevTools, đặt đơn 10 triệu với giá 1đ
Không kiểm tra detail Đơn hàng rỗng vẫn được tạo
Không kiểm tra user Không có tên/SĐT → không giao hàng được
Không giới hạn field Ai cũng chèn field lạ vào DB, làm bẩn dữ liệu
Không có trạng thái đơn Không biết đơn nào đã xử lý
Không có thời gian tạo Không thống kê được theo ngày/tháng

🔒 Nguyên tắc vàng: Never trust the client. Mọi dữ liệu từ client đều phải được backend kiểm tra lại. Giá tiền phải được backend tra từ database, không lấy từ client.


💻 5. Phiên bản hoàn chỉnh (khuyến nghị)

// backend/routes/oder.js — PHIÊN BẢN CẢI TIẾN
var express = require("express");
var router = express.Router();

const connectDb = require("../model/db");
const { ObjectId } = require("mongodb");

// Phụ phí theo size (nên trùng với frontend)
const SIZE_FEE = { s: 0, m: 15000, l: 10000 };

// ============================================
// GET /orders — danh sách đơn, mới nhất trước
// ============================================
router.get("/", async (req, res, next) => {
  try {
    const db = await connectDb();
    const orders = await db.collection("orders")
      .find()
      .sort({ createdAt: -1 })
      .toArray();
    res.status(200).json(orders);
  } catch (error) {
    next(error);
  }
});

// ============================================
// GET /orders/:id — chi tiết 1 đơn
// ============================================
router.get("/:id", async (req, res, next) => {
  try {
    if (!ObjectId.isValid(req.params.id)) {
      return res.status(400).json({ message: "ID không hợp lệ" });
    }

    const db = await connectDb();
    const order = await db.collection("orders")
      .findOne({ _id: new ObjectId(req.params.id) });

    if (!order) {
      return res.status(404).json({ message: "Không tìm thấy đơn hàng" });
    }

    res.status(200).json(order);
  } catch (error) {
    next(error);
  }
});

// ============================================
// POST /orders — tạo đơn hàng
// ============================================
router.post("/", async (req, res, next) => {
  try {
    const { user, detail } = req.body;

    // --- 1. Validate thông tin người nhận ---
    if (!user || !user.fullname || !user.fullname.trim()) {
      return res.status(400).json({ message: "Vui lòng nhập họ tên" });
    }
    if (!user.phone || !/^0\d{9,10}$/.test(user.phone.trim())) {
      return res.status(400).json({ message: "Số điện thoại không hợp lệ" });
    }
    if (!user.address || !user.address.trim()) {
      return res.status(400).json({ message: "Vui lòng nhập địa chỉ" });
    }

    // --- 2. Validate giỏ hàng ---
    if (!Array.isArray(detail) || detail.length === 0) {
      return res.status(400).json({ message: "Giỏ hàng đang trống" });
    }

    const db = await connectDb();

    // --- 3. Tra giá THẬT từ database, KHÔNG tin giá từ client ---
    const productIds = detail.map((item) => new ObjectId(item._id));
    const dbProducts = await db.collection("products")
      .find({ _id: { $in: productIds } })
      .toArray();

    let totalMoney = 0;
    const verifiedDetail = [];

    for (const item of detail) {
      const dbProduct = dbProducts.find((p) => p._id.toString() === item._id.toString());

      if (!dbProduct) {
        return res.status(400).json({
          message: `Sản phẩm "${item.name}" không còn tồn tại`,
        });
      }

      const quantity = Math.max(1, parseInt(item.quantity, 10) || 1);
      const size = ["s", "m", "l"].includes(item.size) ? item.size : "s";
      const lineTotal = (dbProduct.price + (SIZE_FEE[size] || 0)) * quantity;

      totalMoney += lineTotal;

      verifiedDetail.push({
        productId: dbProduct._id,
        name: dbProduct.name,          // tên từ DB, không phải từ client
        image: dbProduct.image,
        price: dbProduct.price,        // giá từ DB
        size,
        quantity,
        lineTotal,
      });
    }

    // --- 4. Tạo đơn ---
    const newOrder = {
      user: {
        fullname: user.fullname.trim(),
        phone: user.phone.trim(),
        address: user.address.trim(),
      },
      detail: verifiedDetail,
      total_money: totalMoney,          // tính bởi SERVER
      status: "pending",                // pending | confirmed | shipping | done | cancelled
      createdAt: new Date(),
    };

    const result = await db.collection("orders").insertOne(newOrder);

    res.status(201).json({
      message: "Đặt hàng thành công",
      orderId: result.insertedId,
      total_money: totalMoney,
    });
  } catch (error) {
    next(error);
  }
});

// ============================================
// PATCH /orders/:id/status — đổi trạng thái (dùng cho admin, bài 20)
// ============================================
router.patch("/:id/status", async (req, res, next) => {
  try {
    const { status } = req.body;
    const allowed = ["pending", "confirmed", "shipping", "done", "cancelled"];

    if (!allowed.includes(status)) {
      return res.status(400).json({ message: "Trạng thái không hợp lệ" });
    }
    if (!ObjectId.isValid(req.params.id)) {
      return res.status(400).json({ message: "ID không hợp lệ" });
    }

    const db = await connectDb();
    const result = await db.collection("orders").updateOne(
      { _id: new ObjectId(req.params.id) },
      { $set: { status, updatedAt: new Date() } }
    );

    if (result.matchedCount === 0) {
      return res.status(404).json({ message: "Không tìm thấy đơn hàng" });
    }

    res.status(200).json({ message: "Cập nhật thành công" });
  } catch (error) {
    next(error);
  }
});

module.exports = router;

🔍 Kỹ thuật mới trong bản này

Toán tử $in — lấy nhiều document trong 1 truy vấn

const dbProducts = await db.collection("products")
  .find({ _id: { $in: productIds } })
  .toArray();

Thay vì gọi findOne() cho từng sản phẩm trong vòng lặp (n truy vấn), $in lấy tất cả trong 1 truy vấn. Đây là cách tránh vấn đề "N+1 query".

Regex kiểm tra số điện thoại

/^0\d{9,10}$/.test(user.phone.trim())
  • ^0 — bắt đầu bằng số 0
  • \d{9,10} — theo sau là 9 đến 10 chữ số
  • $ — kết thúc

Khớp 0901234567 (10 số) và 09012345678 (11 số). Không khớp abc, 123.

Tính lại tổng tiền ở server

Đây là thay đổi quan trọng nhất. Frontend gửi total_money gì cũng bị bỏ qua — server tự tra giá từ DB và tính lại. Không ai gian lận được.

Trường status

Đơn hàng cần vòng đời. Bài 20 sẽ làm trang admin cho phép đổi trạng thái.

⚠️ Lưu ý về tính nhất quán: hằng SIZE_FEE ở backend phải khớp với công thức frontend đang dùng, nếu không số tiền hiển thị ở giỏ hàng sẽ khác số tiền server tính. Source gốc có logic size không nhất quán — bài 16 sẽ thống nhất lại.


✅ 6. Kiểm thử

Test 1 — Tạo đơn hàng hợp lệ

Postman → POST http://localhost:5000/orders → Body → rawJSON:

{
  "user": {
    "fullname": "Nguyễn Văn A",
    "phone": "0901234567",
    "address": "123 Lê Lợi, Quận 1, TP.HCM"
  },
  "detail": [
    {
      "_id": "6675905ecb0dded448a58bb0",
      "name": "Gucci Flora Gorgeous Magnolia",
      "price": 5050000,
      "size": "s",
      "quantity": 2
    }
  ],
  "total_money": 10100000
}

⚠️ Nhớ đổi _id thành ID sản phẩm thật trong database của bạn.

Kết quả mong đợi: 201 + { "message": "Đặt hàng thành công", "orderId": "...", "total_money": 10100000 }

Test 2 — Thử gian lận giá

Gửi lại request trên nhưng đổi "price": 1"total_money": 2.

Kết quả: server vẫn trả về total_money: 10100000giá thật từ database. Nếu bạn dùng bản gốc (không validate), server sẽ lưu đúng số 2 mà khách gửi. 🚨

Test 3 — Các trường hợp lỗi

Gửi gì Kết quả mong đợi
"fullname": "" 400 "Vui lòng nhập họ tên"
"phone": "abc" 400 "Số điện thoại không hợp lệ"
"detail": [] 400 "Giỏ hàng đang trống"
_id sản phẩm không tồn tại 400 "Sản phẩm ... không còn tồn tại"

Test 4 — Xem danh sách

GET http://localhost:5000/orders → mảng đơn hàng, mới nhất trước.

Test 5 — Đổi trạng thái

PATCH http://localhost:5000/orders/<orderId>/status với body:

{ "status": "confirmed" }

✅ 7. Tổng kết phần Backend

Bạn đã hoàn thành backend! Kiểm tra lại bằng checklist:

  • [ ] npm run dev chạy được, không lỗi
  • [ ] GET /categories → 10 danh mục
  • [ ] GET /products → 12 sản phẩm, mỗi cái có field category
  • [ ] GET /products/id/:id → 1 sản phẩm + danh mục
  • [ ] GET /products/byCategory/:id → mảng không rỗng
  • [ ] GET /products/topRating → mảng sắp theo rating giảm dần
  • [ ] POST /products (form-data + file) → thêm được sản phẩm
  • [ ] GET /img/sp1.jpg → hiển thị ảnh
  • [ ] POST /orders → tạo được đơn hàng
  • [ ] GET /orders → xem được danh sách đơn

Cấu trúc backend hoàn chỉnh

backend/
├── app.js                   # Cấu hình Express, middleware, gắn router
├── package.json
├── bin/
│   └── www                  # Khởi động server ở port 5000
├── model/
│   └── db.js                # Kết nối MongoDB (có cache)
├── routes/
│   ├── categories.js        # GET /categories, GET /categories/:id
│   ├── products.js          # 4 route GET + 1 route POST (upload)
│   └── oder.js              # GET, POST, PATCH /orders
├── public/
│   └── img/                 # Ảnh sản phẩm — phục vụ tĩnh
└── views/
    └── error.ejs            # Trang lỗi

📝 Bài tập

  1. Thống kê doanh thu: viết GET /orders/stats trả về:

    { "totalOrders": 5, "totalRevenue": 25000000, "averageOrder": 5000000 }
    

    Gợi ý: aggregate với $group: { _id: null, total: { $sum: "$total_money" } }

  2. Lọc theo trạng thái: GET /orders?status=pending

  3. Tra cứu đơn theo SĐT: GET /orders/phone/:phone — cho khách tự xem đơn của mình.

  4. Thêm mã đơn hàng dễ đọc: ngoài _id, thêm field orderCode dạng LP240816001 (tiền tố + ngày + số thứ tự trong ngày).

  5. Tìm bug: với bản gốc, gọi GET /orders/id/<id-bất-kỳ>. Chuyện gì xảy ra? Vì sao? (Gợi ý: đọc lại mục 2)


⬅️ Bài trước | Mục lục | Bài tiếp theo: React căn bản ➡️


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í