完整資料流程範例#
本章節透過實際功能說明前後端完整的資料流程,幫助你理解整個系統如何運作。
範例一:用戶登入#
┌─────────────┐ 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 xxxAPI 呼叫模式#
// 標準 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,前端據此顯示升級動畫。
下一步#
恭喜!你已經了解了這個專案的完整架構。建議:
- 實際跑一遍
docker-compose up --build - 登入測試帳號體驗功能
- 閱讀程式碼,嘗試修改功能
- 查看 API 文檔 了解所有端點
有問題歡迎回來查閱這份技術指南!