Xây Dựng Video Platform: Course & Lesson Data Model

User đã register và login được rồi. Giờ cần cái gì đó cho họ xem. Trong bài này, chúng ta sẽ xây dựng content data model — course, section, và lesson — với JPA relationship đúng chuẩn, database migration, và full admin CRUD API.
Data model là xương sống của cả platform. Làm đúng từ đầu thì mọi feature sau này (upload, transcoding, player, catalog) cứ plug in thôi. Làm sai thì suốt ngày chạy schema migration.
Thời gian: 2–3 giờ
Yêu cầu: Phase 2: Authentication
Những gì chúng ta sẽ build trong bài này:
✅ Course, Section, và Lesson JPA entity với relationship
✅ Flyway database migration cho versioned schema management
✅ Admin CRUD API cho course, section, và lesson
✅ Slug generation từ course title
✅ Sort ordering hỗ trợ drag-and-drop
✅ DTO mapping với MapStruct
✅ Validation với Bean Validation (Jakarta)
Data Model Architecture
Trước khi viết code, hãy hình dung các entity liên kết với nhau thế nào:
Hierarchy đơn giản: Course → Section → Lesson. Một course có nhiều section (kiểu "Getting Started", "Advanced Topics"), mỗi section có nhiều lesson. Cấu trúc ba tầng này là chuẩn mà Udemy, Coursera, và mọi course platform lớn đều dùng.
Setup Flyway
Mấy bài trước chúng ta tạo table thủ công. Giờ làm đúng chuẩn với versioned database migration.
Thêm Flyway Dependency
// build.gradle
dependencies {
// ... dependency hiện có
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-database-postgresql'
}Cấu hình Flyway
# src/main/resources/application.yaml
spring:
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
baseline-version: 0Setting baseline-on-migrate: true cho phép Flyway hoạt động với database đã có sẵn — nó không fail nếu table đã tồn tại từ bài trước.
Migration: Users Table (Baseline)
Vì chúng ta đã tạo table users và oauth_accounts trong bài authentication, ta sẽ viết baseline migration dùng IF NOT EXISTS:
-- src/main/resources/db/migration/V1__create_users_tables.sql
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255),
name VARCHAR(255) NOT NULL,
avatar_url VARCHAR(500),
role VARCHAR(20) NOT NULL DEFAULT 'USER',
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS oauth_accounts (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL,
provider_account_id VARCHAR(255) NOT NULL,
access_token VARCHAR(500),
refresh_token VARCHAR(500),
UNIQUE(provider, provider_account_id)
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_oauth_accounts_user_id ON oauth_accounts(user_id);Migration: Course Table
-- src/main/resources/db/migration/V2__create_course_tables.sql
-- Courses
CREATE TABLE courses (
id BIGSERIAL PRIMARY KEY,
slug VARCHAR(255) NOT NULL UNIQUE,
title VARCHAR(255) NOT NULL,
description TEXT,
short_description VARCHAR(500),
thumbnail_url VARCHAR(500),
difficulty VARCHAR(20) NOT NULL DEFAULT 'BEGINNER',
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
total_duration INT NOT NULL DEFAULT 0,
total_lessons INT NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
published_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Sections
CREATE TABLE sections (
id BIGSERIAL PRIMARY KEY,
course_id BIGINT NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Lessons
CREATE TABLE lessons (
id BIGSERIAL PRIMARY KEY,
section_id BIGINT NOT NULL REFERENCES sections(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
video_path VARCHAR(500),
duration INT NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
is_free_preview BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Index
CREATE INDEX idx_courses_slug ON courses(slug);
CREATE INDEX idx_courses_status ON courses(status);
CREATE INDEX idx_sections_course_id ON sections(course_id);
CREATE INDEX idx_sections_sort_order ON sections(course_id, sort_order);
CREATE INDEX idx_lessons_section_id ON lessons(section_id);
CREATE INDEX idx_lessons_sort_order ON lessons(section_id, sort_order);Migration: Progress Table
-- src/main/resources/db/migration/V3__create_progress_tables.sql
-- Course enrollment
CREATE TABLE course_enrollments (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
course_id BIGINT NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
progress_pct INT NOT NULL DEFAULT 0,
last_lesson_id BIGINT REFERENCES lessons(id) ON DELETE SET NULL,
enrolled_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
PRIMARY KEY (user_id, course_id)
);
-- Lesson progress
CREATE TABLE lesson_progress (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
lesson_id BIGINT NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
watched_seconds INT NOT NULL DEFAULT 0,
completed BOOLEAN NOT NULL DEFAULT FALSE,
last_position INT NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, lesson_id)
);
CREATE INDEX idx_course_enrollments_user ON course_enrollments(user_id);
CREATE INDEX idx_lesson_progress_user ON lesson_progress(user_id);Khi Spring Boot start, Flyway tự động chạy mọi migration chưa chạy. Bạn sẽ thấy log kiểu:
Flyway Community Edition 10.x.x
Successfully validated 3 migrations
Current version of schema "public": << Empty Schema >>
Migrating schema "public" to version 1 - create users tables
Migrating schema "public" to version 2 - create course tables
Migrating schema "public" to version 3 - create progress tablesJPA Entity
Enum
Đầu tiên, define các enum dùng chung:
// src/main/java/com/videoplatform/api/model/enums/CourseStatus.java
package com.videoplatform.api.model.enums;
public enum CourseStatus {
DRAFT,
PUBLISHED,
ARCHIVED
}// src/main/java/com/videoplatform/api/model/enums/Difficulty.java
package com.videoplatform.api.model.enums;
public enum Difficulty {
BEGINNER,
INTERMEDIATE,
ADVANCED
}// src/main/java/com/videoplatform/api/model/enums/LessonStatus.java
package com.videoplatform.api.model.enums;
public enum LessonStatus {
PROCESSING,
READY,
FAILED
}Course Entity
// src/main/java/com/videoplatform/api/model/Course.java
package com.videoplatform.api.model;
import com.videoplatform.api.model.enums.CourseStatus;
import com.videoplatform.api.model.enums.Difficulty;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "courses")
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String slug;
@Column(nullable = false)
private String title;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "short_description", length = 500)
private String shortDescription;
@Column(name = "thumbnail_url", length = 500)
private String thumbnailUrl;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Difficulty difficulty = Difficulty.BEGINNER;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private CourseStatus status = CourseStatus.DRAFT;
@Column(name = "total_duration")
private int totalDuration = 0;
@Column(name = "total_lessons")
private int totalLessons = 0;
@Column(name = "sort_order")
private int sortOrder = 0;
@Column(name = "published_at")
private LocalDateTime publishedAt;
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@OneToMany(mappedBy = "course", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("sortOrder ASC")
private List<Section> sections = new ArrayList<>();
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
// Getter và setter
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getSlug() { return slug; }
public void setSlug(String slug) { this.slug = slug; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getShortDescription() { return shortDescription; }
public void setShortDescription(String shortDescription) { this.shortDescription = shortDescription; }
public String getThumbnailUrl() { return thumbnailUrl; }
public void setThumbnailUrl(String thumbnailUrl) { this.thumbnailUrl = thumbnailUrl; }
public Difficulty getDifficulty() { return difficulty; }
public void setDifficulty(Difficulty difficulty) { this.difficulty = difficulty; }
public CourseStatus getStatus() { return status; }
public void setStatus(CourseStatus status) { this.status = status; }
public int getTotalDuration() { return totalDuration; }
public void setTotalDuration(int totalDuration) { this.totalDuration = totalDuration; }
public int getTotalLessons() { return totalLessons; }
public void setTotalLessons(int totalLessons) { this.totalLessons = totalLessons; }
public int getSortOrder() { return sortOrder; }
public void setSortOrder(int sortOrder) { this.sortOrder = sortOrder; }
public LocalDateTime getPublishedAt() { return publishedAt; }
public void setPublishedAt(LocalDateTime publishedAt) { this.publishedAt = publishedAt; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public List<Section> getSections() { return sections; }
public void setSections(List<Section> sections) { this.sections = sections; }
public void addSection(Section section) {
sections.add(section);
section.setCourse(this);
}
public void removeSection(Section section) {
sections.remove(section);
section.setCourse(null);
}
}Section Entity
// src/main/java/com/videoplatform/api/model/Section.java
package com.videoplatform.api.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "sections")
public class Section {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "course_id", nullable = false)
private Course course;
@Column(nullable = false)
private String title;
@Column(name = "sort_order")
private int sortOrder = 0;
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@OneToMany(mappedBy = "section", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("sortOrder ASC")
private List<Lesson> lessons = new ArrayList<>();
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
// Getter và setter
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Course getCourse() { return course; }
public void setCourse(Course course) { this.course = course; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public int getSortOrder() { return sortOrder; }
public void setSortOrder(int sortOrder) { this.sortOrder = sortOrder; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public List<Lesson> getLessons() { return lessons; }
public void setLessons(List<Lesson> lessons) { this.lessons = lessons; }
public void addLesson(Lesson lesson) {
lessons.add(lesson);
lesson.setSection(this);
}
public void removeLesson(Lesson lesson) {
lessons.remove(lesson);
lesson.setSection(null);
}
}Lesson Entity
// src/main/java/com/videoplatform/api/model/Lesson.java
package com.videoplatform.api.model;
import com.videoplatform.api.model.enums.LessonStatus;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "lessons")
public class Lesson {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "section_id", nullable = false)
private Section section;
@Column(nullable = false)
private String title;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "video_path", length = 500)
private String videoPath;
private int duration = 0;
@Column(name = "sort_order")
private int sortOrder = 0;
@Column(name = "is_free_preview")
private boolean freePreview = false;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private LessonStatus status = LessonStatus.PROCESSING;
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
// Getter và setter
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Section getSection() { return section; }
public void setSection(Section section) { this.section = section; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getVideoPath() { return videoPath; }
public void setVideoPath(String videoPath) { this.videoPath = videoPath; }
public int getDuration() { return duration; }
public void setDuration(int duration) { this.duration = duration; }
public int getSortOrder() { return sortOrder; }
public void setSortOrder(int sortOrder) { this.sortOrder = sortOrder; }
public boolean isFreePreview() { return freePreview; }
public void setFreePreview(boolean freePreview) { this.freePreview = freePreview; }
public LessonStatus getStatus() { return status; }
public void setStatus(LessonStatus status) { this.status = status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
}Các Quyết Định JPA Quan Trọng
| Quyết định | Lý do |
|---|---|
CascadeType.ALL + orphanRemoval trên sections | Xoá course → tất cả section và lesson biến mất |
FetchType.LAZY trên @ManyToOne | Không load parent course khi chỉ cần lấy section |
@OrderBy("sortOrder ASC") | Section và lesson luôn trả về đúng thứ tự |
@PrePersist / @PreUpdate | Timestamp do JPA quản lý, không phụ thuộc SQL default |
Enum dạng STRING thay vì ORDINAL | Thêm ARCHIVED vào giữa DRAFT và PUBLISHED không phá data cũ |
Slug Generation
Mỗi course cần một URL-friendly slug sinh từ title. Chúng ta sẽ build utility xử lý unicode, ký tự đặc biệt, và trùng lặp.
// src/main/java/com/videoplatform/api/util/SlugUtils.java
package com.videoplatform.api.util;
import java.text.Normalizer;
import java.util.Locale;
import java.util.regex.Pattern;
public final class SlugUtils {
private static final Pattern NON_LATIN = Pattern.compile("[^\\w-]");
private static final Pattern WHITESPACE = Pattern.compile("[\\s]+");
private static final Pattern MULTIPLE_DASHES = Pattern.compile("-{2,}");
private SlugUtils() {}
public static String toSlug(String input) {
if (input == null || input.isBlank()) {
throw new IllegalArgumentException("Slug input cannot be empty");
}
String slug = input.trim().toLowerCase(Locale.ROOT);
// Normalize ký tự unicode (é → e, ñ → n)
slug = Normalizer.normalize(slug, Normalizer.Form.NFD);
slug = slug.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
// Thay whitespace bằng dash
slug = WHITESPACE.matcher(slug).replaceAll("-");
// Xoá ký tự non-latin
slug = NON_LATIN.matcher(slug).replaceAll("");
// Gộp nhiều dash liên tiếp
slug = MULTIPLE_DASHES.matcher(slug).replaceAll("-");
// Trim dash đầu/cuối
slug = slug.replaceAll("^-|-$", "");
return slug;
}
public static String toUniqueSlug(String input, java.util.function.Predicate<String> existsCheck) {
String baseSlug = toSlug(input);
if (!existsCheck.test(baseSlug)) {
return baseSlug;
}
// Thêm số tăng dần cho đến khi unique
for (int i = 2; i < 1000; i++) {
String candidate = baseSlug + "-" + i;
if (!existsCheck.test(candidate)) {
return candidate;
}
}
throw new IllegalStateException("Could not generate unique slug for: " + input);
}
}Ví dụ chuyển đổi:
| Input | Output |
|---|---|
"Spring Boot Masterclass" | spring-boot-masterclass |
"React & Next.js: The Complete Guide" | react--nextjs-the-complete-guide → react-nextjs-the-complete-guide |
"Café Résumé Builder" | cafe-resume-builder |
"Spring Boot Masterclass" (trùng) | spring-boot-masterclass-2 |
Repository
// src/main/java/com/videoplatform/api/repository/CourseRepository.java
package com.videoplatform.api.repository;
import com.videoplatform.api.model.Course;
import com.videoplatform.api.model.enums.CourseStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
import java.util.Optional;
public interface CourseRepository extends JpaRepository<Course, Long> {
Optional<Course> findBySlug(String slug);
boolean existsBySlug(String slug);
List<Course> findByStatusOrderBySortOrderAsc(CourseStatus status);
@Query("SELECT c FROM Course c LEFT JOIN FETCH c.sections s " +
"LEFT JOIN FETCH s.lessons WHERE c.slug = :slug")
Optional<Course> findBySlugWithSectionsAndLessons(String slug);
@Query("SELECT c FROM Course c LEFT JOIN FETCH c.sections s " +
"LEFT JOIN FETCH s.lessons WHERE c.id = :id")
Optional<Course> findByIdWithSectionsAndLessons(Long id);
List<Course> findAllByOrderBySortOrderAsc();
}// src/main/java/com/videoplatform/api/repository/SectionRepository.java
package com.videoplatform.api.repository;
import com.videoplatform.api.model.Section;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface SectionRepository extends JpaRepository<Section, Long> {
List<Section> findByCourseIdOrderBySortOrderAsc(Long courseId);
@Query("SELECT COALESCE(MAX(s.sortOrder), -1) + 1 FROM Section s WHERE s.course.id = :courseId")
int findNextSortOrder(Long courseId);
}// src/main/java/com/videoplatform/api/repository/LessonRepository.java
package com.videoplatform.api.repository;
import com.videoplatform.api.model.Lesson;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface LessonRepository extends JpaRepository<Lesson, Long> {
List<Lesson> findBySectionIdOrderBySortOrderAsc(Long sectionId);
@Query("SELECT COALESCE(MAX(l.sortOrder), -1) + 1 FROM Lesson l WHERE l.section.id = :sectionId")
int findNextSortOrder(Long sectionId);
@Query("SELECT COUNT(l) FROM Lesson l WHERE l.section.course.id = :courseId")
int countByCourseId(Long courseId);
@Query("SELECT COALESCE(SUM(l.duration), 0) FROM Lesson l WHERE l.section.course.id = :courseId")
int sumDurationByCourseId(Long courseId);
}Query findNextSortOrder tính vị trí sort tiếp theo. Khi thêm section hoặc lesson mới, nó tự động append vào cuối.
DTO và MapStruct Mapping
Không bao giờ expose JPA entity trực tiếp qua API response. DTO cho phép kiểm soát chính xác data nào đi qua wire, tránh lazy loading issue, và tách biệt API contract khỏi database schema.
Thêm MapStruct Dependency
// build.gradle
dependencies {
// ... dependency hiện có
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
}Request DTO
// src/main/java/com/videoplatform/api/dto/request/CreateCourseRequest.java
package com.videoplatform.api.dto.request;
import com.videoplatform.api.model.enums.Difficulty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateCourseRequest(
@NotBlank(message = "Title is required")
@Size(max = 255, message = "Title must be under 255 characters")
String title,
String description,
@Size(max = 500, message = "Short description must be under 500 characters")
String shortDescription,
Difficulty difficulty
) {
public CreateCourseRequest {
if (difficulty == null) {
difficulty = Difficulty.BEGINNER;
}
}
}// src/main/java/com/videoplatform/api/dto/request/UpdateCourseRequest.java
package com.videoplatform.api.dto.request;
import com.videoplatform.api.model.enums.CourseStatus;
import com.videoplatform.api.model.enums.Difficulty;
import jakarta.validation.constraints.Size;
public record UpdateCourseRequest(
@Size(max = 255, message = "Title must be under 255 characters")
String title,
String description,
@Size(max = 500)
String shortDescription,
String thumbnailUrl,
Difficulty difficulty,
CourseStatus status
) {}// src/main/java/com/videoplatform/api/dto/request/CreateSectionRequest.java
package com.videoplatform.api.dto.request;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateSectionRequest(
@NotBlank(message = "Title is required")
@Size(max = 255)
String title
) {}// src/main/java/com/videoplatform/api/dto/request/CreateLessonRequest.java
package com.videoplatform.api.dto.request;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateLessonRequest(
@NotBlank(message = "Title is required")
@Size(max = 255)
String title,
String description,
boolean freePreview
) {}// src/main/java/com/videoplatform/api/dto/request/ReorderRequest.java
package com.videoplatform.api.dto.request;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
public record ReorderRequest(
@NotEmpty(message = "Order list cannot be empty")
List<Long> orderedIds
) {}Response DTO
// src/main/java/com/videoplatform/api/dto/response/CourseResponse.java
package com.videoplatform.api.dto.response;
import com.videoplatform.api.model.enums.CourseStatus;
import com.videoplatform.api.model.enums.Difficulty;
import java.time.LocalDateTime;
public record CourseResponse(
Long id,
String slug,
String title,
String description,
String shortDescription,
String thumbnailUrl,
Difficulty difficulty,
CourseStatus status,
int totalDuration,
int totalLessons,
int sortOrder,
LocalDateTime publishedAt,
LocalDateTime createdAt
) {}// src/main/java/com/videoplatform/api/dto/response/CourseDetailResponse.java
package com.videoplatform.api.dto.response;
import com.videoplatform.api.model.enums.CourseStatus;
import com.videoplatform.api.model.enums.Difficulty;
import java.time.LocalDateTime;
import java.util.List;
public record CourseDetailResponse(
Long id,
String slug,
String title,
String description,
String shortDescription,
String thumbnailUrl,
Difficulty difficulty,
CourseStatus status,
int totalDuration,
int totalLessons,
int sortOrder,
LocalDateTime publishedAt,
LocalDateTime createdAt,
List<SectionResponse> sections
) {}// src/main/java/com/videoplatform/api/dto/response/SectionResponse.java
package com.videoplatform.api.dto.response;
import java.util.List;
public record SectionResponse(
Long id,
String title,
int sortOrder,
List<LessonResponse> lessons
) {}// src/main/java/com/videoplatform/api/dto/response/LessonResponse.java
package com.videoplatform.api.dto.response;
import com.videoplatform.api.model.enums.LessonStatus;
public record LessonResponse(
Long id,
String title,
String description,
int duration,
int sortOrder,
boolean freePreview,
LessonStatus status,
String videoPath
) {}MapStruct Mapper
// src/main/java/com/videoplatform/api/mapper/CourseMapper.java
package com.videoplatform.api.mapper;
import com.videoplatform.api.dto.response.*;
import com.videoplatform.api.model.*;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
public interface CourseMapper {
CourseResponse toCourseResponse(Course course);
@Mapping(target = "sections", source = "sections")
CourseDetailResponse toCourseDetailResponse(Course course);
@Mapping(target = "lessons", source = "lessons")
SectionResponse toSectionResponse(Section section);
LessonResponse toLessonResponse(Lesson lesson);
}MapStruct generate implementation lúc compile — không reflection, không runtime overhead. Annotation @Mapper(componentModel = "spring") biến nó thành Spring bean, inject ở đâu cũng được.
Service Layer
CourseService
// src/main/java/com/videoplatform/api/service/CourseService.java
package com.videoplatform.api.service;
import com.videoplatform.api.dto.request.*;
import com.videoplatform.api.dto.response.*;
import com.videoplatform.api.exception.ResourceNotFoundException;
import com.videoplatform.api.mapper.CourseMapper;
import com.videoplatform.api.model.Course;
import com.videoplatform.api.model.enums.CourseStatus;
import com.videoplatform.api.repository.CourseRepository;
import com.videoplatform.api.repository.LessonRepository;
import com.videoplatform.api.util.SlugUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
@Service
public class CourseService {
private final CourseRepository courseRepository;
private final LessonRepository lessonRepository;
private final CourseMapper courseMapper;
public CourseService(CourseRepository courseRepository,
LessonRepository lessonRepository,
CourseMapper courseMapper) {
this.courseRepository = courseRepository;
this.lessonRepository = lessonRepository;
this.courseMapper = courseMapper;
}
public List<CourseResponse> getAllCourses() {
return courseRepository.findAllByOrderBySortOrderAsc()
.stream()
.map(courseMapper::toCourseResponse)
.toList();
}
public List<CourseResponse> getPublishedCourses() {
return courseRepository.findByStatusOrderBySortOrderAsc(CourseStatus.PUBLISHED)
.stream()
.map(courseMapper::toCourseResponse)
.toList();
}
public CourseDetailResponse getCourseBySlug(String slug) {
Course course = courseRepository.findBySlugWithSectionsAndLessons(slug)
.orElseThrow(() -> new ResourceNotFoundException("Course not found: " + slug));
return courseMapper.toCourseDetailResponse(course);
}
public CourseDetailResponse getCourseById(Long id) {
Course course = courseRepository.findByIdWithSectionsAndLessons(id)
.orElseThrow(() -> new ResourceNotFoundException("Course not found: " + id));
return courseMapper.toCourseDetailResponse(course);
}
@Transactional
public CourseResponse createCourse(CreateCourseRequest request) {
Course course = new Course();
course.setTitle(request.title());
course.setDescription(request.description());
course.setShortDescription(request.shortDescription());
course.setDifficulty(request.difficulty());
// Generate unique slug từ title
String slug = SlugUtils.toUniqueSlug(
request.title(),
courseRepository::existsBySlug
);
course.setSlug(slug);
Course saved = courseRepository.save(course);
return courseMapper.toCourseResponse(saved);
}
@Transactional
public CourseResponse updateCourse(Long id, UpdateCourseRequest request) {
Course course = courseRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Course not found: " + id));
if (request.title() != null) {
course.setTitle(request.title());
// Tạo lại slug nếu title thay đổi
String slug = SlugUtils.toUniqueSlug(
request.title(),
s -> !s.equals(course.getSlug()) && courseRepository.existsBySlug(s)
);
course.setSlug(slug);
}
if (request.description() != null) {
course.setDescription(request.description());
}
if (request.shortDescription() != null) {
course.setShortDescription(request.shortDescription());
}
if (request.thumbnailUrl() != null) {
course.setThumbnailUrl(request.thumbnailUrl());
}
if (request.difficulty() != null) {
course.setDifficulty(request.difficulty());
}
if (request.status() != null) {
course.setStatus(request.status());
// Set publishedAt khi publish lần đầu
if (request.status() == CourseStatus.PUBLISHED && course.getPublishedAt() == null) {
course.setPublishedAt(LocalDateTime.now());
}
}
Course saved = courseRepository.save(course);
return courseMapper.toCourseResponse(saved);
}
@Transactional
public void deleteCourse(Long id) {
if (!courseRepository.existsById(id)) {
throw new ResourceNotFoundException("Course not found: " + id);
}
courseRepository.deleteById(id);
}
@Transactional
public void reorderCourses(ReorderRequest request) {
List<Long> orderedIds = request.orderedIds();
for (int i = 0; i < orderedIds.size(); i++) {
Course course = courseRepository.findById(orderedIds.get(i))
.orElseThrow(() -> new ResourceNotFoundException("Course not found"));
course.setSortOrder(i);
courseRepository.save(course);
}
}
@Transactional
public void refreshCourseStats(Long courseId) {
Course course = courseRepository.findById(courseId)
.orElseThrow(() -> new ResourceNotFoundException("Course not found: " + courseId));
course.setTotalLessons(lessonRepository.countByCourseId(courseId));
course.setTotalDuration(lessonRepository.sumDurationByCourseId(courseId));
courseRepository.save(course);
}
}SectionService
// src/main/java/com/videoplatform/api/service/SectionService.java
package com.videoplatform.api.service;
import com.videoplatform.api.dto.request.CreateSectionRequest;
import com.videoplatform.api.dto.request.ReorderRequest;
import com.videoplatform.api.dto.response.SectionResponse;
import com.videoplatform.api.exception.ResourceNotFoundException;
import com.videoplatform.api.mapper.CourseMapper;
import com.videoplatform.api.model.Course;
import com.videoplatform.api.model.Section;
import com.videoplatform.api.repository.CourseRepository;
import com.videoplatform.api.repository.SectionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class SectionService {
private final SectionRepository sectionRepository;
private final CourseRepository courseRepository;
private final CourseMapper courseMapper;
public SectionService(SectionRepository sectionRepository,
CourseRepository courseRepository,
CourseMapper courseMapper) {
this.sectionRepository = sectionRepository;
this.courseRepository = courseRepository;
this.courseMapper = courseMapper;
}
public List<SectionResponse> getSectionsByCourseId(Long courseId) {
return sectionRepository.findByCourseIdOrderBySortOrderAsc(courseId)
.stream()
.map(courseMapper::toSectionResponse)
.toList();
}
@Transactional
public SectionResponse createSection(Long courseId, CreateSectionRequest request) {
Course course = courseRepository.findById(courseId)
.orElseThrow(() -> new ResourceNotFoundException("Course not found: " + courseId));
Section section = new Section();
section.setTitle(request.title());
section.setSortOrder(sectionRepository.findNextSortOrder(courseId));
section.setCourse(course);
Section saved = sectionRepository.save(section);
return courseMapper.toSectionResponse(saved);
}
@Transactional
public SectionResponse updateSection(Long sectionId, CreateSectionRequest request) {
Section section = sectionRepository.findById(sectionId)
.orElseThrow(() -> new ResourceNotFoundException("Section not found: " + sectionId));
section.setTitle(request.title());
Section saved = sectionRepository.save(section);
return courseMapper.toSectionResponse(saved);
}
@Transactional
public void deleteSection(Long sectionId) {
if (!sectionRepository.existsById(sectionId)) {
throw new ResourceNotFoundException("Section not found: " + sectionId);
}
sectionRepository.deleteById(sectionId);
}
@Transactional
public void reorderSections(Long courseId, ReorderRequest request) {
List<Long> orderedIds = request.orderedIds();
for (int i = 0; i < orderedIds.size(); i++) {
Section section = sectionRepository.findById(orderedIds.get(i))
.orElseThrow(() -> new ResourceNotFoundException("Section not found"));
section.setSortOrder(i);
sectionRepository.save(section);
}
}
}LessonService
// src/main/java/com/videoplatform/api/service/LessonService.java
package com.videoplatform.api.service;
import com.videoplatform.api.dto.request.CreateLessonRequest;
import com.videoplatform.api.dto.request.ReorderRequest;
import com.videoplatform.api.dto.response.LessonResponse;
import com.videoplatform.api.exception.ResourceNotFoundException;
import com.videoplatform.api.mapper.CourseMapper;
import com.videoplatform.api.model.Lesson;
import com.videoplatform.api.model.Section;
import com.videoplatform.api.repository.LessonRepository;
import com.videoplatform.api.repository.SectionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class LessonService {
private final LessonRepository lessonRepository;
private final SectionRepository sectionRepository;
private final CourseService courseService;
private final CourseMapper courseMapper;
public LessonService(LessonRepository lessonRepository,
SectionRepository sectionRepository,
CourseService courseService,
CourseMapper courseMapper) {
this.lessonRepository = lessonRepository;
this.sectionRepository = sectionRepository;
this.courseService = courseService;
this.courseMapper = courseMapper;
}
public List<LessonResponse> getLessonsBySectionId(Long sectionId) {
return lessonRepository.findBySectionIdOrderBySortOrderAsc(sectionId)
.stream()
.map(courseMapper::toLessonResponse)
.toList();
}
@Transactional
public LessonResponse createLesson(Long sectionId, CreateLessonRequest request) {
Section section = sectionRepository.findById(sectionId)
.orElseThrow(() -> new ResourceNotFoundException("Section not found: " + sectionId));
Lesson lesson = new Lesson();
lesson.setTitle(request.title());
lesson.setDescription(request.description());
lesson.setFreePreview(request.freePreview());
lesson.setSortOrder(lessonRepository.findNextSortOrder(sectionId));
lesson.setSection(section);
Lesson saved = lessonRepository.save(lesson);
// Update course stat
courseService.refreshCourseStats(section.getCourse().getId());
return courseMapper.toLessonResponse(saved);
}
@Transactional
public LessonResponse updateLesson(Long lessonId, CreateLessonRequest request) {
Lesson lesson = lessonRepository.findById(lessonId)
.orElseThrow(() -> new ResourceNotFoundException("Lesson not found: " + lessonId));
lesson.setTitle(request.title());
lesson.setDescription(request.description());
lesson.setFreePreview(request.freePreview());
Lesson saved = lessonRepository.save(lesson);
return courseMapper.toLessonResponse(saved);
}
@Transactional
public void deleteLesson(Long lessonId) {
Lesson lesson = lessonRepository.findById(lessonId)
.orElseThrow(() -> new ResourceNotFoundException("Lesson not found: " + lessonId));
Long courseId = lesson.getSection().getCourse().getId();
lessonRepository.deleteById(lessonId);
// Update course stat sau khi xoá
courseService.refreshCourseStats(courseId);
}
@Transactional
public void reorderLessons(Long sectionId, ReorderRequest request) {
List<Long> orderedIds = request.orderedIds();
for (int i = 0; i < orderedIds.size(); i++) {
Lesson lesson = lessonRepository.findById(orderedIds.get(i))
.orElseThrow(() -> new ResourceNotFoundException("Lesson not found"));
lesson.setSortOrder(i);
lessonRepository.save(lesson);
}
}
}REST Controller
Admin Course Controller
Tất cả admin endpoint nằm dưới /api/admin/courses và yêu cầu role ADMIN:
// src/main/java/com/videoplatform/api/controller/AdminCourseController.java
package com.videoplatform.api.controller;
import com.videoplatform.api.dto.ApiResponse;
import com.videoplatform.api.dto.request.*;
import com.videoplatform.api.dto.response.*;
import com.videoplatform.api.service.CourseService;
import com.videoplatform.api.service.SectionService;
import com.videoplatform.api.service.LessonService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/admin/courses")
@PreAuthorize("hasRole('ADMIN')")
public class AdminCourseController {
private final CourseService courseService;
private final SectionService sectionService;
private final LessonService lessonService;
public AdminCourseController(CourseService courseService,
SectionService sectionService,
LessonService lessonService) {
this.courseService = courseService;
this.sectionService = sectionService;
this.lessonService = lessonService;
}
// ==================== Course ====================
@GetMapping
public ResponseEntity<ApiResponse<List<CourseResponse>>> getAllCourses() {
List<CourseResponse> courses = courseService.getAllCourses();
return ResponseEntity.ok(ApiResponse.success(courses));
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<CourseDetailResponse>> getCourse(@PathVariable Long id) {
CourseDetailResponse course = courseService.getCourseById(id);
return ResponseEntity.ok(ApiResponse.success(course));
}
@PostMapping
public ResponseEntity<ApiResponse<CourseResponse>> createCourse(
@Valid @RequestBody CreateCourseRequest request) {
CourseResponse course = courseService.createCourse(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success("Course created", course));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<CourseResponse>> updateCourse(
@PathVariable Long id,
@Valid @RequestBody UpdateCourseRequest request) {
CourseResponse course = courseService.updateCourse(id, request);
return ResponseEntity.ok(ApiResponse.success("Course updated", course));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> deleteCourse(@PathVariable Long id) {
courseService.deleteCourse(id);
return ResponseEntity.ok(ApiResponse.success("Course deleted", null));
}
@PutMapping("/reorder")
public ResponseEntity<ApiResponse<Void>> reorderCourses(
@Valid @RequestBody ReorderRequest request) {
courseService.reorderCourses(request);
return ResponseEntity.ok(ApiResponse.success("Courses reordered", null));
}
// ==================== Section ====================
@GetMapping("/{courseId}/sections")
public ResponseEntity<ApiResponse<List<SectionResponse>>> getSections(
@PathVariable Long courseId) {
List<SectionResponse> sections = sectionService.getSectionsByCourseId(courseId);
return ResponseEntity.ok(ApiResponse.success(sections));
}
@PostMapping("/{courseId}/sections")
public ResponseEntity<ApiResponse<SectionResponse>> createSection(
@PathVariable Long courseId,
@Valid @RequestBody CreateSectionRequest request) {
SectionResponse section = sectionService.createSection(courseId, request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success("Section created", section));
}
@PutMapping("/sections/{sectionId}")
public ResponseEntity<ApiResponse<SectionResponse>> updateSection(
@PathVariable Long sectionId,
@Valid @RequestBody CreateSectionRequest request) {
SectionResponse section = sectionService.updateSection(sectionId, request);
return ResponseEntity.ok(ApiResponse.success("Section updated", section));
}
@DeleteMapping("/sections/{sectionId}")
public ResponseEntity<ApiResponse<Void>> deleteSection(@PathVariable Long sectionId) {
sectionService.deleteSection(sectionId);
return ResponseEntity.ok(ApiResponse.success("Section deleted", null));
}
@PutMapping("/{courseId}/sections/reorder")
public ResponseEntity<ApiResponse<Void>> reorderSections(
@PathVariable Long courseId,
@Valid @RequestBody ReorderRequest request) {
sectionService.reorderSections(courseId, request);
return ResponseEntity.ok(ApiResponse.success("Sections reordered", null));
}
// ==================== Lesson ====================
@GetMapping("/sections/{sectionId}/lessons")
public ResponseEntity<ApiResponse<List<LessonResponse>>> getLessons(
@PathVariable Long sectionId) {
List<LessonResponse> lessons = lessonService.getLessonsBySectionId(sectionId);
return ResponseEntity.ok(ApiResponse.success(lessons));
}
@PostMapping("/sections/{sectionId}/lessons")
public ResponseEntity<ApiResponse<LessonResponse>> createLesson(
@PathVariable Long sectionId,
@Valid @RequestBody CreateLessonRequest request) {
LessonResponse lesson = lessonService.createLesson(sectionId, request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success("Lesson created", lesson));
}
@PutMapping("/lessons/{lessonId}")
public ResponseEntity<ApiResponse<LessonResponse>> updateLesson(
@PathVariable Long lessonId,
@Valid @RequestBody CreateLessonRequest request) {
LessonResponse lesson = lessonService.updateLesson(lessonId, request);
return ResponseEntity.ok(ApiResponse.success("Lesson updated", lesson));
}
@DeleteMapping("/lessons/{lessonId}")
public ResponseEntity<ApiResponse<Void>> deleteLesson(@PathVariable Long lessonId) {
lessonService.deleteLesson(lessonId);
return ResponseEntity.ok(ApiResponse.success("Lesson deleted", null));
}
@PutMapping("/sections/{sectionId}/lessons/reorder")
public ResponseEntity<ApiResponse<Void>> reorderLessons(
@PathVariable Long sectionId,
@Valid @RequestBody ReorderRequest request) {
lessonService.reorderLessons(sectionId, request);
return ResponseEntity.ok(ApiResponse.success("Lessons reordered", null));
}
}Public Course Controller
Public endpoint phục vụ course catalog — chỉ course đã publish, không có admin field:
// src/main/java/com/videoplatform/api/controller/CourseController.java
package com.videoplatform.api.controller;
import com.videoplatform.api.dto.ApiResponse;
import com.videoplatform.api.dto.response.CourseDetailResponse;
import com.videoplatform.api.dto.response.CourseResponse;
import com.videoplatform.api.service.CourseService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/courses")
public class CourseController {
private final CourseService courseService;
public CourseController(CourseService courseService) {
this.courseService = courseService;
}
@GetMapping
public ResponseEntity<ApiResponse<List<CourseResponse>>> getPublishedCourses() {
List<CourseResponse> courses = courseService.getPublishedCourses();
return ResponseEntity.ok(ApiResponse.success(courses));
}
@GetMapping("/{slug}")
public ResponseEntity<ApiResponse<CourseDetailResponse>> getCourseBySlug(
@PathVariable String slug) {
CourseDetailResponse course = courseService.getCourseBySlug(slug);
return ResponseEntity.ok(ApiResponse.success(course));
}
}Update Security Config
Thêm các route mới vào Spring Security config từ Post #3:
// Thêm vào SecurityConfig.java filterChain() method:
.requestMatchers("/api/courses/**").permitAll() // Public catalog
.requestMatchers("/api/admin/**").hasRole("ADMIN") // Admin endpointAPI Request Flow
Đây là flow hoàn chỉnh khi admin tạo course mới với section và lesson:
Sort Ordering và Drag-and-Drop
Các reorder endpoint nhận mảng ID theo thứ tự mong muốn. Frontend (sẽ build ở Post #5) gửi mảng này sau drag-and-drop:
# Reorder section trong course 1
curl -X PUT http://localhost:8080/api/admin/courses/1/sections/reorder \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${TOKEN}" \
-d '{"orderedIds": [3, 1, 2]}'Lệnh này set sort_order thành 0, 1, 2 cho section có ID 3, 1, 2. Annotation @OrderBy("sortOrder ASC") đảm bảo query luôn trả về đúng thứ tự.
Test API
1. Tạo Course
# Đầu tiên, login admin để lấy token
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "admin123"}' \
| jq -r '.data.accessToken')
# Tạo course
curl -X POST http://localhost:8080/api/admin/courses \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${TOKEN}" \
-d '{
"title": "Spring Boot Masterclass",
"description": "Learn Spring Boot from scratch to production deployment",
"shortDescription": "Complete Spring Boot course",
"difficulty": "BEGINNER"
}'Response:
{
"success": true,
"message": "Course created",
"data": {
"id": 1,
"slug": "spring-boot-masterclass",
"title": "Spring Boot Masterclass",
"description": "Learn Spring Boot from scratch to production deployment",
"shortDescription": "Complete Spring Boot course",
"thumbnailUrl": null,
"difficulty": "BEGINNER",
"status": "DRAFT",
"totalDuration": 0,
"totalLessons": 0,
"sortOrder": 0,
"publishedAt": null,
"createdAt": "2026-03-22T10:30:00"
}
}2. Thêm Section
# Thêm section "Getting Started"
curl -X POST http://localhost:8080/api/admin/courses/1/sections \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${TOKEN}" \
-d '{"title": "Getting Started"}'
# Thêm section "Core Concepts"
curl -X POST http://localhost:8080/api/admin/courses/1/sections \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${TOKEN}" \
-d '{"title": "Core Concepts"}'3. Thêm Lesson
# Thêm lesson vào section 1
curl -X POST http://localhost:8080/api/admin/courses/sections/1/lessons \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${TOKEN}" \
-d '{
"title": "What is Spring Boot?",
"description": "Introduction to the Spring Boot framework",
"freePreview": true
}'
curl -X POST http://localhost:8080/api/admin/courses/sections/1/lessons \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${TOKEN}" \
-d '{
"title": "Setting Up Your Development Environment",
"description": "Install Java, IntelliJ, and create your first project",
"freePreview": true
}'4. Lấy Course với Cấu Trúc Đầy Đủ
curl http://localhost:8080/api/admin/courses/1 \
-H "Authorization: Bearer ${TOKEN}" | jqResponse:
{
"success": true,
"data": {
"id": 1,
"slug": "spring-boot-masterclass",
"title": "Spring Boot Masterclass",
"status": "DRAFT",
"totalLessons": 2,
"sections": [
{
"id": 1,
"title": "Getting Started",
"sortOrder": 0,
"lessons": [
{
"id": 1,
"title": "What is Spring Boot?",
"sortOrder": 0,
"freePreview": true,
"status": "PROCESSING"
},
{
"id": 2,
"title": "Setting Up Your Development Environment",
"sortOrder": 1,
"freePreview": true,
"status": "PROCESSING"
}
]
},
{
"id": 2,
"title": "Core Concepts",
"sortOrder": 1,
"lessons": []
}
]
}
}5. Publish Course
curl -X PUT http://localhost:8080/api/admin/courses/1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${TOKEN}" \
-d '{"status": "PUBLISHED"}'6. Truy Cập Public Catalog
# Không cần auth
curl http://localhost:8080/api/courses | jq
curl http://localhost:8080/api/courses/spring-boot-masterclass | jqAPI Reference Đầy Đủ
| Method | Endpoint | Mô tả | Auth |
|---|---|---|---|
GET | /api/courses | List course đã publish | Public |
GET | /api/courses/:slug | Lấy course theo slug (kèm section/lesson) | Public |
GET | /api/admin/courses | List tất cả course (kể cả draft) | Admin |
GET | /api/admin/courses/:id | Lấy course theo ID (kèm section/lesson) | Admin |
POST | /api/admin/courses | Tạo course | Admin |
PUT | /api/admin/courses/:id | Update course | Admin |
DELETE | /api/admin/courses/:id | Xoá course (cascade) | Admin |
PUT | /api/admin/courses/reorder | Sắp xếp lại course | Admin |
GET | /api/admin/courses/:courseId/sections | List section | Admin |
POST | /api/admin/courses/:courseId/sections | Tạo section | Admin |
PUT | /api/admin/courses/sections/:sectionId | Update section | Admin |
DELETE | /api/admin/courses/sections/:sectionId | Xoá section (cascade) | Admin |
PUT | /api/admin/courses/:courseId/sections/reorder | Sắp xếp lại section | Admin |
GET | /api/admin/courses/sections/:sectionId/lessons | List lesson | Admin |
POST | /api/admin/courses/sections/:sectionId/lessons | Tạo lesson | Admin |
PUT | /api/admin/courses/lessons/:lessonId | Update lesson | Admin |
DELETE | /api/admin/courses/lessons/:lessonId | Xoá lesson | Admin |
PUT | /api/admin/courses/sections/:sectionId/lessons/reorder | Sắp xếp lại lesson | Admin |
Tổng Kết: Những Gì Đã Build
Đến lúc này platform của bạn đã có:
- 3 Flyway migration version hoá schema và chạy tự động khi start
- 3 JPA entity (Course → Section → Lesson) với cascading relationship
- Slug generation xử lý trùng lặp và ký tự unicode
- Sort ordering sẵn sàng cho drag-and-drop reorder
- MapStruct DTO tách biệt API contract khỏi database entity
- 20 REST endpoint cho full CRUD trên course, section, và lesson
- Role-based access: admin endpoint được bảo vệ bởi
@PreAuthorize("hasRole('ADMIN')")
Lỗi Thường Gặp
1. LazyInitializationException
Nếu bạn truy cập course.getSections() ngoài transaction, Hibernate sẽ throw LazyInitializationException. Đó là lý do chúng ta dùng JOIN FETCH query trong repository:
// Sai — sẽ throw LazyInitializationException
Course course = courseRepository.findById(id).orElseThrow();
course.getSections(); // BOOM
// Đúng — fetch hết trong một query
Course course = courseRepository.findByIdWithSectionsAndLessons(id).orElseThrow();
course.getSections(); // OK2. N+1 Query Problem
Không có JOIN FETCH, load course với 10 section và 50 lesson tạo ra 1 + 10 + 50 = 61 query. Query findByIdWithSectionsAndLessons làm trong 1 lần.
3. Quên orphanRemoval = true
Không có orphanRemoval, xoá section khỏi list của course không xoá nó trong database — nó chỉ set course_id thành null (vi phạm NOT NULL constraint). Luôn kết hợp CascadeType.ALL với orphanRemoval = true cho parent-child relationship.
4. Enum ORDINAL vs STRING
// Sai — lưu dạng integer (0, 1, 2)
@Enumerated(EnumType.ORDINAL)
private CourseStatus status; // DRAFT=0, PUBLISHED=1, ARCHIVED=2
// Đúng — lưu dạng string
@Enumerated(EnumType.STRING)
private CourseStatus status; // "DRAFT", "PUBLISHED", "ARCHIVED"Với ORDINAL, thêm enum value mới ở giữa sẽ shift hết data cũ. Luôn dùng STRING.
Bước Tiếp Theo
Content data model đã sẵn sàng. Bạn có thể tạo, update, xoá, và sắp xếp lại course, section, và lesson qua admin API. Trong Post #5, chúng ta sẽ build admin dashboard frontend:
- Next.js admin layout với sidebar navigation
- Course editor form với
react-hook-form+zodvalidation - Section và lesson management panel
- Drag-and-drop reorder với
@dnd-kit - Real-time form validation và error handling
Đã đến lúc cho admin một giao diện xịn.
Series: Xây Dựng Video Streaming Platform
Trước đó: Phase 2: Authentication
Tiếp theo: Phase 4: Admin Dashboard
📬 Đăng ký nhận bản tin
Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.
Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.
💬 Comments
Đăng nhập để bình luận
Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.