0

EzCode # Bài 17 — Khu vực giảng viên

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

  • Dùng __construct() làm lớp chặn quyền cho toàn bộ một Controller
  • Viết truy vấn có subquery để đếm học viên của từng khóa học
  • Có dashboard giảng viên với sidebar riêng và bảng quản lý khóa học

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


1. Vấn đề đặt ra

Khu vực giảng viên của EzCode có 10 chức năng: dashboard, danh sách khóa học, tạo khóa, sửa khóa, xóa khóa, quản lý bài học, tạo bài học, xem học viên, xem thống kê.

Cả 10 đều phải chặn học viên. Nếu viết theo cách của UserController ở Bài 16, bạn sẽ lặp lại đoạn kiểm tra này 10 lần:

if (!isset($_SESSION['user_id']) || $_SESSION['user_role'] !== 'teacher') {
    header("Location: ?ctrl=user&act=login");
    exit();
}

Lặp 10 lần là 10 cơ hội để quên. Và chỉ cần quên một chỗ là học viên vào được nơi không được phép.

Bài này cho bạn thấy một cách gọn hơn nhiều — dùng đúng kiến thức về __construct() mà bạn đã học từ Bài 6.


2. Kiến thức mới

2.1. Constructor làm lớp chặn quyền

Nhớ lại index.php ở Bài 9:

$ctrl = new (ucfirst($_GET['ctrl']) . "Controller")();   // dòng 15 — tạo object
$act = $_GET['act'];
$ctrl->$act(...$args);                                    // dòng 24 — gọi phương thức

Object được tạo ở dòng 15, trước khi phương thức được gọi ở dòng 24. Mà new luôn chạy __construct().

Nghĩa là: đặt kiểm tra quyền vào __construct() thì nó chạy trước mọi phương thức, không sót cái nào.

class TeacherController
{
    public function __construct()
    {
        if (!isset($_SESSION['user_id']) || $_SESSION['user_role'] !== 'teacher') {
            header("Location: ?ctrl=user&act=login");
            exit();
        }
    }

    public function dashboard() { ... }   // đã được bảo vệ
    public function courses()   { ... }   // đã được bảo vệ
    public function analytics() { ... }   // đã được bảo vệ
}

Thêm phương thức mới cũng tự động được bảo vệ. Không thể quên.

Đây là cách EzCode làm "middleware" — một lớp trung gian chạy trước mọi yêu cầu — mà không cần framework. Laravel gọi nó là middleware, Symfony gọi là event listener, nhưng ý tưởng giống hệt.

Vì sao UserController không làm được như vậy? Vì register() và login() phải cho người chưa đăng nhập vào. Constructor sẽ chặn cả hai. Muốn dùng constructor, bạn phải tách thành hai Controller riêng.

2.2. Điều kiện hai vế với ||

if (!isset($_SESSION['user_id']) || $_SESSION['user_role'] !== 'teacher') {

Đọc là: "nếu chưa đăng nhập hoặc vai trò không phải giảng viên thì đá ra".

Giống && ở Bài 15 mục 2.2, toán tử || cũng đánh giá tắt — nhưng theo chiều ngược lại: nếu vế trái đã đúng thì vế phải không chạy.

Thứ tự ở đây bắt buộc phải như vậy. Nếu đảo lại:

if ($_SESSION['user_role'] !== 'teacher' || !isset($_SESSION['user_id'])) {   // SAI

thì với khách chưa đăng nhập, PHP đọc $_SESSION['user_role'] — một khóa không tồn tại — và sinh Warning: Undefined array key.

Quy tắc chung: kiểm tra sự tồn tại trước, kiểm tra nội dung sau. Đúng với cả && lẫn ||.

2.3. Subquery trong SELECT

Đây là kỹ thuật SQL mới của bài.

SELECT c.*, 
       (SELECT COUNT(*) FROM enrollments WHERE course_id = c.id AND role = 'student') as student_count
FROM courses c 
WHERE c.teacher_id = ?

Câu SELECT bên trong ngoặc gọi là subquery (truy vấn con). Nó chạy một lần cho mỗi dòng của truy vấn ngoài.

Hình dung: giảng viên có 2 khóa học. MySQL làm việc như sau:

  1. Lấy dòng khóa học thứ nhất, c.id = 20
  2. Chạy SELECT COUNT(*) FROM enrollments WHERE course_id = 20 AND role = 'student' → kết quả 3
  3. Gắn số 3 vào cột student_count của dòng đó
  4. Lấy dòng khóa học thứ hai, c.id = 21
  5. Chạy lại subquery với course_id = 21 → kết quả 0
  6. Gắn số 0 vào

Kết quả cuối cùng:

id title student_count
21 Internet of Things 0
20 Lập trình Python 3

Điểm mấu chốt: subquery tham chiếu tới c.id — cột của truy vấn ngoài. Đây gọi là subquery tương quan, vì nó phụ thuộc vào từng dòng bên ngoài.

So sánh với JOIN:

Bạn cũng làm được việc này bằng LEFT JOIN cộng GROUP BY:

SELECT c.*, COUNT(e.user_id) as student_count
FROM courses c
LEFT JOIN enrollments e ON c.id = e.course_id AND e.role = 'student'
WHERE c.teacher_id = ?
GROUP BY c.id
Subquery LEFT JOIN + GROUP BY
Dễ đọc Dễ hơn với người mới Cần hiểu GROUP BY
Hiệu năng Chậm hơn khi nhiều dòng Nhanh hơn
Đếm nhiều thứ cùng lúc Mỗi thứ một subquery Một JOIN cho mỗi bảng

Với 2 khóa học thì không khác gì. Với 10.000 khóa học thì subquery chạy 10.000 lần và chậm rõ rệt.

EzCode dùng subquery ở 4 truy vấn. Đây là lựa chọn hợp lý cho quy mô của project.

2.4. array_column() và array_sum()

Hai hàm này bạn đã gặp ở Bài 3 đáp án 3. Giờ chúng xuất hiện trong code thật.

number_format(array_sum(array_column($teacherCourses ?? [], 'price')))

Đọc từ trong ra ngoài:

  1. $teacherCourses ?? [] — nếu biến chưa tồn tại thì dùng mảng rỗng
  2. array_column(..., 'price') — rút cột price ra thành mảng phẳng [30000, 25000]
  3. array_sum(...) — cộng lại thành 55000
  4. number_format(...) — thành 55,000

Bốn hàm lồng nhau trong một dòng. Gọn nhưng khó đọc — người mới nên tách ra:

$giaCacKhoa = array_column($teacherCourses ?? [], 'price');
$tongGia = array_sum($giaCacKhoa);
echo number_format($tongGia);

array_slice($mang, 0, 4) lấy 4 phần tử đầu tiên, dùng ở dashboard để chỉ hiện 4 khóa học gần nhất.

2.5. Sidebar tự tô sáng mục đang mở

<?php
$current_action = isset($_GET['act']) ? $_GET['act'] : 'dashboard';
?>
...
<a href="?ctrl=teacher&act=courses" class="nav-item <?= $current_action == 'courses' ? 'active' : '' ?>">

Sidebar đọc $_GET['act'] để biết đang ở trang nào, rồi thêm class active vào đúng mục đó. CSS tô class active bằng màu đậm hơn.

Kỹ thuật này giống hệt cách form giữ lại lựa chọn ở Bài 11 mục 2.5: so giá trị hiện tại với giá trị của phần tử, khớp thì in thêm một chuỗi.

Chú ý biến $current_action được khai báo ngay trong file View. Điều này phá vỡ nguyên tắc "View chỉ hiển thị" một chút, nhưng chấp nhận được vì nó chỉ là logic trình bày, không phải logic nghiệp vụ.

2.6. Toán tử ?? khi hiển thị dữ liệu

<?= $course['student_count'] ?? 0 ?>
<?= count($teacherCourses ?? []) ?>
<?= htmlspecialchars($teacher['name'] ?? 'Unknown') ?>

Ba dòng này đều phòng trường hợp biến hoặc khóa không tồn tại.

Ở đây ?? được dùng khá dày. Nhìn kỹ thì hơi thừa: $teacherCourses chắc chắn tồn tại vì Controller vừa gán nó, và student_count chắc chắn có vì subquery luôn trả về số.

Nhưng đây là thói quen phòng thủ hợp lý với View: View không nên tin rằng Controller đã làm đúng. Nếu ai đó sửa Controller mà quên gán biến, trang vẫn hiện ra thay vì đầy cảnh báo.


3. Áp dụng vào EzCode

3.1. Thêm phương thức đếm học viên vào Model

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

📁 Models/Courses.php — sửa

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

Điều quan trọng nhất về phương thức này: nó KHÔNG có WHERE c.status = 1.

Khác hẳn với getAll() và filter() mà bạn viết ở Bài 10 và 12. Đây là chủ ý đúng: giảng viên phải thấy mọi khóa học của mình, kể cả khóa đang ẩn, để bật lại được.

So sánh ba phương thức lấy danh sách khóa học:

Phương thức Lọc status Dùng cho
getAll() Có Trang chủ, người xem
filter() Có Trang danh sách, người xem
getByTeacher() Không Khu vực giảng viên

Cột student_count được đặt tên bằng as để PHP đọc được $course['student_count']. Không có as, tên cột sẽ là cả câu subquery dài — không thể viết trong PHP.

3.2. Tạo Controller giảng viên

📁 Controllers/TeacherController.php — tạo mới

<?php

class TeacherController
{
    public function __construct()
    {
        // Kiểm tra quyền truy cập - chỉ teacher mới được vào
        if (!isset($_SESSION['user_id']) || $_SESSION['user_role'] !== 'teacher') {
            header("Location: ?ctrl=user&act=login");
            exit();
        }
    }

    public function dashboard()
    {
        // Lấy thông tin teacher
        include_once "./Models/User.php";
        $userModel = new User();
        $teacher = $userModel->getById($_SESSION['user_id']);
        
        // Kiểm tra dữ liệu teacher
        if (!$teacher) {
            $_SESSION['error'] = "Không tìm thấy thông tin teacher";
            header("Location: ?ctrl=user&act=login");
            exit();
        }
        
        // Lấy thống kê khóa học của teacher
        include_once "./Models/Courses.php";
        $coursesModel = new Courses();
        $teacherCourses = $coursesModel->getByTeacher($_SESSION['user_id']);
        
        // Kiểm tra dữ liệu courses
        if ($teacherCourses === false) {
            $_SESSION['error'] = "Lỗi khi lấy danh sách khóa học";
            $teacherCourses = [];
        }
        
        // Lấy số lượng học viên đăng ký
        $totalStudents = 0;
        foreach ($teacherCourses as $course) {
            $totalStudents += $course['student_count'] ?? 0;
        }
        
        include_once "./Views/teacher_dashboard.php";
    }
    
    public function courses()
    {
        include_once "./Models/Courses.php";
        $coursesModel = new Courses();
        $teacherCourses = $coursesModel->getByTeacher($_SESSION['user_id']);
        
        include_once "./Views/teacher_course.php";
    }

Chú ý: khối trên chưa đóng class. Thêm } ở cuối file.

Bài 18 sẽ thêm 3 phương thức, Bài 19 thêm 3, Bài 20 thêm 1. Cuối cùng Controller này có 10 phương thức và dài 360 dòng — dài nhất project.

Phương thức courses() chỉ có 5 dòng vì mọi kiểm tra quyền đã nằm ở constructor. Đây là lợi ích trực tiếp của mục 2.1.

Phương thức dashboard() có hai lớp kiểm tra thừa:

if (!$teacher) { ... }
if ($teacherCourses === false) { ... }

Lớp thứ nhất phòng trường hợp người dùng đang đăng nhập nhưng tài khoản đã bị xóa khỏi database. Hiếm nhưng có thể xảy ra — và nếu xảy ra thì $teacher['name'] sẽ gây lỗi.

Lớp thứ hai phòng trường hợp truy vấn thất bại. Nhớ lại Bài 8: khối catch trong Database::query() chỉ echo lỗi rồi trả về null, không trả về false. Nên điều kiện === false này không bao giờ đúng.

Kiểm tra đúng phải là:

if (!is_array($teacherCourses)) {

Đây là một lỗi nhỏ, không gây hậu quả vì foreach trên null chỉ sinh cảnh báo chứ không chết. Nhưng nó cho thấy tác giả có ý thức phòng thủ mà chưa hiểu rõ Model của mình trả về gì.

Vòng lặp cộng dồn học viên:

$totalStudents = 0;
foreach ($teacherCourses as $course) {
    $totalStudents += $course['student_count'] ?? 0;
}

Đây có thể viết gọn bằng array_sum(array_column($teacherCourses, 'student_count')) như mục 2.4. Tác giả dùng vòng lặp ở đây nhưng lại dùng array_sum trong View. Lại một sự thiếu nhất quán về phong cách.

3.3. Tạo sidebar giảng viên

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

<?php
// Get current action to highlight active menu item
$current_action = isset($_GET['act']) ? $_GET['act'] : 'dashboard';
?>
<style>
    /* Sidebar Container */
  

    /* Header */
    .sidebar-header {
        text-align: center;
        margin-bottom: 30px;
        padding: 0 15px;
    }

    .sidebar-header h3 {
        font-size: 20px;
        margin: 0;
        color: #212396ff;
    }

    /* Navigation Links */
    .sidebar-nav {
        display: flex;
        flex-direction: column;
    }

    .nav-item {
        display: flex;
        align-items: center;
        padding: 12px 20px;
        color: #5371dcff;
        text-decoration: none;
        transition: background-color 0.3s ease;
        font-size: 15px;
        border-left: 4px solid transparent;
    }

    .nav-item i {
        margin-right: 10px;
        width: 20px;
        text-align: center;
    }

    /* Hover & Active State */
    .nav-item:hover {
        background-color: #3b82f6;
        border-left: 4px solid #ffffff;
    }

    .nav-item.active {
        background-color: #2563eb;
        /* xanh dương đậm hơn */
        border-left: 4px solid #ffffff;
        font-weight: bold;
    }
</style>

<div class="sidebar">
    <div class="sidebar-header">
        <h3>Teacher Dashboard</h3>
    </div>
    <nav class="sidebar-nav">
        <a href="?ctrl=teacher&act=dashboard" class="nav-item <?= $current_action == 'dashboard' ? 'active' : '' ?>">
            <i class="fas fa-tachometer-alt"></i>
            Tổng quan
        </a>
        <a href="?ctrl=teacher&act=courses" class="nav-item <?= $current_action == 'courses' ? 'active' : '' ?>">
            <i class="fas fa-book"></i>
            Quản lý khóa học
        </a>
        <a href="?ctrl=teacher&act=createCourse" class="nav-item <?= $current_action == 'createCourse' ? 'active' : '' ?>">
            <i class="fas fa-plus"></i>
            Tạo khóa học mới
        </a>
        <a href="?ctrl=teacher&act=students" class="nav-item <?= $current_action == 'students' ? 'active' : '' ?>">
            <i class="fas fa-users"></i>
            Quản lý học viên
        </a>
        <a href="?ctrl=teacher&act=analytics" class="nav-item <?= $current_action == 'analytics' ? 'active' : '' ?>">
            <i class="fas fa-chart-bar"></i>
            Thống kê
        </a>
    </nav>
</div>

File này chứa cả CSS trong thẻ <style>. Đây là cách làm không chuẩn — CSS nên nằm trong public/css/style.css.

Hậu quả: vì sidebar được include vào 8 View khác nhau, khối <style> này bị lặp lại 8 lần trong mã nguồn trang. Trình duyệt xử lý được nhưng lãng phí.

Ngoài ra khối /* Sidebar Container */ để trống hoàn toàn — dấu vết của một đoạn CSS bị xóa mà quên xóa cả tiêu đề.

Các thẻ <i class="fas fa-book"></i> là biểu tượng Font Awesome, thư viện được nạp từ layout_header.php ở Bài 10:

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" />

Vì nạp từ mạng ngoài nên khi không có internet, các biểu tượng sẽ biến mất.

3.4. Tạo View dashboard

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

<?php
// Debug: Kiểm tra xem file có được load không
?>
<main class="dashboard-page">
    <?php 
    // Debug: Kiểm tra include sidebar
    include_once "./Views/layout_teacher_sidebar.php"; 
    ?>
    
    <div class="dashboard-content">
        <h2>Dashboard - <?= htmlspecialchars($teacher['name'] ?? 'Unknown') ?></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; ?>
        
        <!-- Thống kê -->
        <div class="stats">
            <div class="stat-box">
                <h3><?= count($teacherCourses ?? []) ?></h3>
                <p>Khóa học</p>
            </div>
            <div class="stat-box">
                <h3><?= $totalStudents ?? 0 ?></h3>
                <p>Học viên</p>
            </div>
            <div class="stat-box">
                <h3><?= number_format(array_sum(array_column($teacherCourses ?? [], 'price'))) ?>đ</h3>
                <p>Tổng giá trị</p>
            </div>
        </div>
        
        <!-- Danh sách khóa học gần đây -->
        <h3>Khóa học của bạn</h3>
        <?php if (empty($teacherCourses ?? [])): ?>
            <div style="text-align: center; padding: 30px; color: #666;">
                <h4>Bạn chưa có khóa học nào</h4>
                <p>Hãy <a href="?ctrl=teacher&act=createCourse" style="color: #0868b4;">tạo khóa học đầu tiên</a>!</p>
            </div>
        <?php else: ?>
            <div class="courses">
                <?php foreach (array_slice($teacherCourses, 0, 4) as $course): ?>
                    <div class="course">
                        <img src="public/img/courses/<?= $course['image'] ?>" alt="<?= $course['title'] ?>" />
                        <h3><?= $course['title'] ?></h3>
                        <p>Giá: <?= number_format($course['price']) ?>đ</p>
                        <p>Học viên: <?= $course['student_count'] ?? 0 ?></p>
                        <a href="?ctrl=teacher&act=editCourse&id=<?= $course['id'] ?>" class="btn">Chỉnh sửa</a>
                    </div>
                <?php endforeach; ?>
            </div>
            
            <?php if (count($teacherCourses) > 4): ?>
                <div style="text-align: center; margin-top: 20px;">
                    <a href="?ctrl=teacher&act=courses" class="btn">Xem tất cả khóa học</a>
                </div>
            <?php endif; ?>
        <?php endif; ?>
    </div>
</main>

Hai khối ghi chú // Debug: là dấu vết tác giả để lại khi gỡ lỗi. Chúng không làm gì cả và đúng ra nên xóa. Cứ gõ y hệt.

⚠️ Lưu ý về ô thống kê thứ ba. Nó ghi nhãn "Tổng giá trị" và tính bằng array_sum(array_column($teacherCourses, 'price')) — tức cộng giá niêm yết của các khóa học.

Con số này không phải doanh thu. Giảng viên có 2 khóa giá 30.000 và 25.000 sẽ thấy "55.000đ" kể cả khi chưa ai mua khóa nào.

Đây không phải lỗi kỹ thuật — code chạy đúng như nó được viết. Nhưng nó là số liệu dễ gây hiểu nhầm, và một giảng viên mới sẽ tưởng mình đã kiếm được tiền.

Bài 20 sẽ tính doanh thu thật bằng SUM(e.price) trên bảng enrollments.

3.5. Tạo View quản lý khóa học

📁 Views/teacher_course.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ý khóa học</h2>
            <a href="?ctrl=teacher&act=createCourse" class="create-course-btn">+ Tạo khóa 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($teacherCourses)): ?>
            <div style="text-align: center; padding: 50px; color: #666;">
                <h3>Bạn chưa có khóa học nào</h3>
                <p>Hãy tạo khóa học đầu tiên để bắt đầu!</p>
                <a href="?ctrl=teacher&act=createCourse" class="btn">Tạo khóa học</a>
            </div>
        <?php else: ?>
            <table class="course-table">
                <thead>
                    <tr>
                        <th>Hình ảnh</th>
                        <th>Tên khóa học</th>
                        <th>Giá</th>
                        <th>Học viên</th>
                        <th>Trạng thái</th>
                        <th>Thao tác</th>
                    </tr>
                </thead>
                <tbody>
                    <?php foreach ($teacherCourses as $course): ?>
                        <tr>
                            <td>
                                <img src="public/img/courses/<?= $course['image'] ?>" alt="<?= $course['title'] ?>" style="width: 60px; height: 40px; object-fit: cover; border-radius: 4px;">
                            </td>
                            <td><?= htmlspecialchars($course['title']) ?></td>
                            <td><?= number_format($course['price']) ?>đ</td>
                            <td><?= $course['student_count'] ?? 0 ?></td>
                            <td>
                                <span style="color: <?= $course['status'] ? '#28a745' : '#dc3545' ?>;">
                                    <?= $course['status'] ? 'Hoạt động' : 'Ẩn' ?>
                                </span>
                            </td>
                            <td>
                                <a href="?ctrl=teacher&act=editCourse&id=<?= $course['id'] ?>" class="btn btn-sm">Sửa</a>
                                <a href="?ctrl=teacher&act=manageLessons&id=<?= $course['id'] ?>" class="btn btn-sm">Bài học</a>
                                <button onclick="deleteCourse(<?= $course['id'] ?>)" class="btn btn-sm" style="background-color: #dc3545;">Xóa</button>
                            </td>
                        </tr>
                    <?php endforeach; ?>
                </tbody>
            </table>
        <?php endif; ?>
    </div>
</main>

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

Cột Trạng thái dùng toán tử ba ngôi hai lần — một lần chọn màu, một lần chọn chữ:

<span style="color: <?= $course['status'] ? '#28a745' : '#dc3545' ?>;">
    <?= $course['status'] ? 'Hoạt động' : 'Ẩn' ?>
</span>

Cột status là tinyint với giá trị 1 hoặc 0. PHP coi 1 là đúng, 0 là sai, nên dùng thẳng làm điều kiện được.

Hãy để ý dòng này rất kỹ:

<a href="?ctrl=teacher&act=manageLessons&id=<?= $course['id'] ?>" class="btn btn-sm">Bài học</a>

Tham số tên là id. Đúng — nên nó sẽ hoạt động.

Nhưng ở Bài 19, bạn sẽ thấy Views/teacher_lessons.php sinh liên kết dùng tên course_id. Và đó là chỗ mọi thứ vỡ. Hãy nhớ sự khác biệt này.

⚠️ Lưu ý: Ba liên kết editCourse, manageLessons, deleteCourse trong bảng này trỏ tới các phương thức chưa tồn tại. Bấm vào sẽ nhận Call to undefined method.

Bài 18 sẽ viết editCourse và deleteCourse, Bài 19 viết manageLessons. Đây là hiện tượng bình thường khi xây từng phần, không phải lỗi.


4. Chạy thử

Đăng nhập bằng tài khoản giảng viên:

  • Email: nguyenhuyhoang2004k3a@gmail.com
  • Mật khẩu: 12345

Thử 1 — Vào dashboard. Bạn được chuyển thẳng tới ?ctrl=teacher&act=dashboard ngay sau khi đăng nhập.

Kết quả:

  1. Sidebar bên trái với 5 mục, mục "Tổng quan" được tô đậm
  2. Tiêu đề "Dashboard - Hoàng Nguyễn"
  3. Ba ô thống kê: 2 khóa học, 0 học viên, 55.000đ tổng giá trị
  4. Hai thẻ khóa học với nút "Chỉnh sửa"

Thử 2 — Sidebar tô sáng. Bấm "Quản lý khóa học". Mục đó chuyển sang tô đậm, mục "Tổng quan" trở lại bình thường. Đó là $current_action ở mục 2.5.

Thử 3 — Bảng quản lý. Trang ?ctrl=teacher&act=courses hiện bảng 6 cột với 2 khóa học, cột Trạng thái ghi "Hoạt động" màu xanh.

Thử 4 — Kiểm chứng subquery. Vào phpMyAdmin thêm vài dòng vào enrollments:

user_id course_id role price
2 20 student 30000
3 20 student 30000

Tải lại dashboard. Cột "Học viên" của khóa 20 hiện 2, và ô thống kê "Học viên" hiện 2. Subquery đang đếm đúng.

Thử 5 — Kiểm chứng chặn quyền. Đây là phần quan trọng nhất.

Đăng xuất. Gõ thẳng vào thanh địa chỉ:

?ctrl=teacher&act=dashboard

Bạn bị đá về trang đăng nhập. Constructor đã chặn.

Bây giờ đăng nhập bằng tài khoản học viên, rồi gõ lại địa chỉ đó. Vẫn bị đá về trang đăng nhập — vì vế thứ hai của điều kiện, $_SESSION['user_role'] !== 'teacher', đúng.

Thử thêm với ?ctrl=teacher&act=courses và ?ctrl=teacher&act=analytics. Tất cả đều bị chặn, dù bạn chưa viết analytics(). Đó là sức mạnh của constructor: nó chặn trước cả khi phương thức được tìm.

Thử 6 — Kiểm chứng khóa học ẩn. Vào phpMyAdmin, đổi status của khóa 20 thành 0.

  • Mở trang chủ và trang danh sách: khóa đó biến mất
  • Mở ?ctrl=teacher&act=courses: khóa đó vẫn còn, cột Trạng thái ghi "Ẩn" màu đỏ

Đó là sự khác biệt giữa getAll() và getByTeacher() ở mục 3.1.

Nhớ đổi lại status = 1 sau khi thử.


5. Bài tập

Bài tập 1

Thêm ô thống kê thứ tư vào dashboard: tổng số bài học của tất cả khóa học.

Dùng Lession::countByCourse() mà bạn đã viết ở Bài 13 bài tập 1, gọi trong vòng lặp ở Controller.

Sau đó suy nghĩ: cách này tốn bao nhiêu truy vấn nếu giảng viên có 20 khóa học? Có cách nào tốt hơn không?

Bài tập 2

Thêm mục "Về trang chủ" vào cuối sidebar giảng viên, trỏ về ?ctrl=page&act=home, dùng biểu tượng fa-home.

Mục này không bao giờ được tô active — hãy giải thích vì sao dựa trên cách $current_action hoạt động.

Bài tập 3

Đây là bài tập tư duy, không cần viết nhiều code.

Đăng nhập bằng tài khoản học viên, rồi lần lượt thử ba địa chỉ:

  1. ?ctrl=teacher&act=dashboard
  2. ?ctrl=teacher&act=courses
  3. ?ctrl=teacher&act=phuongThucKhongTonTai

Với mỗi trường hợp, cho biết:

  • Bạn thấy gì
  • Dòng code nào chặn bạn
  • Điều đó xảy ra trước hay sau khi index.php gọi $ctrl->$act()

6. Đáp án

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

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

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

📄 Views/teacher_dashboard.php — thêm ô thứ tư

            <div class="stat-box">
                <h3><?= $totalLessons ?? 0 ?></h3>
                <p>Bài học</p>
            </div>

Cách này tốn bao nhiêu truy vấn?

Với 20 khóa học: 1 truy vấn lấy danh sách khóa học, cộng 20 truy vấn đếm bài học. Tổng 21 truy vấn cho một lần tải trang.

Đây chính là vấn đề N+1 truy vấn mà Bài 11 đáp án 1 đã cảnh báo. Nó tăng tuyến tính theo số khóa học và sẽ giết hiệu năng khi dữ liệu lớn.

Cách tốt hơn — dùng subquery, đúng kỹ thuật của bài này:

📄 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);
  }

Rồi trong Controller chỉ cần:

$totalLessons = array_sum(array_column($teacherCourses, 'lesson_count'));

Kết quả: 1 truy vấn thay vì 21. Và bạn còn được thêm cột lesson_count cho từng khóa để hiện trong bảng ở bài tập của Bài 19.

Bài học rút ra: khi bạn thấy mình gọi Model bên trong vòng lặp, hãy dừng lại và tự hỏi liệu có gộp được vào một truy vấn không. Câu trả lời gần như luôn là có.

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

📄 Views/layout_teacher_sidebar.php — thêm vào cuối <nav>

        <a href="?ctrl=page&act=home" class="nav-item <?= $current_action == 'home' ? 'active' : '' ?>">
            <i class="fas fa-home"></i>
            Về trang chủ
        </a>

Vì sao mục này không bao giờ được tô active?

Vì $current_action đọc từ $_GET['act'] của trang hiện tại. Sidebar chỉ xuất hiện trong các View của TeacherController, mà những trang đó luôn có ctrl=teacher.

Khi người dùng bấm "Về trang chủ", họ rời khỏi khu vực giảng viên. Trang chủ dùng Views/page_home.php — file này không include sidebar. Nên không có lúc nào mà sidebar hiện ra với act=home.

Điều kiện $current_action == 'home' luôn sai. Viết ra chỉ để nhất quán với các mục khác.

Một cách viết trung thực hơn là bỏ hẳn phần kiểm tra:

        <a href="?ctrl=page&act=home" class="nav-item">
            <i class="fas fa-home"></i>
            Về trang chủ
        </a>

Code phản ánh đúng thực tế: mục này không bao giờ là mục đang mở.

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

Trường hợp 1 và 2 — dashboard và courses:

  • Bạn thấy: bị chuyển về trang đăng nhập

  • Dòng chặn: trong TeacherController::__construct()

    if (!isset($_SESSION['user_id']) || $_SESSION['user_role'] !== 'teacher') {
    

    Vế thứ nhất sai (đã đăng nhập), nhưng vế thứ hai đúng (role là student), nên || cho kết quả đúng và bạn bị đá ra.

  • Xảy ra khi nào: TRƯỚC khi index.php gọi $ctrl->$act().

    Nhớ lại index.php: dòng 15 tạo object bằng new, dòng 24 mới gọi phương thức. Constructor chạy ở dòng 15. exit() trong constructor dừng chương trình ngay tại đó — dòng 24 không bao giờ được thực hiện.

Trường hợp 3 — phương thức không tồn tại:

  • Bạn thấy: vẫn bị chuyển về trang đăng nhập, không thấy lỗi Call to undefined method
  • Dòng chặn: vẫn là constructor
  • Xảy ra khi nào: TRƯỚC. Và đây là điểm thú vị nhất của bài tập.

Vì constructor chạy ở dòng 15 và gọi exit(), PHP không bao giờ chạy tới dòng 24 để phát hiện phương thức không tồn tại.

Điều này có ý nghĩa bảo mật thật: kẻ tấn công không thể dùng lỗi Call to undefined method để dò tìm xem Controller có những phương thức nào.

Nếu constructor không chặn — chẳng hạn bạn đăng nhập bằng tài khoản giảng viên rồi thử ?ctrl=teacher&act=abcxyz — bạn sẽ thấy:

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

Thông báo này xác nhận TeacherController tồn tại và cho biết cách nó được gọi. Với một Controller không có lớp chặn quyền, kẻ tấn công có thể thử hàng loạt tên phương thức để lập bản đồ hệ thống.

Kết luận: đặt kiểm tra quyền vào __construct() không chỉ gọn code — nó còn chặn cả việc dò tìm thông tin. Đây là một lợi ích mà tác giả có lẽ không cố ý, nhưng rất đáng giá.


➡️ Bài tiếp theo: Bài 18 — CRUD khóa học và upload ảnh

⬅️ 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í