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 enrollment

Transactional 屬性#

屬性說明預設值
readOnly唯讀模式,效能優化false
rollbackFor指定哪些例外要 rollbackRuntimeException
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,原因:

  1. 隱藏 Entity 內部結構
  2. 只傳遞前端需要的資料
  3. 避免循環引用問題

Q: 為什麼用 @RequiredArgsConstructor 而不是 @Autowired?#

@RequiredArgsConstructor + final 欄位是推薦做法:

  1. 欄位是 final,不能被修改
  2. 依賴明確可見
  3. 便於單元測試
// 推薦寫法
@RequiredArgsConstructor
public class MyService {
    private final UserRepository userRepository;  // final!
}

// 不推薦寫法
public class MyService {
    @Autowired
    private UserRepository userRepository;  // 不是 final
}

下一步#

理解 Service 後,接下來看 Controller 層 了解如何定義 REST API。