跳至主要内容

AppFuse Server 應用

本文檔說明如何在花店管理系統中使用 AppFuse Server 框架。

框架基礎

AppFuse Server 是本專案使用的後端框架。關於框架的基本概念與完整文檔,請參閱:

在花店系統中的應用

專案結構

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

參考:AppFuse Server 快取指南

多租戶模組

花店系統是多租戶 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 條件

參考:AppFuse Server 多租戶指南

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

參考:AppFuse Server HTTP 指南

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 自動產生建構子注入
  • 使用框架提供的 FilterPropertyMap 進行動態查詢和部分更新
  • 拋出框架提供的異常(NotFoundExceptionDuplicateExceptionConflictException
  • 刪除前檢查關聯資料,避免資料不一致
  • 圖片處理:使用 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_RPRODUCT_WPRODUCT_DPRODUCT_X
  • 使用 PropertyMap 接收請求,支援部分更新(PATCH)
  • 列表查詢支援 FilterPageable
  • 使用 X-Total-Count header 回傳總筆數
  • 回應正確的 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單數名詞ProductOrderCustomer
RepositoryEntity + RepositoryProductRepository
ServiceEntity + ServiceProductService
ControllerEntity + ControllerProductController
狀態枚舉Entity + StatusProductStatusOrderStatus
權限常數模組_操作PRODUCT_RPRODUCT_WPRODUCT_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 擴展處理邏輯
  • 框架已處理常見異常(NotFoundExceptionDuplicateExceptionConflictException 等)

學習資源

框架文檔

專案範例

以下為 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 遷移腳本

下一步