回到文章列表
技術分享ArchitectureAWSNext.jsHono

SiyaMap — 演唱會場館座位視角分享平台:系統架構

演唱會場館座位視角分享平台的全端架構:AWS Serverless 基礎設施、圖片浮水印與縮圖管線、PostgreSQL 資料庫建模、Hono 後端分層與設計模式。

更新於 13 min22 次觀看文章語言

專案概覽

買演唱會門票時,最常見的焦慮是:「這個位置看得到舞台嗎?會不會被柱子擋住?冷氣會不會太冷?散場要走多久?」官方售票系統只給一張平面座位圖,真正有用的資訊散落在社群貼文與留言裡,很難依「場館 → 區域 → 排 → 座位」查找。

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 元件與設計 tokennpm 套件(workspace 內)+ Storybook 靜態站apps/web 在 build 時引用

模組之間的請求流程

下圖是三個模組與外部服務之間的分工,以及三條主要的請求流程:

  1. 頁面與 API:瀏覽器 → CloudFront → apps/web(SSR 頁面與 Hono API 在同一支 Lambda)→ Neon PostgreSQL
  2. 上傳圖片:瀏覽器先向 apps/web 取得 Presigned URL,再直傳 S3;S3 事件觸發 apps/image-processor 加浮水印
  3. 讀取圖片:瀏覽器 → cdn.siyamap.com,原尺寸直接由 S3 回應,/resize/* 則交給 apps/image-processor 即時縮圖
模組分工與請求流程 · 在 Miro 開啟

三個模組之間沒有直接的程式碼呼叫: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 到回應的完整路徑,以及每一層會攔下哪些錯誤:

後端請求處理鏈 · 在 Miro 開啟

依賴注入(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.0Authorization Code Flow,不使用 Google SDK,直接呼叫 token 與 userinfo 端點;以 email 做 find-or-insert 自動建立帳號
CSRF 防護產生 64 bytes 隨機 token,寫入 httpOnly cookie(5 分鐘),同時與「登入後要返回的頁面」一起 base64 編碼放進 OAuth state;callback 時比對兩者,並立即刪除 cookie 防止重放
Sessionjose 簽發 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)。

資料庫 ER 圖(drawDB,由 repo 中的 schema.dbml 匯入)· 在 drawDB 開啟

主鍵: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_areas
  • seat_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 Enumuser_role、venue_status、taiwan_area、seat_type、distance、aircon_strength 等有限值域使用 pgEnum,在資料庫層就擋下非法值
timestamptz所有時間欄位帶時區;updated_at 透過 Drizzle $onUpdate 在每次更新時自動寫入
可空的 users.passwordGoogle 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 依資料夾決定要不要處理。下圖是一張圖片從上傳到被瀏覽的完整時序:

圖片上傳與處理時序 · 在 Miro 開啟

單一 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)}`)
}

浮水印演算法

目標是「斜向、錯位平鋪、低透明度」的浮水印,難以用裁切去除,又不影響觀看:

  1. 浮水印縮放至原圖寬度的 10%,並以 linear() 只縮放 alpha 通道,把透明度降到 10%
  2. 以磚塊錯位方式平鋪:奇數列起點往後退半個間距
  3. 平鋪畫布邊長取原圖對角線長度再加上邊距,確保整層旋轉 -45° 後仍能完整覆蓋原圖四角
  4. 從旋轉後圖層的中心裁切回原圖尺寸,再與原圖合成輸出 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 與後端:

AWS 網域與路徑分流 · 在 Miro 開啟(白板上另有從 ISP 遞迴 DNS 到 Route 53 的完整解析鏈路與各服務計費方式)

為什麼選 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 RoleLambda 內的 AWS SDK 透過 Role 取得 STS 暫時憑證,程式碼與環境變數都不放長期 Access Key

CI/CD 部署流程

推送到 stage 分支時,一支 GitHub Actions workflow 依序部署三個模組。每個 Docker Image 以 git commit SHA 作為 tag,任何一次部署都能精準追溯並回滾到對應的程式碼版本。

CI/CD 部署流程 · 在 Miro 開啟
階段重點
共用建置除了私有 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 小時。

留言(0)

還沒有人留言,來當第一個吧!