今天我們透過一次完整的實際操作示範,從新職工「阿樂」申請加入機構開始,一步步走完「身分審核 -> 登入與預約 -> 改期規章與簽到 -> 帳戶停用/移除 -> 月底月結單」的完整營運生命週期,並附上全流程架構圖。
我們的系統採用輕量化、無伺服器(Serverless)導向的微服務構想,將前端 UI、邏輯控制層與資料持久層解耦。
┌─────────────────────────────────────────────────────────────┐
│ 用戶端 (Client Layer) │
│ │
│ ┌─────────────────────┐ ┌───────────────────────┐ │
│ │ Web 預約前端介面 │ │ Telegram Bot 機器人 │ │
│ └──────────┬──────────┘ └───────────┬───────────┘ │
└──────────────┼──────────────────────────────┼───────────────┘
(HTTP POST │ / REST) (Webhook │ / HTTPS)
▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ 應用程式層 (App Layer) │
│ │
│ ┌───────────────────────────────┐ │
│ │ Google Apps Script │ │
│ │ (或後端 Node.js API) │ │
│ └──────────────┬────────────────┘ │
└─────────────────────────────┼───────────────────────────────┘
│
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌────────────────┐ ┌──────────────────┐
│ Google Calendar │ │ Google Sheets │ │ Telegram API │
│ (日曆同步/排程) │ │ (主資料庫/紀錄) │ │ (即時推播通知) │
└──────────────────┘ └────────────────┘ └──────────────────┘
當使用者在前端提出預約請求時,後端系統執行的完整檢驗與處理流程如下:
[ 使用者送出預約表單 ]
│
▼
[ 後端接收 Payload ]
│
▼
/ 檢查欄位是否完整? \
< > ─── (否) ───► [ 回傳 400 錯誤訊息 ]
\ /
│ (是)
▼
/ 時段是否重複/衝圖? \
< > ─── (是) ───► [ 回傳 409 衝突通知 ]
\ /
│ (否)
▼
[ 寫入 Google Sheets ]
│
▼
[ 建立 Google Calendar 事件 ]
│
▼
[ 觸發 Telegram 機器人通知 ]
│
▼
[ 回傳 200 預約成功結果 ]
以下呈現使用者、前端介面、後端服務、資料庫與 Telegram 通知模組之間的互動順序:
| 使用者 (User) | Web 前端 | 後端 API | 資料庫 (Sheets) | Telegram Bot |
|---|---|---|---|---|
| ─ 1. 填寫預約─► | ||||
| . | ─2. 送出 API (POST Request) ─► | |||
| . | ─3. 查詢排程衝突─► | |||
| . | ◄─ 4. 回傳查詢結果─ | |||
| . | ─5. 寫入預約紀錄─► | |||
| . | ◄─6. 確認寫入成功─ | |||
| . | ─7. 發送管理員 | 通知推播 ─► | ||
| . | ◄─8. 回傳成功─ | |||
| . | ◄─9. 顯示結果─ |
lok@ngo.org)首次開啟系統頁面,點擊「新職工註冊」並輸入機構 Email。系統會自動發送一組 6 位數一次性驗證碼 (OTP) 至其電子郵箱。/**
* 發送 Email 驗證碼 (OTP)
*/
function sendVerificationCode(email) {
const code = Math.floor(100000 + Math.random() * 900000).toString();
const cache = CacheService.getScriptCache();
cache.put(`OTP_${email}`, code, 600); // 有效時間 10 分鐘
const subject = '【房間預約系統】新職工電郵驗證碼';
const body = `親愛的職工您好,\n\n您的系統註冊驗證碼為:${code}\n請於 10 分鐘內於頁面輸入此驗證碼完成核實。`;
GmailApp.sendEmail(email, subject, body);
return { success: true, message: '驗證碼已發送至您的電郵' };
}
User_Roles 資料表(狀態為 PENDING_APPROVAL),並即時發送審批通知給管理員。此時阿樂無法登入系統,需待管理員完成審核並收到開通通知後方可登入。/**
* 核實驗證碼、建立待審核帳戶並發送審批通知給管理員
*/
function verifyOTPAndRegister(email, inputCode, department) {
const cache = CacheService.getScriptCache();
const savedCode = cache.get(`OTP_${email}`);
if (!savedCode || savedCode !== inputCode) {
throw new Error('VERIFICATION_FAILED: 驗證碼錯誤或已過期');
}
// 寫入 User_Roles,初始狀態為 PENDING_APPROVAL
const ss = SpreadsheetApp.openById(getEnvConfig().SPREADSHEET_ID);
const sheet = ss.getSheetByName('User_Roles');
sheet.appendRow([email, 'USER', department, 'PENDING_APPROVAL', new Date()]);
// 即時發送審批通知給管理員(Email + Telegram)
const adminEmail = getEnvConfig().ADMIN_EMAIL;
const approvalSubject = '【系統審批通知】有新職工申請系統權限';
const approvalBody = `管理員您好:\n\n新職工申請資訊如下:\n- Email: ${email}\n- 部門: ${department}\n\n請前往後端管理主頁進行審核開通。`;
GmailApp.sendEmail(adminEmail, approvalSubject, approvalBody);
sendTelegramNotice(` Notification: 新職工註冊審批申請\nEmail: ${email}\n部門: ${department}\n請管理員前往後端審核。`);
return { status: 'PENDING_APPROVAL', message: '驗證成功!申請已送出,請等待管理員審核開通通知後方可登入。' };
}
/**
* 管理員開通職工帳戶權限
*/
function approveUserAccount(adminEmail, targetEmail) {
const ss = SpreadsheetApp.openById(getEnvConfig().SPREADSHEET_ID);
const sheet = ss.getSheetByName('User_Roles');
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (data[i][0].toLowerCase() === targetEmail.toLowerCase()) {
sheet.getRange(i + 1, 4).setValue('ACTIVE'); // 更新狀態為 ACTIVE
// 發送開通通知 Email 給職工
GmailApp.sendEmail(
targetEmail,
'【房間預約系統】帳戶開通通知',
'親愛的職工您好:\n\n您的系統帳戶已通過管理員審核,現在可以正式登入系統並使用預約服務!'
);
return { success: true, message: `已成功開通 ${targetEmail} 之帳戶權限。` };
}
}
}
APPROVED),同步生成 QR Code 與通知。/**
* 職工發起新預約
*/
function submitNewBooking(userEmail, roomId, startTime, endTime, purpose) {
if (!isRoomVacant(roomId, startTime, endTime)) {
throw new Error('CONFLICT: 該時段已被預約,請選擇其他時段');
}
const bookingId = 'BK_' + new Date().getTime();
const ss = SpreadsheetApp.openById(getEnvConfig().SPREADSHEET_ID);
const sheet = ss.getSheetByName('Bookings');
sheet.appendRow([
bookingId,
roomId,
userEmail,
new Date(startTime),
new Date(endTime),
'APPROVED',
'NOT_CHECKED_IN',
purpose,
new Date(),
'' // 備註/酌情處理欄位
]);
return { bookingId, status: 'APPROVED' };
}
/**
* 職工申請取消預約(含 24HR 檢測邏輯)
*/
function cancelBooking(userEmail, bookingId, reason) {
const ss = SpreadsheetApp.openById(getEnvConfig().SPREADSHEET_ID);
const sheet = ss.getSheetByName('Bookings');
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (data[i][0] === bookingId && data[i][2] === userEmail) {
const bookingStart = new Date(data[i][3]);
const now = new Date();
const hoursDifference = (bookingStart - now) / (1000 * 60 * 60);
let cancellationStatus = 'CANCELLED_VALID';
let isBillable = false;
// 檢測是否少於 24 小時
if (hoursDifference < 24) {
cancellationStatus = 'LATE_CANCELLED'; // 少於一天取消(算作預約不使用)
isBillable = true;
}
sheet.getRange(i + 1, 6).setValue(cancellationStatus);
const noticeMsg = isBillable
? `您的預約 ${bookingId} 已取消。注意:因距原預約不足 24 小時,系統記錄為「預約不使用」並照常計費。您仍可在 60 天內改訂新日期。`
: `您的預約 ${bookingId} 已成功取消。您可以在 60 天內重新改訂新日期與時段。`;
GmailApp.sendEmail(userEmail, '【房間預約系統】預約取消確認通知', noticeMsg);
return { success: true, isBillable, message: noticeMsg };
}
}
}
SUSPENDED(暫停) 或 REMOVED(徹底移除),系統將即刻阻擋其登入與預約權限。/**
* 管理員隨時變更職工帳戶狀態(暫停或徹底移除)
*/
function updateUserStatus(adminEmail, targetEmail, newStatus) {
// newStatus: 'SUSPENDED' (暫停) 或 'REMOVED' (移除) 或 'ACTIVE' (復權)
const ss = SpreadsheetApp.openById(getEnvConfig().SPREADSHEET_ID);
const sheet = ss.getSheetByName('User_Roles');
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (data[i][0].toLowerCase() === targetEmail.toLowerCase()) {
if (newStatus === 'REMOVED') {
sheet.deleteRow(i + 1); // 刪除資料列
return { success: true, message: `已將職工 ${targetEmail} 之權限紀錄徹底移除。` };
} else {
sheet.getRange(i + 1, 4).setValue(newStatus); // 更新狀態為 SUSPENDED 或 ACTIVE
return { success: true, message: `已將職工 ${targetEmail} 之帳戶狀態更新為:${newStatus}` };
}
}
}
throw new Error('NOT_FOUND: 找不到該職工帳戶');
}
/**
* 管理員行使酌情權:剔除不作計費
*/
function applyAdminDiscretion(bookingId, adminEmail, note) {
const ss = SpreadsheetApp.openById(getEnvConfig().SPREADSHEET_ID);
const sheet = ss.getSheetByName('Bookings');
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (data[i][0] === bookingId) {
sheet.getRange(i + 1, 6).setValue('CANCELLED_WAIVED'); // 酌情豁免計費
sheet.getRange(i + 1, 10).setValue(`管理員酌情豁免:${note}`);
return { success: true, message: '已執行管理員酌情權,該筆預約於月結單上剔除不作計費。' };
}
}
}
/**
* 每月 1 號自動執行:生成報表並寄發個人月結單
*/
function generateMonthlyReportAndSendStatements() {
const ss = SpreadsheetApp.openById(getEnvConfig().SPREADSHEET_ID);
const bookingSheet = ss.getSheetByName('Bookings');
const data = bookingSheet.getDataRange().getValues();
const now = new Date();
const lastMonthYear = now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear();
const lastMonth = now.getMonth() === 0 ? 11 : now.getMonth() - 1;
const userStats = {};
for (let i = 1; i < data.length; i++) {
const userEmail = data[i][2];
const startTime = new Date(data[i][3]);
const endTime = new Date(data[i][4]);
const status = data[i][5];
const checkInStatus = data[i][6];
if (startTime.getFullYear() === lastMonthYear && startTime.getMonth() === lastMonth) {
const durationHours = (endTime - startTime) / (1000 * 60 * 60);
if (!userStats[userEmail]) {
userStats[userEmail] = { totalHours: 0, billableHours: 0, count: 0, checkedInCount: 0, lateCancelCount: 0, waivedCount: 0 };
}
userStats[userEmail].count += 1;
if (status === 'APPROVED' || status === 'LATE_CANCELLED') {
userStats[userEmail].billableHours += durationHours;
}
if (status === 'APPROVED') {
userStats[userEmail].totalHours += durationHours;
}
if (checkInStatus === 'CHECKED_IN') {
userStats[userEmail].checkedInCount += 1;
}
if (status === 'LATE_CANCELLED') {
userStats[userEmail].lateCancelCount += 1;
}
if (status === 'CANCELLED_WAIVED') {
userStats[userEmail].waivedCount += 1;
}
}
}
// 逐一寄發個人月結單 Email
Object.keys(userStats).forEach(email => {
const stat = userStats[email];
const monthLabel = `${lastMonthYear} 年 ${lastMonth + 1} 月`;
const emailContent = `
親愛的職工 (${email}) 您好:
以下是您於【${monthLabel}】的房間使用統計與月結對帳單:
【預約與使用統計】
• 總預約次數:${stat.count} 次
• 成功簽到次數:${stat.checkedInCount} 次
• 少於24小時取消 / 預約不使用次數:${stat.lateCancelCount} 次
• 管理員酌情豁免次數:${stat.waivedCount} 次
【租用費用計算】
• 實際使用時數:${stat.totalHours} 小時
• 應計費總時數(含預約不使用時數):${stat.billableHours} 小時
感謝您對機構資源的合規使用!
※ 備註:若有少於一天取消之紀錄但屬特殊緊急情況,請聯絡管理員評估執行酌情權調整。
`;
GmailApp.sendEmail(email, `【房間預約系統】${monthLabel} 個人租用對帳月結單`, emailContent);
});
return { totalUsersProcessed: Object.keys(userStats).length };
}
| 營運階段 | 操作角色 | 觸發條件 / 動作 | 系統響應與驗證點 |
|---|---|---|---|
| 身分註冊 | 新職工 | 填寫 Email 申請 | 收到 6 位數 OTP 驗證碼 |
| 審批通知 | 系統 | 職工通過 OTP 核實 | 即時發送審批通知給管理員;職工狀態為 PENDING_APPROVAL 且無法登入 |
| 審核開通 | 管理員 | 後端點擊核准 | 狀態更新為 ACTIVE,發送開通 Email,職工方可登入 |
| 房間預約 | 職工 | 查詢 A1/A2/B1/B2/洽談室 | 空置時段預約直接成功 (APPROVED) |
| 預約改期 | 職工 | 提前 $\ge 24$ 小時取消 | 收到取消成功通知,可於 60 天內 改訂新日期;不予計費 |
| 違規取消 | 職工 | 少於 24 小時取消 | 標記為 LATE_CANCELLED(預約不使用),照樣納入月結計費 |
| 離職/暫停 | 管理員 | 設定狀態為 SUSPENDED / REMOVED |
系統即刻阻擋登入與預約權限 |
| 酌情扣除 | 管理員 | 後端勾選豁免 | 狀態轉為 CANCELLED_WAIVED,月結單上剔除不作計費 |
| 月底結算 | 管理員 | 執行月結統計 | 自動計算總計費時數,寄送個人 Email 月結對帳單 |