BỘ DATABASE BLUEPRINT — WEB/APP DEVELOPMENT (MySQL 8.x/9.x) Mỗi bài là một schema khởi đầu có thể copy–dán và tùy biến. Trước production, hãy dùng migration có version, user database tối thiểu quyền, sao lưu có kiểm tra khôi phục và đánh giá bảo mật/pháp lý. Không lưu mật khẩu, token đăng nhập hay khóa API ở dạng thô. # 05/10 — Schema cho LMS/e-learning Một LMS cần tách **khóa học** khỏi **phiên bản khóa học**. Nếu giáo viên thay video/bài kiểm tra, học viên cũ vẫn có thể hoàn thành theo cấu trúc mà họ đã đăng ký. Không lưu đáp án đúng ở frontend; chấm điểm phía server, và chỉ lưu dữ liệu học viên cần thiết. ## Quan hệ chính ```text courses ─< course_versions ─< course_modules ─< lessons users ─< enrollments >─ course_versions users ─< lesson_progress >─ lessons course_versions ─< quizzes ─< questions ─< choices users ─< quiz_attempts >─ quizzes ``` ## Schema SQL ```sql CREATE DATABASE IF NOT EXISTS lms_app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE lms_app; CREATE TABLE users ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL, full_name VARCHAR(160) NOT NULL, status ENUM('active','disabled') NOT NULL DEFAULT 'active', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uq_lms_users_email (email) ) ENGINE=InnoDB; CREATE TABLE courses ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, owner_id BIGINT UNSIGNED NOT NULL, code VARCHAR(60) NOT NULL, title VARCHAR(255) NOT NULL, status ENUM('draft','published','archived') NOT NULL DEFAULT 'draft', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uq_courses_code (code), CONSTRAINT fk_courses_owner FOREIGN KEY (owner_id) REFERENCES users(id), KEY idx_courses_status (status) ) ENGINE=InnoDB; CREATE TABLE course_versions ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, course_id BIGINT UNSIGNED NOT NULL, version_no INT UNSIGNED NOT NULL, title_snapshot VARCHAR(255) NOT NULL, status ENUM('draft','published','retired') NOT NULL DEFAULT 'draft', published_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uq_course_version (course_id, version_no), CONSTRAINT fk_versions_course FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, KEY idx_versions_course_status (course_id, status) ) ENGINE=InnoDB; CREATE TABLE course_modules ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, course_version_id BIGINT UNSIGNED NOT NULL, title VARCHAR(255) NOT NULL, sort_order INT UNSIGNED NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uq_modules_order (course_version_id, sort_order), CONSTRAINT fk_modules_version FOREIGN KEY (course_version_id) REFERENCES course_versions(id) ON DELETE CASCADE ) ENGINE=InnoDB; CREATE TABLE lessons ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, module_id BIGINT UNSIGNED NOT NULL, lesson_type ENUM('video','article','file','live') NOT NULL, title VARCHAR(255) NOT NULL, content_json JSON NULL, duration_seconds INT UNSIGNED NULL, sort_order INT UNSIGNED NOT NULL, is_preview BOOLEAN NOT NULL DEFAULT FALSE, UNIQUE KEY uq_lessons_order (module_id, sort_order), CONSTRAINT fk_lessons_module FOREIGN KEY (module_id) REFERENCES course_modules(id) ON DELETE CASCADE, KEY idx_lessons_module (module_id) ) ENGINE=InnoDB; CREATE TABLE enrollments ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, course_version_id BIGINT UNSIGNED NOT NULL, status ENUM('active','completed','cancelled','expired') NOT NULL DEFAULT 'active', enrolled_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, completed_at DATETIME NULL, expires_at DATETIME NULL, UNIQUE KEY uq_enrollments_user_version (user_id, course_version_id), CONSTRAINT fk_enrollments_user FOREIGN KEY (user_id) REFERENCES users(id), CONSTRAINT fk_enrollments_version FOREIGN KEY (course_version_id) REFERENCES course_versions(id), KEY idx_enrollments_user_status (user_id, status) ) ENGINE=InnoDB; CREATE TABLE lesson_progress ( enrollment_id BIGINT UNSIGNED NOT NULL, lesson_id BIGINT UNSIGNED NOT NULL, progress_percent DECIMAL(5,2) NOT NULL DEFAULT 0, completed_at DATETIME NULL, last_position_seconds INT UNSIGNED NOT NULL DEFAULT 0, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (enrollment_id, lesson_id), CONSTRAINT fk_progress_enrollment FOREIGN KEY (enrollment_id) REFERENCES enrollments(id) ON DELETE CASCADE, CONSTRAINT fk_progress_lesson FOREIGN KEY (lesson_id) REFERENCES lessons(id) ON DELETE CASCADE, CHECK (progress_percent BETWEEN 0 AND 100) ) ENGINE=InnoDB; CREATE TABLE quizzes ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, course_version_id BIGINT UNSIGNED NOT NULL, title VARCHAR(255) NOT NULL, passing_score DECIMAL(5,2) NOT NULL DEFAULT 70, max_attempts INT UNSIGNED NULL, CONSTRAINT fk_quizzes_version FOREIGN KEY (course_version_id) REFERENCES course_versions(id) ON DELETE CASCADE, CHECK (passing_score BETWEEN 0 AND 100) ) ENGINE=InnoDB; CREATE TABLE questions ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, quiz_id BIGINT UNSIGNED NOT NULL, question_type ENUM('single_choice','multiple_choice','text') NOT NULL, prompt TEXT NOT NULL, points DECIMAL(7,2) NOT NULL DEFAULT 1, sort_order INT UNSIGNED NOT NULL, CONSTRAINT fk_questions_quiz FOREIGN KEY (quiz_id) REFERENCES quizzes(id) ON DELETE CASCADE, KEY idx_questions_quiz_order (quiz_id, sort_order) ) ENGINE=InnoDB; CREATE TABLE question_choices ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, question_id BIGINT UNSIGNED NOT NULL, choice_text TEXT NOT NULL, is_correct BOOLEAN NOT NULL DEFAULT FALSE, sort_order INT UNSIGNED NOT NULL, CONSTRAINT fk_choices_question FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE ) ENGINE=InnoDB; CREATE TABLE quiz_attempts ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, quiz_id BIGINT UNSIGNED NOT NULL, enrollment_id BIGINT UNSIGNED NOT NULL, attempt_no INT UNSIGNED NOT NULL, status ENUM('in_progress','submitted','graded') NOT NULL DEFAULT 'in_progress', score DECIMAL(5,2) NULL, started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, submitted_at DATETIME NULL, UNIQUE KEY uq_attempt_no (quiz_id, enrollment_id, attempt_no), CONSTRAINT fk_attempt_quiz FOREIGN KEY (quiz_id) REFERENCES quizzes(id), CONSTRAINT fk_attempt_enrollment FOREIGN KEY (enrollment_id) REFERENCES enrollments(id) ON DELETE CASCADE, KEY idx_attempt_enrollment (enrollment_id, submitted_at) ) ENGINE=InnoDB; CREATE TABLE attempt_answers ( attempt_id BIGINT UNSIGNED NOT NULL, question_id BIGINT UNSIGNED NOT NULL, answer_json JSON NULL, awarded_points DECIMAL(7,2) NULL, graded_at DATETIME NULL, PRIMARY KEY (attempt_id, question_id), CONSTRAINT fk_answers_attempt FOREIGN KEY (attempt_id) REFERENCES quiz_attempts(id) ON DELETE CASCADE, CONSTRAINT fk_answers_question FOREIGN KEY (question_id) REFERENCES questions(id) ) ENGINE=InnoDB; ``` ## Quy tắc triển khai Khi ghi `lesson_progress`, backend phải xác nhận lesson thuộc `course_version_id` đúng với enrollment; nếu không, người dùng có thể sửa ID để ghi tiến độ vào khóa khác. Với quiz, server đọc đáp án đúng, tính `awarded_points`, khóa attempt thành `submitted/graded` trong transaction; đừng gửi `is_correct` xuống browser trước thời điểm cho phép. Khi phát hành version mới, không sửa lesson của version cũ đã có người học — clone sang version mới. Bài 06 xử lý bài toán đặt lịch, trong đó một slot chỉ được bán/giữ cho một booking.