# 尾牙簽到及抽獎系統
> 版本: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()
```