AppFuse Server 應用
本文檔說明如何在花店管理系統中使用 AppFuse Server 框架。
框架基礎
AppFuse Server 是本專案使用的後端框架。關於框架的基本概念與完整文檔,請參閱:
- AppFuse Server 快速開始 - 框架入門
- AppFuse Server API 參考 - 完整 API 文檔
- Javadoc - 詳細的類別與方法文檔
在花店系統中的應用
專案結構
app-server/src/main/java/io/leandev/app/
├── config/ # 配置類別
│ ├── SecurityConfig.java
│ ├── CacheConfig.java
│ ├── TenantConfig.java
│ └── ...
├── entity/ # Entity 層(按業務模組組織)
│ ├── tenant/ # 多租戶模組
│ ├── auth/ # 認證模組(帳號、角色、權限)
│ ├── sales/ # 銷售模組(商品、分類)
│ ├── order/ # 訂單模組
│ ├── customer/ # 客戶模組
│ ├── file/ # 檔案模組
│ └── base/ # 基礎類別(AuditableBase 等)
├── repository/ # Repository 層(按業務模組組織)
│ ├── tenant/
│ ├── auth/
│ ├── sales/
│ ├── order/
│ ├── customer/
│ └── ...
├── service/ # Service 層(按業務模組組織)
│ ├── auth/
│ ├── sales/
│ ├── order/
│ ├── customer/
│ ├── cache/ # 快取服務
│ └── ...
├── controller/ # REST API 控制器(按業務模組組織)
│ ├── auth/
│ ├── sales/
│ ├── order/
│ ├── customer/
│ └── base/ # 共用 API(參考資料等)
├── dto/ # DTO(Request/Response)
│ ├── auth/
│ └── order/
├── handler/ # 全域異常處理
├── exception/ # 自訂異常
├── security/ # 安全相關(Authority 等)
├── actuator/ # Spring Actuator 端點
├── scheduler/ # 排程任務
├── initializer/ # 資料初始化
└── AppServer.java # 主程式入口
使用框架模組
AppFuse Server 提供多個功能模組,在花店系統中的應用:
快取模組
使用 AppFuse Cache 建構快取,支援多層架構與持久化:
// config/CacheConfig.java
@Configuration
@Profile("!test")
public class CacheConfig {
@Value("${app.cache.path:${app.home:${user.home}}/var/cache}")
private String cachePath;
@Bean
public CacheManager cacheManager() {
return CacheManagerBuilder
.newCacheManager()
.withPersistence(Path.of(cachePath))
.build();
}
/// 使用者快取
@Bean
public Cache<Long, String> userCache(CacheManager cacheManager) {
return CacheBuilder
.newCache(cacheManager, "users", Long.class, String.class)
.heap(100) // heap 100 個
.offheap(20) // offheap 20MB
.ttl(30) // 30 分鐘過期
.managed(true) // 啟用管理功能
.build();
}
/// Session 快取(雙層架構)
@Bean
public DualLayerCache<String, String> sessionCache(CacheManager cacheManager) {
return DualCacheBuilder
.newCache(cacheManager, "sessions", String.class, String.class)
.fastHeap(100) // 快速層:heap
.fastOffheap(10) // 快速層:offheap 10MB
.fastTtl(30) // 快速層:30 分鐘過期
.store(200) // 持久層:200MB,永不過期
.managed(true)
.build();
}
}
使用快取服務(Cache-Aside Pattern):
// service/cache/UserCacheService.java
@Slf4j
@Service
@RequiredArgsConstructor
public class UserCacheService {
private final Cache<Long, String> userCache;
/// 取得使用者資訊(Cache-Aside Pattern)
public Optional<String> getUser(Long userId, DatabaseLoader databaseLoader) {
// 1. 先查快取
String cachedUser = userCache.get(userId);
if (cachedUser != null) {
log.debug("Cache hit for user: {}", userId);
return Optional.of(cachedUser);
}
// 2. 快取 miss,查詢資料庫
log.debug("Cache miss for user: {}, loading from database", userId);
Optional<String> userFromDb = databaseLoader.load(userId);
// 3. 寫入快取
userFromDb.ifPresent(user -> userCache.put(userId, user));
return userFromDb;
}
/// 更新使用者資訊(Write-Through Pattern)
public void updateUser(Long userId, String userData, DatabaseUpdater databaseUpdater) {
databaseUpdater.update(userId, userData); // 1. 更新資料庫
userCache.put(userId, userData); // 2. 更新快取
}
/// 手動失效快取
public void invalidate(Long userId) {
userCache.remove(userId);
}
/// 取得快取統計資訊
public CacheStatistics getStatistics() {
return userCache.getStatistics();
}
@FunctionalInterface
public interface DatabaseLoader {
Optional<String> load(Long userId);
}
@FunctionalInterface
public interface DatabaseUpdater {
void update(Long userId, String userData);
}
}
多租戶模組
花店系統是多租戶 SaaS,每個花店是一個獨立租戶:
// entity/tenant/Tenant.java
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "tenant", indexes = {
@Index(name = "idx_tenant_slug", columnList = "slug", unique = true)
})
public class Tenant extends AuditableBase {
/// 租戶 ID(可手動指定或自動生成 UUID)
@Id
@Column(length = 36)
@Size(max = 36)
private String id;
/// 租戶名稱(花店名稱)
@Column(length = 100, nullable = false)
@NotBlank
@Size(max = 100)
private String name;
/// URL 友好名稱(用於子網域或路徑)
@Column(length = 50, nullable = false, unique = true)
@NotBlank
@Size(max = 50)
@Pattern(regexp = "^[a-z0-9-]+$")
private String slug;
/// 租戶代碼(用於訂單號碼、客戶編號前綴)
@Column(length = 10, nullable = false, unique = true)
@NotBlank
@Size(max = 10)
@Pattern(regexp = "^[A-Z0-9]+$")
private String code;
/// Logo URL
@Column(length = 500)
@Size(max = 500)
private String logo;
/// 聯絡 Email
@Column(length = 255, nullable = false)
@NotBlank
@Email
private String contactEmail;
/// 聯絡電話
@Column(length = 20, nullable = false)
@NotBlank
@Size(max = 20)
private String contactPhone;
/// 地址
@Column(length = 500, nullable = false)
@NotBlank
@Size(max = 500)
private String address;
/// 稅率(如 0.05 代表 5%)
@Column(precision = 5, scale = 4, nullable = false)
@NotNull
@DecimalMin("0.0")
@DecimalMax("1.0")
private BigDecimal taxRate = BigDecimal.valueOf(0.05);
/// 是否啟用
@Column(nullable = false)
private boolean enabled = true;
/// 在持久化前,若 ID 未設定則自動生成 UUID
@PrePersist
protected void onPrePersist() {
if (id == null || id.isBlank()) {
id = java.util.UUID.randomUUID().toString();
}
}
}
業務 Entity 繼承 AuditableTenantEntity 自動處理租戶隔離:
// entity/sales/Product.java
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "product")
public class Product extends AuditableTenantEntity implements Stateful<ProductStatus> {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(length = 36)
private String id;
// tenantId 由 AuditableTenantEntity 自動管理
// 不需要手動設定,@PrePersist 會從 TenantContext 取得
// ...
}
租戶上下文由框架自動管理:
// 在 Service 中取得當前租戶
String tenantId = TenantContext.getCurrentTenantId();
// Hibernate Filter 自動過濾當前租戶資料
// 查詢時不需要手動加入 tenantId 條件
HTTP 客戶端
用於呼叫第三方 API(如支付、物流):
// service/PaymentService.java
@Service
public class PaymentService {
private final HttpClient httpClient;
public PaymentService() {
this.httpClient = HttpClientBuilder.create()
.baseUrl("https://api.payment-gateway.com")
.timeout(5000)
.build();
}
public PaymentResponse charge(PaymentRequest request) {
return httpClient.post("/v1/charges", request, PaymentResponse.class);
}
}
Entity 設計
花店系統的 Entity 設計遵循框架建議,使用 Lombok 簡化程式碼:
// entity/sales/Product.java
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "product",
uniqueConstraints = {
@UniqueConstraint(name = "uk_product_tenant_sku", columnNames = {"tenant_id", "sku"})
},
indexes = {
@Index(name = "idx_product_tenant", columnList = "tenant_id"),
@Index(name = "idx_product_sku", columnList = "sku"),
@Index(name = "idx_product_category", columnList = "category_code"),
@Index(name = "idx_product_status", columnList = "status")
}
)
public class Product extends AuditableTenantEntity implements Stateful<ProductStatus> {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(length = 36)
private String id;
/// SKU 編號(租戶內唯一)
@Column(length = 30, nullable = false)
@NotBlank
@Size(max = 30)
private String sku;
/// 商品名稱
@Column(length = 200, nullable = false)
@NotBlank
@Size(max = 200)
private String name;
/// 商品分類代碼(使用欄位而非 @ManyToOne 避免 JOIN 查詢)
@Column(name = "category_code", length = 36, nullable = false)
@NotBlank
@Size(max = 36)
private String categoryCode;
/// 商品描述
@Column(length = 2000)
@Size(max = 2000)
private String description;
/// 售價
@Column(precision = 10, scale = 2, nullable = false)
@NotNull
@Min(0)
private BigDecimal price;
/// 成本(僅管理者可見)
@Column(precision = 10, scale = 2)
@Min(0)
private BigDecimal cost;
/// 庫存數量
@Column(nullable = false)
@Min(0)
private int stock = 0;
/// 低庫存警戒值
@Column(nullable = false)
@Min(0)
private int lowStockThreshold = 5;
/// 庫存單位(如「個」、「盆」)
@Column(length = 20, nullable = false)
@NotBlank
@Size(max = 20)
private String stockUnit = "個";
/// 上架狀態
@Enumerated(EnumType.STRING)
@Column(length = 20, nullable = false)
@NotNull
private ProductStatus status = ProductStatus.ACTIVE;
/// 是否熱門商品
@Column(nullable = false)
private boolean featured = false;
/// 是否新品
@Column(nullable = false)
private boolean newArrival = false;
/// 主圖(嵌入式 FileDescriptor)
@Embedded
private FileDescriptor mainImage;
/// 副圖列表
@ElementCollection
@CollectionTable(indexes = @Index(name = "idx_product_gallery_images_product_id", columnList = "product_id"))
private Set<FileDescriptor> galleryImages = new HashSet<>();
// ============================================
// 業務方法
// ============================================
public boolean isLowStock() {
return stock <= lowStockThreshold;
}
public boolean isOutOfStock() {
return stock <= 0;
}
public StockStatus getStockStatus() {
if (isOutOfStock()) return StockStatus.OUT_OF_STOCK;
if (isLowStock()) return StockStatus.LOW_STOCK;
return StockStatus.IN_STOCK;
}
public void deductStock(int quantity) {
if (stock < quantity) {
throw new IllegalStateException("Insufficient stock");
}
this.stock -= quantity;
}
public void addStock(int quantity) {
this.stock += quantity;
}
public boolean isActive() {
return isInStatus(ProductStatus.ACTIVE);
}
public void activate() {
this.status = ProductStatus.ACTIVE;
}
public void deactivate() {
this.status = ProductStatus.INACTIVE;
}
}
設計原則:
- 使用 Lombok(
@Getter、@Setter、@NoArgsConstructor)簡化程式碼 - 繼承
AuditableTenantEntity自動處理 tenantId 與審計欄位 - 實作
Stateful<T>介面支援狀態管理 - 使用欄位關聯(
categoryCode)而非@ManyToOne避免不必要的 JOIN - 業務方法封裝領域邏輯(如
deductStock()、isLowStock()) - 使用
@Index定義索引提升查詢效能
Service 層設計
// service/sales/ProductService.java
@Slf4j
@RequiredArgsConstructor
@Service
@Transactional
public class ProductService {
private final ProductRepository productRepository;
private final OrderRepository orderRepository;
private final FileStorage fileStorage;
private final ObjectMapper objectMapper;
// ============================================
// 查詢方法
// ============================================
/// 分頁查詢商品列表
@Transactional(readOnly = true)
public Page<PropertyMap> findAll(Filter filter, Pageable pageable) {
return productRepository.findAll(filter, pageable);
}
/// 根據 ID 查詢商品(租戶過濾由 Hibernate Filter 自動處理)
@Transactional(readOnly = true)
public Optional<Product> findById(@NonNull String id) {
return productRepository.findById(id);
}
// ============================================
// CRUD 操作
// ============================================
/// 創建商品(可選擇性包含圖片)
public Product create(@NonNull Product product) {
// 檢查名稱是否重複
Filter filter = Filter.eq("name", product.getName());
if (productRepository.count(filter) > 0) {
throw new DuplicateException("Product name already exists: ${0}", product.getName());
}
// 提取圖片欄位,清空後交給 handler 處理
FileDescriptor mainImage = product.getMainImage();
List<FileDescriptor> galleryImages = product.getGalleryImages() != null
? new ArrayList<>(product.getGalleryImages()) : null;
product.setMainImage(null);
product.setGalleryImages(null);
// 處理主圖(staging → permanent)
if (mainImage != null && !mainImage.isEmpty()) {
applyMainImage(product, mainImage);
}
// 處理副圖(staging → permanent)
if (galleryImages != null && !galleryImages.isEmpty()) {
applyGalleryImages(product, galleryImages);
}
// tenantId 由 @PrePersist 自動設定,JPA 執行驗證
return productRepository.save(product);
}
/// 系統管理欄位,不允許用戶直接更新
private static final List<String> SYSTEM_FIELDS = List.of("id", "tenantId", "sku");
/// 部分更新商品(含圖片處理)
public Product update(@NonNull String id, @NonNull PropertyMap props) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Product not found: ${0}", id));
// 提取圖片欄位(需要特殊處理)
Object mainImageObj = props.remove("mainImage");
Object galleryImagesObj = props.remove("galleryImages");
// 移除系統欄位,防止用戶修改
SYSTEM_FIELDS.forEach(props::remove);
// 使用 ObjectMapper 合併一般欄位到現有實體
try {
objectMapper.readerForUpdating(product)
.readValue(objectMapper.writeValueAsString(props));
} catch (Exception e) {
throw new InvalidDataException("Failed to update product: ${0}", e, e.getMessage());
}
// 處理主圖
if (mainImageObj != null) {
FileDescriptor mainImage = objectMapper.convertValue(mainImageObj, FileDescriptor.class);
applyMainImage(product, mainImage);
}
// 處理副圖
if (galleryImagesObj != null) {
List<FileDescriptor> galleryImages = objectMapper.convertValue(
galleryImagesObj,
objectMapper.getTypeFactory().constructCollectionType(List.class, FileDescriptor.class));
applyGalleryImages(product, galleryImages);
}
return productRepository.save(product);
}
/// 更新商品狀態
public Product updateStatus(@NonNull String id, @NonNull ProductStatus status) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Product not found: ${0}", id));
product.setStatus(status);
return productRepository.save(product);
}
/// 刪除商品(檢查是否有相關訂單)
public void deleteWithCheck(@NonNull String id) {
String tenantId = TenantContext.getCurrentTenantId();
Product product = productRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Product not found: ${0}", id));
long relatedOrderCount = orderRepository.countByItemsProductId(id);
if (relatedOrderCount > 0) {
throw new ConflictException(
"Cannot delete product with existing orders. Order count: ${0}",
relatedOrderCount);
}
// 刪除關聯的圖片
if (product.getMainImage() != null && !product.getMainImage().isEmpty()) {
fileStorage.delete(tenantId, product.getMainImage().getFileId());
}
if (product.getGalleryImages() != null) {
for (FileDescriptor fd : product.getGalleryImages()) {
if (fd != null && !fd.isEmpty()) {
fileStorage.delete(tenantId, fd.getFileId());
}
}
}
productRepository.delete(product);
}
// ============================================
// 圖片存取
// ============================================
/// 取得商品主圖 FileDescriptor
@Transactional(readOnly = true)
public Optional<FileDescriptor> getMainImage(@NonNull String id) {
return productRepository.findById(id)
.map(Product::getMainImage)
.filter(fd -> fd != null && !fd.isEmpty());
}
// ============================================
// 輔助方法
// ============================================
/// 套用主圖:持久化 staging 檔案,刪除被替換的舊檔
private void applyMainImage(Product product, FileDescriptor mainImage) {
String tenantId = TenantContext.getCurrentTenantId();
if (mainImage.isEmpty()) {
// 刪除主圖
if (product.getMainImage() != null && !product.getMainImage().isEmpty()) {
fileStorage.delete(tenantId, product.getMainImage().getFileId());
product.setMainImage(null);
}
} else if (mainImage.isStaging()) {
// 新上傳:刪除舊的,持久化新的
if (product.getMainImage() != null && !product.getMainImage().isEmpty()) {
fileStorage.delete(tenantId, product.getMainImage().getFileId());
}
String fileId = fileStorage.persist(tenantId, mainImage.getFileId());
product.setMainImage(FileDescriptor.ofPermanent(
fileId, mainImage.getFilename(), mainImage.getSize(), mainImage.getMimeType()));
}
// 如果是永久區檔案,表示保留現有(不做任何事)
}
/// 套用副圖:持久化 staging 檔案,刪除被移除的舊檔
private void applyGalleryImages(Product product, List<FileDescriptor> galleryImages) {
// 實作邏輯:區分新上傳(staging)與保留(permanent),刪除不在列表中的舊圖
// ...(詳見實際程式碼)
}
}
設計原則:
- 類別層級
@Transactional,讀取方法標記@Transactional(readOnly = true) - 使用
@RequiredArgsConstructor自動產生建構子注入 - 使用框架提供的
Filter和PropertyMap進行動態查詢和部分更新 - 拋出框架提供的異常(
NotFoundException、DuplicateException、ConflictException) - 刪除前檢查關聯資料,避免資料不一致
- 圖片處理:使用
FileStorage管理 staging → permanent 轉換,刪除時清理關聯檔案
REST API 設計
// controller/sales/ProductController.java
@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
@Slf4j
public class ProductController {
private final ProductService productService;
private final ProductCategoryService productCategoryService;
private final FileStorage fileStorage;
private final ObjectMapper objectMapper;
// ============================================
// 商品 CRUD
// ============================================
/// GET /api/v1/products
@GetMapping
@PreAuthorize("hasAuthority('" + PRODUCT_R + "')")
public ResponseEntity<List<PropertyMap>> findAll(
@RequestParam(required = false) Filter filter,
@SortDefault(sort = "name", direction = Sort.Direction.DESC) Pageable pageable) {
Page<PropertyMap> data = productService.findAll(filter, pageable);
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.set("X-Total-Count", String.valueOf(data.getTotalElements()));
return ResponseEntity.ok().headers(httpHeaders).body(data.getContent());
}
/// GET /api/v1/products/{id}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('" + PRODUCT_R + "')")
public ResponseEntity<Product> getById(@PathVariable String id) {
Product product = productService.findById(id)
.orElseThrow(() -> new NotFoundException("Product not found: ${0}", id));
enrichProductImageUrls(product); // 設定圖片 URL
return ResponseEntity.ok(product);
}
/// POST /api/v1/products
@PostMapping
@PreAuthorize("hasAuthority('" + PRODUCT_W + "')")
public ResponseEntity<Product> create(@RequestBody PropertyMap props) {
// 移除系統欄位,防止用戶設定
List.of("id", "tenantId").forEach(props::remove);
// 使用 Jackson 轉換,處理 enum 等複雜類型
Product product = objectMapper.convertValue(props, Product.class);
Product created = productService.create(product);
enrichProductImageUrls(created);
log.info("[API] POST /products - Product {} created", created.getSku());
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
/// PATCH /api/v1/products/{id}
@PatchMapping("/{id}")
@PreAuthorize("hasAuthority('" + PRODUCT_W + "')")
public ResponseEntity<Product> update(
@PathVariable String id,
@RequestBody PropertyMap props) {
Product updated = productService.update(id, props);
enrichProductImageUrls(updated);
log.info("[API] PATCH /products/{} - Updated", id);
return ResponseEntity.ok(updated);
}
/// PATCH /api/v1/products/{id}/status
@PatchMapping("/{id}/status")
@PreAuthorize("hasAuthority('" + PRODUCT_X + "')")
public ResponseEntity<Product> updateStatus(
@PathVariable String id,
@RequestBody Map<String, String> request) {
String statusStr = request.get("status");
if (statusStr == null || statusStr.isBlank()) {
throw new ConstraintException(new Violation("status", "Status is required"));
}
ProductStatus status = ProductStatus.valueOf(statusStr.toUpperCase());
Product updated = productService.updateStatus(id, status);
enrichProductImageUrls(updated);
log.info("[API] PATCH /products/{}/status - Status: {}", id, status);
return ResponseEntity.ok(updated);
}
/// DELETE /api/v1/products/{id}
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('" + PRODUCT_D + "')")
public ResponseEntity<Void> delete(@PathVariable String id) {
productService.deleteWithCheck(id);
log.info("[API] DELETE /products/{}", id);
return ResponseEntity.noContent().build();
}
// ============================================
// 商品分類 CRUD
// ============================================
/// GET /api/v1/products/categories
@GetMapping("/categories")
@PreAuthorize("hasAuthority('" + PRODUCT_R + "')")
public ResponseEntity<List<ProductCategory>> getCategories() {
List<ProductCategory> categories = productCategoryService.findAll();
return ResponseEntity.ok(categories);
}
/// POST /api/v1/products/categories
@PostMapping("/categories")
@PreAuthorize("hasAuthority('" + PRODUCT_W + "')")
public ResponseEntity<ProductCategory> createCategory(@RequestBody ProductCategory category) {
ProductCategory created = productCategoryService.create(category);
log.info("[API] POST /products/categories - Category {} created", created.getId());
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
// ============================================
// 圖片下載端點
// ============================================
/// GET /api/v1/products/{id}/image - 取得商品主圖
@GetMapping("/{id}/image")
@PreAuthorize("hasAuthority('" + PRODUCT_R + "')")
public ResponseEntity<Resource> getMainImage(
@PathVariable String id,
@RequestHeader(value = "Range", required = false) String rangeHeader) {
String tenantId = TenantContext.getCurrentTenantId();
FileDescriptor fd = productService.getMainImage(id).orElse(null);
if (fd == null || fd.isEmpty()) {
return ResponseEntity.notFound().build();
}
return FileResponseBuilder
.from(fileStorage, tenantId, fd.getFileId())
.range(rangeHeader)
.build();
}
/// GET /api/v1/products/{id}/gallery/{index} - 取得商品副圖
@GetMapping("/{id}/gallery/{index}")
@PreAuthorize("hasAuthority('" + PRODUCT_R + "')")
public ResponseEntity<Resource> getGalleryImage(
@PathVariable String id,
@PathVariable int index,
@RequestHeader(value = "Range", required = false) String rangeHeader) {
// 實作類似 getMainImage,詳見實際程式碼
// ...
}
// ============================================
// 輔助方法
// ============================================
/// 為 Product 的圖片欄位設定 URL
private void enrichProductImageUrls(Product product) {
if (product.getMainImage() != null && !product.getMainImage().isEmpty()) {
String url = "/" + LinkBuilder.linkTo(ProductController.class)
.slash(product.getId()).slash("image").toHref();
product.getMainImage().setUrl(url);
}
// 副圖 URL 設定類似...
}
}
設計原則:
- API 版本化:
/api/v1/products - 使用
@PreAuthorize進行權限控制(PRODUCT_R、PRODUCT_W、PRODUCT_D、PRODUCT_X) - 使用
PropertyMap接收請求,支援部分更新(PATCH) - 列表查詢支援
Filter和Pageable - 使用
X-Total-Countheader 回傳總筆數 - 回應正確的 HTTP 狀態碼(201 Created、204 No Content)
- 記錄操作日誌
- 圖片端點:提供
/{id}/image、/{id}/gallery/{index}下載端點,支援 Range Request
DTO 與 PropertyMap
本專案使用 PropertyMap(框架提供)取代傳統的 Request/Response DTO,簡化部分更新邏輯:
// PropertyMap 是 Map<String, Object> 的封裝,提供類型安全的存取方法
PropertyMap props = new PropertyMap();
props.put("name", "新商品名稱");
props.put("price", 1500);
// 類型安全的取值
String name = props.getString("name");
BigDecimal price = props.getBigDecimal("price");
Long id = props.getLong("id");
// 使用 Jackson ObjectMapper 轉換為 Entity
Product product = objectMapper.convertValue(props, Product.class);
對於需要結構化的請求,仍可使用 record:
// dto/auth/LoginRequest.java
public record LoginRequest(
@NotBlank String username,
@NotBlank String password
) {}
// dto/auth/TokenResponse.java
public record TokenResponse(
String accessToken,
String refreshToken,
long expiresIn
) {}
資料庫遷移
使用 Flyway 管理 Schema 變更:
-- src/main/resources/db/migration/V3__create_product_table.sql
CREATE TABLE product (
id VARCHAR(36) PRIMARY KEY,
tenant_id VARCHAR(36) NOT NULL,
sku VARCHAR(30) NOT NULL,
name VARCHAR(200) NOT NULL,
category_code VARCHAR(36) NOT NULL,
description VARCHAR(2000),
price DECIMAL(10, 2) NOT NULL,
cost DECIMAL(10, 2),
stock INTEGER NOT NULL DEFAULT 0,
low_stock_threshold INTEGER NOT NULL DEFAULT 5,
stock_unit VARCHAR(20) NOT NULL DEFAULT '個',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
featured BOOLEAN NOT NULL DEFAULT FALSE,
new_arrival BOOLEAN NOT NULL DEFAULT FALSE,
main_image_file_id VARCHAR(36),
main_image_filename VARCHAR(255),
main_image_size BIGINT,
main_image_mime_type VARCHAR(100),
created_at TIMESTAMP NOT NULL,
created_by VARCHAR(100),
updated_at TIMESTAMP,
updated_by VARCHAR(100),
CONSTRAINT fk_product_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id),
CONSTRAINT uk_product_tenant_sku UNIQUE (tenant_id, sku)
);
CREATE INDEX idx_product_tenant ON product(tenant_id);
CREATE INDEX idx_product_sku ON product(sku);
CREATE INDEX idx_product_category ON product(category_code);
CREATE INDEX idx_product_status ON product(status);
專案特定慣例
套件組織
採用分層架構,每層按業務模組組織:
io.leandev.app/
├── entity/sales/ # Entity 層 - 銷售模組
├── repository/sales/ # Repository 層 - 銷售模組
├── service/sales/ # Service 層 - 銷售模組
├── controller/sales/ # Controller 層 - 銷售模組
└── dto/ # DTO(跨模組共用)
命名規範
| 類型 | 命名規則 | 範例 |
|---|---|---|
| Entity | 單數名詞 | Product、Order、Customer |
| Repository | Entity + Repository | ProductRepository |
| Service | Entity + Service | ProductService |
| Controller | Entity + Controller | ProductController |
| 狀態枚舉 | Entity + Status | ProductStatus、OrderStatus |
| 權限常數 | 模組_操作 | PRODUCT_R、PRODUCT_W、PRODUCT_D |
異常處理
使用框架提供的異常類別,由 GlobalRestExceptionHandler 統一處理:
// 框架提供的異常
throw new NotFoundException("Product not found: ${0}", id);
throw new DuplicateException("Product name already exists: ${0}", name);
throw new ConflictException("Cannot delete product with existing orders");
throw new InvalidDataException("Invalid price value");
throw new ConstraintException(new Violation("status", "Status is required"));
// handler/GlobalRestExceptionHandler.java
/// 全域 REST API 異常處理器
///
/// 繼承 StandardRestExceptionHandler 以獲得標準的異常處理功能。
/// 可在靜態區塊中註冊自定義 Mapper 來擴展異常處理邏輯。
@ControllerAdvice
public class GlobalRestExceptionHandler extends StandardRestExceptionHandler {
// 註冊自定義 Mapper(可選)
static {
// 範例:註冊業務異常處理器
getRegistry().register(new CustomBusinessExceptionMapper());
}
}
標準錯誤回應格式:
{
"status": 404,
"error": "Not Found",
"message": "The requested resource does not exist",
"timestamp": "2025-10-22T10:30:00Z",
"path": "/api/users/123"
}
設計原則:
- 繼承
StandardRestExceptionHandler獲得標準異常處理 - 使用 Registry 模式註冊自定義 Mapper 擴展處理邏輯
- 框架已處理常見異常(
NotFoundException、DuplicateException、ConflictException等)
學習資源
框架文檔
專案範例
以下為 app-server 中的實際範例,可直接參考:
| 模組 | 路徑 | 說明 |
|---|---|---|
| 銷售模組 | entity/sales/、service/sales/、controller/sales/ | 商品、分類 CRUD |
| 訂單模組 | entity/order/、service/order/、controller/order/ | 訂單管理 |
| 客戶模組 | entity/customer/、service/customer/、controller/customer/ | 客戶管理 |
| 認證模組 | entity/auth/、service/auth/、controller/auth/ | 登入、Token 管理 |
| 快取服務 | service/cache/ | UserCacheService、SessionCacheService |
| 資料庫遷移 | src/main/resources/db/migration/ | Flyway 遷移腳本 |