BookService.java
Springboot/bookstore-api/src/main/java/com/example/bookstore/service/BookService.java
package com.example.bookstore.service;
import com.example.bookstore.dto.BookPatchRequest;
import com.example.bookstore.dto.BookRequest;
import com.example.bookstore.dto.BookResponse;
import com.example.bookstore.dto.PageResponse;
import com.example.bookstore.entity.Book;
import com.example.bookstore.exception.DuplicateResourceException;
import com.example.bookstore.exception.ResourceNotFoundException;
import com.example.bookstore.repository.BookRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 書籍 Service(商業邏輯層)。
*
* 【三層式架構中的角色】
* Controller(接 HTTP)→ Service(商業邏輯)→ Repository(存取資料庫)
*
* Service 層的職責:
* 1. 商業規則(例如:ISBN 不可重複)
* 2. 交易管理(@Transactional)
* 3. Entity 與 DTO 之間的轉換
*
* 【@Transactional 交易管理】
* 標註後,方法內的所有資料庫操作會包在「同一個交易」裡:
* 全部成功才 commit;中途拋出例外則全部 rollback,確保資料一致性。
* readOnly = true 用於純查詢方法,可讓資料庫與 Hibernate 做效能最佳化。
*/
@Service
public class BookService {
private final BookRepository bookRepository;
/**
* 【建構子注入 Constructor Injection】
* Spring 建立 BookService 時,會自動把 BookRepository 的實例傳進來
* (這就是「依賴注入 DI」)。相較於 @Autowired 欄位注入,
* 建構子注入的依賴關係更明確、欄位可宣告為 final、也更容易撰寫單元測試。
*/
public BookService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
/** 分頁查詢所有書籍(給 GET /api/books 使用) */
@Transactional(readOnly = true)
public PageResponse<BookResponse> findAll(Pageable pageable) {
// Page.map():把分頁裡的每個 Book Entity 轉成 BookResponse DTO
Page<BookResponse> page = bookRepository.findAll(pageable)
.map(BookResponse::from);
return PageResponse.from(page);
}
/** 依 id 查詢單一書籍(給 GET /api/books/{id} 使用) */
@Transactional(readOnly = true)
public BookResponse findById(Long id) {
Book book = getBookOrThrow(id);
return BookResponse.from(book);
}
/** 關鍵字搜尋(給 GET /api/books/search?keyword=xxx 使用) */
@Transactional(readOnly = true)
public List<BookResponse> search(String keyword) {
return bookRepository.searchByKeyword(keyword).stream()
.map(BookResponse::from)
.toList();
}
/** 新增書籍(給 POST /api/books 使用) */
@Transactional
public BookResponse create(BookRequest request) {
// 商業規則:ISBN 不可重複
if (bookRepository.existsByIsbn(request.isbn())) {
throw new DuplicateResourceException("ISBN 已存在:" + request.isbn());
}
Book book = new Book(
request.title(), request.author(), request.isbn(),
request.price(), request.stock(), request.description());
// save() 回傳「儲存後」的 Entity(此時 id 已由資料庫產生)
Book saved = bookRepository.save(book);
return BookResponse.from(saved);
}
/**
* 完整更新書籍(給 PUT /api/books/{id} 使用)。
* PUT 的語意是「整個資源替換」:所有欄位一律以請求內容覆寫。
*/
@Transactional
public BookResponse update(Long id, BookRequest request) {
Book book = getBookOrThrow(id);
// 檢查新 ISBN 是否與「其他書籍」衝突(排除自己)
if (bookRepository.existsByIsbnAndIdNot(request.isbn(), id)) {
throw new DuplicateResourceException("ISBN 已被其他書籍使用:" + request.isbn());
}
// 覆寫全部欄位
book.setTitle(request.title());
book.setAuthor(request.author());
book.setIsbn(request.isbn());
book.setPrice(request.price());
book.setStock(request.stock());
book.setDescription(request.description());
// 【JPA 小知識:Dirty Checking 髒值檢查】
// book 是交易中的「受管理(Managed)」Entity,
// 交易提交時 Hibernate 會自動偵測欄位變更並送出 UPDATE。
//
// 這裡用 saveAndFlush()(而非 save())立刻執行 UPDATE:
// @PreUpdate 回呼是在「flush」階段才觸發的,
// 若等交易提交才 flush,回應中的 updatedAt 會是舊值。
Book saved = bookRepository.saveAndFlush(book);
return BookResponse.from(saved);
}
/**
* 部分更新書籍(給 PATCH /api/books/{id} 使用)。
* PATCH 的語意是「只修改有提供的欄位」:null 代表「不要動這個欄位」。
*/
@Transactional
public BookResponse patch(Long id, BookPatchRequest request) {
Book book = getBookOrThrow(id);
// 逐一判斷:有帶值(非 null)的欄位才更新
if (request.title() != null) {
book.setTitle(request.title());
}
if (request.author() != null) {
book.setAuthor(request.author());
}
if (request.isbn() != null) {
if (bookRepository.existsByIsbnAndIdNot(request.isbn(), id)) {
throw new DuplicateResourceException("ISBN 已被其他書籍使用:" + request.isbn());
}
book.setIsbn(request.isbn());
}
if (request.price() != null) {
book.setPrice(request.price());
}
if (request.stock() != null) {
book.setStock(request.stock());
}
if (request.description() != null) {
book.setDescription(request.description());
}
// 同 update():立刻 flush,讓 @PreUpdate 更新 updatedAt 後再組回應
Book saved = bookRepository.saveAndFlush(book);
return BookResponse.from(saved);
}
/** 刪除書籍(給 DELETE /api/books/{id} 使用) */
@Transactional
public void delete(Long id) {
// 先確認資料存在,不存在就回 404(而不是默默地什麼都沒刪)
Book book = getBookOrThrow(id);
bookRepository.delete(book);
}
/** 共用小工具:依 id 取書,查無資料就拋出 404 例外 */
private Book getBookOrThrow(Long id) {
return bookRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("書籍", "id", id));
}
}
相關文章
BookstoreApiApplication.java
BookstoreApiApplication.java — java source code from the Springboot learning materials (Springboot/bookstore-api/src/main/java/com/example/bookstore/BookstoreApiApplication.java).
閱讀文章 →DataInitializer.java
DataInitializer.java — java source code from the Springboot learning materials (Springboot/bookstore-api/src/main/java/com/example/bookstore/config/DataInitializer.java).
閱讀文章 →SecurityConfig.java
SecurityConfig.java — java source code from the Springboot learning materials (Springboot/bookstore-api/src/main/java/com/example/bookstore/config/SecurityConfig.java).
閱讀文章 →AuthController.java
AuthController.java — java source code from the Springboot learning materials (Springboot/bookstore-api/src/main/java/com/example/bookstore/controller/AuthController.java).
閱讀文章 →BookController.java
BookController.java — java source code from the Springboot learning materials (Springboot/bookstore-api/src/main/java/com/example/bookstore/controller/BookController.java).
閱讀文章 →ApiErrorResponse.java
ApiErrorResponse.java — java source code from the Springboot learning materials (Springboot/bookstore-api/src/main/java/com/example/bookstore/dto/ApiErrorResponse.java).
閱讀文章 →