Ứng dụng đặt tour du lịch
Nền tảng web và mobile cho đặt tour, khách sạn, vé máy bay và thuê xe, tích hợp thanh toán MoMo.
- React Native
- ReactJS
- Node.js
- MongoDB
- MoMo
Một nền tảng du lịch hoàn chỉnh gồm web app và ứng dụng di động, cho phép người dùng tìm kiếm và đặt tour, khách sạn, vé máy bay, thuê xe, đồng thời chia sẻ trải nghiệm chuyến đi của mình.
Vấn đề cần giải quyết#
Phần lớn ứng dụng đặt tour đều tách rời từng nghiệp vụ: đặt tour một nơi, đặt khách
sạn một nơi khác. Mục tiêu của tôi là gom chúng về một flow đặt chỗ duy nhất,
trong đó mỗi loại dịch vụ chỉ là một biến thể của cùng một model Booking.
Kiến trúc hệ thống#
Hệ thống chia làm ba phần chạy độc lập nhưng dùng chung tầng dữ liệu:
| Thành phần | Công nghệ | Role |
|---|---|---|
| Web | ReactJS + Vite | Giao diện đặt chỗ, quản lý đơn |
| Mobile | React Native | Trải nghiệm trên điện thoại, dùng chung API |
| Backend | Node.js + MongoDB | REST API, xác thực, xử lý thanh toán |
Mô hình dữ liệu dùng chung#
Thay vì tạo bốn collection riêng cho tour, khách sạn, vé bay và thuê xe, tôi dùng
một collection bookings với trường type phân biệt, còn dữ liệu riêng nằm trong
payload:
type BookingType = 'tour' | 'hotel' | 'flight' | 'car';
interface Booking<T extends BookingType = BookingType> {
_id: ObjectId;
userId: ObjectId;
type: T;
status: 'pending' | 'paid' | 'cancelled';
total: number;
/* Dữ liệu đặc thù của từng loại dịch vụ. */
payload: BookingPayloadMap[T];
createdAt: Date;
}Nhờ vậy, màn hình "Đơn của tôi" chỉ cần một query thay vì bốn, và việc thêm loại dịch vụ mới không phải sửa lại tầng UI.
Tích hợp thanh toán MoMo#
Phần khó nhất là xử lý webhook: MoMo có thể gửi callback nhiều lần cho cùng một giao dịch, và không đảm bảo thứ tự so với lúc người dùng được redirect về.
router.post('/momo/ipn', async (req, res) => {
const { orderId, resultCode, signature } = req.body;
if (!verifySignature(req.body, signature)) return res.status(400).end();
/* Idempotent: callback trùng lặp sẽ không ghi đè trạng thái đã có. */
const booking = await Booking.findOneAndUpdate(
{ _id: orderId, status: 'pending' },
{ status: resultCode === 0 ? 'paid' : 'cancelled' },
{ new: true },
);
/* Luôn trả 204 để MoMo ngừng retry, kể cả khi đơn đã xử lý trước đó. */
return res.status(204).end();
});Kết quả#
- Một flow đặt chỗ duy nhất cho bốn loại dịch vụ khác nhau
- Web và mobile dùng chung toàn bộ tầng API, không lặp lại logic nghiệp vụ
- Thanh toán idempotent, chịu được callback trùng và sai thứ tự