Service 層詳解#
Service 層負責業務邏輯,將 Controller(API 端點)和 Repository(資料存取)連接起來。這是整個應用程式最核心的部分。
檔案位置#
backend/src/main/java/tw/waterballsa/academy/service/
├── AuthService.java # 認證:登入、註冊、OAuth
├── UserService.java # 用戶:個人資料
├── EnrollmentService.java # 報名:購課、查詢報名
├── MissionService.java # 任務:進度追蹤、領獎
├── CourseService.java # 課程:列表、詳情
├── GymService.java # 道場:解鎖檢查
├── ChallengeService.java # 挑戰:提交作業
├── GradeService.java # 評分:老師批改
├── FileStorageService.java # 檔案:上傳下載
└── GoogleAuthService.java # Google OAuth 整合核心概念#
1. 基本 Service 結構#
@Service // 標記為 Spring Service
@RequiredArgsConstructor // Lombok: 自動產生建構子注入依賴
public class EnrollmentService {
// final 欄位會被 @RequiredArgsConstructor 自動注入
private final CourseEnrollmentRepository enrollmentRepository;
private final CourseRepository courseRepository;
private final UserRepository userRepository;
// 業務方法
public EnrollmentDto enrollCourse(Long userId, Long courseId, String paymentMethod) {
// 業務邏輯...
}
}Django 對照:
# services.py
class EnrollmentService:
def __init__(self):
# Django 通常不用依賴注入,直接 import model
pass
def enroll_course(self, user_id, course_id, payment_method):
# 業務邏輯...
pass
# 或直接在 views.py 中寫業務邏輯2. 依賴注入#
Spring 使用依賴注入 (DI) 管理元件之間的依賴關係:
@Service
@RequiredArgsConstructor // Lombok 自動產生建構子
public class MissionService {
private final MissionRepository missionRepository; // 自動注入
private final MissionProgressRepository progressRepository; // 自動注入
private final UserRepository userRepository; // 自動注入
private final EnrollmentService enrollmentService; // Service 也可以注入 Service
// Spring 會自動建立這個建構子:
// public MissionService(MissionRepository missionRepository, ...) {
// this.missionRepository = missionRepository;
// ...
// }
}Django 對照:
# Django 通常直接 import,不用依賴注入
from .models import Mission, MissionProgress, User
from .services import EnrollmentService
class MissionService:
def __init__(self):
self.enrollment_service = EnrollmentService()交易管理 (@Transactional)#
基本用法#
@Service
@RequiredArgsConstructor
public class EnrollmentService {
@Transactional // 整個方法在一個交易中
public EnrollmentDto enrollCourse(Long userId, Long courseId, String paymentMethod) {
// 1. 檢查是否已購買
if (enrollmentRepository.existsByUserIdAndCourseIdAndStatus(...)) {
throw new IllegalStateException("Already enrolled"); // 拋例外會自動 rollback
}
// 2. 建立報名記錄
CourseEnrollment enrollment = CourseEnrollment.builder()...build();
enrollmentRepository.save(enrollment);
// 3. 更新課程學員數
course.setStudentCount(course.getStudentCount() + 1);
courseRepository.save(course);
// 方法結束後自動 commit
return convertToDto(enrollment);
}
@Transactional(readOnly = true) // 唯讀交易,效能更好
public List<EnrollmentDto> getUserEnrollments(Long userId) {
return enrollmentRepository.findByUserIdAndStatus(...)
.stream()
.map(this::convertToDto)
.collect(Collectors.toList());
}
}Django 對照:
from django.db import transaction
@transaction.atomic # 等同 @Transactional
def enroll_course(user_id, course_id, payment_method):
# 任何例外都會 rollback
enrollment = CourseEnrollment.objects.create(...)
course.student_count += 1
course.save()
return enrollmentTransactional 屬性#
| 屬性 | 說明 | 預設值 |
|---|---|---|
readOnly | 唯讀模式,效能優化 | false |
rollbackFor | 指定哪些例外要 rollback | RuntimeException |
propagation | 交易傳播行為 | REQUIRED |
timeout | 超時秒數 | -1 (無限) |
@Transactional(
readOnly = true,
timeout = 30,
rollbackFor = Exception.class
)
public List<Course> getCourses() { ... }實際範例分析#
AuthService - 認證服務#
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder; // Spring Security 密碼編碼器
private final JwtUtil jwtUtil; // JWT 工具類
private final GoogleAuthService googleAuthService;
// 註冊
public AuthResponse register(RegisterRequest request) {
// 1. 檢查 email 是否已存在
if (userRepository.existsByEmail(request.getEmail())) {
throw new RuntimeException("Email already exists");
}
// 2. 建立用戶(密碼加密)
User user = User.builder()
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword())) // BCrypt 加密
.name(request.getName())
.avatarUrl("https://ui-avatars.com/api/?name=" + request.getName())
.authProvider(User.AuthProvider.LOCAL)
.role(User.UserRole.STUDENT)
.level(1)
.exp(0)
.build();
user = userRepository.save(user);
// 3. 產生 JWT Token
String token = jwtUtil.generateToken(user.getEmail());
// 4. 回傳認證結果
return new AuthResponse(token, user.getId(), user.getName(), ...);
}
// 登入
public AuthResponse login(AuthRequest request) {
// 1. 查找用戶
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new RuntimeException("Invalid credentials"));
// 2. 驗證密碼
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new RuntimeException("Invalid credentials");
}
// 3. 產生 JWT Token
String token = jwtUtil.generateToken(user.getEmail());
return new AuthResponse(token, user.getId(), user.getName(), ...);
}
// Google OAuth 登入
public AuthResponse googleLogin(String idToken) {
// 1. 驗證 Google Token
GoogleIdToken.Payload payload = googleAuthService.verifyGoogleToken(idToken);
// 2. 取得用戶資訊
String email = googleAuthService.getEmail(payload);
String name = googleAuthService.getName(payload);
String pictureUrl = googleAuthService.getPictureUrl(payload);
// 3. 查找或建立用戶
User user = userRepository.findByEmail(email)
.map(existingUser -> {
// 已存在:更新資料
existingUser.setAvatarUrl(pictureUrl);
return userRepository.save(existingUser);
})
.orElseGet(() -> {
// 不存在:建立新用戶
User newUser = User.builder()
.email(email)
.name(name)
.avatarUrl(pictureUrl)
.authProvider(User.AuthProvider.GOOGLE)
.build();
return userRepository.save(newUser);
});
String token = jwtUtil.generateToken(user.getEmail());
return new AuthResponse(token, user.getId(), ...);
}
}MissionService - 任務服務#
@Service
@RequiredArgsConstructor
public class MissionService {
private final MissionRepository missionRepository;
private final MissionProgressRepository progressRepository;
private final UserRepository userRepository;
private final EnrollmentService enrollmentService;
// 取得任務詳情
public MissionDetailDto getMissionDetail(Long missionId, Long userId) {
Mission mission = missionRepository.findById(missionId)
.orElseThrow(() -> new RuntimeException("Mission not found"));
// 權限檢查
Course course = mission.getChapter().getCourse();
boolean isFreeToView = course.getHasFreePreview() && mission.getIsFreePreview();
if (!isFreeToView && userId != null) {
boolean isEnrolled = enrollmentService.isEnrolled(userId, course.getId());
if (!isEnrolled) {
throw new AccessDeniedException("You must enroll in this course");
}
}
// 取得用戶進度
MissionProgressDto progressDto = null;
if (userId != null) {
MissionProgress progress = progressRepository
.findByUserIdAndMissionId(userId, missionId)
.orElse(null);
if (progress != null) {
progressDto = MissionProgressDto.builder()
.progress(progress.getProgress())
.completed(progress.getCompleted())
.rewardClaimed(progress.getRewardClaimed())
.build();
}
}
return MissionDetailDto.builder()
.id(mission.getId())
.title(mission.getTitle())
.type(mission.getType().name())
.videoUrl(mission.getVideoUrl())
.expReward(mission.getExpReward())
.userProgress(progressDto)
.build();
}
// 更新觀看進度
@Transactional
public MissionProgressDto updateProgress(Long userId, Long missionId,
int watchedPosition, int duration, Integer watchedTime) {
Mission mission = missionRepository.findById(missionId)
.orElseThrow(() -> new RuntimeException("Mission not found"));
// 取得或建立進度記錄
MissionProgress progress = progressRepository.findByUserIdAndMissionId(userId, missionId)
.orElseGet(() -> {
User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found"));
return MissionProgress.builder()
.user(user)
.mission(mission)
.progress(0)
.completed(false)
.rewardClaimed(false)
.build();
});
// 計算觀看百分比
int progressPercent = (duration > 0) ? (int) ((watchedPosition * 100.0) / duration) : 0;
progress.setLastWatchedPosition(watchedPosition);
progress.setVideoDuration(duration);
progress.updateProgress(progressPercent);
progress = progressRepository.save(progress);
return MissionProgressDto.builder()
.missionId(missionId)
.progress(progress.getProgress())
.completed(progress.getCompleted())
.build();
}
// 領取獎勵
@Transactional
public ClaimRewardResponse claimReward(Long userId, Long missionId) {
MissionProgress progress = progressRepository.findByUserIdAndMissionId(userId, missionId)
.orElseThrow(() -> new RuntimeException("Progress not found"));
// 檢查條件
if (progress.getRewardClaimed()) {
throw new RuntimeException("Reward already claimed");
}
if (progress.getProgress() < 100) {
throw new RuntimeException("Mission not completed yet");
}
// 發放經驗值
User user = progress.getUser();
int oldLevel = user.getLevel();
int expGained = progress.getMission().getExpReward();
user.addExp(expGained); // Entity 內部方法會自動更新等級
progress.setRewardClaimed(true);
userRepository.save(user);
progressRepository.save(progress);
int newLevel = user.getLevel();
boolean levelUp = newLevel > oldLevel;
return ClaimRewardResponse.builder()
.expGained(expGained)
.totalExp(user.getExp())
.currentLevel(newLevel)
.levelUp(levelUp)
.build();
}
}DTO 轉換#
Service 通常需要將 Entity 轉換為 DTO(Data Transfer Object):
@Service
@RequiredArgsConstructor
public class EnrollmentService {
// Entity → DTO 轉換方法
private EnrollmentDto convertToDto(CourseEnrollment enrollment) {
Course course = enrollment.getCourse();
Long userId = enrollment.getUser().getId();
Long courseId = course.getId();
// 計算課程完成度
int completionPercentage = calculateCourseCompletion(userId, courseId);
return EnrollmentDto.builder()
.id(enrollment.getId())
.courseId(course.getId())
.courseName(course.getName())
.courseImageUrl(course.getImageUrl())
.enrollmentType(enrollment.getType().name())
.status(enrollment.getStatus().name())
.enrolledAt(enrollment.getEnrolledAt())
.completionPercentage(completionPercentage)
.build();
}
// 使用 Stream 批量轉換
public List<EnrollmentDto> getUserEnrollments(Long userId) {
return enrollmentRepository.findByUserIdAndStatus(userId, PaymentStatus.COMPLETED)
.stream()
.map(this::convertToDto) // 方法引用
.collect(Collectors.toList());
}
}Django 對照:
# serializers.py
class EnrollmentSerializer(serializers.ModelSerializer):
course_name = serializers.CharField(source='course.name')
completion_percentage = serializers.SerializerMethodField()
class Meta:
model = CourseEnrollment
fields = ['id', 'course_id', 'course_name', 'completion_percentage']
def get_completion_percentage(self, obj):
return calculate_course_completion(obj.user_id, obj.course_id)例外處理#
業務例外#
@Service
public class EnrollmentService {
@Transactional
public EnrollmentDto enrollCourse(Long userId, Long courseId, String paymentMethod) {
// 業務驗證
if (enrollmentRepository.existsByUserIdAndCourseIdAndStatus(...)) {
throw new IllegalStateException("Already enrolled in this course");
}
Course course = courseRepository.findById(courseId)
.orElseThrow(() -> new RuntimeException("Course not found"));
User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found"));
// ...
}
}權限例外#
import org.springframework.security.access.AccessDeniedException;
if (!isEnrolled) {
throw new AccessDeniedException("You must enroll in this course");
}Django 對照:
from rest_framework.exceptions import PermissionDenied, NotFound
if not is_enrolled:
raise PermissionDenied("You must enroll in this course")
try:
course = Course.objects.get(id=course_id)
except Course.DoesNotExist:
raise NotFound("Course not found")Service 設計原則#
1. 單一職責#
每個 Service 負責一個業務領域:
AuthService- 只處理認證EnrollmentService- 只處理報名MissionService- 只處理任務
2. Service 可以呼叫 Service#
@Service
@RequiredArgsConstructor
public class MissionService {
private final EnrollmentService enrollmentService; // 注入其他 Service
public MissionDetailDto getMissionDetail(Long missionId, Long userId) {
// 使用其他 Service 的功能
boolean isEnrolled = enrollmentService.isEnrolled(userId, courseId);
}
}3. 所有業務邏輯都在 Service#
- Controller 只負責接收請求、回傳響應
- Repository 只負責資料存取
- Service 處理所有業務邏輯
常見問題#
Q: 什麼時候用 @Transactional?#
- 需要寫入資料 的方法一定要用
- 多個資料庫操作需要一起成功或失敗 時要用
- 只讀取資料 可以用
@Transactional(readOnly = true)優化效能
Q: Service 要 return Entity 還是 DTO?#
通常 return DTO,原因:
- 隱藏 Entity 內部結構
- 只傳遞前端需要的資料
- 避免循環引用問題
Q: 為什麼用 @RequiredArgsConstructor 而不是 @Autowired?#
@RequiredArgsConstructor + final 欄位是推薦做法:
- 欄位是 final,不能被修改
- 依賴明確可見
- 便於單元測試
// 推薦寫法
@RequiredArgsConstructor
public class MyService {
private final UserRepository userRepository; // final!
}
// 不推薦寫法
public class MyService {
@Autowired
private UserRepository userRepository; // 不是 final
}下一步#
理解 Service 後,接下來看 Controller 層 了解如何定義 REST API。