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) |
@Builder | Builder 模式建構 | 無對照 |
@Builder.Default | Builder 預設值 | default=... |
@RequiredArgsConstructor | final 欄位建構子 | 無對照 |
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;
}
}
}
}重點說明:
@Enumerated(EnumType.STRING)- Enum 存字串而非數字@Builder.Default- 使用 Builder 時的預設值- 業務方法
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();
}
}重點說明:
@UniqueConstraint- 複合唯一約束(一個用戶對一個任務只有一筆進度)FetchType.LAZY- 延遲載入,避免 N+1 查詢問題- 業務方法封裝進度更新邏輯
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 層 了解如何查詢資料。