完整資料流程範例#

本章節透過實際功能說明前後端完整的資料流程,幫助你理解整個系統如何運作。


範例一:用戶登入#

┌─────────────┐     POST /api/auth/login      ┌─────────────┐
│   Frontend  │  ────────────────────────────> │   Backend   │
│  (Next.js)  │     { email, password }        │ (Spring)    │
│             │                                │             │
│             │  <──────────────────────────── │             │
│             │     { token, user data }       │             │
└─────────────┘                                └─────────────┘

前端:sign-in/page.tsx#

'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useUserStore } from '@/store/useUserStore';

export default function SignInPage() {
  const router = useRouter();
  const setUser = useUserStore((state) => state.setUser);
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [loading, setLoading] = useState(false);

  const handleLogin = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);

    try {
      // 1. 呼叫登入 API
      const response = await fetch(`${API_URL}/api/auth/login`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      });

      if (!response.ok) {
        throw new Error('登入失敗');
      }

      const data = await response.json();

      // 2. 儲存 JWT Token
      localStorage.setItem('token', data.token);

      // 3. 更新全域狀態
      setUser({
        id: data.userId,
        name: data.name,
        email: data.email,
        level: data.level,
        exp: data.exp,
        avatarUrl: data.avatarUrl,
        role: data.role,
      });

      // 4. 導向首頁
      router.push('/');
    } catch (error) {
      alert('登入失敗,請檢查帳號密碼');
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleLogin}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        required
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        required
      />
      <button type="submit" disabled={loading}>
        {loading ? '登入中...' : '登入'}
      </button>
    </form>
  );
}

後端:AuthController.java#

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    @PostMapping("/login")
    public ResponseEntity<AuthResponse> login(@Valid @RequestBody AuthRequest request) {
        // 呼叫 Service 處理業務邏輯
        return ResponseEntity.ok(authService.login(request));
    }
}

後端:AuthService.java#

@Service
@RequiredArgsConstructor
public class AuthService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtUtil jwtUtil;

    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());

        // 4. 回傳認證結果
        return new AuthResponse(
            token,
            user.getId(),
            user.getName(),
            user.getEmail(),
            user.getLevel(),
            user.getExp(),
            user.getRole().name(),
            user.getAvatarUrl()
        );
    }
}

範例二:觀看影片更新進度#

┌─────────────┐     POST /api/missions/123/progress    ┌─────────────┐
│   Frontend  │  ──────────────────────────────────────> │   Backend   │
│             │     Authorization: Bearer xxx           │             │
│             │     { watchedPosition, duration }       │             │
│             │                                         │             │
│             │  <────────────────────────────────────── │             │
│             │     { progress: 75, completed: false }  │             │
└─────────────┘                                         └─────────────┘

前端:影片播放器#

// 定時更新進度(每秒)
useEffect(() => {
  const interval = setInterval(() => {
    if (playerRef.current) {
      const currentTime = playerRef.current.getCurrentTime();
      const duration = playerRef.current.getDuration();
      updateProgress(currentTime, duration);
    }
  }, 1000);

  return () => clearInterval(interval);
}, []);

async function updateProgress(watchedPosition: number, duration: number) {
  const token = localStorage.getItem('token');
  if (!token) return;

  const response = await fetch(`${API_URL}/api/missions/${missionId}/progress`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token}`,
    },
    body: JSON.stringify({
      watchedPosition: Math.floor(watchedPosition),
      duration: Math.floor(duration),
      watchedTime: watchedTimeRef.current,  // 防作弊用
    }),
  });

  if (response.ok) {
    const progressData = await response.json();
    setProgress(progressData.progress);
    if (progressData.completed) {
      setCanClaimReward(true);
    }
  }
}

後端:MissionController.java#

@PostMapping("/{missionId}/progress")
public ResponseEntity<MissionProgressDto> updateProgress(
        @PathVariable Long missionId,
        @Valid @RequestBody UpdateProgressRequest request,
        Authentication authentication) {

    Long userId = getUserId(authentication);
    MissionProgressDto progress = missionService.updateProgress(
            userId,
            missionId,
            request.getWatchedPosition(),
            request.getDuration(),
            request.getWatchedTime()
    );
    return ResponseEntity.ok(progress);
}

後端:MissionService.java#

@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"));

    // 權限檢查
    Course course = mission.getChapter().getCourse();
    if (!course.getHasFreePreview() || !mission.getIsFreePreview()) {
        boolean isEnrolled = enrollmentService.isEnrolled(userId, course.getId());
        if (!isEnrolled) {
            throw new AccessDeniedException("Must enroll in course");
        }
    }

    // 取得或建立進度記錄
    MissionProgress progress = progressRepository.findByUserIdAndMissionId(userId, missionId)
            .orElseGet(() -> createNewProgress(userId, mission));

    // 計算進度百分比(基於實際觀看時間,防作弊)
    int progressPercent = (watchedTime != null && watchedTime > 0)
            ? (int) ((watchedTime * 100.0) / duration)
            : (int) ((watchedPosition * 100.0) / duration);

    progress.setLastWatchedPosition(watchedPosition);
    progress.setVideoDuration(duration);
    progress.updateProgress(progressPercent);

    progress = progressRepository.save(progress);

    return MissionProgressDto.builder()
            .missionId(missionId)
            .progress(progress.getProgress())
            .completed(progress.getCompleted())
            .rewardClaimed(progress.getRewardClaimed())
            .build();
}

範例三:領取經驗值獎勵#

┌─────────────┐     POST /api/missions/123/claim     ┌─────────────┐
│   Frontend  │  ──────────────────────────────────> │   Backend   │
│             │     Authorization: Bearer xxx        │             │
│             │                                      │             │
│             │  <────────────────────────────────── │             │
│             │     { expGained: 100,               │             │
│             │       totalExp: 3100,               │             │
│             │       currentLevel: 10,             │             │
│             │       levelUp: true }               │             │
└─────────────┘                                      └─────────────┘

前端#

async function handleClaimReward() {
  const token = localStorage.getItem('token');
  if (!token) {
    router.push('/sign-in');
    return;
  }

  const response = await fetch(`${API_URL}/api/missions/${missionId}/claim`, {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${token}` },
  });

  if (response.ok) {
    const result = await response.json();

    // 顯示獎勵訊息
    if (result.levelUp) {
      alert(`🎉 恭喜升級!獲得 ${result.expGained} XP,升級至 Lv.${result.currentLevel}!`);
    } else {
      alert(`✓ 獲得 ${result.expGained} XP`);
    }

    // 更新用戶狀態
    const userStore = useUserStore.getState();
    userStore.setUser({
      ...userStore.user!,
      exp: result.totalExp,
      level: result.currentLevel,
    });

    // 重新載入任務資料
    await fetchMission();
  }
}

後端:MissionService.java#

@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);  // 自動計算升級
    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)
            .newLevel(levelUp ? newLevel : null)
            .build();
}

Entity:User.java(自動升級邏輯)#

public void addExp(int amount) {
    this.exp += amount;
    updateLevel();  // 自動計算等級
}

private void updateLevel() {
    int[] thresholds = {0, 200, 500, 1500, 3000, 5000, 7000, 9000, ...};

    for (int i = thresholds.length - 1; i >= 0; i--) {
        if (this.exp >= thresholds[i]) {
            this.level = i + 1;
            break;
        }
    }
}

資料流程總結#

認證流程#

1. 用戶輸入帳密 → 前端送 POST /api/auth/login
2. 後端驗證 → 產生 JWT → 回傳 token + user data
3. 前端儲存 token → 更新 Zustand store → 導向首頁
4. 後續請求都帶 Authorization: Bearer xxx

API 呼叫模式#

// 標準 API 呼叫模式
async function callApi(endpoint: string, options?: RequestInit) {
  const token = localStorage.getItem('token');

  const response = await fetch(`${API_URL}${endpoint}`, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...(token && { 'Authorization': `Bearer ${token}` }),
      ...options?.headers,
    },
  });

  if (!response.ok) {
    if (response.status === 401) {
      // Token 過期,導向登入頁
      localStorage.removeItem('token');
      window.location.href = '/sign-in';
    }
    throw new Error('API 錯誤');
  }

  return response.json();
}

狀態同步#

Local State (useState)    →  元件內部狀態
     ↓
Global State (Zustand)    →  用戶資訊、認證狀態
     ↓
Server State              →  從 API 取得的資料

常見問題#

Q: 為什麼每次請求都要帶 Token?#

JWT 是無狀態認證,伺服器不儲存 session,所以每次請求都要帶 token 讓伺服器知道你是誰。

Q: Token 過期怎麼處理?#

目前是導向登入頁重新登入。更好的做法是使用 Refresh Token 自動續期。

Q: 如何處理並發請求?#

使用 @Transactional 確保資料一致性,避免 race condition。

Q: 前端怎麼知道用戶等級升級了?#

後端在 claimReward 回應中包含 levelUp: true,前端據此顯示升級動畫。


下一步#

恭喜!你已經了解了這個專案的完整架構。建議:

  1. 實際跑一遍 docker-compose up --build
  2. 登入測試帳號體驗功能
  3. 閱讀程式碼,嘗試修改功能
  4. 查看 API 文檔 了解所有端點

有問題歡迎回來查閱這份技術指南!