專案概覽
買演唱會門票時,最常見的焦慮是:「這個位置看得到舞台嗎?會不會被柱子擋住?冷氣會不會太冷?散場要走多久?」官方售票系統只給一張平面座位圖,真正有用的資訊散落在社群貼文與留言裡,很難依「場館 → 區域 → 排 → 座位」查找。
SiyaMap 想解決的就是這件事:以場館座位圖為索引,讓看過演出的人針對自己坐過的座位投稿「實拍視角照片 + 觀演體驗評分」,後來的人點選座位圖上的區塊就能看到那個位置的真實視角。
| 核心概念 | 說明 |
|---|---|
| 場館(Venue) | 小巨蛋、高雄巨蛋等場館的基本資訊、地區、交通指引、置物櫃數量 |
| 座位圖(Seat Map) | 以 SVG 繪製的互動式場館平面圖,每個區塊對應一個座位區 |
| 座位(Seat) | 區域 → 排 → 座位編號,含站區/座位/無障礙座位類型 |
| 投稿(Contribution) | 使用者對某個座位的觀演體驗:滿意度、是否視線受阻、是否靠走道、離出口距離、散場時間、冷氣強度、評論 |
| 投稿圖片 | 投稿附上的實拍照,上傳後自動加上浮水印,並提供多種尺寸的縮圖 |
Monorepo 架構
模組分工
整個專案是一個 pnpm workspace,由三個子模組組成,各自有獨立的建置產物與部署目標:
siyamap/
├── apps/
│ ├── web/ # @siyamap/web:Next.js 16 SSR 頁面 + Hono REST API + 資料庫 schema
│ └── image-processor/ # @siyamap/image-processor:浮水印與縮圖的 Lambda(sharp)
├── packages/
│ └── ui/ # @siyamap/ui:共用 UI 元件庫 + Storybook
├── .github/workflows/ # 依模組分段部署的 CI/CD
├── pnpm-workspace.yaml # workspace 範圍 + catalog(統一 React 版本)
└── turbo.json # build/typecheck/lint 的任務相依與快取
| 模組 | 職責 | 部署目標 | 被誰使用 |
|---|---|---|---|
apps/web | 頁面渲染、REST API、認證、資料庫存取、簽發上傳網址 | Lambda(Container Image)+ S3 靜態資產 | 使用者瀏覽器 |
apps/image-processor | 原圖加浮水印、按需縮圖 | Lambda(Container Image) | S3 事件、CDN 的 /resize/* 路徑 |
packages/ui | 共用 React 元件與設計 token | npm 套件(workspace 內)+ Storybook 靜態站 | apps/web 在 build 時引用 |
模組之間的請求流程
下圖是三個模組與外部服務之間的分工,以及三條主要的請求流程:
- 頁面與 API:瀏覽器 → CloudFront →
apps/web(SSR 頁面與 Hono API 在同一支 Lambda)→ Neon PostgreSQL - 上傳圖片:瀏覽器先向
apps/web取得 Presigned URL,再直傳 S3;S3 事件觸發apps/image-processor加浮水印 - 讀取圖片:瀏覽器 →
cdn.siyamap.com,原尺寸直接由 S3 回應,/resize/*則交給apps/image-processor即時縮圖
三個模組之間沒有直接的程式碼呼叫:web 與 image-processor 透過 S3 的資料夾約定(origin/、watermark/、static/)與 CDN 的網址格式協作,ui 只在 build 時被 web 打包進去。任何一個模組都可以單獨部署,不會連動影響其他模組。
工程化設定
- pnpm Catalog:React、React DOM 與其型別的版本只在
pnpm-workspace.yaml定義一次,各模組以catalog:引用,避免 monorepo 中出現多份 React 造成 hooks 錯誤 - Turborepo 任務相依:
build設定dependsOn: ["^build"],保證web打包時使用的是ui最新的編譯結果;typecheck啟用快取,未變更的模組不重複檢查 - Husky pre-commit:每次 commit 前執行
turbo typecheck,任何一個模組有型別錯誤都無法進入 commit - 共用 lint 規則:根目錄的 ESLint flat config(
typescript-eslint、simple-import-sort、eslint-plugin-storybook)與 Prettier 套用到所有模組
apps/web:Next.js 全端應用
職責
apps/web 是整個系統的核心,同時負責:
- 頁面:Next.js 16 App Router 的 SSR 頁面(場館列表、座位圖、上傳、個人頁、登入)
- REST API:以 Hono 實作的
/api/*,涵蓋auth、oauth、user、profile、file、venue、seat-map、health-check八個業務模組 - 資料層:Drizzle ORM 的 schema 定義與資料存取,詳見下一章〈資料庫設計〉
與其他模組的連動
| 對象 | 方式 |
|---|---|
packages/ui | 以 "@siyamap/ui": "workspace:*" 依賴,next.config.ts 設定 transpilePackages: ['@siyamap/ui'],build 時一起編譯 |
apps/image-processor | 不直接呼叫。POST /api/files 依 withWatermark 決定把 Presigned URL 簽到 origin/(需要浮水印)或 static/(不需要),image-processor 只監聽 origin/ 的事件 |
| S3 | 只簽發 Presigned PUT URL,圖片本身不經過這支 Lambda |
| Neon PostgreSQL | 透過 @neondatabase/serverless 的 HTTP driver 查詢 |
SSR 與 API 共用部署單位
API 不是另外架一台伺服器,而是以 Hono 掛在 Next.js 的 catch-all Route Handler(app/api/[[...route]]/route.ts)上,所以 SSR 頁面與 REST API 打包成同一個 Docker Image、同一支 Lambda。對 side project 來說,部署、監控、成本都只有一份;而 API 層寫的是框架無關的 Hono,未來流量成長需要拆分時,可以原封不動搬到獨立的 Lambda 或容器。
後端分層與請求處理鏈
每個業務模組都採用相同的三層結構:
server/
├── middlewares/
│ ├── auth.middleware.ts # setUser/requireLoggedIn/requireAdmin
│ └── validator.middleware.ts # 包裝 zValidator,統一驗證錯誤格式
├── modules/
│ └── venue/
│ ├── venue.route.ts # HTTP 綁定 + middleware 串接 + Swagger JSDoc
│ ├── venue.controller.ts # 解析請求、組裝回應、對應錯誤碼
│ └── venue.service.ts # 資料存取與業務邏輯(Drizzle)
└── schema/ # Drizzle 資料表定義 + relations
types/dto/ # Zod DTO(前後端共用)
一個請求從進入 Hono 到回應的完整路徑,以及每一層會攔下哪些錯誤:
依賴注入(Constructor Injection)
Controller 與 Service 都透過建構子參數注入依賴,並以預設值提供正式環境的實例;模組匯出的是預設組裝好的單例:
export class FileService {
constructor(
private readonly db = _db,
private readonly s3Client = _s3Client,
private readonly getSignedUrl = _getSignedUrl
) {}
// ...
}
export default new FileService()
這個做法沒有引入 DI container 的複雜度,但保留了 DI 最重要的好處:單元測試時可以直接 new FileService(mockDb, mockS3, mockSigner),不需要 monkey-patch 模組。連 bcrypt.compare、setSessionCookie 這類函式也一併注入,讓認證流程可以在不碰真實加密與 cookie 的情況下測試。
Zod DTO:同一份 Schema 的三種用途
DTO 以 Zod 定義,並用 pick/omit/partial 從同一份基礎 schema 衍生出各種操作的版本,欄位定義只寫一次:
export const InsertVenueDto = VenueDto.pick({ name: true, area: true, address: true, /* ... */ })
export const UpdateVenueDto = VenueDto.omit({ id: true, status: true, createdAt: true, updatedAt: true }).partial()
| 用途 | 位置 | 作用 |
|---|---|---|
| 請求驗證 | reqValidator.json(InsertVenueDto) | 不合法的 body/params 在進入 controller 前就回傳 400 |
| 回應過濾 | VenueDto.parse(venue) | 回應只輸出 DTO 定義的欄位,資料庫新增欄位也不會意外外洩 |
| 型別推導 | z.infer<typeof InsertVenueDto> | 前端 API client 與後端共用同一份型別,改一處兩端同步報錯 |
統一錯誤處理
ErrorCode聯集型別集中定義所有錯誤碼(UNAUTHORIZED、DATA_NOT_FOUND、CSRF_ERROR…),前後端共用- 所有回應經過
ResponseFormatter,成功固定為{ data, pagination? },失敗固定為{ code, message, details? };並正確處理 204/304 等不可帶 body 的狀態碼 - Hono 的
app.onError作為最後防線,未預期的例外統一轉成INTERNAL_SERVER_ERROR - 前端的
RequestClient把非 2xx 回應與網路錯誤都正規化成同一個ApiErrorResponse格式,再由getErrorConfig依錯誤碼對應到使用者看得懂的訊息
認證與授權
| 機制 | 實作 |
|---|---|
| Email/密碼 | bcrypt 雜湊(salt rounds 10)儲存 |
| Google OAuth 2.0 | Authorization Code Flow,不使用 Google SDK,直接呼叫 token 與 userinfo 端點;以 email 做 find-or-insert 自動建立帳號 |
| CSRF 防護 | 產生 64 bytes 隨機 token,寫入 httpOnly cookie(5 分鐘),同時與「登入後要返回的頁面」一起 base64 編碼放進 OAuth state;callback 時比對兩者,並立即刪除 cookie 防止重放 |
| Session | jose 簽發 HS256 JWT,存於 httpOnly、SameSite=Lax、正式環境 Secure 的 cookie,效期 7 天;前端 JavaScript 無法讀取 |
| 授權(RBAC) | 全域 setUser middleware 解析 session 並把使用者放進 Hono context;寫入場館、座位圖等管理操作的路由再串上 requireAdmin,上傳檔案等操作串上 requireLoggedIn |
頁面路由守衛:責任鏈模式
頁面層級的存取控制(僅開發環境可見的 /api-doc、需登入的頁面、已登入就不該看到的 /login)寫成多個 FunctionFactory,再用一個泛型的 chain() 由後往前組合成單一 Next.js proxy:
export default chain(
[withDevOnly, withLoggedInOnly, withLoggedOutOnly],
0,
() => NextResponse.next()
)
每個 guard 只負責一件事,判斷不需攔截時就交給下一個;新增規則只需要多寫一個 factory 並加入陣列,順序即優先級。
同構的 API Client 與資料取得
RequestClient 同時在 Server Component 與瀏覽器中使用:在伺服器端執行時,會動態 import('next/headers') 取得當前請求的 cookie 並轉發給 API,讓 SSR 階段也能以使用者身分呼叫需要登入的端點;在瀏覽器端則改用 credentials: 'include'。頁面先在伺服器端取得資料,再透過 React Query 的 initialData 交給客戶端,首屏不需要再發一次請求,之後的新增、修改、刪除則以 mutation 完成後 invalidateQueries 重新同步。
API 文件
每個 route 上方都以 OpenAPI 3.0 格式的 JSDoc 描述請求與回應,由 next-swagger-doc 掃描 server/modules 自動產生規格,並在 /api-doc 以 Swagger UI 呈現;該頁面透過 proxy 限制只在開發環境開放。
資料庫設計(apps/web)
資料庫 schema 位於 apps/web/server/schema/,以 Drizzle ORM 的 TypeScript 定義為準(schema-as-code),並另外維護一份 DBML 作為設計文件;部署時由 CI 透過 drizzle-kit push 同步到 Neon PostgreSQL。
ER 圖
共 9 張資料表,可分成三個領域:使用者與檔案(users、files)、投稿(contributions、contribution_images)、場館與座位(venues、seat_maps、seat_areas、seats、restrooms)。
主鍵:UUID v7
所有資料表的主鍵都使用由應用層產生的 UUID v7($defaultFn(() => uuidv7())):
- 時間有序:前 48 bits 是毫秒時間戳,新資料永遠插入 B-tree 索引的尾端,避免 UUID v4 隨機插入造成的頁分裂與索引膨脹
- 不可列舉:對外 API 與圖片檔名都直接使用 ID,不像自增整數會洩漏資料量或被逐一爬取
- 應用層產生:寫入前就知道 ID,可以先拿 ID 組 S3 檔名、再寫入資料庫,不需等待資料庫回傳
場館座位的階層建模
座位資料是「場館 → 座位圖 → 座位區 → 排 → 座位」的階層:
seat_maps.map_svg以文字儲存整張 SVG 座位圖。座位圖編輯工具匯出 SVG 時會先經過 DOMPurify(USE_PROFILES: { svg: true })淨化,避免 SVG 內嵌 script 造成 Stored XSS;SVG 中的每個區塊(rect/polygon/path)對應一筆seat_areasseat_areas → seat_maps設定ON DELETE CASCADE:座位區是座位圖的組成部分,重新上傳座位圖時舊的區塊應一併移除seats除了seat_area,也刻意冗餘一份venue_id,讓「列出某場館所有座位」不必 JOIN 三張表- 複合索引
(seat_area, seat_row):對應前端「點選區塊 → 依排列出座位」的主要查詢路徑
投稿與圖片
contributions同時參照user_id、seat_id,並冗餘venue_id,搭配venue_id、user_id、created_at三個索引,分別支援「場館頁的投稿列表」、「我的投稿」、「最新投稿時間軸」satisfaction使用NUMERIC(2,1)(0.0~9.9)而非浮點數,避免評分平均值計算時的精度誤差files是統一的檔案登記表,只記錄擁有者、URL 與檔名,業務表透過外鍵參照它,而不是各自存 URL。contribution_images對files有兩條外鍵:file_id_with_watermark(NOT NULL):對外公開顯示的版本file_id_without_watermark(NULLABLE):未加浮水印的原圖,與公開版本分開記錄
- Drizzle 中同一張表對另一張表有兩條關聯時,以
relationName區分,讓with: { fileWithWatermark: true }這類關聯查詢可以明確指定方向
其他設計細節
| 設計 | 說明 |
|---|---|
| PostgreSQL Enum | user_role、venue_status、taiwan_area、seat_type、distance、aircon_strength 等有限值域使用 pgEnum,在資料庫層就擋下非法值 |
timestamptz | 所有時間欄位帶時區;updated_at 透過 Drizzle $onUpdate 在每次更新時自動寫入 |
可空的 users.password | Google OAuth 註冊的帳號沒有密碼;登入 API 會據此回傳 USER_FROM_OAUTH,提示使用者改用第三方登入 |
users.metadata(JSONB) | 保留給尚未定型的使用者屬性,避免為實驗性欄位頻繁做 migration |
| 場館草稿狀態 | venues.status 預設 DRAFT,管理者補齊座位圖與資訊後才發布 |
apps/image-processor:圖片處理服務
職責
投稿照片是平台的核心內容,也是最容易被盜用的資產。image-processor 是一支獨立的 Lambda,負責兩件事:
- 加浮水印:原圖上傳到
origin/後,自動產生加上浮水印的版本寫入watermark/ - 按需縮圖:
cdn.siyamap.com/resize/w_{width}/{folder}/{key}的請求即時縮圖,交由 CloudFront 快取
圖片的上傳與處理都不經過 apps/web 的 Lambda,避免大檔案佔用 SSR 的執行時間與記憶體。
與其他模組的連動
image-processor 和 apps/web 之間沒有任何 API 呼叫,兩者只靠 S3 的資料夾約定與同一個 bucket 的環境變數(AWS_S3_BUCKET_IMAGE_UPLOAD)協作:web 決定檔案寫進哪個資料夾,image-processor 依資料夾決定要不要處理。下圖是一張圖片從上傳到被瀏覽的完整時序:
單一 Lambda、兩種事件來源
浮水印(S3 Event)和縮圖(API Gateway HTTP 事件)是兩種完全不同的觸發來源,但它們共用 sharp 原生模組、S3 client 與工具函式。因此兩者打包成同一個 Container Image,在入口用 TypeScript type guard 判斷事件型別再分派給對應的 handler(Strategy pattern),只需維護一個部署單位:
export const handler = async (event: S3Event | APIGatewayProxyEventV2) => {
if (isS3Event(event)) return watermarkHandler(event)
if (isApiGatewayProxyEvent(event)) return resizeHandler(event)
throw new Error(`[錯誤] 不支援的事件類型: ${JSON.stringify(event)}`)
}
浮水印演算法
目標是「斜向、錯位平鋪、低透明度」的浮水印,難以用裁切去除,又不影響觀看:
- 浮水印縮放至原圖寬度的 10%,並以
linear()只縮放 alpha 通道,把透明度降到 10% - 以磚塊錯位方式平鋪:奇數列起點往後退半個間距
- 平鋪畫布邊長取原圖對角線長度再加上邊距,確保整層旋轉 -45° 後仍能完整覆蓋原圖四角
- 從旋轉後圖層的中心裁切回原圖尺寸,再與原圖合成輸出 JPEG
NOTE
sharp 的 pipeline 中 rotate 會固定在 composite 之前執行,與程式碼的呼叫順序無關。因此必須先把平鋪結果輸出成 buffer,再開一條新的 pipeline 做旋轉,否則旋轉的會是空白畫布而不是平鋪後的浮水印。
縮圖端點的防濫用設計
縮圖網址的 width 只接受 200、400、800 三種白名單值,並設定 withoutEnlargement。如果允許任意寬度,攻擊者只要不斷變換寬度參數,就能讓每個請求都 cache miss,直接打到按執行時間計費的 Lambda,形成成本型攻擊;白名單也讓 CloudFront 的快取命中率維持在可預期的範圍。
踩坑:Presigned URL 上傳 403
這個問題發生在 web 與 S3 的交界:AWS SDK v3(約 3.370 以後)預設 requestChecksumCalculation: 'when_supported',會在 Presigned URL 中自動帶入 x-amz-checksum-crc32,S3 便要求 PUT 請求也必須附上對應的 CRC32 header。瀏覽器的 fetch 不會計算這個值,結果所有直傳請求都回 403。解法是在 S3 client 設定 requestChecksumCalculation: 'WHEN_REQUIRED',只在 API 強制要求時才計算 checksum。
測試
浮水印與縮圖都以 Jest 撰寫處理器測試,addWatermarkToImage 以函式多載同時支援「輸出到檔案」與「回傳 Buffer」兩種模式,方便在本機用實際圖片檢查輸出效果。
packages/ui:共用元件庫
職責
@siyamap/ui 是與 UI 設計對接的元件庫,提供按鈕、圖示等共用元件與 Tailwind 主題設定。元件在 Storybook 中獨立開發與展示,讓設計稿與實作可以在不啟動整個應用的情況下對齊。
與其他模組的連動
- 被
apps/web引用:web以workspace:*依賴,Turborepo 保證ui先 build 完成,web再以transpilePackages編譯進自己的 bundle - 獨立部署 Storybook:CI 將
storybook-static同步到 S3,經 CloudFront 提供在stage.ui.siyamap.com,與web的部署互不影響
打包設計
- 以 tsup 輸出 CJS/ESM 兩種格式與型別宣告,開啟 code splitting 與 minify,
react、react-dom設為 external 避免重複打包 - 在 bundle 開頭注入
"use client"banner,讓元件可以直接被 Next.js App Router 的 Server Component 引用,不必在每個使用處包一層 client component - 各元件提供子路徑匯出(如
@siyamap/ui/Icon),只引用需要的元件以利 tree-shaking - React 列為
peerDependencies,版本由 pnpm Catalog 統一管理
Storybook 與測試
- Storybook 10 搭配 a11y addon,開發元件時就能看到無障礙檢查結果
- Vitest browser mode 以 Playwright 在真實瀏覽器中執行元件測試,比 jsdom 更貼近實際渲染行為
基礎設施與部署
AWS 架構
所有服務部署在 AWS ap-southeast-1。四個網域經 Route 53 解析後,依網域與路徑分流到不同的 CloudFront 與後端:
為什麼選 Serverless
這是一個 side project,流量低且不穩定,最在意的是「沒有人用的時候不要花錢」。因此每一層都選擇按用量計費、可縮到零的服務:
| 需求 | 選擇 | 理由 |
|---|---|---|
| SSR 與 API 運算 | Lambda + Lambda Web Adapter | 依執行時間計費,無請求時不收費;Web Adapter 讓 Next.js standalone 的 server.js 不需改寫成 Lambda handler 就能直接跑 |
| 部署單位 | Container Image(ECR) | Container Image 的大小上限(10 GB)遠高於 zip 部署,Next.js standalone 產物與 sharp 等原生模組不必擔心容量;Docker 也讓本機與線上環境一致 |
| 靜態資產 | S3 + CloudFront | 帶 hash 的 _next/static 設定 max-age=31536000, immutable,完全不打到 Lambda |
| 資料庫 | Neon Serverless PostgreSQL | 閒置自動暫停;使用 HTTP driver(drizzle-orm/neon-http),每次查詢都是無狀態 HTTP 請求,避開 Lambda 冷啟動下 TCP 連線池耗盡的經典問題 |
| 雲端權限 | IAM Execution Role | Lambda 內的 AWS SDK 透過 Role 取得 STS 暫時憑證,程式碼與環境變數都不放長期 Access Key |
CI/CD 部署流程
推送到 stage 分支時,一支 GitHub Actions workflow 依序部署三個模組。每個 Docker Image 以 git commit SHA 作為 tag,任何一次部署都能精準追溯並回滾到對應的程式碼版本。
| 階段 | 重點 |
|---|---|
| 共用建置 | 除了私有 ECR,另外登入公有 ECR,避免從 public.ecr.aws 拉取 Lambda Web Adapter 與 Lambda base image 時被匿名 rate limit 擋下 |
packages/ui | 帶 hash 的資產不需清快取,只 invalidate index.html、iframe.html 兩個入口檔,縮短生效時間 |
apps/web | 先以 drizzle-kit push 更新資料庫 schema 再切換程式碼;lambda wait function-updated 確保新版本就緒後才同步靜態資產 |
apps/image-processor | 與 web 分開建置、分開更新 Lambda,獨立部署、獨立擴展 |
靜態資產依「檔名是否帶 hash」採用不同的快取策略:_next/static 設定 1 年 immutable,public/ 中不帶 hash 的檔案只快取 1 小時。


Comments(0)
No comments yet. Be the first to share your thoughts!