Entity 層詳解#

Entity 層負責定義資料模型,等同於 Django 的 models.py。本專案使用 JPA (Java Persistence API) 搭配 Lombok 簡化程式碼。

檔案位置#

backend/src/main/java/tw/waterballsa/academy/entity/
├── User.java              # 用戶
├── Course.java            # 課程
├── Chapter.java           # 章節
├── Mission.java           # 任務單元
├── MissionProgress.java   # 學習進度
├── CourseEnrollment.java  # 課程報名
├── Gym.java               # 道場
├── Challenge.java         # 挑戰
├── Submission.java        # 提交
└── ...

核心概念#

1. 基本 Entity 結構#

@Entity                          // 標記為 JPA Entity
@Table(name = "users")           // 對應資料表名稱
@Getter @Setter                  // Lombok: 自動產生 getter/setter
@NoArgsConstructor               // Lombok: 無參數建構子
@AllArgsConstructor              // Lombok: 全參數建構子
@Builder                         // Lombok: Builder 模式
public class User {

    @Id                                              // 主鍵
    @GeneratedValue(strategy = GenerationType.IDENTITY)  // 自動遞增
    private Long id;

    @Column(unique = true, nullable = false)         // 唯一、不可為空
    private String email;

    @Column(nullable = false)
    private String name;

    private Integer level = 1;   // 預設值
    private Integer exp = 0;
}

Django 對照:

class User(models.Model):
    email = models.EmailField(unique=True)
    name = models.CharField(max_length=100)
    level = models.IntegerField(default=1)
    exp = models.IntegerField(default=0)

    class Meta:
        db_table = 'users'

2. Lombok 註解說明#

Lombok 註解功能Django 對照
@Getter自動產生所有欄位的 getter不需要(Python 直接存取)
@Setter自動產生所有欄位的 setter不需要
@NoArgsConstructor無參數建構子__init__(self)
@AllArgsConstructor全參數建構子__init__(self, *args)
@BuilderBuilder 模式建構無對照
@Builder.DefaultBuilder 預設值default=...
@RequiredArgsConstructorfinal 欄位建構子無對照

Builder 使用範例:

User user = User.builder()
    .email("test@example.com")
    .name("Test User")
    .level(1)
    .exp(0)
    .build();

3. 欄位註解詳解#

@Column(
    name = "email",           // 資料庫欄位名稱(預設同變數名)
    nullable = false,         // 不可為 NULL
    unique = true,            // 唯一值
    length = 255              // 字串長度限制
)
private String email;

@Enumerated(EnumType.STRING)  // Enum 存成字串(而非數字)
private UserRole role;

@Builder.Default              // Builder 預設值
private Integer level = 1;

關聯關係#

一對多 (OneToMany)#

場景: 一個 Course 有多個 Chapter

// Course.java
@OneToMany(
    mappedBy = "course",              // 對應 Chapter.course 欄位
    cascade = CascadeType.ALL,        // 級聯操作(刪除 Course 也刪除 Chapter)
    orphanRemoval = true              // 移除孤兒(從 list 移除即刪除)
)
@Builder.Default
private List<Chapter> chapters = new ArrayList<>();
// Chapter.java
@ManyToOne(fetch = FetchType.LAZY)    // 延遲載入
@JoinColumn(name = "course_id", nullable = false)
private Course course;

Django 對照:

# Chapter model
class Chapter(models.Model):
    course = models.ForeignKey(
        Course,
        on_delete=models.CASCADE,      # 級聯刪除
        related_name='chapters'        # 反向關聯名稱
    )

多對多 (ManyToMany) - 透過中間表#

場景: User 和 Course 透過 CourseEnrollment 關聯

// CourseEnrollment.java (中間表實體)
@Entity
@Table(name = "course_enrollments", uniqueConstraints = {
    @UniqueConstraint(columnNames = {"user_id", "course_id"})
})
public class CourseEnrollment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "course_id", nullable = false)
    private Course course;

    @Enumerated(EnumType.STRING)
    private EnrollmentType type;  // PURCHASED, FREE, GIFTED

    private LocalDateTime enrolledAt;
}

Django 對照:

class CourseEnrollment(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    course = models.ForeignKey(Course, on_delete=models.CASCADE)
    type = models.CharField(choices=ENROLLMENT_TYPE_CHOICES)
    enrolled_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        unique_together = ['user', 'course']

生命週期 Callback#

@Entity
public class User {

    @Column(nullable = false)
    private LocalDateTime createdAt;

    private LocalDateTime updatedAt;

    @PrePersist    // INSERT 之前執行
    protected void onCreate() {
        createdAt = LocalDateTime.now();
        updatedAt = LocalDateTime.now();
    }

    @PreUpdate     // UPDATE 之前執行
    protected void onUpdate() {
        updatedAt = LocalDateTime.now();
    }
}

Django 對照:

class User(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)  # @PrePersist
    updated_at = models.DateTimeField(auto_now=True)      # @PreUpdate

實際範例分析#

User Entity#

// entity/User.java
@Entity
@Table(name = "users")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String email;

    private String password;

    @Column(nullable = false)
    private String name;

    private String avatarUrl;

    @Enumerated(EnumType.STRING)
    @Builder.Default
    private AuthProvider authProvider = AuthProvider.LOCAL;

    private String googleId;

    @Enumerated(EnumType.STRING)
    @Builder.Default
    private UserRole role = UserRole.STUDENT;

    private Integer level = 1;
    private Integer exp = 0;
    private String title = "初級工程師";

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
    @Builder.Default
    private List<UserSkill> skills = new ArrayList<>();

    // 內部 Enum 定義
    public enum AuthProvider { LOCAL, GOOGLE }
    public enum UserRole { STUDENT, TEACHER }

    // 業務方法:增加經驗值
    public void addExp(int amount) {
        this.exp += amount;
        updateLevel();
    }

    // 私有方法:根據經驗值計算等級
    private void updateLevel() {
        int[] thresholds = {0, 200, 500, 1500, 3000, 5000, ...};
        for (int i = thresholds.length - 1; i >= 0; i--) {
            if (this.exp >= thresholds[i]) {
                this.level = i + 1;
                break;
            }
        }
    }
}

重點說明:

  1. @Enumerated(EnumType.STRING) - Enum 存字串而非數字
  2. @Builder.Default - 使用 Builder 時的預設值
  3. 業務方法 addExp() 直接定義在 Entity 中(Rich Domain Model)

MissionProgress Entity#

// entity/MissionProgress.java
@Entity
@Table(name = "mission_progress", uniqueConstraints = {
    @UniqueConstraint(columnNames = {"user_id", "mission_id"})
})
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class MissionProgress {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "mission_id", nullable = false)
    private Mission mission;

    @Column(nullable = false)
    private Integer progress = 0;           // 進度百分比 (0-100)

    @Column(nullable = false)
    private Boolean completed = false;       // 是否完成

    @Column(nullable = false)
    private Boolean rewardClaimed = false;   // 是否已領獎

    private Integer lastWatchedPosition = 0; // 影片播放位置(秒)
    private Integer videoDuration;           // 影片總長度(秒)
    private Integer watchedTime = 0;         // 實際觀看時間(防作弊)

    private LocalDateTime completedAt;
    private LocalDateTime updatedAt;

    // 業務方法:更新進度
    public void updateProgress(int newProgress) {
        this.progress = Math.min(100, newProgress);
        if (this.progress >= 100 && !this.completed) {
            this.completed = true;
            this.completedAt = LocalDateTime.now();
        }
        this.updatedAt = LocalDateTime.now();
    }
}

重點說明:

  1. @UniqueConstraint - 複合唯一約束(一個用戶對一個任務只有一筆進度)
  2. FetchType.LAZY - 延遲載入,避免 N+1 查詢問題
  3. 業務方法封裝進度更新邏輯

Entity 關係圖#

User
 ├── 1:N → MissionProgress → N:1 → Mission
 ├── 1:N → CourseEnrollment → N:1 → Course
 ├── 1:N → UserSkill
 └── 1:N → Submission → N:1 → Challenge

Course
 └── 1:N → Chapter
         └── 1:N → Mission
         └── 1:N → Gym
                 └── 1:N → Challenge

常見問題#

Q: 為什麼用 FetchType.LAZY#

避免 N+1 查詢問題。LAZY 表示只在需要時才載入關聯資料。

// EAGER: 查詢 User 時自動載入所有 MissionProgress(可能很多)
// LAZY: 只在呼叫 user.getMissionProgress() 時才查詢
@ManyToOne(fetch = FetchType.LAZY)

Q: 什麼是 orphanRemoval#

當從 List 中移除元素時,自動刪除該筆資料:

@OneToMany(mappedBy = "course", orphanRemoval = true)
private List<Chapter> chapters;

// 這樣會自動刪除該 Chapter
course.getChapters().remove(0);

Q: Builder.Default 是什麼?#

Lombok Builder 預設會忽略欄位預設值,需要用 @Builder.Default 明確指定:

@Builder.Default
private Integer level = 1;  // 使用 Builder 時預設為 1

// 沒有 @Builder.Default 的話Builder 建出來的 level 會是 null

下一步#

理解 Entity 後,接下來看 Repository 層 了解如何查詢資料。