之前,我們順利實現了一鍵將預約資訊直連寫入 Google Calendar 的功能。然而在實際營運中,最讓系統管理者頭痛的極端狀況莫過於「搶場地/搶資源」——當兩位職工在同一秒鐘按下「送出預約」,或是兩人在不知道對方正在填表的情況下選擇了相同的場地與時段,系統該如何應對?
今天我們要來處理這個關鍵的競態條件(Race Condition)!透過在 Google Apps Script (GAS) 後端寫入防呆檢查與 LockService(互斥鎖),確保兩人同時提交同一個房間時,系統能準確攔截衝突時段,達到 100% 零重複預約!
許多初學者會在前端(React)載入資料時檢查該時段是否被預約,如果空閒才允許點擊。但這種做法存在嚴重的安全盲區:
⚠️ 常見漏洞情境:
時間差(Time-to-Check to Time-to-Use): 使用者 A 在 10:00 開啟表單(顯示有空檔),填寫表單花費了 2 分鐘。
並發請求(Concurrency): 使用者 B 在 10:01 同樣開啟表單並於 10:02:00 按下送出;使用者 A 也在 10:02:01 按下送出。
結果: 兩筆請求幾乎同時抵達後端,若後端沒有控制機制,兩筆資料都會成功寫入 Google Calendar,造成 Overbooking (重複預約)
[使用者 A] ──(10:02:00 送出請求) ──┐
├──► [GAS 後端無鎖處理] ──► 建立事件 A ─┐
[使用者 B] ──(10:02:01 送出請求) ──┘ └──► 建立事件 B ─┴─► 💥 雙方成功,重複預約!
為了達成嚴密的防護,後端處理邏輯必須採用「先鎖定、再檢查、後寫入」的三部曲策略:
┌─────────────────────────────────────────┐
│ 1. 請求進入,嘗試取得 LockService 互斥鎖 │
└────────────────────┬────────────────────┘
│
▼
┌───────────────────────────────────────────┐
│ 2. 檢查指定場地在該時段是否已有事件 (Conflict) │
└──────────┬────────────────────┬───────────┘
│ │
[有重疊 / 衝突] [無衝突]
│ │
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ 回傳 CONFLICT 錯誤 │ │ 3. 寫入 Calendar │
└──────────┬─────────┘ └────────┬───────────┘
│ │
└──────────┬─────────┘
│
▼
┌─────────────────────────────────────────┐
│ 4. 關鍵步驟:於 finally 區塊強制釋放鎖 │
└─────────────────────────────────────────┘
LockService(互斥鎖): 強制讓後端程式排隊。當請求 A 進入時會鎖定執行區塊,請求 B 必須等待請求 A 處理完成後才能進入。
時間重疊檢查 (Overlap Check): 在鎖定保護下,查詢 Google Calendar 指定場地在該時段是否已存在其他活動,若有重疊則直接攔截並退回。
請將以下程式碼更新至你的 Google Apps Script 專案中:
JavaScript
// 設定共享的 Google Calendar ID (請替換為你的日曆 ID)
const GOOGLE_CALENDAR_ID = 'your_organization_calendar_id@group.calendar.google.com';
/**
* 處理 POST 請求 (API 入口)
*/
function doPost(e) {
// 1. 取得指令碼鎖 (Script Lock)
const lock = LockService.getScriptLock();
// 嘗試獲取鎖,最多等待 10 秒 (10000 毫秒)
const success = lock.tryLock(10000);
if (!success) {
return createJsonResponse({
status: 'ERROR',
message: '系統繁忙中(其他人正在同時預約),請稍後再試一次!'
});
}
try {
const data = JSON.parse(e.postData.contents);
if (data.action === 'createBooking') {
return handleCreateBookingWithLock(data);
}
return createJsonResponse({ status: 'ERROR', message: '無效的 Action' });
} catch (error) {
return createJsonResponse({ status: 'ERROR', message: error.toString() });
} finally {
// 💡 關鍵步驟:無論成功或失敗,最後務必釋放鎖,避免造成死鎖 (Deadlock)
lock.releaseLock();
}
}
/**
* 核心預約建立邏輯 (含衝突檢查)
*/
function handleCreateBookingWithLock(data) {
const calendar = CalendarApp.getCalendarById(GOOGLE_CALENDAR_ID);
if (!calendar) {
throw new Error('找不到指定的 Google Calendar,請檢查 Calendar ID 設定。');
}
const start = new Date(data.startTime);
const end = new Date(data.endTime);
// 基礎時間合理性檢查
if (start >= end) {
return createJsonResponse({ status: 'ERROR', message: '結束時間必須晚於開始時間!' });
}
// 2. 進行時間重疊檢查 (Conflict Check)
// 取得該時間區間內的所有日曆事件
const existingEvents = calendar.getEvents(start, end);
// 篩選出屬於「同一個場地/資源」的衝突事件
const hasConflict = existingEvents.some(event => {
const title = event.getTitle();
const location = event.getLocation();
// 透過標題格式 [場地名稱] 或 Location 欄位進行比對
return title.includes(`[${data.roomName}]`) || location === data.roomName;
});
if (hasConflict) {
return createJsonResponse({
status: 'CONFLICT',
message: `預約失敗!【${data.roomName}】在您選擇的時段已有其他人預約,請重新選擇時段。`
});
}
// 3. 通過檢查,正式建立 Google 日曆事件
const eventTitle = `[${data.roomName}] ${data.purpose} (${data.applicantName})`;
const eventDescription = `申請部門/姓名:${data.applicantName}\n預約場地:${data.roomName}\n活動內容:${data.purpose}\n由預約系統自動建立。`;
const event = calendar.createEvent(eventTitle, start, end, {
description: eventDescription,
location: data.roomName,
});
// 設定事件視覺顏色 (可依據需求調整)
if (data.roomId === 'RM-A1') {
event.setColor(CalendarApp.EventColor.PALE_GREEN);
} else if (data.roomId === 'RM-B1') {
event.setColor(CalendarApp.EventColor.PALE_BLUE);
}
// 4. (可選) 寫入 Google Sheets 作為 Audit Log 備份
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Bookings');
if (sheet) {
sheet.appendRow([
event.getId(),
data.applicantName,
data.roomId,
data.startTime,
data.endTime,
data.purpose,
new Date(),
]);
}
return createJsonResponse({
status: 'SUCCESS',
eventId: event.getId(),
message: '成功建立 Google 日曆事件!',
});
}
/**
* 輔助函式:統一包裝 JSON 回傳格式
*/
function createJsonResponse(payload) {
return ContentService.createTextOutput(JSON.stringify(payload))
.setMimeType(ContentService.MimeType.JSON);
}
JavaScript
const success = lock.tryLock(10000);
tryLock(10000) 表示當有其他請求正在執行時,當前請求最多排隊等待 10 秒。
10 秒內: 前一個請求處理完成並解鎖,當前請求立即接手執行。
超過 10 秒: 若系統極度繁忙仍未拿到鎖,tryLock 會回傳 false。此時程式會優雅地回傳提示訊息「系統繁忙,請稍後再試」,避免請求無休止地 hang 住。
JavaScript
try {
// 預約處理...
} finally {
lock.releaseLock(); // 必不可少!
}
💡 極度重要原則:
無論預約成功、時間重複、或是程式中間遭遇 JSON 解析錯誤與 Calendar API 異常,都必須在 finally 區塊中執行 lock.releaseLock()!如果忘記釋放鎖,後續所有預約請求都將被擋在門外直到逾時。
JavaScript
title.includes(`[${data.roomName}]`) || location === data.roomName
這樣能確保:
預約 活動室 A1 時,只會檢查 活動室 A1 的衝突。
若其他人預約的是 活動室 B1,則完全不受影響,兩者可平行順暢提交!
當 GAS 後端回傳 status === 'CONFLICT' 時,React 前端可以進行相應的體驗優化:
TypeScript
if (result.status === 'CONFLICT') {
// 1. 跳出警告提示
alert(result.message);
// 2. 觸發背景重新整理 (Re-fetch),更新 Day 8 的 FullCalendar 看板
refetchCalendarEvents();
}
這樣一來,使用者不僅知道自己為什麼沒有預約成功,畫面上的日曆看板也會自動刷新,秀出剛剛被其他人搶先一步預約走的時段,大幅降低使用者的迷惘感。
今天我們透過 LockService 互斥鎖 與 Calendar 時間重疊檢查,為這套零成本場地預約系統補上了最強大的後端防衛機制,徹底杜絕了 Overbooking 的可能!