0

EzCode # Bài 19 — Quản lý bài học và học viên

Học xong bài này bạn sẽ:

  • Hoàn thiện Model Lession với đủ bốn thao tác CRUD
  • Dùng GROUP BY, COUNT(DISTINCT), SUM, MAX để thống kê học viên
  • Gặp và hiểu tận gốc lỗi số 1 — lỗi router nghiêm trọng nhất của project

Cần học trước: Bài 18


1. Vấn đề đặt ra

Giảng viên đã tạo được khóa học. Nhưng khóa học rỗng thì không ai học được — cần thêm bài học vào.

Và sau khi bán được, họ cần biết ai đã mua: bao nhiêu người, mỗi người mua mấy khóa, chi bao nhiêu tiền, mua lần gần nhất khi nào.

Hai chức năng này mở ra hai chủ đề:

  1. Một phương thức phục vụ hai trang khác nhau — trang "học viên của một khóa" và trang "toàn bộ học viên" dùng chung một students().
  2. Thống kê bằng GROUP BY — gom nhiều dòng ghi danh của cùng một người thành một dòng tóm tắt.

Ngoài ra, đây là bài mà bạn sẽ đụng phải một lỗi thật khiến trang chết hẳn. Tôi sẽ nói trước cách đi vòng để bạn không bị kẹt.


2. Kiến thức mới

2.1. Tham số mặc định cho hai mục đích

public function students($course_id = null)
{
    if ($course_id) {
        // Xem học viên của MỘT khóa học
    } else {
        // Xem TẤT CẢ học viên
    }
}

Một phương thức, hai hành vi, tùy có tham số hay không.

Địa chỉ $course_id Kết quả
?ctrl=teacher&act=students null Mọi học viên của giảng viên
?ctrl=teacher&act=students&id=20 "20" Học viên của khóa 20

Kỹ thuật tham số mặc định bạn đã học ở Bài 2 mục 2.9, và giá trị null được chọn vì nó bị coi là "sai" trong điều kiện if.

Cách này tốt hay xấu?

Ưu điểm: hai trang rất giống nhau nên gộp lại tránh trùng lặp; View cũng dùng chung được.

Nhược điểm: phương thức làm hai việc, và View phải tự đoán mình đang ở chế độ nào. Nếu hai chế độ khác nhau nhiều hơn nữa, tách thành students() và studentsByCourse($id) sẽ rõ ràng hơn.

2.2. GROUP BY — gom dòng thành nhóm

Đây là kỹ thuật SQL quan trọng nhất của bài.

Bảng enrollments có thể chứa nhiều dòng của cùng một người:

user_id course_id price enrolled_at
2 20 30000 2025-08-01
2 21 25000 2025-08-05
3 20 30000 2025-08-03

Nhưng trang "Quản lý học viên" cần mỗi người một dòng, kèm tóm tắt:

Tên Số khóa đã mua Tổng chi Mua gần nhất
Trần Thị B 2 55.000 05/08/2025
Phạm Văn C 1 30.000 03/08/2025

GROUP BY u.id làm đúng việc đó: gom mọi dòng có cùng u.id thành một nhóm, rồi các hàm tổng hợp tính toán trên từng nhóm.

2.3. Bốn hàm tổng hợp

Hàm Làm gì Ví dụ với nhóm của user 2
COUNT(*) Đếm số dòng trong nhóm 2
COUNT(DISTINCT cot) Đếm số giá trị khác nhau 2
SUM(cot) Cộng 55000
MAX(cot) Giá trị lớn nhất 2025-08-05
MIN(cot) Giá trị nhỏ nhất 2025-08-01
AVG(cot) Trung bình 27500

COUNT(*) khác COUNT(DISTINCT ...) ở đâu?

COUNT(*) đếm mọi dòng. COUNT(DISTINCT e.course_id) đếm số khóa học khác nhau.

Với dữ liệu ở mục 2.2 thì hai hàm cho cùng kết quả, vì mỗi dòng là một khóa khác nhau — bảng enrollments có khóa chính ghép (user_id, course_id) nên không thể trùng.

Nhưng câu truy vấn này nối ba bảng. Khi một khóa học có nhiều dòng khớp ở bảng khác, INNER JOIN sẽ nhân bản dòng lên, và COUNT(*) sẽ đếm thừa. COUNT(DISTINCT ...) thì không bị.

Nguyên tắc: khi truy vấn có JOIN và GROUP BY, hãy dùng COUNT(DISTINCT ...) cho an toàn.

SUM() có thể trả về NULL. Nếu không có dòng nào trong nhóm, SUM trả null chứ không phải 0. Đây là lý do các phương thức thống kê luôn có ?? 0 ở cuối.

2.4. Nối ba bảng

FROM users u 
INNER JOIN enrollments e ON u.id = e.user_id 
INNER JOIN courses c ON e.course_id = c.id 
WHERE c.teacher_id = ?

Đọc thành lời: "Lấy người dùng, nối với ghi danh của họ, nối tiếp với khóa học được ghi danh, rồi chỉ giữ những khóa học thuộc về giảng viên này."

Bảng enrollments đóng vai cầu nối giữa users và courses. Không có nó, không có cách nào biết một học viên có liên quan gì tới một giảng viên.

Thứ tự nối quan trọng về mặt logic: bạn không thể nối users thẳng với courses vì hai bảng đó không có cột chung nào.

2.5. sort_order với giá trị mặc định 999

$sort_order = $_POST['sort_order'] ?? 999;

Cột sort_order trong bảng lessions có DEFAULT 999 (Bài 7). Giá trị lớn nghĩa là đẩy xuống cuối.

Cách này cho phép giảng viên thêm bài học mà không cần nghĩ tới thứ tự — bài mới tự xuống cuối. Muốn sắp lại thì sửa số.

Và như Bài 13 mục 2.6 đã giải thích, vì nhiều bài có thể cùng sort_order = 999, câu truy vấn phải có tiêu chí phụ ORDER BY sort_order ASC, id ASC để kết quả ổn định.

2.6. strlen() và substr() để cắt ngắn

<?= strlen($lesson['content']) > 50 ? substr($lesson['content'], 0, 50) . '...' : $lesson['content'] ?>

Nội dung bài học có thể dài hàng nghìn ký tự. Trong một ô bảng thì phải cắt ngắn.

  • strlen($chuoi) đếm số byte
  • substr($chuoi, 0, 50) lấy 50 byte đầu

Đây là chỗ có lỗi với tiếng Việt. Một ký tự có dấu như "ế" chiếm 3 byte trong UTF-8. substr() cắt theo byte nên có thể cắt giữa một ký tự, tạo ra ký tự lỗi hiển thị thành ô vuông hoặc dấu hỏi.

Hàm đúng là mb_substr() và mb_strlen() — chúng đếm và cắt theo ký tự:

mb_strlen($chuoi) > 50 ? mb_substr($chuoi, 0, 50) . '...' : $chuoi

Lỗi này không nằm trong danh sách 17 lỗi chính của Bài 22 vì hậu quả nhẹ, nhưng nó là ví dụ điển hình về việc code viết cho tiếng Anh gặp vấn đề với tiếng Việt.


3. Áp dụng vào EzCode

3.1. Hoàn thiện Model Lession

Chèn vào Models/Lession.php trước dấu } cuối.

📁 Models/Lession.php — sửa

  function getById($id)
  {
    $sql = "SELECT * FROM lessions WHERE id=?";
    return $this->db->queryOne($sql, $id);
  }
  
  function create($course_id, $title, $video, $content, $sort_order)
  {
    $sql = "INSERT INTO lessions (course_id, title, video, content, sort_order) VALUES (?, ?, ?, ?, ?)";
    return $this->db->insert($sql, $course_id, $title, $video, $content, $sort_order);
  }
  
  function update($id, $title, $video, $content, $sort_order)
  {
    $sql = "UPDATE lessions SET title=?, video=?, content=?, sort_order=? WHERE id=?";
    return $this->db->update($sql, $title, $video, $content, $sort_order, $id);
  }
  
  function delete($id)
  {
    $sql = "DELETE FROM lessions WHERE id=?";
    return $this->db->delete($sql, $id);
  }
  
  function countByCourse($course_id)
  {
    $sql = "SELECT COUNT(*) as total FROM lessions WHERE course_id=?";
    $result = $this->db->queryOne($sql, $course_id);
    return $result['total'] ?? 0;
  }

Model Lession giờ đã hoàn chỉnh với 6 phương thức.

update() không cập nhật course_id — bài học không được chuyển sang khóa khác qua form. Cùng nguyên tắc với Courses::update() không đổi teacher_id ở Bài 18.

⚠️ Lưu ý: Hai phương thức update() và delete() được viết ra nhưng chưa có Controller nào gọi tới, vì TeacherController thiếu editLesson() và deleteLesson().

Trong khi đó Views/teacher_lessons.php mà bạn sắp viết lại có liên kết trỏ tới hai phương thức đó. Bấm vào sẽ chết trang.

Đây là lỗi số 2 và số 3 trong Bài 22. Bài tập 1 và 2 dưới đây chính là bản vá.

3.2. Thêm hai phương thức thống kê học viên vào Model User

📁 Models/User.php — sửa

  function getStudentsByCourse($course_id)
  {
    $sql = "SELECT u.*, e.enrolled_at, e.price 
            FROM users u 
            INNER JOIN enrollments e ON u.id = e.user_id 
            WHERE e.course_id = ? AND e.role = 'student'
            ORDER BY e.enrolled_at DESC";
    return $this->db->query($sql, $course_id);
  }
  
  function getAllStudentsByTeacher($teacher_id)
  {
    $sql = "SELECT DISTINCT u.*, 
            COUNT(DISTINCT e.course_id) as total_courses,
            SUM(e.price) as total_spent,
            MAX(e.enrolled_at) as last_enrollment
            FROM users u 
            INNER JOIN enrollments e ON u.id = e.user_id 
            INNER JOIN courses c ON e.course_id = c.id 
            WHERE c.teacher_id = ? AND e.role = 'student'
            GROUP BY u.id
            ORDER BY last_enrollment DESC";
    return $this->db->query($sql, $teacher_id);
  }

Hai phương thức này trả về cấu trúc dữ liệu khác nhau, và đó là nguồn gốc của một lỗi sẽ nói ở mục 3.5.

getStudentsByCourse() getAllStudentsByTeacher()
Số bảng nối 2 3
Có GROUP BY Không Có
Cột riêng enrolled_at, price total_courses, total_spent, last_enrollment
Một người xuất hiện Một lần cho mỗi khóa Đúng một lần

Về SELECT DISTINCT trong phương thức thứ hai: nó thừa. Câu truy vấn đã có GROUP BY u.id nên mỗi người chỉ ra một dòng rồi. DISTINCT không làm gì thêm ngoài việc bắt MySQL làm một bước sắp xếp không cần thiết.

Đây là dấu vết của quá trình thử nghiệm: có lẽ tác giả viết DISTINCT trước để chống trùng, sau đó thêm GROUP BY và quên xóa.

ORDER BY last_enrollment DESC sắp xếp theo một bí danh được tạo ở mệnh đề SELECT. MySQL cho phép điều này trong ORDER BY (nhưng không cho trong WHERE).

3.3. Thêm manageLessons và createLesson vào Controller

📁 Controllers/TeacherController.php — sửa

    public function manageLessons($course_id)
    {
        include_once "./Models/Courses.php";
        $coursesModel = new Courses();
        $course = $coursesModel->getById($course_id);
        
        // Kiểm tra quyền sở hữu
        if (!$course || $course['teacher_id'] != $_SESSION['user_id']) {
            header("Location: ?ctrl=teacher&act=courses");
            exit();
        }
        
        include_once "./Models/Lession.php";
        $lessionModel = new Lession();
        $lessons = $lessionModel->getAllByCourse($course_id);
        
        include_once "./Views/teacher_lessons.php";
    }
    
    public function createLesson($course_id)
    {
        include_once "./Models/Courses.php";
        $coursesModel = new Courses();
        $course = $coursesModel->getById($course_id);
        
        // Kiểm tra quyền sở hữu
        if (!$course || $course['teacher_id'] != $_SESSION['user_id']) {
            header("Location: ?ctrl=teacher&act=courses");
            exit();
        }
        
        if ($_SERVER['REQUEST_METHOD'] === 'POST') {
            $title = $_POST['title'] ?? '';
            $video = $_POST['video'] ?? '';
            $content = $_POST['content'] ?? '';
            $sort_order = $_POST['sort_order'] ?? 999;
            
            $errors = [];
            
            if (empty($title)) $errors[] = "Tên bài học không được để trống";
            if (empty($content)) $errors[] = "Nội dung bài học không được để trống";
            
            if (empty($errors)) {
                include_once "./Models/Lession.php";
                $lessionModel = new Lession();
                
                $result = $lessionModel->create($course_id, $title, $video, $content, $sort_order);
                
                if ($result) {
                    $_SESSION['success'] = "Tạo bài học thành công!";
                    header("Location: ?ctrl=teacher&act=manageLessons&course_id=$course_id");
                    exit();
                } else {
                    $_SESSION['error'] = "Có lỗi xảy ra khi tạo bài học";
                }
            } else {
                $_SESSION['error'] = implode(", ", $errors);
            }
        }
        
        include_once "./Views/teacher_lesson_create.php";
    }

⚠️ LƯU Ý QUAN TRỌNG NHẤT CỦA BÀI NÀY — hãy đọc kỹ trước khi chạy thử.

Cả hai phương thức nhận tham số tên $course_id, không phải $id.

Nhớ lại index.php ở Bài 9 mục 3.1:

$args = [];
if (isset($_GET['id'])) {
    $args[] = $_GET['id'];
}
$ctrl->$act(...$args);

Router chỉ nạp $_GET['id'] vào mảng tham số. Mọi tham số mang tên khác đều bị bỏ qua.

Hậu quả cụ thể:

  • Liên kết ?act=manageLessons&id=20 từ Views/teacher_course.php — CHẠY ĐƯỢC, vì tên tham số trên URL là id.
  • Liên kết ?act=createLesson&course_id=20 từ Views/teacher_lessons.php — CHẾT, vì tên là course_id.
  • Dòng chuyển hướng header("Location: ?ctrl=teacher&act=manageLessons&course_id=$course_id") ngay trong code trên — cũng CHẾT vì cùng lý do.

Thông báo bạn sẽ nhận:

Fatal error: Uncaught ArgumentCountError: Too few arguments to function
TeacherController::createLesson(), 0 passed in ...\index.php on line 24 and exactly 1 expected

CÁCH ĐI TIẾP NGAY BÂY GIỜ: khi cần vào trang tạo bài học, hãy gõ tay địa chỉ với tham số tên id:

?ctrl=teacher&act=createLesson&id=20

Đừng bấm nút "+ Thêm bài học mới" trên giao diện.

Đây là lỗi số 1 trong Bài 22, và ở đó bạn sẽ vá index.php để mọi tên tham số đều hoạt động.

Ngoài ra cả hai phương thức đều gọi getById() — bản lọc status = 1. Nên khóa học đang ẩn thì không quản lý bài học được, đúng lỗi số 8 mà Bài 18 đáp án 2 đã chỉ ra.

3.4. Thêm students vào Controller

📁 Controllers/TeacherController.php — sửa

    public function students($course_id = null)
    {
        include_once "./Models/User.php";
        $userModel = new User();
        
        if ($course_id) {
            // Xem học viên của một khóa học cụ thể
            $students = $userModel->getStudentsByCourse($course_id);
            $course = null;
            
            include_once "./Models/Courses.php";
            $coursesModel = new Courses();
            $course = $coursesModel->getById($course_id);
            
            // Kiểm tra quyền sở hữu
            if (!$course || $course['teacher_id'] != $_SESSION['user_id']) {
                header("Location: ?ctrl=teacher&act=students");
                exit();
            }
        } else {
            // Xem tất cả học viên của teacher
            $students = $userModel->getAllStudentsByTeacher($_SESSION['user_id']);
        }
        
        include_once "./Views/teacher_students.php";
    }

⚠️ Lưu ý: Nhánh else không gán biến $course. Nhưng Views/teacher_students.php mà bạn sắp viết lại dùng if (!$course) để quyết định hiển thị cột nào.

Khi mở ?ctrl=teacher&act=students không có tham số, bạn sẽ thấy:

Warning: Undefined variable $course in ...\Views\teacher_students.php on line 30

Trang vẫn hiện ra vì PHP coi biến chưa định nghĩa là null, và !null là true — tình cờ đúng nhánh cần. Nhưng cảnh báo hiện ra giữa trang rất xấu.

Đây là lỗi số 5 trong Bài 22. Cách vá đơn giản: thêm $course = null; ở đầu phương thức.

Cũng chú ý một chi tiết thừa: nhánh if gán $course = null; rồi ngay dòng sau gán lại bằng kết quả truy vấn. Dòng đầu vô nghĩa. Nếu tác giả đặt nó ở đầu phương thức thay vì trong nhánh if, lỗi số 5 đã không tồn tại.

Đây là ví dụ hay về việc đặt code đúng chỗ quan trọng hơn viết đúng code.

3.5. Ba View còn lại

📁 Views/teacher_lessons.php — tạo mới

<main class="dashboard-page">
    <?php include_once "./Views/layout_teacher_sidebar.php"; ?>
    
    <div class="dashboard-content">
        <div class="d-flex justify-content-between align-items-center">
            <h2>Quản lý bài học: <?= htmlspecialchars($course['title']) ?></h2>
            <a href="?ctrl=teacher&act=createLesson&course_id=<?= $course['id'] ?>" class="add-lesson-btn">+ Thêm bài học mới</a>
        </div>
        
        <?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($lessons)): ?>
            <div style="text-align: center; padding: 50px; color: #666;">
                <h3>Chưa có bài học nào</h3>
                <p>Hãy thêm bài học đầu tiên cho khóa học này!</p>
                <a href="?ctrl=teacher&act=createLesson&course_id=<?= $course['id'] ?>" class="btn">Thêm bài học</a>
            </div>
        <?php else: ?>
            <table class="lesson-table">
                <thead>
                    <tr>
                        <th>Thứ tự</th>
                        <th>Tên bài học</th>
                        <th>Video</th>
                        <th>Nội dung</th>
                        <th>Thao tác</th>
                    </tr>
                </thead>
                <tbody>
                    <?php foreach ($lessons as $lesson): ?>
                        <tr>
                            <td><?= $lesson['sort_order'] ?></td>
                            <td><?= htmlspecialchars($lesson['title']) ?></td>
                            <td>
                                <?php if ($lesson['video']): ?>
                                    <a href="<?= $lesson['video'] ?>" target="_blank">Xem video</a>
                                <?php else: ?>
                                    <span style="color: #999;">Chưa có</span>
                                <?php endif; ?>
                            </td>
                            <td>
                                <?= strlen($lesson['content']) > 50 ? substr($lesson['content'], 0, 50) . '...' : $lesson['content'] ?>
                            </td>
                            <td>
                                <a href="?ctrl=teacher&act=editLesson&id=<?= $lesson['id'] ?>" class="btn btn-sm">Sửa</a>
                                <button onclick="deleteLesson(<?= $lesson['id'] ?>)" class="btn btn-sm" style="background-color: #dc3545;">Xóa</button>
                            </td>
                        </tr>
                    <?php endforeach; ?>
                </tbody>
            </table>
        <?php endif; ?>
    </div>
</main>

<script>
function deleteLesson(lessonId) {
    if (confirm('Bạn có chắc chắn muốn xóa bài học này?')) {
        window.location.href = '?ctrl=teacher&act=deleteLesson&id=' + lessonId;
    }
}
</script> 

⚠️ Lưu ý — file này có ba vấn đề.

Thứ nhất, hai liên kết "+ Thêm bài học mới" dùng tham số course_id — đúng nguyên nhân của lỗi số 1. Bấm vào là trang chết.

Thứ hai, liên kết "Sửa" trỏ tới ?act=editLesson, một phương thức chưa hề tồn tại. Đây là lỗi số 2.

Thứ ba, nút "Xóa" gọi ?act=deleteLesson, cũng chưa tồn tại. Đây là lỗi số 3.

Nghĩa là trong bảng này, cả ba nút thao tác đều hỏng. Đây là phần chưa hoàn thiện nhất của project.

Nội dung $lesson['content'] cũng chưa qua htmlspecialchars() — thuộc lỗi số 10.

📁 Views/teacher_lesson_create.php — tạo mới

<main class="dashboard-page">
    <?php include_once "./Views/layout_teacher_sidebar.php"; ?>
    
    <div class="dashboard-content">
        <h2>Thêm bài học mới</h2>
        
        <?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; ?>
        
        <form method="POST" class="add-lesson-form">
            <div>
                <label for="title">Tên bài học *</label>
                <input type="text" id="title" name="title" value="<?= isset($_POST['title']) ? htmlspecialchars($_POST['title']) : '' ?>" required>
            </div>
            
            <div>
                <label for="video">Link video (YouTube, Vimeo...)</label>
                <input type="url" id="video" name="video" value="<?= isset($_POST['video']) ? htmlspecialchars($_POST['video']) : '' ?>" placeholder="https://youtu.be/...">
            </div>
            
            <div>
                <label for="sort_order">Thứ tự bài học</label>
                <input type="number" id="sort_order" name="sort_order" value="<?= isset($_POST['sort_order']) ? $_POST['sort_order'] : '999' ?>" min="1">
                <small>Số càng nhỏ thì hiển thị càng trước</small>
            </div>
            
            <div>
                <label for="content">Nội dung bài học *</label>
                <textarea id="content" name="content" rows="10" required><?= isset($_POST['content']) ? htmlspecialchars($_POST['content']) : '' ?></textarea>
            </div>
            
            <button type="submit">Tạo bài học</button>
        </form>
    </div>
</main> 

Form này không có action, nên nó gửi về chính địa chỉ hiện tại. Nghĩa là nếu bạn vào bằng ?act=createLesson&id=20 thì form cũng gửi về đó — và tham số id được giữ. Tốt.

Ô video dùng type="url" nên trình duyệt bắt buộc nhập địa chỉ đầy đủ có http://. Nhưng dữ liệu mẫu trong database lại có bài lưu mã trần alDeWlUHYSA — không khớp định dạng này. Lại một sự thiếu nhất quán giữa dữ liệu và form.

📁 Views/teacher_students.php — tạo mới

<main class="dashboard-page">
    <?php include_once "./Views/layout_teacher_sidebar.php"; ?>
    
    <div class="dashboard-content">
        <h2>Quản lý học viê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($students)): ?>
            <div style="text-align: center; padding: 50px; color: #666;">
                <h3>Chưa có học viên nào</h3>
                <p>Học viên sẽ xuất hiện ở đây khi họ đăng ký khóa học của bạn.</p>
            </div>
        <?php else: ?>
            <table class="student-table">
                <thead>
                    <tr>
                        <th>Họ và tên</th>
                        <th>Email</th>
                        <th>Số điện thoại</th>
                        <?php if (!$course): ?>
                            <th>Số khóa học đã đăng ký</th>
                            <th>Tổng chi tiêu</th>
                        <?php else: ?>
                            <th>Ngày đăng ký</th>
                            <th>Giá đã trả</th>
                        <?php endif; ?>
                    </tr>
                </thead>
                <tbody>
                    <?php foreach ($students as $student): ?>
                        <tr>
                            <td><?= htmlspecialchars($student['name']) ?></td>
                            <td><?= htmlspecialchars($student['email']) ?></td>
                            <td><?= htmlspecialchars($student['phone']) ?></td>
                            <?php if (!$course): ?>
                                <td><?= $student['total_courses'] ?></td>
                                <td><?= number_format($student['total_spent']) ?>đ</td>
                            <?php else: ?>
                                <td><?= date('d/m/Y', strtotime($student['enrolled_at'])) ?></td>
                                <td><?= number_format($student['price']) ?>đ</td>
                            <?php endif; ?>
                        </tr>
                    <?php endforeach; ?>
                </tbody>
            </table>
        <?php endif; ?>
    </div>
</main> 

File này dùng if (!$course) hai lần — một lần cho tiêu đề cột, một lần cho nội dung. Hai khối phải luôn khớp nhau về số cột, nếu không bảng sẽ lệch.

Đây chính là chỗ gây ra lỗi số 5: khi mở trang không có tham số, biến $course chưa được gán.

Ba cột đầu đều có htmlspecialchars() — file này làm tốt hơn nhiều View khác. Nhưng nó cũng cho thấy sự ngẫu nhiên: cùng một tác giả, file này cẩn thận còn file kia thì không.


4. Chạy thử

Đăng nhập giảng viên. Chuẩn bị dữ liệu: vào phpMyAdmin thêm vài dòng vào enrollments cho khóa học của bạn nếu chưa có.

Thử 1 — Vào quản lý bài học. Mở ?ctrl=teacher&act=courses, bấm nút "Bài học" ở một khóa.

Trang mở được, vì liên kết đó dùng tham số id. Bạn thấy bảng bài học hoặc thông báo "Chưa có bài học nào".

Thử 2 — Gặp lỗi số 1. Bấm nút "+ Thêm bài học mới".

Fatal error: Uncaught ArgumentCountError: Too few arguments to function
TeacherController::createLesson(), 0 passed in C:\xampp\htdocs\EZCode_khoahoc\index.php
on line 24 and exactly 1 expected

Trang chết hẳn. Nhìn thanh địa chỉ, bạn thấy ?ctrl=teacher&act=createLesson&course_id=20.

Bạn vừa gặp lỗi số 1 tận mắt. Nguyên nhân đã giải thích ở mục 3.3.

Thử 3 — Đi vòng qua lỗi. Gõ tay vào thanh địa chỉ, đổi course_id thành id:

?ctrl=teacher&act=createLesson&id=20

Trang mở được. Điền tên bài học, nội dung, để thứ tự là 1, bấm "Tạo bài học".

Kết quả: bạn lại nhận cùng lỗi đó. Vì dòng chuyển hướng sau khi tạo thành công cũng dùng course_id:

header("Location: ?ctrl=teacher&act=manageLessons&course_id=$course_id");

Nhưng bài học ĐÃ được tạo. Hãy gõ tay ?ctrl=teacher&act=manageLessons&id=20 để kiểm chứng — bài học mới nằm trong bảng.

Đây là loại lỗi khó chịu nhất: chức năng hoạt động nhưng người dùng thấy trang lỗi và tưởng thất bại, rồi bấm lại và tạo trùng.

Thử 4 — Gặp lỗi số 2 và 3. Trong bảng bài học, bấm nút "Sửa":

Fatal error: Uncaught Error: Call to undefined method TeacherController::editLesson()

Bấm nút "Xóa" và xác nhận:

Fatal error: Uncaught Error: Call to undefined method TeacherController::deleteLesson()

Cả ba nút trong bảng đều hỏng, đúng như hộp cảnh báo ở mục 3.5.

Thử 5 — Gặp lỗi số 5. Mở ?ctrl=teacher&act=students.

Đầu trang có cảnh báo:

Warning: Undefined variable $course in ...\Views\teacher_students.php on line 30

Nhưng bảng vẫn hiện đúng với 5 cột: Họ tên, Email, Số điện thoại, Số khóa học đã đăng ký, Tổng chi tiêu.

Thử 6 — Xem học viên của một khóa. Mở ?ctrl=teacher&act=students&id=20.

Lần này không có cảnh báo, và hai cột cuối đổi thành "Ngày đăng ký" và "Giá đã trả".

So sánh hai trang, bạn thấy rõ tác dụng của tham số mặc định ở mục 2.1.

Thử 7 — Kiểm chứng GROUP BY. Vào phpMyAdmin, thêm cho một học viên hai dòng ghi danh vào hai khóa khác nhau của bạn.

Tải lại ?ctrl=teacher&act=students. Người đó xuất hiện đúng một dòng, cột "Số khóa học" hiện 2, cột "Tổng chi tiêu" là tổng hai lần mua.

Đó chính là GROUP BY u.id cộng COUNT(DISTINCT) và SUM() đang làm việc.

Bạn có thể chạy thử câu SQL đó trong tab SQL của phpMyAdmin, bỏ dòng GROUP BY u.id đi rồi chạy lại — bạn sẽ thấy kết quả sai hoàn toàn.


5. Bài tập

Bài tập 1

Vá lỗi số 2. Viết TeacherController::editLesson($id) và View Views/teacher_lesson_edit.php.

Yêu cầu:

  • Dùng Lession::getById() lấy bài học
  • Kiểm tra quyền sở hữu qua course_id của bài học đó
  • Form điền sẵn dữ liệu cũ, lưu bằng Lession::update()

Bài tập 2

Vá lỗi số 3. Viết TeacherController::deleteLesson($id).

Yêu cầu: cũng phải kiểm tra quyền sở hữu, và sau khi xóa quay về trang quản lý bài học của đúng khóa đó.

Bài tập 3

Thêm cột "Số bài học" vào bảng ở Views/teacher_course.php.

Làm bằng hai cách, rồi so sánh:

  • Cách A: gọi Lession::countByCourse() trong vòng lặp
  • Cách B: thêm một subquery vào Courses::getByTeacher()

Đếm số truy vấn của mỗi cách khi giảng viên có 10 khóa học.


6. Đáp án

Đáp án bài tập 1

📄 Controllers/TeacherController.php

    public function editLesson($id)
    {
        include_once "./Models/Lession.php";
        $lessionModel = new Lession();
        $lesson = $lessionModel->getById($id);

        if (!$lesson) {
            header("Location: ?ctrl=teacher&act=courses");
            exit();
        }

        // Kiểm tra quyền sở hữu qua khóa học chứa bài này
        include_once "./Models/Courses.php";
        $coursesModel = new Courses();
        $course = $coursesModel->getByIdForTeacher($lesson['course_id']);

        if (!$course || $course['teacher_id'] != $_SESSION['user_id']) {
            header("Location: ?ctrl=teacher&act=courses");
            exit();
        }

        if ($_SERVER['REQUEST_METHOD'] === 'POST') {
            $title = $_POST['title'] ?? '';
            $video = $_POST['video'] ?? '';
            $content = $_POST['content'] ?? '';
            $sort_order = $_POST['sort_order'] ?? 999;

            $errors = [];
            if (empty($title))   $errors[] = "Tên bài học không được để trống";
            if (empty($content)) $errors[] = "Nội dung bài học không được để trống";

            if (empty($errors)) {
                if ($lessionModel->update($id, $title, $video, $content, $sort_order)) {
                    $_SESSION['success'] = "Cập nhật bài học thành công!";
                    header("Location: ?ctrl=teacher&act=manageLessons&id=" . $lesson['course_id']);
                    exit();
                }
                $_SESSION['error'] = "Có lỗi xảy ra khi cập nhật bài học";
            } else {
                $_SESSION['error'] = implode(", ", $errors);
            }
        }

        include_once "./Views/teacher_lesson_edit.php";
    }

📄 Views/teacher_lesson_edit.php

<main class="dashboard-page">
    <?php include_once "./Views/layout_teacher_sidebar.php"; ?>

    <div class="dashboard-content">
        <h2>Sửa bài học: <?= htmlspecialchars($lesson['title']) ?></h2>

        <?php if (isset($_SESSION['error'])): ?>
            <div style="background-color: #f8d7da; color: #721c24; padding: 10px; border-radius: 5px; margin-bottom: 15px;">
                <?= htmlspecialchars($_SESSION['error']) ?>
            </div>
            <?php unset($_SESSION['error']); ?>
        <?php endif; ?>

        <form method="POST" class="add-lesson-form">
            <div>
                <label for="title">Tên bài học *</label>
                <input type="text" id="title" name="title" value="<?= htmlspecialchars($lesson['title']) ?>" required>
            </div>

            <div>
                <label for="video">Link video</label>
                <input type="text" id="video" name="video" value="<?= htmlspecialchars($lesson['video'] ?? '') ?>">
            </div>

            <div>
                <label for="sort_order">Thứ tự bài học</label>
                <input type="number" id="sort_order" name="sort_order" value="<?= $lesson['sort_order'] ?>" min="1">
            </div>

            <div>
                <label for="content">Nội dung bài học *</label>
                <textarea id="content" name="content" rows="10" required><?= htmlspecialchars($lesson['content']) ?></textarea>
            </div>

            <button type="submit">Cập nhật bài học</button>
            <a href="?ctrl=teacher&act=manageLessons&id=<?= $lesson['course_id'] ?>" class="btn">Quay lại</a>
        </form>
    </div>
</main>

Ba điểm quan trọng:

Thứ nhất, kiểm tra quyền sở hữu phải đi qua hai bước. Bảng lessions không có cột teacher_id. Bạn phải lấy course_id của bài học, rồi từ đó lấy khóa học, rồi mới so teacher_id.

Bỏ qua bước này thì giảng viên A sửa được bài học của giảng viên B chỉ bằng cách đổi số trên URL — đúng loại lỗ hổng ở Bài 18 mục 2.9.

Thứ hai, dùng getByIdForTeacher() chứ không phải getById(). Vì giảng viên phải sửa được bài học của khóa đang ẩn — đúng bài học từ lỗi số 8.

Thứ ba, dùng id= trong chuyển hướng chứ không phải course_id=. Đây là cách đi vòng qua lỗi số 1 mà không phải sửa index.php.

Ô video đổi từ type="url" sang type="text" để chấp nhận cả mã video trần như alDeWlUHYSA có trong dữ liệu mẫu.

Đáp án bài tập 2

📄 Controllers/TeacherController.php

    public function deleteLesson($id)
    {
        include_once "./Models/Lession.php";
        $lessionModel = new Lession();
        $lesson = $lessionModel->getById($id);

        if (!$lesson) {
            header("Location: ?ctrl=teacher&act=courses");
            exit();
        }

        // Kiểm tra quyền sở hữu qua khóa học chứa bài này
        include_once "./Models/Courses.php";
        $coursesModel = new Courses();
        $course = $coursesModel->getByIdForTeacher($lesson['course_id']);

        if (!$course || $course['teacher_id'] != $_SESSION['user_id']) {
            header("Location: ?ctrl=teacher&act=courses");
            exit();
        }

        if ($lessionModel->delete($id)) {
            $_SESSION['success'] = "Xóa bài học thành công!";
        } else {
            $_SESSION['error'] = "Có lỗi xảy ra khi xóa bài học";
        }

        header("Location: ?ctrl=teacher&act=manageLessons&id=" . $lesson['course_id']);
        exit();
    }

Điểm mấu chốt: phải lấy $lesson TRƯỚC khi xóa.

Sau khi delete() chạy, dòng đó không còn trong database. Nếu bạn định gọi getById() sau khi xóa để biết course_id mà chuyển hướng về, sẽ không lấy được gì.

Đây là mẫu chung cho mọi thao tác xóa: thu thập mọi thông tin cần dùng trước, rồi mới xóa.

Khác với courses, bảng lessions không bị bảng nào tham chiếu tới, nên xóa bài học luôn thành công — không gặp vấn đề khóa ngoại như xóa khóa học ở Bài 18.

Đáp án bài tập 3

Cách A — gọi Model trong vòng lặp:

📄 Controllers/TeacherController.php — thêm vào courses()

        include_once "./Models/Lession.php";
        $lessionModel = new Lession();
        $soBaiHoc = [];
        foreach ($teacherCourses as $c) {
            $soBaiHoc[$c['id']] = $lessionModel->countByCourse($c['id']);
        }

📄 Views/teacher_course.php

                        <th>Bài học</th>
                        ...
                            <td><?= $soBaiHoc[$course['id']] ?? 0 ?></td>

Số truy vấn với 10 khóa học: 1 + 10 = 11.

Cách B — thêm subquery:

📄 Models/Courses.php — sửa getByTeacher()

  function getByTeacher($teacher_id)
  {
    $sql = "SELECT c.*, 
            (SELECT COUNT(*) FROM enrollments WHERE course_id = c.id AND role = 'student') as student_count,
            (SELECT COUNT(*) FROM lessions WHERE course_id = c.id) as lesson_count
            FROM courses c 
            WHERE c.teacher_id = ?
            ORDER BY c.id DESC";
    return $this->db->query($sql, $teacher_id);
  }

📄 Views/teacher_course.php

                            <td><?= $course['lesson_count'] ?? 0 ?></td>

Số truy vấn với 10 khóa học: 1.

So sánh:

Cách A Cách B
Truy vấn với 10 khóa 11 1
Truy vấn với 100 khóa 101 1
Code trong Controller 6 dòng 0 dòng
Kết nối database mới 1 (thêm Model Lession) 0

Cách B thắng ở mọi mặt. Ít truy vấn hơn, ít code hơn, và không phải tạo thêm object Model.

Điểm duy nhất cách A hơn: nếu bạn cần danh sách bài học chứ không chỉ số lượng, thì subquery không giúp được.

Nguyên tắc rút ra — nhận diện vấn đề N+1:

Mỗi khi bạn thấy mình viết:

foreach ($danhSach as $item) {
    $model->layGiDo($item['id']);      // dấu hiệu N+1
}

hãy dừng lại và tự hỏi: có gộp được vào truy vấn lấy $danhSach không?

Ba cách gộp, theo thứ tự nên thử:

  1. Subquery trong SELECT — dễ viết nhất, hợp khi chỉ cần một con số
  2. LEFT JOIN cộng GROUP BY — nhanh hơn với dữ liệu lớn
  3. Một truy vấn phụ lấy hết rồi tra cứu trong PHP — hợp khi cần nhiều dữ liệu, như cách bạn làm ở Bài 11 đáp án 1

➡️ Bài tiếp theo: Bài 20 — Thống kê và báo cáo

⬅️ Về mục lục · Bài trước


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í