iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0

今天要來實作歸還書籍return.php的後端邏輯。除了將借閱狀態更新、歸還書籍庫存之外,還需要判斷是否逾期,並計算逾期天數。

一、還書流程與邏輯分析

  1. 身份與狀態驗證:
    • 檢查使用者是否登入。
    • 驗證傳入的借閱紀錄 ID(loan_id)。
    • 確定該筆借閱紀錄存在,且屬於當前登入的會員,且狀態仍為 borrowed(避免重複還書)。
  2. 計算逾期天數:
    • 比較「當前時間(歸還當下)」與「預計歸還日(due_date)」。
    • 若歸還時間已超過預計歸還日,計算相差天數,並在提示訊息中提醒使用者已逾期多久。
  3. 資料庫更新:
    • 更新 loans 資料表:狀態改為 returned,並寫入實際歸還時間 returned_at。
    • 更新 books 資料表:將書籍的可借數量 available 補回 +1。

二、實作 return.php 完整程式碼

向 Claude Code 發送 Prompt 協助撰寫歸還邏輯:

請幫我撰寫 return.php 的還書邏輯。
1. 只接受已登入會員的 POST 請求,並驗證傳入的 loan_id。
2. 使用 PDO Transaction 與 FOR UPDATE 鎖定借閱紀錄,確認該紀錄屬於目前會員且狀態為 borrowed(避免重複歸還或越權操作)。
3. 使用 PHP DateTime 比對 due_date 與今天日期,計算逾期天數。
4. 將 loans 的狀態改為 returned 並紀錄 returned_at 為當前時間,同時將 books 的 available 庫存 +1。
5. 加上完整的 try-catch(Throwable)錯誤處理、inTransaction() 檢查與註解,處理完成後將提示訊息寫入 $_SESSION['flash'] 並導回 my_loans.php。

生成的return.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 請求
// ============================================================
// 還書會改動資料,只允許表單 POST 觸發,避免有人直接打開網址(GET)就把書還掉
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    header('Location: my_loans.php');
    exit;
}
// ============================================================
// 【PHP】區塊:驗證 loan_id
// ============================================================
$loanId = $_POST['loan_id'] ?? null; // ?? 是 null 合併運算子:左邊不存在或為 null 時改用右邊的值

// ctype_digit() 檢查字串是否全為數字,排除負數、小數與非數字輸入
if ($loanId === null || !ctype_digit((string) $loanId) || (int) $loanId < 1) {
    $_SESSION['flash'] = ['type' => 'error', 'message' => '借閱編號不正確,還書失敗']; // 把提示訊息存進 Session,讓 my>
    header('Location: my_loans.php');
    exit;
}

$loanId = (int) $loanId; // (int) 是型別轉換,把字串轉成整數
$memberId = (int) $_SESSION['member_id']; // 還書者就是目前登入的會員

// ============================================================
// 【PHP】區塊:載入資料庫連線
// ============================================================
require __DIR__ . '/config/database.php'; // __DIR__ 是目前檔案所在的資料夾路徑,確保路徑正確

$pdo = Database::getConnection(); // :: 用來呼叫類別的 static 方法,取得 PDO 連線

// ============================================================
// 【PHP】區塊:還書流程(使用交易確保資料一致)
// ============================================================
try { // try...catch 捕捉執行過程拋出的例外(Exception)
    // 開始交易:更新借閱紀錄與補回庫存必須同時成功,任一步失敗就全部復原
    $pdo->beginTransaction();

    // ------------------------------------------------------------
    // 子區塊:鎖定並查詢借閱紀錄
    // ------------------------------------------------------------
    // WHERE 同時比對 member_id,只查得到自己的借閱紀錄,防止改 loan_id 去還別人的書(越權操作)
    // FOR UPDATE 會鎖住這筆借閱紀錄直到交易結束,同一筆紀錄被連按兩次還書時,第二個請求會等第一個完成才讀取
    $sql = 'SELECT l.book_id, l.due_date, l.status, b.title
            FROM loans AS l
            JOIN books AS b ON b.id = l.book_id
            WHERE l.id = :loan_id AND l.member_id = :member_id
            FOR UPDATE';
    $stmt = $pdo->prepare($sql); // prepare() 建立 prepared statement,:loan_id 等是參數佔位符,可防止 SQL injection
    $stmt->execute([
        ':loan_id' => $loanId,
        ':member_id' => $memberId,
    ]);
    $loan = $stmt->fetch(); // 取出一筆資料,查無資料時回傳 false
    // 查不到代表借閱紀錄不存在,或不屬於目前會員,兩種情況都用同一個訊息,不透露別人的借閱資訊
    if ($loan === false) {
        $pdo->rollBack(); // 復原交易並釋放鎖定
        $_SESSION['flash'] = ['type' => 'error', 'message' => '找不到這筆借閱紀錄,還書失敗'];
        header('Location: my_loans.php');
        exit;
    }
    // 狀態不是 borrowed 代表已經還過了,擋下重複歸還,避免庫存被多加
    if ($loan['status'] !== 'borrowed') {
        $pdo->rollBack();
        $_SESSION['flash'] = ['type' => 'error', 'message' => "《{$loan['title']}》已經歸還過了"]; // 雙引號字串中可用 >
        header('Location: my_loans.php');
        exit;
    }
    // ------------------------------------------------------------
    // 子區塊:計算逾期天數
    // ------------------------------------------------------------
    // PHP 預設時區是 UTC,比台灣慢 8 小時,凌晨還書會被算成前一天,所以明確指定 Asia/Taipei
    $timezone = new DateTimeZone('Asia/Taipei');
    $now = new DateTime('now', $timezone); // 目前時間,稍後寫進 returned_at
    $today = new DateTime('today', $timezone); // 'today' 是今天 00:00:00,只比日期不比時分秒

    // createFromFormat() 依指定格式解析字串,開頭的 ! 會把沒給的時分秒歸零,讓 due_date 也是當天 00:00:00
    $dueDate = DateTime::createFromFormat('!Y-m-d', $loan['due_date'], $timezone);

    // DateTime 物件可以直接用 > 比較先後;diff() 回傳 DateInterval,->days 是相差的總天數
    $overdueDays = $today > $dueDate ? $dueDate->diff($today)->days : 0;

    // ------------------------------------------------------------
    // 子區塊:更新借閱紀錄為已歸還
    // ------------------------------------------------------------
    // WHERE 多加 status = 'borrowed' 當第二道防線,確保只會從「借出中」改成「已歸還」
    $sql = 'UPDATE loans SET status = :status, returned_at = :returned_at
            WHERE id = :loan_id AND status = :old_status';
    $stmt = $pdo->prepare($sql);
    $stmt->execute([
        ':status' => 'returned',
        ':returned_at' => $now->format('Y-m-d H:i:s'), // format() 把 DateTime 轉成 MySQL DATETIME 格式的字串
        ':loan_id' => $loanId,
        ':old_status' => 'borrowed',
    ]);
    / rowCount() 回傳實際被更新的列數,不是 1 就丟出例外,讓 catch 復原整筆交易
    if ($stmt->rowCount() !== 1) {
        throw new RuntimeException("更新借閱紀錄失敗,loan_id={$loanId}");
    }

    // ------------------------------------------------------------
    // 子區塊:可借數量加 1
    // ------------------------------------------------------------
    // WHERE 多加 available < total,避免資料異常時可借數量超過館藏總數
    $stmt = $pdo->prepare('UPDATE books SET available = available + 1 WHERE id = :id AND available < total');
    $stmt->execute([':id' => $loan['book_id']]);

    if ($stmt->rowCount() !== 1) {
        throw new RuntimeException("補回庫存失敗,book_id={$loan['book_id']}");
    }

    $pdo->commit(); // 提交交易,兩個寫入動作正式生效
    // ------------------------------------------------------------
    // 子區塊:依逾期與否組合提示訊息
    // ------------------------------------------------------------
    if ($overdueDays > 0) {
        $_SESSION['flash'] = ['type' => 'warning', 'message' => "已歸還《{$loan['title']}》,逾期 {$overdueDays} 天"];
    } else {
        $_SESSION['flash'] = ['type' => 'success', 'message' => "成功歸還《{$loan['title']}》,感謝準時還書"];
    }
} catch (Throwable $e) { // Throwable 涵蓋 PDOException(資料庫錯誤)、RuntimeException(更新失敗)與其他執行期錯誤
    // inTransaction() 判斷交易是否仍在進行中,是的話才需要 rollBack,把借閱紀錄與庫存一起復原
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }
    // 詳細錯誤只寫進伺服器 log 方便除錯,畫面只顯示友善訊息,不把 SQL 細節暴露給使用者
    error_log('[return.php] member_id=' . $memberId . ' loan_id=' . $loanId . ' ' . get_class($e) . ': ' . $e->getMessa>
    $_SESSION['flash'] = ['type' => 'error', 'message' => '還書時發生錯誤,請稍後再試'];
}

// ============================================================
// 【PHP】區塊:處理完成導回我的借閱頁
// ============================================================
header('Location: my_loans.php');
exit;

三、關鍵技術觀念拆解

  1. 精準處理時區與 DateTime 開頭驚嘆號 !:
    • 許多伺服器環境預設時區為 UTC,若未指定 Asia/Taipei,在深夜時段操作時,伺服器時間仍留在前一天,這會導致逾期天數計算錯誤。
    • 使用 DateTime::createFromFormat('!Y-m-d', ...) 時,格式字串開頭的驚嘆號 ! 非常重要,它能將未設定的「時、分、秒」強制歸零(即當天 00:00:00),避免因為當下時間的時分秒影響天數比對結果。
  2. 多重安全防禦:
    • 在 UPDATE loans 時條件加上 status = 'borrowed',並且要求 $stmt->rowCount() === 1,確保資料庫層級絕不允許重複更新狀態。
    • 在 UPDATE books 加上 available < total,能防止因資料異常或程式 Bug 導致庫存超出館藏總數。

今天完成了 歸還書籍與動態計算逾期天數的核心後端邏輯,補齊了圖書借閱系統最重要的一環。

目前為止,後端的借書、還書、併發鎖定與逾期判斷都已經開發完畢。明天會實作前端介面,讓會員能清楚看到自己目前借了哪些書、何時到期,並提供一鍵點擊還書的按鈕。


上一篇
Day 19 用 transaction 解決時序問題
系列文
從零打造圖書管理系統:WSL2 × MySQL × Claude Code 的整合實作 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言