iT邦幫忙

2025 iThome 鐵人賽

DAY 27
0
Software Development

spring boot 3 學習筆記系列 第 27

Day27 - Spring Data JPA 效能優化與生命週期管理:從批次處理到安全刪除

  • 分享至 

  • xImage
  •  

在先前的單元中,我們已經成功建立了 Customer 實體、定義了 Repository,甚至實作了複雜的實體關聯。現在,我們要從「功能實現」邁向「效能與維護性」,探討在真實世界中至關重要的議題:如何高效地處理大量資料,以及如何安全地管理資料的生命週期。

本單元將深入 JPA 底層的 Hibernate 實作,解析 save()persist()merge() 這些看似相似卻截然不同的方法,並帶您實作強大的批次處理 (Batch Processing) 與業界推崇的軟刪除 (Soft Delete) 策略。

學習目標

完成今日學習後,你將能夠:

  1. 瞭解 Hibernate 的 save()persist()merge() 差異:知道在何種情境下該使用哪個方法來操作實體 (Entity),並理解實體的三種狀態 (Transient, Persistent, Detached)。
  2. 學會在 PostgreSQL 中進行批次寫入 (Batch Insert) 與更新 (Update):大幅提升大量資料寫入的效能,避免應用程式與資料庫之間產生過多的網路來回 (Round Trip)。
  3. 學會安全刪除 (Soft Delete vs Hard Delete):掌握軟刪除 (Soft Delete) 的實作方式,在不實際刪除資料庫紀錄的情況下,達成「刪除」的業務需求,保留資料的完整性以供未來稽核或分析。

深入理解實體生命週期 (Entity Lifecycle)

在我們開始批次儲存或刪除物件之前,必須先理解一個核心觀念:JPA 中的實體 (Entity) 是有「狀態」的。Hibernate (作為 JPA 的實作) 會根據實體的狀態來決定如何管理它。

一個實體物件主要有三種狀態:

  1. 瞬時態 (Transient):一個剛用 new 建立出來的物件,它還沒有與任何資料庫紀錄關聯,也沒有被 JPA 的 EntityManager (實體管理器) 所管理。
  2. 持續態 (Persistent):這個物件已經被納入 EntityManager 的管理中,並且對應到資料庫中的一筆紀錄。任何在此狀態下對物件欄位的修改,都會在交易 (Transaction) 提交時自動同步回資料庫。這就是所謂的「髒檢查 (Dirty Checking)」。
  3. 游離態 (Detached):物件曾經是持續態,但 EntityManager 已經關閉或被清空,不再管理它。它雖然還保有資料庫紀錄的 ID,但對它的任何修改都不會自動同步回資料庫。

理解了這三種狀態後,我們就能輕易分辨 persist()save()merge() 的不同之處。

方法 來源 主要用途 返回值 處理游離態 (Detached) 物件 建議
persist(obj) JPA 標準 將一個新的 (Transient) 物件轉為持續態 (Persistent) void 會拋出例外! 推薦用於新增資料
merge(obj) JPA 標準 將一個游離態 (Detached) 物件的變更,合併回一個持續態 (Persistent) 物件 返回一個新的、處於持續態的物件 尋找對應 ID 的物件並更新 推薦用於更新資料
save(obj) Hibernate 獨有 persist 類似,但會立即指派 ID Serializable (ID) 建立一筆新的紀錄,可能導致資料重複! 已被棄用,避免使用

核心結論:

  • 當你要新增一筆全新的資料時,請使用 entityManager.persist(newObject)repository.save(newObject) (Spring Data JPA 的 save 方法在內部會判斷是新物件還是舊物件,並呼叫對應的 persistmerge)。
  • 當你要更新一筆來自外部 (例如從前端 API 接收到) 的資料時,請使用 entityManager.merge(detachedObject)。請務必接收 merge 方法的回傳值,因為這才是真正被 EntityManager 管理的物件。

批次處理 (Batch Processing) 的威力

想像一下,如果要新增 10,000 筆客戶資料,預設情況下,JPA 會發送 10,000 次獨立的 INSERT SQL 敘述到資料庫。這中間包含了大量的網路延遲與資料庫負擔。批次處理能將這些 SQL 敘述打包成幾批 (例如每批 100 筆),一次性發送,效能提升非常顯著。

步驟一:啟用批次設定

application.properties 中加入以下設定,這是啟用批次處理的關鍵。

# --- Batch Processing Settings for PostgreSQL ---

# 設定批次大小。建議值為 10 到 50 之間。
spring.jpa.properties.hibernate.jdbc.batch_size=30

# 啟用批次插入排序,Hibernate 會先收集所有同類型的 INSERT 敘述再一起執行。
spring.jpa.properties.hibernate.order_inserts=true

# 啟用批次更新排序,同理於更新操作。
spring.jpa.properties.hibernate.order_updates=true

# 針對有版本控制 (@Version) 的資料啟用批次更新。
spring.jpa.properties.hibernate.batch_versioned_data=true

步驟二:選擇正確的主鍵生成策略

這是最常被忽略的陷阱!要讓批次插入生效,絕對不能使用 GenerationType.IDENTITY

因為 IDENTITY 策略需要資料庫在插入當下立即回傳產生的主鍵 ID,這會打斷批次執行的流程。對於 PostgreSQL,最佳選擇是 GenerationType.SEQUENCE (或 AUTO)。

請確保您的 Customer.java 和其他實體的主鍵是這樣設定的:

// 在 Customer.java 中
@Id
// 使用 UUID 或 SEQUENCE 都能很好地支援批次處理
@GeneratedValue(strategy = GenerationType.UUID) 
private UUID id;

// 如果您偏好數字 ID,可以使用 SEQUENCE
// @Id
// @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "customer_seq")
// @SequenceGenerator(name = "customer_seq", sequenceName = "customer_id_seq", allocationSize = 1)
// private Long id;

步驟三:注意記憶體管理

當您處理極大量的資料時 (數十萬筆以上),即使啟用了批次處理,JPA 的持續性上下文 (Persistence Context) 仍會快取所有您 persist 的物件,最終可能導致記憶體溢出 (OutOfMemoryError)。

在這種情況下,您需要手動介入,定期地將快取同步 (flush) 到資料庫,並清空 (clear) 快取。

// 在 Service 層
@Transactional
public void batchCreateCustomers(List<Customer> customers) {
    for (int i = 0; i < customers.size(); i++) {
        entityManager.persist(customers.get(i));
        // 當達到批次大小時,手動 flush 並 clear
        if (i > 0 && i % 30 == 0) {
            entityManager.flush(); // 將快取中的 INSERT 敘述發送到資料庫
            entityManager.clear(); // 清空快取,釋放記憶體
        }
    }
}

安全刪除 (Soft Delete vs Hard Delete)

直接從資料庫刪除紀錄 (DELETE FROM ...) 稱為硬刪除 (Hard Delete)。這種方式簡單直接,但被刪除的資料就永遠消失了,對於需要稽核或保留歷史紀錄的系統來說是個災難。

軟刪除 (Soft Delete) 是一種更安全、更受推薦的做法。我們不在資料庫中實際刪除紀錄,而是增加一個欄位 (例如 deletedis_active) 來標記這筆資料的狀態。

實作軟刪除

我們將修改 Customer 實體來支援軟刪除。

步驟一:修改 Customer 實體

加入 deleted 欄位,並加上 Hibernate 獨有的 @SQLDelete@SQLRestriction 註解 (Annotation)。

@SQLRestriction 的魔力在於,之後所有針對 Customer 的查詢 (包含 findById, findAll 等),Hibernate 都會自動在 SQL 語法中加上 WHERE deleted = false 的條件!請注意,在 Hibernate 6+ (Spring Boot 3 預設使用) 中,舊的 @Where 註解 (Annotation) 已被棄用,應改用功能完全相同的 @SQLRestriction

Customer.java

import org.hibernate.annotations.SQLDelete;
// 匯入新的 @SQLRestriction 註解
import org.hibernate.annotations.SQLRestriction;

// ...

@Entity
@Table(name = "customers")
// 魔法發生的地方:所有查詢都會自動加上這個條件
// 使用新的 @SQLRestriction 註解來取代已棄用的 @Where
@SQLRestriction("deleted = false")
// 當執行 delete 操作時,Hibernate 會執行這條 SQL 來代替真正的 DELETE
@SQLDelete(sql = "UPDATE customers SET deleted = true WHERE id = ?")
public class Customer {

    // ... 其他欄位 ...

    @Column(name = "deleted", nullable = false)
    private boolean deleted = false;

    // --- Getters and Setters for deleted ---
    public boolean isDeleted() {
        return deleted;
    }

    public void setDeleted(boolean deleted) {
        this.deleted = deleted;
    }
}
  • @SQLRestriction("deleted = false"): 自動過濾掉被標記為刪除的客戶。
  • @SQLDelete(...): 當我們呼叫 repository.delete(customer) 時,Hibernate 不會執行 DELETE,而是執行我們定義的 UPDATE 敘述,將 deleted 設為 true

範例實作與測試

現在,讓我們將以上所有理論應用到我們的專案中,並提供完整的程式碼與測試指南。

API 1: 批次新增 10 筆客戶

這個 API 將接收一個客戶列表,並使用我們配置好的批次設定將它們一次性存入資料庫。

CustomerService.java

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;

@Service
public class CustomerService {

    private final CustomerRepository customerRepository;

    // 使用建構子注入 (Constructor Injection)
    public CustomerService(CustomerRepository customerRepository) {
        this.customerRepository = customerRepository;
    }

    @Transactional
    public List<Customer> batchCreateCustomers(List<Customer> customers) {
        // Spring Data JPA 的 saveAll 方法會自動利用我們在 properties 中設定的批次功能
        return customerRepository.saveAll(customers);
    }
    
    // ... 其他方法
}

CustomerController.java

// ... imports ...
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;

@RestController
@RequestMapping("/api/customers")
public class CustomerController {

    private final CustomerService customerService;

    public CustomerController(CustomerService customerService) {
        this.customerService = customerService;
    }

    @PostMapping("/batch")
    public ResponseEntity<List<Customer>> createBatchCustomers() {
        // 為了方便測試,我們在後端直接生成 10 筆假資料
        List<Customer> newCustomers = new ArrayList<>();
        for (int i = 1; i <= 10; i++) {
            Customer customer = new Customer();
            customer.setName("Batch User " + i);
            customer.setEmail("batch" + i + "@example.com");
            customer.setStatus(CustomerStatus.ACTIVE);
            newCustomers.add(customer);
        }
        
        List<Customer> savedCustomers = customerService.batchCreateCustomers(newCustomers);
        return new ResponseEntity<>(savedCustomers, HttpStatus.CREATED);
    }

    // ... 其他 API
}

測試:啟動應用程式後,使用 POST 請求訪問 http://localhost:8080/api/customers/batch。檢查您的主控台 (Console) 輸出與資料庫,您會發現 Hibernate 將 INSERT 敘述打包成一批發送,而不是 10 次單獨的請求。

API 2: 刪除客戶 (軟刪除)

由於我們已經在 Customer 實體上設定了 @SQLDelete@SQLRestriction,刪除操作的實現變得異常簡單。

CustomerService.java (新增方法)

@Transactional
public void deleteCustomer(UUID customerId) {
    // 檢查客戶是否存在,若不存在則拋出例外
    // 注意:因為有 @SQLRestriction,這裡的 existsById 也只會尋找 deleted = false 的客戶
    if (!customerRepository.existsById(customerId)) {
        throw new RuntimeException("Customer not found with id: " + customerId);
    }
    // 這裡的 deleteById 會觸發 @SQLDelete 定義的 UPDATE,而不是真正的 DELETE
    // 並且因為我們有 cascade = CascadeType.ALL 和 orphanRemoval = true,
    // 相關的 AllowedDomain 也會被「硬刪除」。
    // 如果 AllowedDomain 也需要軟刪除,則需要在 AllowedDomain 實體上進行類似的設定。
    customerRepository.deleteById(customerId);
}

CustomerController.java (新增 API)

// 在 CustomerController 類別中新增以下 import 和方法
import java.util.UUID;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;

// ...
@DeleteMapping("/{customerId}")
public ResponseEntity<Void> deleteCustomer(@PathVariable UUID customerId) {
    customerService.deleteCustomer(customerId);
    return ResponseEntity.noContent().build();
}

測試:

  1. 先查詢一個客戶,確認能成功獲取。
  2. 使用 DELETE 請求訪問 http://localhost:8080/api/customers/{customerId}
  3. 再次查詢同一個客戶 ID,您應該會收到 404 Not Found,因為 @Where 條款生效了。
  4. 直接查詢資料庫,您會發現該客戶的 deleted 欄位已變為 true,但紀錄本身依然存在。其關聯的 allowed_domains 紀錄則因為 orphanRemoval=true 而被硬刪除了。

API 3: 批次更新客戶狀態

我們需要一個功能,將所有 INACTIVE 狀態的客戶一次性更新為 ACTIVE。最佳做法是使用 @Query 搭配 @Modifying 直接在資料庫層級執行更新,效率最高。

CustomerRepository.java (新增方法)

import com.example.demo.models.CustomerStatus;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;

public interface CustomerRepository extends JpaRepository<Customer, UUID> {
    // ... 其他查詢方法 ...

    @Modifying
    @Transactional
    @Query("UPDATE Customer c SET c.status = :newStatus WHERE c.status = :oldStatus")
    int updateStatusForInactiveCustomers(
        @Param("oldStatus") CustomerStatus oldStatus, 
        @Param("newStatus") CustomerStatus newStatus
    );
}

CustomerService.java (新增方法)

// 在 CustomerService 類別中新增以下方法
@Transactional
public int activateInactiveCustomers() {
    return customerRepository.updateStatusForInactiveCustomers(
        CustomerStatus.INACTIVE, 
        CustomerStatus.ACTIVE
    );
}

CustomerController.java (新增 API)

// 在 CustomerController 類別中新增以下 import 和方法
import org.springframework.web.bind.annotation.PutMapping;

// ...
@PutMapping("/activate-all")
public ResponseEntity<String> activateAllInactiveCustomers() {
    int updatedCount = customerService.activateInactiveCustomers();
    return ResponseEntity.ok("Successfully activated " + updatedCount + " customers.");
}

如何執行與測試

在完成所有程式碼的撰寫後,您可以啟動 Spring Boot 應用程式,並使用 curl 或 Postman 等工具來測試我們剛剛建立的 API。

測試 1:批次新增客戶

執行以下 curl 指令,或在 Postman 中對 http://localhost:8080/api/customers/batch 發送 POST 請求。

curl -X POST http://localhost:8080/api/customers/batch

預期結果:

  1. 您會收到一個包含 10 位新客戶資料的 JSON 陣列,HTTP 狀態碼為 201 CREATED
  2. 檢查應用程式的日誌,您會看到 Hibernate 將多個 INSERT 敘述打包成一批次執行,而不是分 10 次發送。
  3. 查詢資料庫 customers 表,會多出 10 筆紀錄。

測試 2:軟刪除客戶

  1. 首先,從資料庫中複製一筆客戶的 id。假設 ID 為 your-customer-id

  2. 執行 GET 請求,確認可以查詢到該客戶。

    curl http://localhost:8080/api/customers/your-customer-id
    
  3. 執行 DELETE 請求來進行軟刪除。

    curl -X DELETE http://localhost:8080/api/customers/your-customer-id
    

預期結果:

  1. DELETE 請求會回傳 204 No Content
  2. 再次執行步驟 2 的 GET 請求,此時您應該會收到 404 Not Found 或錯誤訊息,因為 @SQLRestriction 生效,查詢時自動過濾掉了 deleted = true 的紀錄。
  3. 直接查詢資料庫,您會發現該客戶的 deleted 欄位已變為 true,但紀錄本身依然存在。

測試 3:批次啟用客戶

  1. 手動進入資料庫,將幾筆客戶的 status 欄位從 ACTIVE 改為 INACTIVE

  2. 執行以下 PUT 請求。

    curl -X PUT http://localhost:8080/api/customers/activate-all
    

預期結果:

  1. 您會收到一個成功訊息,例如 "Successfully activated 2 customers."
  2. 查詢資料庫,您會發現所有 INACTIVE 的客戶都變成了 ACTIVE
  3. 檢查應用程式日誌,這個操作只用了一條 SQL UPDATE 語句就完成了,極其高效。

總結與回顧

恭喜您!至此,我們已經成功地將本指南中的所有理論知識轉化為具體的程式碼實作。您的專案現在不僅功能更強大,而且在效能和資料安全性方面也更加穩健。

讓我們快速回顧一下我們完成的成果:

✅ 功能實作總覽

  1. 批次處理設定 (application.properties)

    • 我們在 application.properties 中加入了 Hibernate 的批次處理設定,將批次大小設為 30,並啟用了插入與更新的排序功能,為高效能的資料操作奠定了基礎。
  2. Customer 實體增強 (軟刪除)

    • Customer.java 中,我們加入了 deleted 欄位。
    • 透過 @SQLRestriction("deleted = false"),所有查詢將自動過濾掉被標記為「已刪除」的客戶,讓軟刪除對業務邏輯層幾乎透明。
    • 透過 @SQLDelete(...),我們巧妙地將 delete 操作攔截,轉換為一個 UPDATE 操作,從而實現了資料的「假性」刪除,保留了資料的完整性。
  3. CustomerRepository 擴展

    • 我們新增了 updateStatusForInactiveCustomers 方法,並使用 @Modifying@Query 註解,允許我們直接在資料庫層級執行高效的批次更新。
  4. CustomerService 服務層邏輯

    • 實作了 batchCreateCustomers 方法,利用 Spring Data JPA 的 saveAll 來觸發批次插入。
    • 更新了 deleteCustomer 方法,使其與我們的軟刪除策略無縫接軌。
    • 新增了 activateInactiveCustomers 方法,用於一次性啟用所有非活躍客戶。
  5. CustomerController 新增 API 端點

    • POST /api/customers/batch:用於批次新增客戶。
    • DELETE /api/customers/{customerId}:現在執行的是安全的軟刪除。
    • PUT /api/customers/activate-all:用於批次更新客戶狀態。

相關完整程式碼

Customer.java

package com.example.demo.entities;

import com.example.demo.enums.CustomerStatus;
import jakarta.persistence.*;
import jakarta.validation.constraints.Size;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;

import java.util.HashSet;
import java.util.Set;
import java.util.UUID;

@Entity
@Table(name = "customers")
// 魔法發生的地方:所有查詢都會自動加上這個條件
// 使用新的 @SQLRestriction 註解來取代已棄用的 @Where
@SQLRestriction("deleted = false")
// 當執行 delete 操作時,Hibernate 會執行這條 SQL 來代替真正的 DELETE
@SQLDelete(sql = "UPDATE customers SET deleted = true WHERE id = ?")
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(name = "name", length = 50, nullable = false)
    @Size(min = 2, max = 50)
    private String name;

    // unique=true 會在資料庫中為此欄位建立一個 UNIQUE 約束
    @Column(name = "email", unique = true, nullable = false)
    private String email;

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false)
    private CustomerStatus status;

    @Column(name = "deleted", nullable = false)
    private boolean deleted = false;

    /**
     * 一對多關聯 (One-to-Many).
     * 這一方 (Customer) 是關聯的反向方。
     * mappedBy = "customer": 告訴 JPA,這段關係的設定由 AllowedDomain 實體中的 "customer" 屬性負責。
     * JPA 會去 AllowedDomain.java 裡找那個 customer 欄位上的 @JoinColumn 設定。
     * cascade = CascadeType.ALL: 級聯操作。當我們對 Customer 執行操作 (儲存、刪除) 時,
     * 相關的 AllowedDomain 也會一併被操作。這在新增時非常方便。
     * orphanRemoval = true: "孤兒移除"。如果一個 AllowedDomain 從這個 Set 集合中被移除,
     * 那麼這個 AllowedDomain 在資料庫中也應該被刪除。
     */
    @OneToMany(
        mappedBy = "customer",
        cascade = CascadeType.ALL,
        orphanRemoval = true
    )
    private Set<AllowedDomain> allowedDomains = new HashSet<>();

    /**
     * 一對多關聯 (One-to-Many) - Customer 和 Order
     * 反向方:Order 擁有 customer_id 外鍵
     */
    @OneToMany(
        mappedBy = "customer",
        cascade = CascadeType.ALL,
        orphanRemoval = true
    )
    private Set<Order> orders = new HashSet<>();

    // Helper method to sync both sides of the association
    public void addDomain(AllowedDomain domain) {
        allowedDomains.add(domain);
        domain.setCustomer(this);
    }

    public void removeDomain(AllowedDomain domain) {
        allowedDomains.remove(domain);
        domain.setCustomer(null);
    }

    // Helper method to sync both sides of the association for orders
    public void addOrder(Order order) {
        orders.add(order);
        order.setCustomer(this);
    }

    public void removeOrder(Order order) {
        orders.remove(order);
        order.setCustomer(null);
    }

    // ... 省略 getter 和 setter
}

CustomerRepository.java

package com.example.demo.repositories;

import com.example.demo.entities.AllowedDomain;
import com.example.demo.entities.Customer;
import com.example.demo.enums.CustomerStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;

@Repository
public interface CustomerRepository extends JpaRepository<Customer, UUID> {

    /**
     * 根據 Email 查詢客戶
     */
    Optional<Customer> findByEmail(String email);

    /**
     * 根據客戶狀態查詢客戶清單。
     */
    List<Customer> findByStatus(CustomerStatus status);

    /**
     * 根據狀態分頁查詢客戶 (Derived Query Method with Pagination).
     * @param status 客戶狀態
     * @param pageable 分頁與排序資訊
     * @return 一個包含客戶分頁結果的 Page 物件
     */
    Page<Customer> findByStatus(CustomerStatus status, Pageable pageable);

    /**
     * 使用 JPQL 更新客戶狀態 (@Query with @Modifying).
     * @param id 要更新的客戶 ID
     * @param status 新的狀態
     */
    @Modifying
    @Transactional
    @Query("UPDATE Customer c SET c.status = :status WHERE c.id = :id")
    void updateCustomerStatus(@Param("id") UUID id, @Param("status") CustomerStatus status);

    /**
     * 查詢特定客戶的所有允許網域
     * @param customerId 客戶 ID
     * @return 該客戶的所有允許網域
     */
    @Query("SELECT c.allowedDomains FROM Customer c WHERE c.id = :customerId")
    Set<AllowedDomain> findAllowedDomainsByCustomerId(@Param("customerId") UUID customerId);

    /**
     * 批次更新客戶狀態
     * @param oldStatus 舊狀態
     * @param newStatus 新狀態
     * @return 更新的記錄數量
     */
    @Modifying
    @Transactional
    @Query("UPDATE Customer c SET c.status = :newStatus WHERE c.status = :oldStatus")
    int updateStatusForInactiveCustomers(
        @Param("oldStatus") CustomerStatus oldStatus, 
        @Param("newStatus") CustomerStatus newStatus
    );
}

CustomerService.java

package com.example.demo.services;

import com.example.demo.entities.AllowedDomain;
import com.example.demo.entities.Customer;
import com.example.demo.enums.CustomerStatus;
import com.example.demo.repositories.CustomerRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;

/**
 * Customer 服務層類別
 * 提供客戶相關的業務邏輯處理
 */
@Service
public class CustomerService {

    @Autowired
    private CustomerRepository customerRepository;

    /**
     * 分頁查詢所有客戶,並按姓名排序
     * 根據 README.md 範例:查詢第二頁的資料,每頁顯示 5 筆,並依據 name 欄位升序排列
     */
    public Page<Customer> findCustomersPaginated() {
        // 建立分頁請求
        // PageRequest.of(page, size, sort)
        // page: 頁碼 (從 0 開始) -> 第 2 頁就是 1
        // size: 每頁筆數
        // Sort: 排序條件
        Pageable pageable = PageRequest.of(1, 5, Sort.by("name").ascending());
        
        // 執行分頁查詢
        return customerRepository.findAll(pageable);
    }

    /**
     * 自訂分頁查詢所有客戶
     * @param page 頁碼 (從 0 開始)
     * @param size 每頁筆數
     * @param sortBy 排序欄位
     * @param sortDirection 排序方向 (ASC 或 DESC)
     * @return 分頁結果
     */
    public Page<Customer> findCustomersPaginated(int page, int size, String sortBy, String sortDirection) {
        Sort sort = sortDirection.equalsIgnoreCase("DESC") 
            ? Sort.by(sortBy).descending() 
            : Sort.by(sortBy).ascending();
        
        Pageable pageable = PageRequest.of(page, size, sort);
        return customerRepository.findAll(pageable);
    }

    /**
     * 根據狀態分頁查詢客戶
     * @param status 客戶狀態
     * @param page 頁碼 (從 0 開始)
     * @param size 每頁筆數
     * @param sortBy 排序欄位
     * @param sortDirection 排序方向
     * @return 分頁結果
     */
    public Page<Customer> findCustomersByStatusPaginated(CustomerStatus status, int page, int size, String sortBy, String sortDirection) {
        Sort sort = sortDirection.equalsIgnoreCase("DESC") 
            ? Sort.by(sortBy).descending() 
            : Sort.by(sortBy).ascending();
        
        Pageable pageable = PageRequest.of(page, size, sort);
        return customerRepository.findByStatus(status, pageable);
    }

    /**
     * 根據 Email 查詢客戶
     * @param email 客戶 Email
     * @return 客戶資訊
     */
    public Optional<Customer> findByEmail(String email) {
        return customerRepository.findByEmail(email);
    }

    /**
     * 根據狀態查詢客戶清單
     * @param status 客戶狀態
     * @return 客戶清單
     */
    public List<Customer> findByStatus(CustomerStatus status) {
        return customerRepository.findByStatus(status);
    }

    /**
     * 新增或更新客戶
     * @param customer 客戶資訊
     * @return 儲存後的客戶資訊
     */
    public Customer saveCustomer(Customer customer) {
        return customerRepository.save(customer);
    }

    /**
     * 根據 ID 查詢客戶
     * @param id 客戶 ID
     * @return 客戶資訊
     */
    public Optional<Customer> findById(UUID id) {
        return customerRepository.findById(id);
    }

    /**
     * 更新客戶狀態
     * @param id 客戶 ID
     * @param status 新的狀態
     */
    public void updateCustomerStatus(UUID id, CustomerStatus status) {
        customerRepository.updateCustomerStatus(id, status);
    }

    /**
     * 查詢所有客戶
     * @return 所有客戶清單
     */
    public List<Customer> findAllCustomers() {
        return customerRepository.findAll();
    }

    /**
     * 計算客戶總數
     * @return 客戶總數
     */
    public long countCustomers() {
        return customerRepository.count();
    }

    /**
     * 取得特定客戶的所有允許網域
     * @param customerId 客戶 ID
     * @return 該客戶的所有允許網域
     */
    public Set<AllowedDomain> getDomainsForCustomer(UUID customerId) {
        return customerRepository.findAllowedDomainsByCustomerId(customerId);
    }

    /**
     * 批次建立客戶
     * @param customers 客戶列表
     * @return 儲存後的客戶列表
     */
    @Transactional
    public List<Customer> batchCreateCustomers(List<Customer> customers) {
        // Spring Data JPA 的 saveAll 方法會自動利用我們在 properties 中設定的批次功能
        return customerRepository.saveAll(customers);
    }

    /**
     * 軟刪除客戶
     * @param customerId 客戶 ID
     */
    @Transactional
    public void deleteCustomer(UUID customerId) {
        // 檢查客戶是否存在,若不存在則拋出例外
        if (!customerRepository.existsById(customerId)) {
            throw new RuntimeException("Customer not found with id: " + customerId);
        }
        // 這裡的 deleteById 會觸發 @SQLDelete 定義的 UPDATE,而不是真正的 DELETE
        customerRepository.deleteById(customerId);
    }

    /**
     * 批次更新所有 INACTIVE 客戶為 ACTIVE
     * @return 更新的客戶數量
     */
    @Transactional
    public int activateInactiveCustomers() {
        return customerRepository.updateStatusForInactiveCustomers(
            CustomerStatus.INACTIVE, 
            CustomerStatus.ACTIVE
        );
    }
}

CustomerController.java

package com.example.demo.controllers;

import com.example.demo.entities.AllowedDomain;
import com.example.demo.entities.Customer;
import com.example.demo.enums.CustomerStatus;
import com.example.demo.services.CustomerService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;

/**
 * Customer 控制器
 * 提供客戶相關的 REST API 端點
 */
@RestController
@RequestMapping("/api/customers")
public class CustomerController {

    @Autowired
    private CustomerService customerService;

    /**
     * 分頁查詢所有客戶
     * 根據 README.md 範例:預設查詢第二頁,每頁 5 筆,按姓名升序排列
     * 
     * GET /api/customers/paginated
     */
    @GetMapping("/paginated")
    public ResponseEntity<Page<Customer>> getCustomersPaginated() {
        Page<Customer> customers = customerService.findCustomersPaginated();
        return ResponseEntity.ok(customers);
    }

    /**
     * 自訂分頁查詢所有客戶
     * 
     * GET /api/customers/paginated/custom?page=0&size=10&sortBy=name&sortDirection=ASC
     * 
     * @param page 頁碼 (從 0 開始,預設為 0)
     * @param size 每頁筆數 (預設為 10)
     * @param sortBy 排序欄位 (預設為 name)
     * @param sortDirection 排序方向 (預設為 ASC)
     */
    @GetMapping("/paginated/custom")
    public ResponseEntity<Page<Customer>> getCustomersPaginatedCustom(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "10") int size,
            @RequestParam(defaultValue = "name") String sortBy,
            @RequestParam(defaultValue = "ASC") String sortDirection) {
        
        Page<Customer> customers = customerService.findCustomersPaginated(page, size, sortBy, sortDirection);
        return ResponseEntity.ok(customers);
    }

    /**
     * 根據狀態分頁查詢客戶
     * 
     * GET /api/customers/status/ACTIVE/paginated?page=0&size=5&sortBy=name&sortDirection=ASC
     */
    @GetMapping("/status/{status}/paginated")
    public ResponseEntity<Page<Customer>> getCustomersByStatusPaginated(
            @PathVariable CustomerStatus status,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "5") int size,
            @RequestParam(defaultValue = "name") String sortBy,
            @RequestParam(defaultValue = "ASC") String sortDirection) {
        
        Page<Customer> customers = customerService.findCustomersByStatusPaginated(status, page, size, sortBy, sortDirection);
        return ResponseEntity.ok(customers);
    }

    /**
     * 查詢所有客戶
     * GET /api/customers
     */
    @GetMapping
    public ResponseEntity<List<Customer>> getAllCustomers() {
        List<Customer> customers = customerService.findAllCustomers();
        return ResponseEntity.ok(customers);
    }

    /**
     * 根據 ID 查詢客戶
     * GET /api/customers/{id}
     */
    @GetMapping("/{id}")
    public ResponseEntity<Customer> getCustomerById(@PathVariable UUID id) {
        Optional<Customer> customer = customerService.findById(id);
        return customer.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
    }

    /**
     * 根據 Email 查詢客戶
     * GET /api/customers/email/{email}
     */
    @GetMapping("/email/{email}")
    public ResponseEntity<Customer> getCustomerByEmail(@PathVariable String email) {
        Optional<Customer> customer = customerService.findByEmail(email);
        return customer.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
    }

    /**
     * 根據狀態查詢客戶清單
     * GET /api/customers/status/{status}
     */
    @GetMapping("/status/{status}")
    public ResponseEntity<List<Customer>> getCustomersByStatus(@PathVariable CustomerStatus status) {
        List<Customer> customers = customerService.findByStatus(status);
        return ResponseEntity.ok(customers);
    }

    /**
     * 新增客戶
     * POST /api/customers
     */
    @PostMapping
    public ResponseEntity<Customer> createCustomer(@RequestBody Customer customer) {
        Customer savedCustomer = customerService.saveCustomer(customer);
        return ResponseEntity.ok(savedCustomer);
    }

    /**
     * 更新客戶
     * PUT /api/customers/{id}
     */
    @PutMapping("/{id}")
    public ResponseEntity<Customer> updateCustomer(@PathVariable UUID id, @RequestBody Customer customer) {
        Optional<Customer> existingCustomer = customerService.findById(id);
        if (existingCustomer.isPresent()) {
            customer.setId(id);
            Customer updatedCustomer = customerService.saveCustomer(customer);
            return ResponseEntity.ok(updatedCustomer);
        }
        return ResponseEntity.notFound().build();
    }

    /**
     * 更新客戶狀態
     * PATCH /api/customers/{id}/status/{status}
     */
    @PatchMapping("/{id}/status/{status}")
    public ResponseEntity<Void> updateCustomerStatus(@PathVariable UUID id, @PathVariable CustomerStatus status) {
        Optional<Customer> customer = customerService.findById(id);
        if (customer.isPresent()) {
            customerService.updateCustomerStatus(id, status);
            return ResponseEntity.ok().build();
        }
        return ResponseEntity.notFound().build();
    }

    /**
     * 刪除客戶 (軟刪除)
     * DELETE /api/customers/{id}
     */
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteCustomer(@PathVariable UUID id) {
        try {
            customerService.deleteCustomer(id);
            return ResponseEntity.noContent().build();
        } catch (RuntimeException e) {
            return ResponseEntity.notFound().build();
        }
    }

    /**
     * 取得客戶總數
     * GET /api/customers/count
     */
    @GetMapping("/count")
    public ResponseEntity<Long> getCustomerCount() {
        long count = customerService.countCustomers();
        return ResponseEntity.ok(count);
    }

    /**
     * 取得特定客戶的所有允許網域
     * GET /api/customers/{customerId}/domains
     * 
     * @param customerId 客戶 ID
     * @return 該客戶的所有允許網域
     */
    @GetMapping("/{customerId}/domains")
    public ResponseEntity<Set<AllowedDomain>> getCustomerDomains(@PathVariable UUID customerId) {
        Set<AllowedDomain> domains = customerService.getDomainsForCustomer(customerId);
        if (domains.isEmpty()) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(domains);
    }

    /**
     * 批次新增客戶
     * POST /api/customers/batch
     */
    @PostMapping("/batch")
    public ResponseEntity<List<Customer>> createBatchCustomers() {
        // 為了方便測試,我們在後端直接生成 10 筆假資料
        List<Customer> newCustomers = new ArrayList<>();
        for (int i = 1; i <= 10; i++) {
            Customer customer = new Customer();
            customer.setName("Batch User " + i);
            customer.setEmail("batch" + i + "@example.com");
            customer.setStatus(CustomerStatus.ACTIVE);
            newCustomers.add(customer);
        }
        
        List<Customer> savedCustomers = customerService.batchCreateCustomers(newCustomers);
        return new ResponseEntity<>(savedCustomers, HttpStatus.CREATED);
    }

    /**
     * 批次啟用所有 INACTIVE 客戶
     * PUT /api/customers/activate-all
     */
    @PutMapping("/activate-all")
    public ResponseEntity<String> activateAllInactiveCustomers() {
        int updatedCount = customerService.activateInactiveCustomers();
        return ResponseEntity.ok("Successfully activated " + updatedCount + " customers.");
    }
}

相關資料來源


上一篇
Day26 - Spring Data JPA 關聯對映實戰:從一對多到多層級關聯
下一篇
Day28 - Spring Data JPA 進階實戰:從高效查詢到平行處理
系列文
spring boot 3 學習筆記30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言