# 尾牙簽到及抽獎系統 > 版本:2.4.0 > 更新日期:2026-01-16 > 對應檔案:`Code.gs`, `Index.html`, `SignIn.html` ## 目錄 [整體架構](#整體架構) [簽到資料流程圖](#簽到資料流程圖) [抽獎資料流程圖](#抽獎資料流程圖) [獎項來源與規則系統](#獎項來源與規則系統) [動態佇列機制](#動態佇列機制) [Property 機制說明](#Property-機制說明) [Cache 機制說明](#Cache-機制說明) [Lock 與併發控制](#Lock-與併發控制) [部署與設定](#部署與設定) [維運指令](#維運指令) --- ## 技術堆疊 ### 前端 | 技術 | 用途 | 說明 | |------|------|------| | **Vue 3** (CDN) | 前端框架 | Composition API 響應式設計 | | **Tailwind CSS** (CDN) | 樣式框架 | Utility-first CSS | | **google.script.run** | 前後端通訊 | GAS 原生 RPC 機制 | ### 後端 | 技術 | 用途 | 說明 | |------|------|------| | **Google Apps Script (GAS)** | 後端服務 | 提供 Web App 與 API 端點 | | **Google Sheets API** | 資料持久化 | 主資料表及 9 個 Mirror 分流表 | | **ScriptProperties** | 狀態管理 | 儲存動態佇列、系統狀態等 | | **CacheService** | 效能優化 | 多層快取減少試算表 I/O | | **LockService** | 併發控制 | 確保原子性操作 | | **Time-driven Triggers** | 排程任務 | 每分鐘刷新佇列與同步資料 | ### 資料儲存層級 ```mermaid graph TB subgraph "Layer 1: CacheService" C1[記憶體快取] C1 --> |TTL| C1a[30秒 ~ 6小時] C1 --> |限制| C1b[單鍵 100KB] C1 --> |用途| C1c[熱資料快取] end subgraph "Layer 2: ScriptProperties" P1[持久化 Key-Value] P1 --> |限制| P1a[單鍵 9KB / 總量 500KB] P1 --> |用途| P1b[動態佇列 / 系統狀態] end subgraph "Layer 3: Google Sheets" S1[結構化儲存] S1 --> |Master| S1a[核心資料] S1 --> |Mirror| S1b[分流顯示 x9] end C1 --> P1 P1 --> S1 ``` --- ## 整體架構 ```mermaid graph TB subgraph "前端 Browser" SignIn[SignIn.html<br/>簽到頁面] Admin[Index.html<br/>管理控制台] end subgraph "後端 Google Apps Script" WebApp[doGet<br/>Web App 進入點] subgraph "API Layer" SignInAPI[signIn] LotteryAPI[runLottery] VoidAPI[voidAndRefresh] StatsAPI[getSignInStats] ConfigAPI[forceReloadConfig] ToggleAPI[toggleRegistration] end subgraph "Service Layer" ValidateService[validateEmployee] EnqueueService[enqueueSignIn] FlushQueue[flushQueueToSheet] SyncService[syncToMirrorsProcess] SourceService[getCandidatesBySource] RulesService[applySourceRules] end subgraph "Data Access Layer" CacheLayer[CacheService] PropsLayer[ScriptProperties] SheetLayer[SpreadsheetApp] end end subgraph "資料儲存" MasterSheet[(Master Sheet)] MirrorSheets[(Mirror Sheets x9)] end subgraph "排程任務" Trigger1[scheduledFlushQueue<br/>每 5 分鐘] Trigger2[triggerSyncJob<br/>每分鐘] end SignIn -->|google.script.run| SignInAPI Admin -->|google.script.run| LotteryAPI Admin -->|google.script.run| VoidAPI WebApp --> SignIn WebApp --> Admin SignInAPI --> ValidateService SignInAPI --> EnqueueService LotteryAPI --> FlushQueue LotteryAPI --> SourceService SourceService --> RulesService ValidateService --> CacheLayer EnqueueService --> PropsLayer FlushQueue --> SheetLayer CacheLayer --> PropsLayer PropsLayer --> SheetLayer SheetLayer --> MasterSheet SyncService --> MirrorSheets Trigger1 --> FlushQueue Trigger2 --> SyncService ``` ### 工作表結構 | 工作表名稱 | 用途 | 欄位 | |-----------|------|------| | `ValidEmployees` | 合格員工名單 | BG, BU, Dept, EmpID, ChName, EnName, ..., Location | | `Employees` | 已簽到員工 | 員工編號, 姓名, 部門, 簽到時間, 工作地點 | | `Prize` | 獎項設定 | PrizeID, PrizeName, Count, SoundURL, Source | | `Winners` | 中獎紀錄 | 時間戳記, 獎項名稱, 員工編號, 姓名, 部門, Status, Audit | --- ## 簽到資料流程圖 ```mermaid sequenceDiagram autonumber participant U as 使用者 participant F as 前端 Vue participant G as GAS API participant C as CacheService participant P as ScriptProperties participant S as Google Sheets U->>F: 輸入員編 + 姓名 F->>F: 表單驗證 + 節流控制 2s Note over F: 指數退避重試機制<br/>最多 3 次,基礎延遲 1.5s F->>G: signIn(empId, empName) G->>C: 檢查 SIGNED_{empId} 快取 alt 快取命中 已簽到 C-->>G: 已簽到標記 G-->>F: success false, alreadySigned true F-->>U: 顯示「已簽到」 else 快取未命中 G->>P: 檢查 IS_REGISTRATION_CLOSED alt 已截止 P-->>G: true G-->>F: success false, registrationClosed true F-->>U: 顯示「簽到已截止」 else 開放中 G->>C: 讀取 VALID_EMP_MAP 分片 alt 快取命中 C-->>G: employeeMap else 快取未命中 G->>S: 讀取 ValidEmployees S-->>G: 員工資料 G->>C: 分片存入快取 TTL 6hr end G->>G: validateEmployee 比對姓名 alt 驗證失敗 G-->>F: success false, error else 驗證成功 G->>G: tryLock 3s G->>C: 雙重檢查 SIGNED_{empId} alt 已在快取中 G-->>F: success false, alreadySigned true else 尚未簽到 G->>P: enqueueSignIn 寫入動態佇列 G->>C: 設定 SIGNED_{empId} = 1 TTL 6hr G-->>F: success true F->>F: 播放煙火動畫 F-->>U: 顯示「簽到成功」 end end end end Note over P,S: 背景排程 每 5 分鐘 P->>G: scheduledFlushQueue G->>P: readAllQueueEntries G->>C: 讀取員工 Map Hydration G->>S: 批次寫入 Employees 表 G->>P: cleanupProcessedQueueShards ``` ### 簽到流程關鍵設計 **雙重檢查機制 (Double-checked Locking)** 1. **Lock 前檢查**:快取 `SIGNED_{empId}` 快速過濾 2. **Lock 後檢查**:確保等待 Lock 期間未被其他請求簽到 **最小化佇列格式** ```javascript // 佇列項目只存最小資訊 (i: ID, t: Timestamp) { i: "0010", t: "2026-01-07T12:00:00.000Z" } ``` **指數退避公式** ```javascript delay = min(baseDelay × 1.5^(attempt-1) + random(0, jitterMax), maxDelay) ``` --- ## 抽獎資料流程圖 ```mermaid sequenceDiagram autonumber participant A as 管理員 participant F as 前端 Vue participant G as GAS API participant C as CacheService participant P as ScriptProperties participant S as Google Sheets participant M as Mirror Sheets A->>F: 選擇獎項 + 設定人數 F->>F: 驗證剩餘名額 A->>F: 點擊「開始抽獎」 F->>G: runLottery(prizeName, count) G->>G: acquireLockWithBackoff LOTTERY config G->>P: flushQueueToSheet 確保資料同步 G->>C: getCachedPrizes 讀取獎項 G->>G: getSourceConfig 取得來源設定 G->>C: getPrizeWinnerCounts G->>G: 計算剩餘名額 alt 名額不足 G-->>F: Error 獎項已抽完 else 名額充足 G->>C: getCandidatesBySource 載入候選人 G->>C: getWinnerIdsSet 讀取已中獎 G->>P: getLotteryCutoffTime G->>G: applySourceRules 套用規則過濾 alt 候選人不足 G-->>F: Error 合格候選人數不足 else 候選人充足 G->>G: shuffleArray Fisher-Yates G->>G: 取前 N 位 G->>S: 批次寫入 Winners 表 G->>C: updateWinnerIdsCache G->>C: 更新 PRIZE_WINNER_COUNTS G->>P: 持久化 WINNER_IDS G->>P: markSyncRequired G-->>F: 回傳新中獎者列表 F->>F: 播放音效 + 更新 UI F-->>A: 顯示中獎名單 Note over G,M: 背景同步 G->>M: syncToMirrorsProcess end end ``` ### 抽獎流程關鍵設計 **資格截止時間** - 管理員執行 `toggleRegistration(true)` 時自動記錄 `LOTTERY_CUTOFF_TIME` - 抽獎時套用 `excludeAfterCutoff` 規則排除截止後簽到者 **Fisher-Yates 洗牌** ```javascript function shuffleArray(array) { for (let i = array.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [array[i], array[j]] = [array[j], array[i]]; } } ``` **Mirror 同步策略** - 批次同步(每批 3 個試算表) - 節流控制(10 秒內不重複同步) - 使用 Sheets API 提升效能 --- ## 獎項來源與規則系統 ### 設計目的 支援不同獎項使用不同的候選人來源與篩選規則,實現靈活的抽獎邏輯。 ### PRIZE_SOURCES 設定結構 ```javascript PRIZE_SOURCES: { Employees: { sheetName: 'Employees', // 資料來源工作表 columns: { id: 0, // 員工編號欄位索引 name: 1, // 姓名欄位索引 dept: 2, // 部門欄位索引 signInTime: 3 // 簽到時間欄位索引 }, rules: ['excludeWinners', 'excludeAfterCutoff'], // 套用規則 displayName: '員工抽獎', // 前端顯示名稱 cacheKey: 'EMPLOYEE_DATA', // 快取鍵名稱 cacheTTL: 1800 // 快取時間(秒) } } ``` ### LOTTERY_RULES 規則定義 ```javascript LOTTERY_RULES: { excludeWinners: { name: '排除已中獎', description: '已中獎的人不可再次中獎', filter: function(candidate, context) { return !context.winnerIds.has(String(candidate.id)); } }, excludeAfterCutoff: { name: '排除遲到簽到', description: '簽到截止後才簽到的人不具抽獎資格', filter: function(candidate, context) { if (!context.cutoffTime) return true; if (!candidate.signInTime) return true; const signInTimestamp = new Date(candidate.signInTime).getTime(); return signInTimestamp <= context.cutoffTime; } } } ``` ### 規則套用流程 ```mermaid graph TB subgraph "來源資料載入" S[getCandidatesBySource] S --> D[從 Sheet 讀取原始資料] D --> N[標準化候選人格式] end subgraph "規則套用" N --> R[applySourceRules] R --> R1{excludeWinners} R1 --> |移除已中獎| R2{excludeAfterCutoff} R2 --> |移除遲到者| F[過濾後候選人] end subgraph "上下文準備" C[context 物件] C --> C1[winnerIds: Set] C --> C2[cutoffTime: timestamp] C --> C3[prizeName: string] end C --> R ``` ### 擴展新來源範例 ```javascript // 在 CONFIG.PRIZE_SOURCES 新增 VIPGuests: { sheetName: 'VIPGuests', columns: { id: 0, name: 1, dept: 2 }, rules: ['excludeWinners'], // VIP 不套用遲到規則 displayName: 'VIP 貴賓抽獎', cacheTTL: 600 } ``` --- ## 動態佇列機制 ### 設計目的 解決多人同時簽到時的高併發寫入問題,避免 Google Sheets API 限制導致失敗。 ### 佇列寫入與刷新機制 採用「**非同步寫入**」策略,將高併發的簽到請求暫存於 `ScriptProperties` 的動態分片佇列中,再透過排程或特定觸發事件,批次將資料刷新至 Google Sheets。 ```mermaid sequenceDiagram autonumber participant T as Trigger/Caller participant L as LockService participant P as ScriptProperties participant C as CacheService participant S as Google Sheets Note over T: 觸發刷新 (定時/強制) T->>L: 嘗試獲取 Lock alt Lock 获取失敗 L-->>T: 放棄/等待 else Lock 获取成功 T->>P: readAllQueueEntries (讀取 Q_TAIL 到 Q_HEAD) P-->>T: 回傳 Raw Entries [{i, t}...] Note right of T: Critical Section 結束 (僅限 flushQueue) loop 資料處理 (Hydration) T->>C: 讀取員工資料 Map (getValidEmployeeMap) Note over T: 將 ID (i) 轉換為<br/>姓名/部門/地點 end T->>S: 讀取現有 ID (防止重複) loop 去重與過濾 Note over T: 1. 檢查 Sheet 是否已有 ID<br/>2. 檢查本批次是否重複 end alt 有新資料 T->>S: 批次寫入 Range.setValues() Note over S: 寫入 Employees 工作表 T->>C: 清除 EMPLOYEE_DATA 快取 end T->>P: cleanupProcessedQueueShards (推進 Tail 指標) T->>P: 更新 LAST_FLUSH_TIME L->>T: 釋放 Lock end ``` 接著,簡單介紹使用的兩種刷新模式,分別對應「背景自動處理」與「關鍵操作強制同步」。 | 功能 | `flushQueue()` | `flushQueueToSheet()` | | --- | --- | --- | | **主要用途** | **定時觸發器** | **抽獎/統計/管理操作** | | **Lock 策略** | **兩段式處理**:<br>1. 快速鎖定 (Critical Section) 讀取並移動指標。<br>2. 釋放鎖後再執行緩慢的 Sheet I/O。 | **全程鎖定 (Atomic)**:<br>從讀取佇列到寫入 Sheet 全程持有鎖,確保操作原子性。 | | **優點** | **高吞吐量**:大幅減少 Lock 佔用時間,避免阻塞前端簽到請求。 | **高一致性**:確保函式返回時,資料絕對已寫入 Sheet。 | | **資料安全** | 寫入失敗時會觸發 `BACKUP_QUEUE_FAIL` 備份機制。 | 依賴呼叫方的 Try-Catch 處理。 | ### 動態分片架構 ```mermaid graph TB subgraph "簽到請求" R1[Request 1] R2[Request 2] RN[Request N] end subgraph "ScriptProperties 動態佇列" IDX[Q_HEAD_IDX / Q_TAIL_IDX] Q0[Q_SHARD_0<br/>JSON Array] Q1[Q_SHARD_1<br/>JSON Array] QN[Q_SHARD_N<br/>...] end subgraph "定時刷新" T[scheduledFlushQueue<br/>每 5 分鐘] end subgraph "Google Sheets" S[(Employees)] end R1 -->|enqueueSignIn| Q0 R2 -->|enqueueSignIn| Q0 RN -->|超過 8.5KB 自動切換| Q1 T -->|flushQueue| Q0 T -->|flushQueue| Q1 Q0 -->|Batch Write| S Q1 -->|Batch Write| S ``` ### 佇列指標管理 | Property 鍵 | 說明 | |------------|------| | `Q_HEAD_IDX` | 當前寫入的佇列分片索引 | | `Q_TAIL_IDX` | 當前讀取/刷新的佇列分片索引 | | `Q_SHARD_{N}` | 第 N 個佇列分片資料 | ### 分片切換邏輯 ```javascript function enqueueSignIn(empId, empData, props) { let headIdx = parseInt(props.getProperty('Q_HEAD_IDX') || '0', 10); let queueKey = 'Q_SHARD_' + headIdx; let queueJson = props.getProperty(queueKey) || '[]'; // 超過 8.5KB 限制時切換到下一個分片 if (queueJson.length > 8500) { headIdx++; queueKey = 'Q_SHARD_' + headIdx; queueJson = '[]'; props.setProperty('Q_HEAD_IDX', String(headIdx)); } // 寫入最小化格式 const entry = { i: empId, t: new Date().toISOString() }; const queue = JSON.parse(queueJson); queue.push(entry); props.setProperty(queueKey, JSON.stringify(queue)); } ``` ### Hydration 資料還原 ```mermaid graph LR Q[佇列項目<br/>i, t] --> H{Hydration} M[員工 Map<br/>empId → 完整資料] --> H H --> R[完整紀錄<br/>寫入試算表] ``` 刷新時從 `ValidEmployees` 快取的 Map 還原完整員工資料,確保資料完整性。 --- ## Property 機制說明 ### 設計目的 利用 `ScriptProperties` 作為中間層: 1. 單鍵 9KB 限制 → 動態分片儲存 2. 跨請求狀態共享 → 持久化 3. 減少試算表讀取 → 快速查詢 ### Property 鍵值一覽 | 鍵名 | 用途 | 格式 | |------|------|------| | `Q_HEAD_IDX` | 佇列寫入分片索引 | Number String | | `Q_TAIL_IDX` | 佇列讀取分片索引 | Number String | | `Q_SHARD_{N}` | 佇列分片資料 | JSON Array | | `WINNER_IDS` | 已中獎 ID 列表 | JSON Array | | `IS_REGISTRATION_CLOSED` | 簽到狀態 | "true" / "false" | | `LOTTERY_CUTOFF_TIME` | 抽獎資格截止時間 | Timestamp String | | `SYNC_REQUIRED` | 同步標記 | "true" / "false" | | `LAST_SYNC_TIME` | 上次同步時間 | Timestamp String | | `LAST_FLUSH_TIME` | 上次佇列刷新時間 | Timestamp String | | `CACHE_REBUILD_LOCK` | 快取重建鎖 | Timestamp String | | `ADMIN_TOKEN` | 管理員驗證 Token | String | ### 空間估算 - 單一佇列項目:約 80-100 bytes - 單片 8.5KB 可容納:約 85-100 個簽到 - 10 片總量:約 85KB(在 500KB 總限制內) --- ## Cache 機制說明 ### 設計目的 1. **減少試算表 I/O**:多人同時查詢會超出 API 限制 2. **加速驗證**:員工 Map 快取命中率極高 3. **防止雪崩**:分片儲存 + 重建鎖 ### 快取鍵值一覽 | 鍵名 | TTL | 用途 | |------|-----|------| | `VALID_EMP_MAP` | 6 hr | 合格員工 Map 主快取 | | `VALID_EMP_MAP_{N}` | 6 hr | 員工 Map 分片(每片 300 員工) | | `VALID_EMP_MAP_COUNT` | 6 hr | 員工 Map 分片數量 | | `VALID_EMPLOYEE_COUNT` | 5 min | 合格員工總數 | | `EMPLOYEE_DATA` | 30 min | 已簽到員工資料 | | `PRIZE_DATA` | 5 min | 獎項設定 | | `PRIZE_WINNER_COUNTS_CACHE` | 30 sec | 各獎項中獎人數 | | `WINNER_IDS_CACHE` | 1 min | 已中獎者 ID | | `SIGNED_{empId}` | 6 hr | 單一員工簽到標記 | | `HTML_SIGNIN_CACHE` | 10 min | 編譯後簽到頁面 HTML | | `HTML_ADMIN_CACHE` | 10 min | 編譯後管理頁面 HTML | ### 快取層級讀取流程 ```mermaid graph TB R[讀取請求] --> C1{主快取<br/>命中?} C1 -->|是| Return1[返回快取] C1 -->|否| C2{分片快取<br/>完整?} C2 -->|是| Combine[組合分片] Combine --> Return2[返回] C2 -->|否| Lock{重建鎖<br/>存在?} Lock -->|是| Wait[等待 500ms] Wait --> C1 Lock -->|否| Rebuild[從 Sheet 重建] Rebuild --> Cache[寫入快取] Cache --> Return3[返回] ``` ### 員工 Map 分片快取 當員工數量較多時,自動分片儲存(每片 300 員工): ```mermaid graph LR M[1000 員工 Map] --> Split[分片] Split --> C1[VALID_EMP_MAP_1<br/>員工 1-300] Split --> C2[VALID_EMP_MAP_2<br/>員工 301-600] Split --> CC[VALID_EMP_MAP_COUNT = 2] ``` --- ## Lock 與併發控制 ### Lock 配置 ```javascript LOCK_CONFIG: { // 簽到用 Lock(快速失敗) SIGNIN_WAIT_MS: 3000, // 抽獎用 Lock(指數退避) LOTTERY: { MAX_RETRIES: 3, BASE_TIMEOUT_MS: 5000, TIMEOUT_INCREMENT_MS: 2000, BASE_RETRY_DELAY_MS: 200, MAX_RETRY_DELAY_MS: 1000 }, // 一般操作用 Lock DEFAULT: { MAX_RETRIES: 4, BASE_TIMEOUT_MS: 2000, TIMEOUT_INCREMENT_MS: 1000, BASE_RETRY_DELAY_MS: 100, MAX_RETRY_DELAY_MS: 1000 } } ``` ### 指數退避策略 ```mermaid graph TB subgraph "抽獎 Lock 策略" A1[嘗試 1: 5000ms] A2[嘗試 2: 7000ms] A3[嘗試 3: 9000ms] A1 -->|失敗| D1[延遲 200-400ms] D1 --> A2 A2 -->|失敗| D2[延遲 400-800ms] D2 --> A3 end ``` ### 關鍵 Lock 點 | 操作 | Lock 類型 | 策略 | 說明 | |------|----------|------|------| | 簽到 | Script Lock | 3s 單次嘗試 | 快速失敗避免堆積 | | 抽獎 | Script Lock | 指數退避 3 次 | 確保抽獎完成 | | 作廢 | Script Lock | 指數退避 4 次 | 保護狀態更新 | | 刷新佇列 | Script Lock | 20s 單次 | 排程專用 | | 同步 Mirror | Script Lock | 5s 單次 | 快速嘗試 | --- ## 部署與設定 ### 前置需求 1. 啟用 Google Sheets API 服務 2. 準備 Master Sheet 及 9 個 Mirror Sheet 3. 準備員工名單來源表 ### 部署步驟 ```bash # 1. 執行環境設定 setupEnvironment() # 2. 安裝定時觸發器 installTimeTrigger() # 3. 部署 Web App # - 執行身分:我 # - 存取權限:所有人 # 4. 取得管理員連結 getAdminToken() ``` ### URL 格式 ``` # 簽到頁面 https://script.google.com/macros/s/{DEPLOY_ID}/exec # 管理控制台 https://script.google.com/macros/s/{DEPLOY_ID}/exec?page=admin&token={ADMIN_TOKEN} ``` ### GAS 配額限制 | 項目 | 免費額度 | 本系統使用 | |------|---------|-----------| | Properties 單鍵 | 9 KB | 動態分片 8.5KB | | Properties 總量 | 500 KB | 約 50 KB | | Cache 單鍵 | 100 KB | 分片處理 | | Web App 執行時間 | 30 秒 | 約 5 秒 | | 觸發器執行時間 | 90 分鐘/天 | 約 10 分鐘/天 | --- ## 維運指令 ```javascript // 健康檢查 healthCheck() // 強制刷新佇列 forceFlushQueue() // 重建所有快取 rebuildAllCaches() // 從試算表重建已中獎 ID rebuildWinnerIdsFromSheet() // 清除所有快取 clearAllCaches() // 清除所有簽到資料(危險) clearAllSignIns() // 強制重新載入設定 forceReloadConfig() // 列出所有資源鍵值(除錯用) listAllResourceKeys() ```