完成會員註冊與登入驗證後,今天要進入系統最核心的業務邏輯之一:借書功能。
當會員點擊借書時,後端不能只是單純在資料庫新增一筆紀錄,還必須處理書籍庫存的異動與狀態檢查。
$_SESSION['member_id'])。在專案目錄下啟動 Claude Code 下達需求:
請幫我寫一個 borrow.php。當使用者透過 POST 請求傳入 book_id 時:
1. 檢查使用者是否已登入,若未登入則導向 login.php。
2. 查詢 books 資料表確認該書籍庫存(quantity)是否大於 0。
3. 若有庫存,在 loans 資料表新增一筆借閱紀錄(包含 member_id、book_id、borrowed_at),並將 books 資料表中該書籍的 quantity 減 1。
4. 處理完成後導回 index.php 並顯示成功或失敗訊息。
生成的 borrow.php:
<?php
// ============================================================
// 【PHP】區塊:啟動 Session
// ============================================================
session_start(); // 必須在任何輸出之前呼叫,之後才能讀寫 $_SESSION(登入狀態與提示訊息都存在這裡)
// ============================================================
// 【PHP】區塊:檢查是否已登入
// ============================================================
// login.php 登入成功時會寫入 $_SESSION['member_id'],沒有這個值就代表尚未登入
if (!isset($_SESSION['member_id'])) {
header('Location: login.php'); // header() 送出 HTTP 標頭,Location 會讓瀏覽器跳轉到指定頁面
exit; // 導向後立刻結束程式,避免下方借書邏輯繼續執行
}
// ============================================================
// 【PHP】區塊:只接受 POST 請求
// ============================================================
// $_SERVER['REQUEST_METHOD'] 是這次請求的方法(GET / POST),借書會改動資料,只允許表單 POST 觸發
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: index.php');
exit;
}
// ============================================================
// 【PHP】區塊:驗證 book_id
// ============================================================
$bookId = $_POST['book_id'] ?? null; // ?? 是 null 合併運算子:左邊不存在或為 null 時改用右邊的值
// ctype_digit() 檢查字串是否全為數字,排除負數、小數與非數字輸入
if ($bookId === null || !ctype_digit((string) $bookId) || (int) $bookId < 1) {
$_SESSION['flash'] = ['type' => 'error', 'message' => '書籍編號不正確,借閱失敗']; // 把提示訊息存進 Session,讓 in>
header('Location: index.php');
exit;
}
$bookId = (int) $bookId; // (int) 是型別轉換,把字串轉成整數
$memberId = (int) $_SESSION['member_id']; // 借閱者就是目前登入的會員
// ============================================================
// 【PHP】區塊:載入資料庫連線
// ============================================================
require __DIR__ . '/config/database.php'; // __DIR__ 是目前檔案所在的資料夾路徑,確保路徑正確
$pdo = Database::getConnection(); // :: 用來呼叫類別的 static 方法,取得 PDO 連線
// ============================================================
// 【PHP】區塊:借書流程(使用交易確保資料一致)
// ============================================================
ry { // try...catch 捕捉執行過程拋出的例外(Exception)
// 開始交易:新增借閱紀錄與扣庫存必須同時成功,任一步失敗就全部復原
$pdo->beginTransaction();
// ------------------------------------------------------------
// 子區塊:查詢書籍庫存
// ------------------------------------------------------------
// FOR UPDATE 會鎖住這筆書籍資料直到交易結束,避免兩人同時借走最後一本
$stmt = $pdo->prepare('SELECT title, available FROM books WHERE id = :id FOR UPDATE'); // prepare() 建立 prepared s>
$stmt->execute([':id' => $bookId]); // 把實際值綁定到佔位符並執行查詢
$book = $stmt->fetch(); // 取出一筆資料,查無資料時回傳 false
if ($book === false) {
$pdo->rollBack(); // 復原交易並釋放鎖定
$_SESSION['flash'] = ['type' => 'error', 'message' => '找不到這本書,借閱失敗'];
header('Location: index.php');
exit;
}
// books 資料表用 available 欄位記錄目前可借數量
if ((int) $book['available'] <= 0) {
$pdo->rollBack();
$_SESSION['flash'] = ['type' => 'error', 'message' => "《{$book['title']}》目前沒有庫存,借閱失敗"]; // 雙引號[>
header('Location: index.php');
exit;
}
// ------------------------------------------------------------
// 子區塊:新增借閱紀錄
// ------------------------------------------------------------
// NOW() 是 MySQL 函式,取得目前時間;due_date 為必填欄位,預設借期 14 天
$sql = 'INSERT INTO loans (member_id, book_id, borrowed_at, due_date, status)
VALUES (:member_id, :book_id, NOW(), DATE_ADD(CURDATE(), INTERVAL 14 DAY), :status)';
$stmt = $pdo->prepare($sql);
$stmt->execute([
':member_id' => $memberId,
':book_id' => $bookId,
':status' => 'borrowed',
]);
/ ------------------------------------------------------------
// 子區塊:可借數量減 1
// ------------------------------------------------------------
$stmt = $pdo->prepare('UPDATE books SET available = available - 1 WHERE id = :id');
$stmt->execute([':id' => $bookId]);
$pdo->commit(); // 提交交易,兩個寫入動作正式生效
$_SESSION['flash'] = ['type' => 'success', 'message' => "成功借閱《{$book['title']}》,請於 14 天內歸還"];
} catch (PDOException $e) { // PDOException 是資料庫操作失敗時拋出的例外類別
// inTransaction() 判斷交易是否仍在進行中,是的話才需要 rollBack
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
$_SESSION['flash'] = ['type' => 'error', 'message' => '借閱時發生錯誤,請稍後再試'];
}
// ============================================================
// 【PHP】區塊:處理完成導回首頁
// ============================================================
header('Location: index.php');
exit;
borrow.php 發送請求,系統順利攔截並導向 login.php。今天完成了基礎的借書流程,看起來一切運作正常。
但是,現在的程式碼隱藏著一個漏洞:如果兩個人在同一時間點擊借同一本只剩 1 本的書,會發生什麼事?
明天將模擬這個「兩個人同時借同一本書」的問題,看看 Race Condition 是如何打破系統邏輯!