# 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.categoryId là undefined → new 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 → raw → JSON:
{
"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
_idthà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 và "total_money": 2.
Kết quả: server vẫn trả về total_money: 10100000 — giá 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 devchạy được, không lỗi - [ ]
GET /categories→ 10 danh mục - [ ]
GET /products→ 12 sản phẩm, mỗi cái có fieldcategory - [ ]
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
-
Thống kê doanh thu: viết
GET /orders/statstrả về:{ "totalOrders": 5, "totalRevenue": 25000000, "averageOrder": 5000000 }Gợi ý:
aggregatevới$group: { _id: null, total: { $sum: "$total_money" } } -
Lọc theo trạng thái:
GET /orders?status=pending -
Tra cứu đơn theo SĐT:
GET /orders/phone/:phone— cho khách tự xem đơn của mình. -
Thêm mã đơn hàng dễ đọc: ngoài
_id, thêm fieldorderCodedạngLP240816001(tiền tố + ngày + số thứ tự trong ngày). -
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)
All rights reserved