EzCode # Bài 21 — Tích hợp thanh toán VNPay
Bài 21 — Tích hợp thanh toán VNPay
Học xong bài này bạn sẽ:
- Hiểu luồng thanh toán qua cổng trung gian và vì sao phải có chữ ký
- Tạo chữ ký HMAC-SHA512 và xác thực callback từ VNPay
- Hoàn thiện chức năng mua khóa học và lịch sử giao dịch
1. Vấn đề đặt ra
Học viên bấm "Đăng ký ngay" trên trang chi tiết khóa học. Website phải thu tiền rồi mới cho học.
Nhưng website của bạn không được phép tự nhận tiền. Nhận thẻ ngân hàng đòi chứng chỉ bảo mật, kiểm định, và trách nhiệm pháp lý rất lớn. Không ai làm một mình.
Giải pháp là dùng cổng thanh toán trung gian như VNPay: bạn đẩy người dùng sang trang của họ, họ xử lý tiền, rồi báo kết quả về.
Điều đó tạo ra một vấn đề mới và rất thú vị:
VNPay báo kết quả về bằng cách gọi lại một địa chỉ trên website của bạn, kèm tham số trên URL. Nhưng bất kỳ ai cũng gõ được URL đó.
Kẻ xấu chỉ cần gõ ?ctrl=payment&act=callback&vnp_ResponseCode=00&vnp_TxnRef=ORDER_123 là website tưởng họ đã trả tiền.
Cách chống: chữ ký số. Bài này dạy bạn cách nó hoạt động.
2. Kiến thức mới
2.1. Luồng thanh toán bảy bước
- Người dùng bấm "Đăng ký ngay" trên trang chi tiết khóa học.
- Website tạo mã đơn hàng và lưu một dòng vào bảng
paymentsvới trạng tháipending. - Website dựng URL thanh toán chứa số tiền, mã đơn, và chữ ký tính từ chuỗi bí mật.
- Chuyển hướng người dùng sang trang VNPay.
- Người dùng trả tiền trên trang VNPay. Website của bạn hoàn toàn không thấy thông tin thẻ.
- VNPay gọi lại
returnUrlkèm kết quả và một chữ ký mới. - Website xác thực chữ ký. Đúng thì cập nhật
paymentsthànhcompletedvà ghi một dòng vàoenrollmentscho người dùng được học.
Bước 3 và bước 7 là hai nửa của cùng một cơ chế bảo mật.
2.2. Vì sao cần hai bảng payments và enrollments
Nhìn qua thì hai bảng này trùng lặp — cả hai đều ghi "ai mua khóa nào". Nhưng chúng trả lời hai câu hỏi khác nhau:
payments |
enrollments |
|
|---|---|---|
| Trả lời | Đã có những giao dịch nào | Ai được quyền học khóa nào |
| Ghi khi nào | Ngay khi bắt đầu thanh toán | Chỉ khi thanh toán thành công |
| Có trạng thái | pending, completed, failed, cancelled |
Không |
| Một người mua hai lần cùng khóa | Hai dòng | Không thể — khóa chính ghép |
Một giao dịch thất bại để lại dòng payments với status = 'failed' nhưng không sinh dòng enrollments nào. Nhờ vậy bạn vừa có lịch sử đầy đủ, vừa có danh sách quyền học chính xác.
2.3. Chữ ký HMAC — cơ chế chống giả mạo
Vấn đề: làm sao biết dữ liệu trên URL đúng là của VNPay gửi, không phải ai đó tự gõ?
Ý tưởng: hai bên cùng biết một chuỗi bí mật mà không ai khác biết. Bên gửi dùng nó tính ra một mã kiểm tra từ nội dung; bên nhận tính lại và so sánh.
$chuKy = hash_hmac('sha512', $chuoiDuLieu, $chuoiBiMat);
Ba tính chất khiến nó hoạt động:
- Đổi một ký tự trong dữ liệu là chữ ký đổi hoàn toàn. Kẻ xấu không thể sửa số tiền mà giữ chữ ký cũ.
- Không có chuỗi bí mật thì không tính được chữ ký. Kẻ xấu biết cả thuật toán lẫn dữ liệu vẫn chịu.
- Không thể suy ngược ra chuỗi bí mật từ chữ ký.
sha512 là thuật toán băm cho ra chuỗi 128 ký tự thập lục phân.
Khác với password_hash() ở Bài 14 thế nào?
password_hash() |
hash_hmac() |
|
|---|---|---|
| Mục đích | Lưu mật khẩu an toàn | Xác thực dữ liệu |
| Có muối ngẫu nhiên | Có | Không |
| Cùng đầu vào cho cùng đầu ra | Không | Có |
| Cần chuỗi bí mật | Không | Có |
| Tốc độ | Cố ý chậm | Nhanh |
Điểm thứ ba là mấu chốt: HMAC phải cho cùng kết quả với cùng đầu vào, nếu không hai bên không so sánh được.
2.4. ksort() — sắp xếp để hai bên tính giống nhau
ksort($inputData);
Đây là chi tiết dễ bỏ qua nhưng bắt buộc.
Chữ ký được tính từ một chuỗi ghép các tham số lại. Nếu bạn ghép theo thứ tự A, B, C mà VNPay ghép theo C, A, B, hai chuỗi khác nhau và hai chữ ký khác nhau — dù dữ liệu giống hệt.
ksort() sắp xếp mảng theo khóa theo bảng chữ cái. Cả hai bên cùng làm vậy thì luôn ra cùng một thứ tự.
Kết quả sau ksort:
vnp_Amount, vnp_Command, vnp_CreateDate, vnp_CurrCode, vnp_IpAddr,
vnp_Locale, vnp_OrderInfo, vnp_OrderType, vnp_ReturnUrl, vnp_TmnCode,
vnp_TxnRef, vnp_Version
2.5. urlencode() — mã hóa ký tự đặc biệt
$hashdata .= urlencode($key) . "=" . urlencode($value);
Tên khóa học có dấu cách và dấu tiếng Việt. URL không chứa được những ký tự đó trực tiếp.
urlencode() đổi chúng thành mã an toàn:
| Ký tự gốc | Sau khi mã hóa |
|---|---|
| dấu cách | + |
& |
%26 |
ó |
%C3%B3 |
Cả hai bên phải mã hóa giống nhau trước khi tính chữ ký, nếu không kết quả lệch.
2.6. Nhân 100 số tiền
$vnp_Amount = $amount * 100;
VNPay tính bằng đơn vị nhỏ nhất của tiền tệ. Với đồng Việt Nam, quy ước của họ là nhân 100.
Khóa học 300.000đ gửi đi thành 30000000.
Quên nhân 100 là khách hàng chỉ trả 3.000đ. Đây là lỗi rất đắt và rất hay gặp khi tích hợp cổng thanh toán. Luôn đọc kỹ tài liệu về đơn vị tiền.
2.7. Bỏ chữ ký ra trước khi kiểm tra lại
if (isset($inputData['vnp_SecureHash'])) {
$vnpSecureHash = $inputData['vnp_SecureHash'];
unset($inputData['vnp_SecureHash']);
}
Khi VNPay gọi lại, URL chứa cả dữ liệu lẫn chữ ký.
Muốn kiểm tra, bạn phải tính lại chữ ký từ dữ liệu. Nhưng nếu để chữ ký cũ nằm trong mảng dữ liệu, bạn sẽ tính chữ ký của "dữ liệu cộng chữ ký cũ" — không bao giờ khớp.
Vì vậy phải cắt nó ra, cất riêng, rồi mới tính.
Đây là mẫu chung của mọi hệ thống chữ ký: chữ ký không bao giờ là một phần của thứ được ký.
2.8. vnp_ResponseCode
VNPay trả về mã kết quả. Chỉ "00" là thành công:
| Mã | Nghĩa |
|---|---|
00 |
Thành công |
07 |
Nghi ngờ gian lận |
09 |
Thẻ chưa đăng ký dịch vụ |
24 |
Khách hàng hủy giao dịch |
51 |
Không đủ số dư |
Chú ý so sánh bằng chuỗi "00" chứ không phải số 0. Mã "00" và "0" là hai chuỗi khác nhau, và một số mã lỗi có số 0 đứng đầu.
2.9. Kiểm tra quyền sở hữu đơn hàng
$payment = $vnpayModel->getPaymentByOrderId($orderId);
if (!$payment || $payment['user_id'] != $userId) {
$_SESSION['error'] = "Không có quyền hủy thanh toán này!";
...
}
Cùng nguyên tắc với Bài 18 mục 2.9: mã đơn hàng nằm trên URL, nên người dùng A phải bị chặn khỏi việc hủy đơn của người dùng B.
3. Áp dụng vào EzCode
Bài này tạo 4 file và sửa 2 file.
3.1. Thêm hai phương thức ghi danh vào Model User
📁
Models/User.php— sửa
function checkEnrollment($user_id, $course_id)
{
$sql = "SELECT * FROM enrollments WHERE user_id = ? AND course_id = ?";
$result = $this->db->queryOne($sql, $user_id, $course_id);
return $result ? true : false;
}
function enrollCourse($user_id, $course_id, $price)
{
try {
$sql = "INSERT INTO enrollments (user_id, course_id, price, enrolled_at) VALUES (?, ?, ?, NOW())";
$result = $this->db->insert($sql, $user_id, $course_id, $price);
return $result ? true : false;
} catch (Exception $e) {
return false;
}
}
Model User giờ đã hoàn chỉnh với 10 phương thức.
checkEnrollment() dùng $result ? true : false để đổi mảng hoặc false thành đúng kiểu luận lý. Có thể viết gọn return (bool)$result;, nhưng cách này rõ nghĩa hơn cho người mới.
enrollCourse() không truyền cột role. Câu INSERT chỉ có 4 cột, thiếu role. Nhờ cột đó có DEFAULT 'student' trong ezcode.sql (Bài 7) nên MySQL tự điền.
Điều này quan trọng vì mọi truy vấn thống kê ở Bài 19 và 20 đều lọc AND e.role = 'student'. May mắn là giá trị mặc định khớp.
Khối try/catch ở đây không có tác dụng. Database::insert() đã tự bắt PDOException bên trong rồi — nó echo lỗi và trả về null. Ngoại lệ không bao giờ thoát ra tới đây.
Đây là phòng thủ thừa do không nắm rõ Model của mình. Không hại gì, nhưng tạo cảm giác an toàn giả.
NOW() cũng thừa vì cột enrolled_at đã có DEFAULT current_timestamp(). Lại một sự trùng lặp vô hại.
3.2. Tạo Model Enrollment
📁
Models/Enrollment.php— tạo mới
<?php
include_once "./Models/Database.php";
class Enrollment
{
private $db;
function __construct()
{
$this->db = new Database();
}
public function enroll($user_id, $course_id, $role = 'student', $price = 0)
{
$sql = "INSERT INTO enrollments (`user_id`, `course_id`, `role`, `price`) VALUES (?, ?, ?, ?)";
return $this->db->insert($sql, $user_id, $course_id, $role, $price);
}
}
⚠️ Lưu ý: File này là code chết.
Class
Enrollmentđược định nghĩa đầy đủ, nhưng không một file nào trong projectincludehay khởi tạo nó. Bạn có thể tự kiểm chứng bằng cách tìm chuỗinew Enrollmenttrong toàn bộ project — không có kết quả nào.Chức năng ghi danh thật nằm ở
User::enrollCourse()mà bạn vừa viết ở mục 3.1.Đây là lỗi số 14 trong Bài 22.
Trớ trêu là
Enrollment::enroll()được viết tốt hơnUser::enrollCourse(): nó có tham sốrole, dùng dấu backtick bọc tên cột để tránh trùng từ khóa SQL, và không có khốitry/catchthừa. Nhưng nó bị bỏ quên.Bạn vẫn tạo file này để khớp với repo.
3.3. Tạo Model VNPay
Đây là file dài nhất trong thư mục Models.
📁
Models/VNPay.php— tạo mới
<?php
class VNPay {
private $db;
public function __construct() {
$this->db = new Database();
}
// Tạo URL thanh toán VNPay
public function createPaymentUrl($orderId, $amount, $orderInfo, $returnUrl) {
$vnp_Url = "https://sandbox.vnpayment.vn/paymentv2/vpcpay.html";
$vnp_Returnurl = $returnUrl;
$vnp_TmnCode = "2QXUI4J4"; // Mã website tại VNPAY
$vnp_HashSecret = "KASJDKASJDKASJDKASJDKASJDKASJDKA"; // Chuỗi bí mật
$vnp_TxnRef = $orderId; // Mã đơn hàng
$vnp_OrderInfo = $orderInfo; // Thông tin đơn hàng
$vnp_OrderType = "billpayment"; // Loại hàng hóa
$vnp_Amount = $amount * 100; // Số tiền * 100
$vnp_Locale = "vn"; // Ngôn ngữ
$vnp_IpAddr = $_SERVER['REMOTE_ADDR']; // IP khách hàng
$vnp_CreateDate = date('YmdHis'); // Ngày tạo
$inputData = array(
"vnp_Version" => "2.1.0",
"vnp_TmnCode" => $vnp_TmnCode,
"vnp_Amount" => $vnp_Amount,
"vnp_Command" => "pay",
"vnp_CreateDate" => $vnp_CreateDate,
"vnp_CurrCode" => "VND",
"vnp_IpAddr" => $vnp_IpAddr,
"vnp_Locale" => $vnp_Locale,
"vnp_OrderInfo" => $vnp_OrderInfo,
"vnp_OrderType" => $vnp_OrderType,
"vnp_ReturnUrl" => $vnp_Returnurl,
"vnp_TxnRef" => $vnp_TxnRef,
);
ksort($inputData);
$query = "";
$i = 0;
$hashdata = "";
foreach ($inputData as $key => $value) {
if ($i == 1) {
$hashdata .= '&' . urlencode($key) . "=" . urlencode($value);
} else {
$hashdata .= urlencode($key) . "=" . urlencode($value);
$i = 1;
}
$query .= urlencode($key) . "=" . urlencode($value) . '&';
}
$vnp_Url = $vnp_Url . "?" . $query;
if (isset($vnp_HashSecret)) {
$vnpSecureHash = hash_hmac('sha512', $hashdata, $vnp_HashSecret);
$vnp_Url .= 'vnp_SecureHash=' . $vnpSecureHash;
}
return $vnp_Url;
}
// Xác thực callback từ VNPay
public function verifyPayment($inputData) {
$vnp_HashSecret = "KASJDKASJDKASJDKASJDKASJDKASJDKA";
if (isset($inputData['vnp_SecureHash'])) {
$vnpSecureHash = $inputData['vnp_SecureHash'];
unset($inputData['vnp_SecureHash']);
} else {
$vnpSecureHash = '';
}
ksort($inputData);
$i = 0;
$hashData = "";
foreach ($inputData as $key => $value) {
if ($i == 1) {
$hashData = $hashData . '&' . urlencode($key) . "=" . urlencode($value);
} else {
$hashData = urlencode($key) . "=" . urlencode($value);
$i = 1;
}
}
$secureHash = hash_hmac('sha512', $hashData, $vnp_HashSecret);
return ($vnpSecureHash == $secureHash);
}
// Lưu thông tin thanh toán
public function savePayment($orderId, $amount, $courseId, $userId, $status = 'pending') {
$sql = "INSERT INTO payments (order_id, amount, course_id, user_id, status, created_at)
VALUES (?, ?, ?, ?, ?, NOW())";
return $this->db->insert($sql, $orderId, $amount, $courseId, $userId, $status);
}
// Cập nhật trạng thái thanh toán
public function updatePaymentStatus($orderId, $status, $transactionId = null) {
$sql = "UPDATE payments SET status = ?, transaction_id = ?, updated_at = NOW()
WHERE order_id = ?";
return $this->db->update($sql, $status, $transactionId, $orderId);
}
// Lấy thông tin thanh toán
public function getPaymentByOrderId($orderId) {
$sql = "SELECT * FROM payments WHERE order_id = ?";
return $this->db->queryOne($sql, $orderId);
}
// Tạo mã đơn hàng
public function generateOrderId() {
return 'ORDER_' . date('YmdHis') . '_' . rand(1000, 9999);
}
// Lấy lịch sử thanh toán
public function getPaymentHistory($userId) {
$sql = "SELECT p.*, c.title as course_title, c.image as course_image
FROM payments p
INNER JOIN courses c ON p.course_id = c.id
WHERE p.user_id = ?
ORDER BY p.created_at DESC";
return $this->db->query($sql, $userId);
}
}
?>
Ta đọc bảy phương thức.
generateOrderId() ghép ba phần: tiền tố ORDER_, thời gian tới giây, và bốn số ngẫu nhiên. Kết quả như ORDER_20250805080104_9029.
Phần ngẫu nhiên cần thiết vì hai người có thể bấm mua trong cùng một giây. Cột order_id có ràng buộc UNIQUE nên trùng là chèn thất bại.
createPaymentUrl() dựng URL theo đúng năm bước ở mục 2.4 tới 2.6. Vòng lặp xây hai chuỗi song song:
$hashdata— chuỗi để tính chữ ký, các cặp nối bằng&$query— chuỗi cho URL, mỗi cặp kết thúc bằng&
Biến $i là một mẹo để biết đây có phải cặp đầu tiên không: cặp đầu không có & phía trước, các cặp sau thì có.
Cách viết này khó đọc. Dùng implode() sẽ gọn hơn nhiều:
$cacCap = [];
foreach ($inputData as $key => $value) {
$cacCap[] = urlencode($key) . "=" . urlencode($value);
}
$hashdata = implode('&', $cacCap);
verifyPayment() làm ngược lại: cắt chữ ký ra, tính lại, so sánh. Đúng mẫu ở mục 2.7.
savePayment(), updatePaymentStatus(), getPaymentByOrderId() là ba thao tác CRUD thường trên bảng payments.
getPaymentHistory() dùng INNER JOIN để lấy kèm tên và ảnh khóa học.
⚠️ Lưu ý — file này có hai vấn đề nghiêm trọng.
Thứ nhất, khóa bí mật viết cứng trong mã nguồn.
$vnp_TmnCode = "2QXUI4J4"; $vnp_HashSecret = "KASJDKASJDKASJDKASJDKASJDKASJDKA";Hai giá trị này là mã mẫu trong tài liệu VNPay, không phải mã thật của một website nào. Nên thanh toán sandbox sẽ báo sai chữ ký — đó là hành vi đúng của code, không phải bạn gõ sai.
Nhưng nghiêm trọng hơn là nguyên tắc: khóa bí mật không bao giờ được nằm trong mã nguồn. Đẩy code lên GitHub là công khai khóa cho cả thế giới, và ai có khóa thì tự tạo được giao dịch giả.
Chuỗi bí mật cũng bị lặp lại ở hai phương thức. Đổi khóa mà quên một chỗ là hệ thống hỏng.
Đây là lỗi số 12 trong Bài 22.
Thứ hai, file này dùng
new Database()nhưng KHÔNGinclude_once "./Models/Database.php".So sánh với
Models/Category.phpở Bài 6 hayModels/Courses.phpở Bài 10 — cả hai đều có dòng include ở đầu. File này thì không.Nó chạy được hoàn toàn nhờ may mắn:
PaymentControllertình cờ includeModels/Courses.phptrước, mà file đó đã includeDatabase.phprồi.Đổi thứ tự hai dòng include trong Controller là cả chức năng thanh toán chết với lỗi
Class "Database" not found.Đây là lỗi số 15 trong Bài 22 — một phụ thuộc ngầm, loại lỗi nguy hiểm vì nó ẩn cho tới khi ai đó vô tình động vào.
3.4. Tạo PaymentController
📁
Controllers/PaymentController.php— tạo mới
<?php
class PaymentController {
public function __construct() {
if (!isset($_SESSION['user_id'])) {
header("Location: ?ctrl=user&act=login");
exit();
}
}
// Tạo thanh toán cho khóa học
public function create($courseId) {
include_once "./Models/Courses.php";
include_once "./Models/VNPay.php";
include_once "./Models/User.php";
$coursesModel = new Courses();
$vnpayModel = new VNPay();
$userModel = new User();
// Lấy thông tin khóa học
$course = $coursesModel->getById($courseId);
if (!$course) {
$_SESSION['error'] = "Khóa học không tồn tại!";
header("Location: ?ctrl=course&act=list");
exit();
}
// Kiểm tra đã đăng ký chưa
$userId = $_SESSION['user_id'];
$isEnrolled = $userModel->checkEnrollment($userId, $courseId);
if ($isEnrolled) {
$_SESSION['error'] = "Bạn đã đăng ký khóa học này rồi!";
header("Location: ?ctrl=course&act=detail&id=$courseId");
exit();
}
// Tạo mã đơn hàng
$orderId = $vnpayModel->generateOrderId();
$amount = $course['price'];
$orderInfo = "Thanh toán khóa học: " . $course['title'];
$returnUrl = "http://" . $_SERVER['HTTP_HOST'] . "/EZCode_khoahoc/?ctrl=payment&act=callback";
// Lưu thông tin thanh toán
$vnpayModel->savePayment($orderId, $amount, $courseId, $userId);
// Tạo URL thanh toán
$paymentUrl = $vnpayModel->createPaymentUrl($orderId, $amount, $orderInfo, $returnUrl);
// Chuyển hướng đến trang thanh toán
header("Location: " . $paymentUrl);
exit();
}
// Xử lý callback từ VNPay
public function callback() {
include_once "./Models/VNPay.php";
include_once "./Models/User.php";
$vnpayModel = new VNPay();
$userModel = new User();
// Lấy dữ liệu từ VNPay
$inputData = $_GET;
// Xác thực chữ ký
if ($vnpayModel->verifyPayment($inputData)) {
$orderId = $inputData['vnp_TxnRef'];
$responseCode = $inputData['vnp_ResponseCode'];
$transactionId = $inputData['vnp_TransactionNo'] ?? null;
// Lấy thông tin thanh toán
$payment = $vnpayModel->getPaymentByOrderId($orderId);
if ($payment) {
if ($responseCode == "00") {
// Thanh toán thành công
$vnpayModel->updatePaymentStatus($orderId, 'completed', $transactionId);
// Đăng ký khóa học cho user
$userModel->enrollCourse($payment['user_id'], $payment['course_id'], $payment['amount']);
$_SESSION['success'] = "Thanh toán thành công! Bạn đã được đăng ký khóa học.";
header("Location: ?ctrl=learn&act=detail&id=" . $payment['course_id']);
} else {
// Thanh toán thất bại
$vnpayModel->updatePaymentStatus($orderId, 'failed', $transactionId);
$_SESSION['error'] = "Thanh toán thất bại! Vui lòng thử lại.";
header("Location: ?ctrl=course&act=list");
}
} else {
$_SESSION['error'] = "Không tìm thấy thông tin thanh toán!";
header("Location: ?ctrl=course&act=list");
}
} else {
$_SESSION['error'] = "Chữ ký không hợp lệ!";
header("Location: ?ctrl=course&act=list");
}
exit();
}
// Trang lịch sử thanh toán
public function history() {
include_once "./Models/VNPay.php";
include_once "./Models/Courses.php";
$vnpayModel = new VNPay();
$coursesModel = new Courses();
$userId = $_SESSION['user_id'];
// Lấy lịch sử thanh toán
$payments = $vnpayModel->getPaymentHistory($userId);
include_once "./Views/payment_history.php";
}
// Hủy thanh toán
public function cancel($orderId) {
include_once "./Models/VNPay.php";
$vnpayModel = new VNPay();
$userId = $_SESSION['user_id'];
// Kiểm tra quyền sở hữu
$payment = $vnpayModel->getPaymentByOrderId($orderId);
if (!$payment || $payment['user_id'] != $userId) {
$_SESSION['error'] = "Không có quyền hủy thanh toán này!";
header("Location: ?ctrl=payment&act=history");
exit();
}
// Chỉ cho phép hủy thanh toán đang pending
if ($payment['status'] == 'pending') {
$vnpayModel->updatePaymentStatus($orderId, 'cancelled');
$_SESSION['success'] = "Đã hủy thanh toán thành công!";
} else {
$_SESSION['error'] = "Không thể hủy thanh toán này!";
}
header("Location: ?ctrl=payment&act=history");
exit();
}
}
?>
Constructor chặn người chưa đăng nhập — cùng kỹ thuật với TeacherController ở Bài 17, chỉ khác là không kiểm tra vai trò.
callback() có ba tầng kiểm tra lồng nhau, và thứ tự rất hợp lý:
- Chữ ký hợp lệ không? — nếu không thì dừng ngay
- Đơn hàng có tồn tại trong database không?
- Mã kết quả có phải
"00"không?
Ba tầng này chặn được ba kiểu tấn công khác nhau: giả mạo dữ liệu, bịa mã đơn hàng, và giả mạo kết quả thành công.
create() thấy khóa học đã mua thì chặn bằng checkEnrollment(). Chú ý CourseController::register() ở mục 3.5 cũng kiểm tra y hệt. Trùng lặp — nhưng ở đây là trùng lặp có ích, vì người dùng có thể gõ thẳng ?ctrl=payment&act=create&id=1 bỏ qua bước trước.
⚠️ Lưu ý: Dòng dựng
$returnUrlviết cứng đoạn đường dẫn:$returnUrl = "http://" . $_SERVER['HTTP_HOST'] . "/EZCode_khoahoc/?ctrl=payment&act=callback";Đặt project vào thư mục tên khác là VNPay gọi lại sai địa chỉ và giao dịch treo vĩnh viễn ở trạng thái
pending.Ngoài ra nó dùng
http://viết cứng — website chạy HTTPS sẽ bị trình duyệt cảnh báo nội dung hỗn hợp.Đây cũng thuộc lỗi số 12 trong Bài 22.
Một điểm nhỏ: history() tạo object $coursesModel nhưng không dùng tới. Code thừa — vì getPaymentHistory() đã tự JOIN lấy tên khóa học rồi.
3.5. Thêm register vào CourseController
📁
Controllers/CourseController.php— sửa
public function register($id){
// Kiểm tra xem user đã đăng nhập chưa
if (!isset($_SESSION['user_id'])) {
header("Location: ?ctrl=user&act=login");
exit();
}
$user_id = $_SESSION['user_id'];
include_once "./Models/Courses.php"; // Include the Courses model
include_once "./Models/User.php";
$coursesModel = new Courses(); // Create an instance of the Courses model
$userModel = new User();
$course = $coursesModel->getById($id); // Fetch course details
if (!$course) {
$_SESSION['error'] = "Khóa học không tồn tại!";
header("Location: ?ctrl=course&act=list");
exit();
}
// Kiểm tra đã đăng ký chưa
$isEnrolled = $userModel->checkEnrollment($user_id, $id);
if ($isEnrolled) {
$_SESSION['error'] = "Bạn đã đăng ký khóa học này rồi!";
header("Location: ?ctrl=course&act=detail&id=$id");
exit();
}
// Chuyển hướng đến thanh toán
header("Location: ?ctrl=payment&act=create&id=$id");
exit();
}
}
Khối này kết thúc bằng } đóng class — CourseController đã hoàn chỉnh với 3 phương thức.
Phương thức này chỉ kiểm tra rồi chuyển tiếp, không tự làm gì. Nó đóng vai một trạm kiểm soát trước khi vào luồng thanh toán.
Chú ý dấu ngoặc nhọn { nằm cùng dòng với tên phương thức, khác với mọi phương thức khác trong project. Chi tiết nhỏ cho thấy đoạn này được viết vào lúc khác.
3.6. Tạo View lịch sử thanh toán
📁
Views/payment_history.php— tạo mới
<?php include_once "./Views/layout_header.php"; ?>
<main class="user-page">
<div class="user-sidebar">
<ul class="user-menu">
<li><a href="?ctrl=user&act=profile">Thông tin cá nhân</a></li>
<li><a href="?ctrl=user&act=password">Đổi mật khẩu</a></li>
<li><a href="?ctrl=user&act=course">Khóa học của tôi</a></li>
<li class="active"><a href="?ctrl=payment&act=history">Lịch sử thanh toán</a></li>
</ul>
</div>
<div class="user-content">
<h2>Lịch sử thanh toán</h2>
<?php if (isset($_SESSION['success'])): ?>
<div style="background-color: #d4edda; color: #155724; padding: 10px; border-radius: 5px; margin-bottom: 15px;">
<?= $_SESSION['success'] ?>
</div>
<?php unset($_SESSION['success']); ?>
<?php endif; ?>
<?php if (isset($_SESSION['error'])): ?>
<div style="background-color: #f8d7da; color: #721c24; padding: 10px; border-radius: 5px; margin-bottom: 15px;">
<?= $_SESSION['error'] ?>
</div>
<?php unset($_SESSION['error']); ?>
<?php endif; ?>
<?php if (empty($payments)): ?>
<div style="text-align: center; padding: 50px; color: #666;">
<h3>Chưa có lịch sử thanh toán</h3>
<p>Bạn chưa có giao dịch thanh toán nào.</p>
</div>
<?php else: ?>
<div class="payment-list">
<?php foreach ($payments as $payment): ?>
<div class="payment-item">
<div class="payment-info">
<div class="course-info">
<img src="<?= $payment['course_image'] ? 'public/img/' . $payment['course_image'] : 'public/img/default-course.jpg' ?>" alt="Course" class="course-thumb">
<div>
<h4><?= htmlspecialchars($payment['course_title']) ?></h4>
<p>Mã đơn hàng: <?= $payment['order_id'] ?></p>
<p>Ngày thanh toán: <?= date('d/m/Y H:i', strtotime($payment['created_at'])) ?></p>
</div>
</div>
<div class="payment-details">
<div class="amount"><?= number_format($payment['amount']) ?>đ</div>
<div class="status status-<?= $payment['status'] ?>">
<?php
switch($payment['status']) {
case 'completed':
echo 'Thành công';
break;
case 'pending':
echo 'Đang xử lý';
break;
case 'failed':
echo 'Thất bại';
break;
case 'cancelled':
echo 'Đã hủy';
break;
default:
echo 'Không xác định';
}
?>
</div>
</div>
</div>
<?php if ($payment['status'] == 'pending'): ?>
<div class="payment-actions">
<a href="?ctrl=payment&act=cancel&id=<?= $payment['order_id'] ?>"
onclick="return confirm('Bạn có chắc chắn muốn hủy thanh toán này?')"
class="btn btn-sm" style="background-color: #dc3545;">Hủy thanh toán</a>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</main>
<?php include_once "./Views/layout_footer.php"; ?>
Khối switch ở giữa file chính là đoạn bạn đã đọc từ Bài 2 mục 3.1. Giờ bạn thấy nó trong ngữ cảnh đầy đủ.
⚠️ Lưu ý — file này có ba lỗi.
Thứ nhất, header và footer bị lặp. Dòng đầu và dòng cuối tự
include_oncelạilayout_header.phpvàlayout_footer.php. Nhưngindex.phpđã include chúng rồi (Bài 9 mục 3.1).Vì
include_oncechỉ nạp một lần cho mỗi file, nên thực tế nó không lặp. Nhưng đó chỉ là may mắn: nếu tác giả viếtincludethường, trang sẽ có hai header và hai footer chồng lên nhau.Dù sao đây vẫn là code sai nguyên tắc — View chỉ nên viết phần giữa, như Bài 10 mục 2.2 đã quy định. Đây là lỗi số 6 trong Bài 22.
Thứ hai, liên kết
?ctrl=user&act=passwordtrỏ tới phương thức không tồn tại.UserControllerchỉ cóchangePassword(). Bấm vào sẽ nhậnCall to undefined method.Đây là lỗi số 4 trong Bài 22 — cùng lỗi với
Views/layout_user_sidebar.phpở Bài 16.Thứ ba, đường dẫn ảnh sai. Dòng này ghép
'public/img/' . $payment['course_image'], nhưng ảnh khóa học nằm ởpublic/img/courses/.Ba View khác trong project đều dùng
public/img/courses/. Chỉ file này khác. Đây là lỗi số 17 trong Bài 22.Nó còn trỏ tới
public/img/default-course.jpg— một file không tồn tại trong repo.
4. Chạy thử
Đăng nhập bằng tài khoản học viên chưa mua khóa nào.
Thử 1 — Bấm mua. Mở một khóa học ở ?ctrl=course&act=detail&id=1, bấm "Đăng ký ngay".
Bạn được chuyển sang trang sandbox của VNPay và nhận thông báo lỗi về chữ ký hoặc mã website.
Đây là kết quả đúng. Mã 2QXUI4J4 và chuỗi bí mật trong code là giá trị mẫu, không đăng ký với VNPay nào.
Thử 2 — Kiểm chứng đơn hàng đã được lưu. Mở phpMyAdmin, bảng payments. Có một dòng mới:
| order_id | amount | course_id | user_id | status |
|---|---|---|---|---|
| ORDER_2026... | 300000.00 | 1 | 34 | pending |
Dòng này được tạo ở bước 2 của luồng, trước khi chuyển sang VNPay. Đúng thiết kế: ghi nhận ý định mua trước, cập nhật kết quả sau.
Thử 3 — Xem lịch sử. Mở ?ctrl=payment&act=history.
Bạn thấy đơn hàng vừa tạo với trạng thái "Đang xử lý" và một nút "Hủy thanh toán" màu đỏ.
Chú ý ảnh khóa học bị vỡ — đúng lỗi số 17 ở mục 3.6.
Thử 4 — Hủy đơn. Bấm "Hủy thanh toán", xác nhận.
Trang tải lại với khung xanh "Đã hủy thanh toán thành công!", và trạng thái đổi thành "Đã hủy". Nút hủy biến mất vì điều kiện $payment['status'] == 'pending' không còn đúng.
Thử 5 — Kiểm chứng quyền sở hữu. Ghi lại order_id của một đơn hàng của người dùng khác trong bảng payments — chẳng hạn ORDER_20250805080104_9029 của user 34.
Đăng nhập bằng tài khoản khác, rồi gõ:
?ctrl=payment&act=cancel&id=ORDER_20250805080104_9029
Bạn nhận thông báo "Không có quyền hủy thanh toán này!". Kiểm tra ở mục 2.9 đã chặn.
Thử 6 — Thử giả mạo callback. Đây là phần thú vị nhất.
Gõ thẳng vào thanh địa chỉ, thay ORDER_XXX bằng mã đơn hàng thật của bạn:
?ctrl=payment&act=callback&vnp_TxnRef=ORDER_XXX&vnp_ResponseCode=00
Bạn đang giả vờ mình là VNPay báo thanh toán thành công.
Kết quả: "Chữ ký không hợp lệ!" và bạn bị đá về trang danh sách khóa học.
verifyPayment() đã tính lại chữ ký từ dữ liệu bạn gửi và thấy nó không khớp — vì bạn không có chuỗi bí mật để tạo chữ ký đúng.
Đây là bằng chứng cho thấy cơ chế HMAC ở mục 2.3 thực sự hoạt động. Dù toàn bộ dữ liệu đều nằm công khai trên URL, không ai giả mạo được nếu không biết chuỗi bí mật.
Thử 7 — Mua khóa đã có. Thêm tay một dòng vào enrollments cho tài khoản của bạn với khóa số 1, rồi bấm "Đăng ký ngay" ở khóa đó.
Bạn nhận "Bạn đã đăng ký khóa học này rồi!" — checkEnrollment() đã chặn.
Muốn thử luồng thanh toán thật? Bạn cần đăng ký tài khoản thử nghiệm miễn phí tại sandbox.vnpayment.vn, lấy mã TmnCode và HashSecret của riêng bạn, rồi thay vào Models/VNPay.php. Bài tập 2 sẽ hướng dẫn cách làm đúng — tách chúng ra file cấu hình riêng.
5. Bài tập
Bài tập 1
Sửa PaymentController::create() để $returnUrl tự suy ra từ địa chỉ hiện tại, thay vì viết cứng /EZCode_khoahoc/.
Gợi ý: dùng $_SERVER['SCRIPT_NAME'] và hàm dirname().
Kiểm chứng bằng cách đổi tên thư mục project rồi thử lại.
Bài tập 2
Vá một nửa lỗi số 12.
Tách vnp_TmnCode và vnp_HashSecret ra một file config.php ở gốc project, rồi include_once vào Models/VNPay.php.
Thêm config.php vào .gitignore để nó không bị đẩy lên git.
Tạo thêm config.example.php chứa giá trị rỗng để người khác biết cần điền gì.
Bài tập 3
Thêm phương thức VNPay::getPaymentStats($userId) trả về hai con số: tổng số đơn thành công và tổng tiền đã thanh toán thành công.
Hiển thị chúng ở đầu trang lịch sử thanh toán.
6. Đáp án
Đáp án bài tập 1
📄 Controllers/PaymentController.php
// Tự suy ra đường dẫn gốc của project
$thuMucGoc = rtrim(dirname($_SERVER['SCRIPT_NAME']), '/\\');
$giaoThuc = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$returnUrl = $giaoThuc . "://" . $_SERVER['HTTP_HOST'] . $thuMucGoc . "/?ctrl=payment&act=callback";
Cách hoạt động:
$_SERVER['SCRIPT_NAME'] chứa đường dẫn tới file đang chạy tính từ gốc web:
/EZCode_khoahoc/index.php
dirname() cắt bỏ tên file, còn lại thư mục:
/EZCode_khoahoc
rtrim(..., '/\\') xóa dấu gạch chéo cuối nếu có. Cần thiết vì khi project nằm ngay gốc web, dirname() trả về / hoặc \ tùy hệ điều hành, và ghép thêm / nữa sẽ thành //.
Phần kiểm tra $_SERVER['HTTPS'] giúp code chạy đúng cả khi website có chứng chỉ bảo mật.
Kiểm chứng: đổi tên thư mục project thành EzCodeMoi, mở lại bằng địa chỉ mới, bấm mua. Nhìn URL VNPay, tham số vnp_ReturnUrl đã tự đổi theo.
Với bản gốc viết cứng, VNPay sẽ gọi lại /EZCode_khoahoc/ — một thư mục không còn tồn tại — và giao dịch treo vĩnh viễn ở pending.
Đáp án bài tập 2
📄 config.php — tạo mới ở gốc project
<?php
// File này chứa thông tin nhạy cảm - KHÔNG đưa lên git
return [
'vnp_TmnCode' => 'MA_CUA_BAN',
'vnp_HashSecret' => 'CHUOI_BI_MAT_CUA_BAN',
'vnp_Url' => 'https://sandbox.vnpayment.vn/paymentv2/vpcpay.html',
];
📄 config.example.php — tạo mới, file này ĐƯỢC đưa lên git
<?php
// Chép file này thành config.php rồi điền giá trị thật
// Lấy mã tại: https://sandbox.vnpayment.vn
return [
'vnp_TmnCode' => '',
'vnp_HashSecret' => '',
'vnp_Url' => 'https://sandbox.vnpayment.vn/paymentv2/vpcpay.html',
];
📄 .gitignore — tạo mới hoặc thêm dòng
config.php
📄 Models/VNPay.php — sửa
<?php
include_once "./Models/Database.php"; // vá luôn lỗi số 15
class VNPay {
private $db;
private $config;
public function __construct() {
$this->db = new Database();
$this->config = include "./config.php";
}
public function createPaymentUrl($orderId, $amount, $orderInfo, $returnUrl) {
$vnp_Url = $this->config['vnp_Url'];
$vnp_TmnCode = $this->config['vnp_TmnCode'];
$vnp_HashSecret = $this->config['vnp_HashSecret'];
// ...phần còn lại giữ nguyên...
}
public function verifyPayment($inputData) {
$vnp_HashSecret = $this->config['vnp_HashSecret'];
// ...phần còn lại giữ nguyên...
}
// ...
}
Bốn điều đáng học từ bản vá này:
Thứ nhất, include trả về giá trị. Khi file được include kết thúc bằng return [...], lệnh include trả về chính mảng đó. Đây là cách viết file cấu hình rất phổ biến trong PHP.
Thứ hai, chuỗi bí mật giờ chỉ nằm một chỗ. Bản gốc lặp nó ở hai phương thức — đổi khóa mà quên một chỗ là hệ thống hỏng theo cách rất khó tìm.
Thứ ba, config.example.php là bắt buộc. Nếu chỉ có .gitignore mà không có file mẫu, người tải project về sẽ gặp lỗi Failed to open stream: config.php và không biết phải tạo file gì.
Thứ tư, bản vá này giải quyết luôn lỗi số 15 bằng dòng include_once "./Models/Database.php"; ở đầu file — phụ thuộc ngầm đã trở thành phụ thuộc rõ ràng.
Một điều quan trọng cần biết: nếu bạn đã lỡ đẩy khóa bí mật lên GitHub, việc xóa nó khỏi code không đủ. Lịch sử git vẫn giữ nguyên giá trị cũ, ai cũng xem lại được.
Cách xử lý duy nhất đúng là đổi khóa với nhà cung cấp, coi như khóa cũ đã bị lộ.
Đáp án bài tập 3
📄 Models/VNPay.php
// Thống kê thanh toán của một người dùng
public function getPaymentStats($userId) {
$sql = "SELECT
COUNT(*) as so_don,
SUM(amount) as tong_tien
FROM payments
WHERE user_id = ? AND status = 'completed'";
$result = $this->db->queryOne($sql, $userId);
return [
'so_don' => $result['so_don'] ?? 0,
'tong_tien' => $result['tong_tien'] ?? 0
];
}
📄 Controllers/PaymentController.php — thêm vào history()
$stats = $vnpayModel->getPaymentStats($userId);
📄 Views/payment_history.php — thêm dưới tiêu đề
<h2>Lịch sử thanh toán</h2>
<p style="color: #666;">
Bạn đã thanh toán thành công <strong><?= $stats['so_don'] ?></strong> đơn hàng,
tổng cộng <strong><?= number_format($stats['tong_tien']) ?>đ</strong>
</p>
Ba điểm đáng chú ý:
Thứ nhất, ?? 0 cho cả hai giá trị. COUNT(*) không bao giờ null, nhưng SUM(amount) thì có — đúng cái bẫy ở Bài 20 mục 2.2. Với người chưa mua gì, SUM trả null và number_format(null) sẽ cảnh báo.
Thứ hai, chỉ đếm đơn completed. Đây là quyết định nghiệp vụ quan trọng: đơn pending, failed, cancelled không phải tiền thật đã thu.
Nếu bỏ điều kiện đó đi, với dữ liệu mẫu bạn sẽ thấy "đã thanh toán 8 đơn, 3.030.000đ" trong khi thực tế không có đơn nào thành công — cả 8 dòng đều là pending.
Thứ ba, trả về một mảng thay vì hai phương thức riêng. Cách này chỉ tốn một truy vấn thay vì hai. Nếu viết countCompletedOrders() và sumCompletedAmount() riêng, mỗi trang tải là hai lần đi lại với database cho cùng một bộ dữ liệu.
Nguyên tắc: nếu nhiều con số cùng đến từ một bảng và một điều kiện, hãy lấy chúng trong một truy vấn.
➡️ Bài tiếp theo: Bài 22 — Săn lỗi và vá bảo mật
⬅️ Về mục lục · Bài trước
All rights reserved