跳至主要内容

前後端整合

本指南說明如何整合 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 流程

  1. 登入:前端發送帳號密碼
const response = await api.post('/api/auth/login', {
username: 'user@example.com',
password: 'password',
});

const { accessToken, refreshToken } = response.data;
localStorage.setItem('accessToken', accessToken);
  1. 攜帶 Token:後續請求帶上 Authorization header
api.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
  1. 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

下一步

參考資源