築心相簿 (Chuhsin Photos)
系統架構與技術白皮書
一套專為小型非營利組織量身打造、具備 Google Photos 級流暢體驗的輕量級 Web 相簿系統。本文件詳細記錄其雙軌 WebP 影像管線、安全隔離模型、資料庫調校、拖曳排序與容量規劃原理。
1.1 執行摘要與系統願景
在傳統相簿系統中,小型組織經常面臨以下結構性難題:
- 主機空間爆炸:志工以現代智慧型手機拍攝(單張原檔 8MB~15MB),未經妥善壓縮即直接存放,迅速灌滿 10GB 虛擬主機。
- 行動網路載入延遲:未經多尺度縮圖優化,手機開啟相簿需消耗數十 MB 流量下載原始檔案,導致畫面卡頓。
- EXIF 方向旋轉錯亂:iPhone 與相機在直幅拍攝時依賴 EXIF Orientation 標記,在未校正的網頁中常呈現倒置或旋轉 90 度。
- 協作投稿安全風險:若在公開瀏覽頁面直接開放上傳,容易遭遇未受邀民眾誤傳或網路爬蟲惡意灌水。
- 檔案遺留與孤兒膨脹:後台刪除相簿或照片時,實體磁碟常殘留垃圾檔案,造成儲存黑洞。
築心相簿採用「零原始圖檔儲存(Zero-Original Retention)」、「全規格自動旋轉 WebP 管線」與「專屬 Token 隔離投稿路由」,徹底根治上述痛點,達成以極低運算資源實現企業級流暢度與高安全維運之目標。
1.2 技術選型與環境拓撲
為確保可在標準 Linux / Synology NAS Web Station 或低成本虛擬主機上實現零維護、免 Node.js、免大型框架、隨裝即跑,技術堆疊嚴格選用:
| 組件維度 | 選用技術 | 選型理由與優勢 |
|---|---|---|
| 後端核心 | PHP 8.2+ (原生 Strict Types) | 執行速度極快,記憶體開銷小,無需 npm build 或長駐 Node 程序。 |
| 關聯資料庫 | SQLite 3 (WAL Mode + PDO) | 單一檔案儲存、備份極簡,開啟 WAL 與記憶體快取後並行讀取效能超越 MySQL。 |
| 影像引擎 | PHP GD + ImageMagick (Imagick) | GD 處理超低開銷的 JPEG/WebP 壓縮與 EXIF 校正;Imagick 解析 HEIC/HEIF。 |
| QR Code 模組 | QRCodeService (原生純 PHP) | 無任何外部 Composer 依賴,直接生成乾淨向量 SVG 與高品質 PNG 串流,即時提供活動掃碼投稿。 |
| 壓縮封裝 | PHP ZipArchive | 無額外相依,直接在伺服器端將 WebP 即時轉碼為高品質 JPEG 串流打包下載。 |
| 前端架構 | Vanilla JavaScript + 原生 CSS | 不載入 Vue/React 等龐大函式庫,首屏傳輸體積小於 60KB,啟動時間小於 100ms。 |
| 行動應用 | PWA (Service Worker v1.1.2 + Manifest) | 支援離線靜態資源快取、可安裝至 iPhone/Android 手機主畫面以 App 獨立視窗啟動。 |
2.1 影像處理管線架構
上傳至築心相簿的每一張相片,均嚴格遵循以下確定性流水線(Pipeline):
2.2 EXIF 1~8 全規格旋轉校正演算法
手機拍照時,陀螺儀將拍攝角度寫入 EXIF Orientation 標籤(值為 1 至 8)。多數網頁瀏覽器解碼影像時忽略此標籤導致照片旋轉倒置。ImageService::fixOrientation 完整實作了 8 種幾何轉換:
switch ($orientation) {
case 2: imageflip($image, IMG_FLIP_HORIZONTAL); break;
case 3: $image = imagerotate($image, 180, 0); break;
case 4: imageflip($image, IMG_FLIP_VERTICAL); break;
case 5: imageflip($image, IMG_FLIP_HORIZONTAL); $image = imagerotate($image, 90, 0); break;
case 6: $image = imagerotate($image, -90, 0); break; // 順時針 90°
case 7: imageflip($image, IMG_FLIP_HORIZONTAL); $image = imagerotate($image, -90, 0); break;
case 8: $image = imagerotate($image, 90, 0); break; // 順時針 270°
}
2.3 雙解析度 WebP 編碼參數最佳化
系統經過嚴密的視覺感官與壓縮率實測(SSIM & PSNR 評估),確立了雙尺寸規範:
- Full Image:長邊上限
1200px,WebP 品質設定78。相較於原檔 10MB JPEG,壓縮後僅約 100KB~180KB(壓縮率達 98.5%),視覺細節無肉眼可察覺之損失。 - Thumb Image:長邊上限
400px,WebP 品質設定72。單張縮圖體積僅約 15KB~35KB,專門供應相簿瀑布流格狀載入。 - 禁止放大(No Upscaling):若上傳相片原始尺寸小於上限,維持原尺寸編碼,避免模糊插值。
2.4 零原始圖檔殘留原則 (Zero-Original Retention)
伺服器在
ImageService::processUploadedPhoto() 產出 Full 及 Thumb WebP 之後,立即以 @unlink($file['tmp_name']) 抹除 PHP 暫存原始圖檔。整個系統的磁碟空間永遠不儲存任何原始未壓縮 JPEG 或 HEIC 檔案。此舉徹底避免磁碟被相機原檔撐爆。
2.5 即時轉碼 ZIP 下載管線
雖然伺服器全數儲存 WebP,但為讓志工或民眾匯出後能直接使用於 Office 軟體、社群刊登或實體照片沖印,ImageService::exportAlbumAsZip 實作了「即時動態轉碼為 JPEG 串流封裝」:
- 透過
imagecreatefromwebp()讀取 1200px Full WebP。 - 建立 TrueColor 畫布並填補白色背景(杜絕潛在透明度黑邊),調用
imagejpeg($canvas, null, 90)產出高品質 JPEG 位元組流。 - 使用
ZipArchive::addFromString("001_名稱.jpg", $data)寫入臨時 ZIP,並在每次迭代立即imagedestroy()與unset(),將記憶體佔用壓制在 32MB 以內。 - 輸出標準
Content-Type: application/zip與符合 RFC 5987 之filename*=UTF-8''中文檔名,完成後自動刪除臨時 ZIP 檔案。
2.6 檔案組織架構與 URL 路由隔離
為避免上千張照片堆積在單一目錄造成檔案系統效能劣化,系統採用「年份 / 月日 / 相簿代稱」三層階層式目錄隔離儲存:
├── full/
│ └── 2026/
│ └── 12-25/
│ └── xmas-party/ <-- yyyy / mm-dd / URL slug
│ ├── 799667ce_full.webp
│ └── 05db1368_full.webp
└── thumbs/
└── 2026/
└── 12-25/
└── xmas-party/ <-- yyyy / mm-dd / URL slug
├── 799667ce_thumb.webp
└── 05db1368_thumb.webp
- 相簿公開檢視路由:
/albums/{slug}/(或index.php?album={slug})。公開瀏覽頁面採用完全匿名隱私設計,絕不顯示任何投稿按鈕或透露開放投稿資訊,維護公眾瀏覽的純淨感。 - 專屬訪客投稿路由:
/upload/{token}/(或upload/?token={token})。僅持有專屬加密密鑰 Token 或掃描專屬 QR Code 的活動工作人員與受邀者方可開啟投稿介面。 - 自動相簿重命名遷移:若管理員修改相簿日期或 Slug,系統會自動移動實體磁碟目錄並同步更新資料庫記錄。
- 自動垃圾空目錄回收:照片或相簿刪除時,系統會自動遞迴向上回收已無檔案的空資料夾。
3.1 SQLite 實體關聯模型 (ER Model)
資料庫採用單一實體檔案(data/photos.sqlite),核心資料表具備嚴謹的外鍵關聯與級聯刪除:
-- 1. 相簿資料表 (Albums)
CREATE TABLE albums (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
album_date TEXT NOT NULL,
cover_photo_id INTEGER NULL,
is_public INTEGER NOT NULL DEFAULT 1,
guest_upload_enabled INTEGER NOT NULL DEFAULT 0,
guest_upload_token TEXT NULL UNIQUE,
guest_upload_expires_at TEXT NULL,
guest_upload_until TEXT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')),
updated_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
);
-- 2. 相片資料表 (Photos)
CREATE TABLE photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
album_id INTEGER NOT NULL,
filename TEXT NOT NULL,
thumb_filename TEXT NOT NULL,
original_filename TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'image/webp',
width INTEGER NOT NULL DEFAULT 0,
height INTEGER NOT NULL DEFAULT 0,
file_size INTEGER NOT NULL DEFAULT 0,
uploaded_by TEXT NOT NULL DEFAULT '',
upload_source TEXT NOT NULL DEFAULT 'admin',
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')),
FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE
);
-- 3. 管理員使用者表 (Users)
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '系統管理員',
created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
);
-- 效能索引
CREATE UNIQUE INDEX IF NOT EXISTS idx_albums_slug ON albums(slug);
CREATE UNIQUE INDEX IF NOT EXISTS idx_albums_token ON albums(guest_upload_token);
CREATE INDEX IF NOT EXISTS idx_albums_date ON albums(album_date DESC);
CREATE INDEX IF NOT EXISTS idx_albums_sort ON albums(sort_order ASC, album_date DESC);
CREATE INDEX IF NOT EXISTS idx_photos_album ON photos(album_id, sort_order ASC, id DESC);
CREATE INDEX IF NOT EXISTS idx_photos_created ON photos(created_at DESC);
為確保靜態網址相容性與避免中文 URL 編碼亂碼,
slug 欄位強制限制僅能由英文字母 (a-z, A-Z)、數字 (0-9) 與連字號 (-) 組成,長度介於 1~64 字元,並排除系統保留字(如 admin, api, upload, albums, uploads 等)。
3.2 WAL 並行高吞吐調校 (PRAGMA Tuning)
為避免 SQLite 在多人同時上傳與瀏覽時產生 database is locked 鎖定等待,資料庫連線初始化執行以下 PRAGMA 指令:
PRAGMA journal_mode = WAL;:啟用預寫式日誌(Write-Ahead Logging),實現讀寫分離,讀取者完全不阻擋寫入者,寫入者亦不阻擋讀取者。PRAGMA synchronous = NORMAL;:在 WAL 模式下維持完整資料完整性的同時,減少磁碟 fsync 頻率,寫入吞吐量提升 3~5 倍。PRAGMA busy_timeout = 5000;:並行寫入競爭時自動等待 5000ms 重試,杜絕瞬間鎖定報錯。PRAGMA temp_store = MEMORY;:臨時表與排序運算全數在記憶體進行。
4.1 認證與 Session 防護
管理員密碼採用現代不可逆演算法 PASSWORD_BCRYPT(Cost 10)加鹽雜湊。Session 控制機制遵循 OWASP 安全指引:
HttpOnly:Cookie 禁止透過 JavaScript 存取,杜絕 XSS 竊取憑證。SameSite=Lax與Secure:防範跨站請求偽造(CSRF)。- 雙重 CSRF Token:所有寫入性 API(POST)均需驗證隨機產生的 64 位元 Hex CSRF Token。
4.2 GD 防機器人驗證碼與即時預檢 (CaptchaService)
訪客上傳採用「雙階段校驗架構」,兼顧即時使用者體驗與後端絕對安全性:
- 即時預檢機制(Real-time Pre-check):前端輸入 4 位數驗證碼後,即時觸發
api.php?action=captcha-check呼叫CaptchaService::validateWithoutDestroy($code)。此階段不銷毀 Session 驗證碼,提供前端即時視覺打勾回饋,並配合授權聲明勾選以解鎖相片選取器。 - 最終一次性驗證(Single-Use Verification):在點擊提交上傳時,後端呼叫
CaptchaService::verify($code),比對成功立即自$_SESSION銷毀,徹底杜絕重放攻擊(Anti-Replay Attack)。 - 字元庫優化:排除易混淆字元(如
0/O, 1/I/L),保留高辨識度之 30 個大寫字母與數字。 - 動態干擾繪圖:繪製隨機干擾弧線、高密度噪點像素,並隨機旋轉與字型垂直位移,阻絕 OCR 辨識。
4.3 多層級檔案安全審計與隔離
系統不依賴前端傳入的 MIME 類型,在後端進行三道深度防禦:
- 二進位魔術字節審計:調用 PHP
finfo(FILEINFO_MIME_TYPE)讀取實體檔案檔頭。 - ISOBMFF 特徵識別:對於 iPhone HEIC 檔案,讀取前 16 位元組檢查
ftypheic/ftypmif1特徵。 - 嚴格格式白名單政策:僅允許相機拍照格式(
image/jpeg、image/heic、image/webp,單張上限 15MB);嚴格拒絕非壓縮或向量格式(PNG、GIF、BMP、TIFF、SVG)以及所有影片格式(MP4、MOV 等)。 - 強制相片授權聲明:必須先勾選【相片授權使用與無侵權聲明】,前後端嚴格雙重比對,否則拒絕處理上傳。
4.4 權限隔離與防止代碼執行 (.htaccess)
在 Apache 伺服器環境下,針對敏感目錄實施權限封鎖:
data/.htaccess:直接以Require all denied徹底禁止外部瀏覽器讀取photos.sqlite資料庫檔。uploads/.htaccess:使用php_flag engine off關閉 PHP 解析引擎,並禁止存取.php、.sh、.phtml檔案,防止上傳偽裝的木馬腳本執行。
5.1 10GB 容量推演模型
在組織分配的 10GB(10,240MB)主機空間限制下,容量推演如下:
| 規格項目 | 平均大小 | 可容納數量 (10GB) | 備註 |
|---|---|---|---|
| Full WebP (1200px) | 120 KB | 約 85,000 張 | 高品質大圖展示 |
| Thumb WebP (400px) | 25 KB | 約 400,000 張 | 首頁瀑布流載入 |
| 綜合組合 (Full + Thumb) | 145 KB / 張 | 約 65,000~70,000 張 | 若一年舉辦 100 場活動,每場 100 張,足以連續使用 7 年以上 |
5.2 100 張相片與 512MB 相簿硬性配額
為避免特定熱門活動無節制上傳擠佔主機,系統在 ImageService 與 api.php 實施強制配額規則:
- 張數上限:單本相簿最高限制 100 張相片(登入管理員與訪客上傳皆適用)。上傳前與寫入中均進行精準截斷。
- 容量上限:單本相簿總容量最高限制 512 MB(登入管理員與訪客上傳皆適用)。
- 單張大小:單張照片最大上限 15 MB。
5.3 孤兒檔案掃描與 VACUUM 回收演算法
網路斷線或非預期關閉瀏覽器可能導致暫存檔案殘留在磁碟。ImageService::scanOrphans 與 purgeOrphans:
- 讀取 SQLite
photos取得現存有效檔名清單,建立 O(1) 雜湊對應表。 - 遞迴遍歷
uploads/full與uploads/thumbs,自動過濾 Mac WebDAV 特殊隱藏檔(如._*)。 - 比對找出不在資料庫內的孤兒檔案,並在管理員授權下一鍵刪除。
- 清理完畢後自動觸發
VACUUM命令,壓縮 SQLite 資料庫檔案釋回磁區。
6.1 RESTful API 規格清單
系統全部 API 均以 api.php?action={action} 提供,返回統一結構之 JSON:
| Action | Method | 權限 | 功能說明 |
|---|---|---|---|
auth-status |
GET | 公開 | 取得目前使用者登入狀態、CSRF Token、相簿配額限制常數。 |
login |
POST | 公開 | 管理員帳密驗證登入。 |
logout |
POST | 登入 | 銷毀 Session 安全登出。 |
change-password |
POST | 管理員 | 驗證舊密碼並以 BCrypt (Cost 10) 演算法更新管理員登入密碼。 |
albums |
GET | 公開 | 取得相簿列表(支援 sort_order 自訂排序與日期倒序)。 |
albums-reorder |
POST | 管理員 | 批次更新首頁相簿卡片之自訂拖曳排列順序。 |
album |
GET | 公開 | 依 id 或 slug 取得單一相簿中繼資料與照片分頁列表。 |
album-create |
POST | 管理員 | 建立新相簿(標題、slug、日期、訪客投稿開關/期限、自動生成 Token 等)。 |
album-update-title |
POST | 管理員 | 快速更新相簿標題。 |
album-update |
POST | 管理員 | 完整更新相簿資料(標題、Slug、日期、描述、公開性、訪客截止時間等)。 |
album-regenerate-token |
POST | 管理員 | 重新產生相簿專屬訪客投稿 Secret Token 與 QR Code。 |
upload-info |
GET | 公開 (Token) | 依專屬 Token 取得投稿頁面所需之相簿標題、日期、剩餘配額與截止時間。 |
album-delete |
POST | 管理員 | 永久刪除相簿(若有相片需傳入 confirm_title 驗證)。 |
album-set-cover |
POST | 管理員 | 設定特定照片為相簿封面。 |
album-download-zip |
GET | 公開/權限 | 將整本相簿照片即時轉為 JPEG 並打包成 ZIP 串流下載。 |
photo-upload |
POST | 訪客/管理員 | 批次上傳相片,執行白名單審計、EXIF 校正、雙 WebP 轉碼與配額防護。 |
photos-reorder |
POST | 管理員 | 批次更新相簿內照片自訂順序(支援拖曳排序與前後移動)。 |
photo-delete |
POST | 管理員 | 永久刪除單張相片,並同步重設或解除封面指派。 |
captcha |
GET | 公開 | 輸出 4 位防機器人圖形驗證碼(PNG 串流)。 |
captcha-check |
GET/POST | 公開 | 即時預檢驗證碼正確性(不銷毀 Session,供前端即時解鎖上傳器)。 |
qrcode |
GET | 公開 | 生成指定文字或網址之 QR Code 圖片(支援 PNG/SVG 格式輸出)。 |
orphan-scan |
GET | 管理員 | 遞迴掃描磁碟中未被資料庫記錄之孤兒垃圾檔案。 |
orphan-purge |
POST | 管理員 | 一鍵刪除孤兒檔案並執行 SQLite VACUUM 壓縮。 |
storage-stats |
GET | 管理員 | 取得目前伺服器磁碟用量與相片統計資訊。 |