前後端整合
本指南說明如何整合 AppFuse Server 與 AppFuse Web 前端應用程式。
CORS 配置
開發環境
在 application-dev.yml 中配置 CORS:
spring:
web:
cors:
allowed-origins: "http://localhost:5173"
allowed-methods: "*"
allowed-headers: "*"
allow-credentials: true
生產環境
使用環境變數配置:
spring:
web:
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS}
allowed-methods: "GET,POST,PUT,PATCH,DELETE"
allowed-headers: "Authorization,Content-Type"
allow-credentials: true
API 契約
統一回應格式
使用 HATEOAS 格式提供 API 回應:
@GetMapping("/{id}")
public EntityModel<ProductResponse> findById(@PathVariable Long id) {
Product product = productService.findById(id);
ProductResponse response = ProductResponse.from(product);
return EntityModel.of(response,
linkTo(methodOn(ProductController.class).findById(id)).withSelfRel(),
linkTo(methodOn(ProductController.class).findAll()).withRel("products")
);
}
前端接收:
{
"id": 1,
"name": "Product A",
"_links": {
"self": { "href": "/api/products/1" },
"products": { "href": "/api/products" }
}
}
分頁回應
使用 Spring Data 的 Page 物件:
@GetMapping
public Page<ProductResponse> findAll(Pageable pageable) {
Page<Product> products = productService.findAll(pageable);
return products.map(ProductResponse::from);
}
前端使用:
const response = await fetch('/api/products?page=0&size=10');
const data = await response.json();
// data.content - 資料陣列
// data.totalElements - 總筆數
// data.totalPages - 總頁數
錯誤處理
統一錯誤格式
建立 @ControllerAdvice 處理異常:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
ErrorResponse error = new ErrorResponse(
"NOT_FOUND",
ex.getMessage(),
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(ValidationException.class)
public ResponseEntity<ErrorResponse> handleValidation(ValidationException ex) {
ErrorResponse error = new ErrorResponse(
"VALIDATION_ERROR",
ex.getMessage(),
LocalDateTime.now()
);
return ResponseEntity.badRequest().body(error);
}
}
前端處理:
try {
const response = await api.post('/api/products', data);
} catch (error) {
if (error.response?.status === 400) {
// 處理驗證錯誤
console.error(error.response.data.message);
} else if (error.response?.status === 404) {
// 處理找不到資源
}
}
認證與授權
JWT Token 流程
- 登入:前端發送帳號密碼
const response = await api.post('/api/auth/login', {
username: 'user@example.com',
password: 'password',
});
const { accessToken, refreshToken } = response.data;
localStorage.setItem('accessToken', accessToken);
- 攜帶 Token:後續請求帶上 Authorization header
api.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
- Token 刷新:當 access token 過期時
const response = await api.post('/api/auth/refresh', {
refreshToken,
});
const { accessToken: newAccessToken } = response.data;
localStorage.setItem('accessToken', newAccessToken);
後端配置
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
檔案上傳
後端實作
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<FileUploadResponse> uploadFile(
@RequestParam("file") MultipartFile file) {
String filename = fileService.store(file);
FileUploadResponse response = new FileUploadResponse(
filename,
file.getSize(),
file.getContentType()
);
return ResponseEntity.ok(response);
}
前端實作
const handleUpload = async (file: File) => {
const formData = new FormData();
formData.append('file', file);
const response = await api.post('/api/files/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
};
WebSocket 通訊
後端配置
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOrigins("http://localhost:5173")
.withSockJS();
}
}
前端訂閱
import SockJS from 'sockjs-client';
import { Client } from '@stomp/stompjs';
const socket = new SockJS('http://localhost:8080/ws');
const client = new Client({
webSocketFactory: () => socket,
onConnect: () => {
client.subscribe('/topic/notifications', (message) => {
console.log('Received:', JSON.parse(message.body));
});
},
});
client.activate();
開發環境設定
前端 Proxy 配置
在 vite.config.ts 中配置代理:
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
});
這樣前端可以直接使用相對路徑呼叫 API:
// 不需要完整 URL
await fetch('/api/products');
部署架構
分離部署
前後端分開部署:
Frontend (Nginx) → :443
Backend (Spring Boot) → :8080
統一部署
使用 app-web-host 將前端打包到 Spring Boot:
Spring Boot WAR → :8080
├── /api/* → REST API
└── /* → SPA (index.html)
詳細設定參考 app-web-host。
下一步
參考資源
- app-server API - 後端參考實作
- app-web - 前端參考實作