iT邦幫忙

2025 iThome 鐵人賽

DAY 30
0
Software Development

30 天打造工作室 SaaS 產品 (後端篇)系列 第 30

Day 30: 30天打造SaaS產品後端篇-完賽心得與架構設計總回顧

  • 分享至 

  • xImage
  •  

從框架使用者到架構設計者

30 天的鐵人賽挑戰終於畫下句點,這是一段充滿挑戰與收穫的旅程。

我從事軟體開發已經八年了,從最初的後端工程師起步,後來成為全端工程師,再轉向區塊鏈開發,也成立了自己的工作室,接了不少專案。但這次的鐵人賽挑戰,讓我有了一個全新的體悟:以往在公司工作時,大多是進入一個已經建立好框架和架構的環境,專注在功能開發和優化上。

這次從零開始設計 Kyo System 的後端架構,才真正理解到架構設計的複雜性與重要性。從最基礎的認證系統、API 速率限制、到審計日誌、微服務通訊,每一個環節都需要深入研究,做出最適合的技術決策。

在這個過程中,我學到了:

  • 認證系統不只是簡單的 JWT,還要考慮 token 刷新、session 管理、安全性
  • 速率限制有多種演算法,需要根據業務需求選擇最合適的
  • 日誌系統不只是記錄,還要考慮效能、查詢、合規性
  • 微服務架構需要處理分散式交易、事件驅動、服務發現等問題

最重要的是,成功地為工作室打造出一套可以實際應用的 SaaS 產品後端架構。這套架構具備高可用性、可擴展性、安全性,未來可以用在更多的產品上。

後端架構完整回顧

Fastify 框架選擇原因

在開發 Kyo System 之前,我評估了多個 Node.js 框架:

框架 優點 缺點 適合場景
Express 生態系統龐大、資源多 效能較差、缺乏型別支援 簡單應用、快速原型
Koa 中介軟體機制優雅 社群較小、套件較少 中等規模應用
NestJS 完整的 DI 系統、TypeScript 學習曲線陡峭、較重 大型企業應用
Fastify 效能最佳、TypeScript 友善 生態系統較小 高效能 API 服務

最終選擇 Fastify 的原因:

// Fastify 的效能優勢
// 官方 benchmark: Fastify 比 Express 快 2-3 倍

// 1. 內建 JSON Schema 驗證
fastify.post('/otp/send', {
  schema: {
    body: {
      type: 'object',
      required: ['phoneNumber'],
      properties: {
        phoneNumber: { type: 'string', pattern: '^\\+[1-9]\\d{1,14}$' },
      },
    },
  },
}, async (request, reply) => {
  // 請求自動驗證,無效請求直接返回 400
})

// 2. 完整的 TypeScript 支援
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'

interface SendOTPBody {
  phoneNumber: string
  templateId?: string
}

async function sendOTPHandler(
  request: FastifyRequest<{ Body: SendOTPBody }>,
  reply: FastifyReply
) {
  const { phoneNumber } = request.body // 型別安全
  // ...
}

// 3. 強大的 Plugin 系統
const dbPlugin: FastifyPluginAsync = async (fastify, options) => {
  const pool = new Pool(options.postgres)

  fastify.decorate('db', {
    query: (text: string, params: any[]) => pool.query(text, params),
  })

  fastify.addHook('onClose', async () => {
    await pool.end()
  })
}

fastify.register(dbPlugin, { postgres: { /* config */ } })

TypeScript 在後端的價值

TypeScript 對後端開發的價值,遠超過我的預期:

1. 型別安全防止 Runtime Error

// API 請求型別定義
interface CreateUserRequest {
  email: string
  password: string
  profile: {
    name: string
    phone?: string
  }
}

// API 回應型別定義
interface CreateUserResponse {
  userId: string
  email: string
  createdAt: Date
}

async function createUser(
  data: CreateUserRequest
): Promise<CreateUserResponse> {
  // TypeScript 會檢查所有屬性
  const user = await db.users.create({
    email: data.email,
    password: await hashPassword(data.password),
    name: data.profile.name,
    phone: data.profile.phone,
  })

  return {
    userId: user.id,
    email: user.email,
    createdAt: user.createdAt,
    // password: user.password // 編譯錯誤!不能返回密碼
  }
}

2. 重構信心倍增

// 修改介面定義
interface OTPRequest {
  phoneNumber: string
  templateId?: string
  // 新增欄位
  language?: 'zh-TW' | 'en-US'
}

// TypeScript 會自動提示所有需要更新的地方
// 1. API handler
// 2. 資料庫查詢
// 3. 測試案例
// 4. 文件

3. 自動文件生成

// 使用 TypeDoc 自動生成 API 文件
/**
 * 發送 OTP 驗證碼
 *
 * @param phoneNumber - 手機號碼 (E.164 格式)
 * @param templateId - 簡訊模板 ID (可選)
 * @returns Promise<OTPResponse> - OTP 請求結果
 * @throws {RateLimitError} - 超過速率限制
 * @throws {InvalidPhoneError} - 無效的手機號碼
 *
 * @example
 * ```typescript
 * const result = await sendOTP('+886912345678', 'welcome')
 * console.log(result.requestId) // "req_abc123"
 * ```
 */
async function sendOTP(
  phoneNumber: string,
  templateId?: string
): Promise<OTPResponse> {
  // ...
}

PostgreSQL 與 Redis 數據層設計

PostgreSQL 資料庫架構

-- 多租戶架構設計
-- 每個租戶 (tenant) 擁有獨立的資料空間

-- 租戶表
CREATE TABLE tenants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(255) NOT NULL,
  slug VARCHAR(100) UNIQUE NOT NULL,
  plan VARCHAR(50) NOT NULL DEFAULT 'free',
  status VARCHAR(20) NOT NULL DEFAULT 'active',
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- 用戶表 (屬於租戶)
CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  email VARCHAR(255) NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  name VARCHAR(255),
  phone VARCHAR(20),
  mfa_enabled BOOLEAN DEFAULT FALSE,
  mfa_secret VARCHAR(255),
  status VARCHAR(20) NOT NULL DEFAULT 'active',
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
  UNIQUE(tenant_id, email) -- 同一租戶內 email 唯一
);

CREATE INDEX idx_users_tenant_id ON users(tenant_id);
CREATE INDEX idx_users_email ON users(email);

-- OTP 請求記錄表
CREATE TABLE otp_requests (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  phone_number VARCHAR(20) NOT NULL,
  code VARCHAR(10) NOT NULL,
  request_id VARCHAR(100) UNIQUE NOT NULL,
  status VARCHAR(20) NOT NULL DEFAULT 'pending',
  sent_at TIMESTAMP NOT NULL DEFAULT NOW(),
  verified_at TIMESTAMP,
  expires_at TIMESTAMP NOT NULL,
  attempts INTEGER DEFAULT 0,
  metadata JSONB,
  CONSTRAINT fk_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);

CREATE INDEX idx_otp_tenant_id ON otp_requests(tenant_id);
CREATE INDEX idx_otp_phone ON otp_requests(phone_number);
CREATE INDEX idx_otp_request_id ON otp_requests(request_id);
CREATE INDEX idx_otp_status ON otp_requests(status);

-- 審計日誌表 (高寫入量,使用分區表)
CREATE TABLE audit_logs (
  id BIGSERIAL,
  tenant_id UUID NOT NULL,
  user_id UUID,
  action VARCHAR(100) NOT NULL,
  resource_type VARCHAR(50) NOT NULL,
  resource_id VARCHAR(255),
  ip_address INET,
  user_agent TEXT,
  metadata JSONB,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  PRIMARY KEY (id, created_at) -- 複合主鍵,支援分區
) PARTITION BY RANGE (created_at);

-- 建立月度分區
CREATE TABLE audit_logs_2025_01 PARTITION OF audit_logs
  FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

CREATE TABLE audit_logs_2025_02 PARTITION OF audit_logs
  FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');

-- 自動建立未來月份的分區 (使用 pg_cron)

資料庫優化策略:

  1. 連線池管理:
import { Pool } from 'pg'

const pool = new Pool({
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT || '5432'),
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  max: 20, // 最大連線數
  idleTimeoutMillis: 30000, // 閒置連線超時
  connectionTimeoutMillis: 2000, // 連線超時
})

// 使用 Transaction
async function createOTPRequest(data: OTPRequestData) {
  const client = await pool.connect()

  try {
    await client.query('BEGIN')

    // 1. 建立 OTP 請求
    const otp = await client.query(
      'INSERT INTO otp_requests (tenant_id, phone_number, code, ...) VALUES ($1, $2, $3, ...) RETURNING *',
      [data.tenantId, data.phoneNumber, data.code, ...]
    )

    // 2. 記錄審計日誌
    await client.query(
      'INSERT INTO audit_logs (tenant_id, action, resource_type, ...) VALUES ($1, $2, $3, ...)',
      [data.tenantId, 'otp.created', 'otp_request', ...]
    )

    await client.query('COMMIT')

    return otp.rows[0]
  } catch (error) {
    await client.query('ROLLBACK')
    throw error
  } finally {
    client.release()
  }
}
  1. 查詢優化:
-- 使用 EXPLAIN ANALYZE 分析查詢效能
EXPLAIN ANALYZE
SELECT * FROM otp_requests
WHERE tenant_id = 'xxx'
  AND phone_number = '+886912345678'
  AND status = 'pending'
  AND expires_at > NOW()
ORDER BY created_at DESC
LIMIT 1;

-- 優化建議:
-- 1. 建立複合索引
CREATE INDEX idx_otp_lookup ON otp_requests(tenant_id, phone_number, status, expires_at DESC);

-- 2. 使用 Partial Index (只索引未過期的記錄)
CREATE INDEX idx_otp_active ON otp_requests(tenant_id, phone_number)
WHERE status = 'pending' AND expires_at > NOW();

Redis 快取與速率限制

import Redis from 'ioredis'

const redis = new Redis({
  host: process.env.REDIS_HOST,
  port: parseInt(process.env.REDIS_PORT || '6379'),
  password: process.env.REDIS_PASSWORD,
  retryStrategy: (times) => {
    const delay = Math.min(times * 50, 2000)
    return delay
  },
})

// 1. OTP 驗證碼快取 (5 分鐘過期)
async function cacheOTPCode(requestId: string, code: string) {
  await redis.setex(`otp:${requestId}`, 300, code)
}

async function getOTPCode(requestId: string): Promise<string | null> {
  return await redis.get(`otp:${requestId}`)
}

// 2. 速率限制 (Sliding Window)
async function checkRateLimit(key: string, limit: number, window: number): Promise<boolean> {
  const now = Date.now()
  const windowStart = now - window * 1000

  // 使用 Redis Pipeline 提升效能
  const pipeline = redis.pipeline()

  // 移除過期的請求
  pipeline.zremrangebyscore(key, 0, windowStart)

  // 計算當前視窗內的請求數
  pipeline.zcard(key)

  // 加入新請求
  pipeline.zadd(key, now, `${now}:${Math.random()}`)

  // 設定過期時間
  pipeline.expire(key, window)

  const results = await pipeline.exec()

  const count = results?.[1]?.[1] as number

  return count < limit
}

// 3. Session 快取
async function cacheSession(sessionId: string, data: SessionData, ttl: number = 86400) {
  await redis.setex(
    `session:${sessionId}`,
    ttl,
    JSON.stringify(data)
  )
}

async function getSession(sessionId: string): Promise<SessionData | null> {
  const data = await redis.get(`session:${sessionId}`)
  return data ? JSON.parse(data) : null
}

// 4. 分散式鎖 (防止重複處理)
async function acquireLock(resource: string, ttl: number = 10): Promise<string | null> {
  const lockId = uuidv4()
  const result = await redis.set(
    `lock:${resource}`,
    lockId,
    'EX',
    ttl,
    'NX' // 只在 key 不存在時設定
  )

  return result === 'OK' ? lockId : null
}

async function releaseLock(resource: string, lockId: string): Promise<boolean> {
  // 使用 Lua script 確保原子性
  const script = `
    if redis.call("get", KEYS[1]) == ARGV[1] then
      return redis.call("del", KEYS[1])
    else
      return 0
    end
  `

  const result = await redis.eval(script, 1, `lock:${resource}`, lockId)
  return result === 1
}

BullMQ 背景任務系統

import { Queue, Worker, QueueScheduler } from 'bullmq'

// 建立 Email 隊列
const emailQueue = new Queue('email', {
  connection: {
    host: process.env.REDIS_HOST,
    port: parseInt(process.env.REDIS_PORT || '6379'),
  },
  defaultJobOptions: {
    attempts: 3, // 重試次數
    backoff: {
      type: 'exponential',
      delay: 2000, // 初始延遲 2 秒
    },
    removeOnComplete: {
      age: 3600, // 1 小時後移除完成的任務
    },
    removeOnFail: {
      age: 86400, // 24 小時後移除失敗的任務
    },
  },
})

// 建立排程器 (處理延遲任務)
const scheduler = new QueueScheduler('email', {
  connection: {
    host: process.env.REDIS_HOST,
    port: parseInt(process.env.REDIS_PORT || '6379'),
  },
})

// 加入任務
async function sendWelcomeEmail(userId: string, email: string) {
  await emailQueue.add(
    'welcome',
    { userId, email },
    {
      priority: 1, // 優先級 (數字越小越優先)
    }
  )
}

// 延遲任務
async function sendReminderEmail(userId: string, email: string, delayMinutes: number) {
  await emailQueue.add(
    'reminder',
    { userId, email },
    {
      delay: delayMinutes * 60 * 1000,
    }
  )
}

// 建立 Worker 處理任務
const emailWorker = new Worker(
  'email',
  async (job) => {
    const { userId, email } = job.data

    switch (job.name) {
      case 'welcome':
        await sendEmailViaSES({
          to: email,
          template: 'welcome',
          data: { userId },
        })
        break

      case 'reminder':
        await sendEmailViaSES({
          to: email,
          template: 'reminder',
          data: { userId },
        })
        break

      default:
        throw new Error(`Unknown job type: ${job.name}`)
    }

    // 返回結果
    return { success: true, sentAt: new Date() }
  },
  {
    connection: {
      host: process.env.REDIS_HOST,
      port: parseInt(process.env.REDIS_PORT || '6379'),
    },
    concurrency: 5, // 同時處理 5 個任務
  }
)

// 監聽事件
emailWorker.on('completed', (job) => {
  console.log(`Job ${job.id} completed`)
})

emailWorker.on('failed', (job, error) => {
  console.error(`Job ${job?.id} failed:`, error)

  // 發送告警
  sendAlert({
    level: 'error',
    message: `Email job failed: ${error.message}`,
    metadata: { jobId: job?.id, jobData: job?.data },
  })
})

技術學習

1. Email 服務與隊列系統

學到的架構模式:

// 生產者-消費者模式
// Producer: API Server
fastify.post('/auth/register', async (request, reply) => {
  const { email, password, name } = request.body

  // 1. 建立用戶
  const user = await createUser({ email, password, name })

  // 2. 加入歡迎郵件到隊列 (異步)
  await emailQueue.add('welcome', {
    userId: user.id,
    email: user.email,
    name: user.name,
  })

  // 3. 立即返回回應 (不等待郵件發送)
  return { userId: user.id, email: user.email }
})

// Consumer: Email Worker (獨立進程)
const emailWorker = new Worker('email', async (job) => {
  const { userId, email, name } = job.data

  // 使用 AWS SES 發送郵件
  await ses.sendEmail({
    Source: 'noreply@kyo-system.com',
    Destination: { ToAddresses: [email] },
    Message: {
      Subject: { Data: '歡迎加入 Kyo System!' },
      Body: {
        Html: {
          Data: renderWelcomeEmail({ name, userId }),
        },
      },
    },
  })
})

可靠性保證:

// 1. 重試機制
emailQueue.add('welcome', data, {
  attempts: 3,
  backoff: {
    type: 'exponential',
    delay: 2000,
  },
})

// 2. 死信隊列 (Dead Letter Queue)
emailWorker.on('failed', async (job, error) => {
  if (job && job.attemptsMade >= 3) {
    // 放入死信隊列,供人工處理
    await deadLetterQueue.add('email-failed', {
      originalJob: job.data,
      error: error.message,
      failedAt: new Date(),
    })
  }
})

// 3. 冪等性保證 (Idempotency)
async function sendEmail(job: Job) {
  const emailId = `${job.id}:${job.data.userId}:${job.name}`

  // 檢查是否已經發送過
  const sent = await redis.get(`email:sent:${emailId}`)
  if (sent) {
    console.log(`Email ${emailId} already sent, skipping`)
    return
  }

  // 發送郵件
  await ses.sendEmail(/* ... */)

  // 標記為已發送 (保留 7 天)
  await redis.setex(`email:sent:${emailId}`, 7 * 86400, '1')
}

2. JWT 認證策略

Token 設計:

// Access Token (短效,15 分鐘)
interface AccessTokenPayload {
  userId: string
  tenantId: string
  email: string
  roles: string[]
  permissions: string[]
  iat: number // Issued At
  exp: number // Expiration Time
}

// Refresh Token (長效,7 天 or 30 天)
interface RefreshTokenPayload {
  userId: string
  tenantId: string
  tokenId: string // 用於撤銷
  iat: number
  exp: number
}

// 生成 Token
import * as jose from 'jose'

async function generateTokens(user: User): Promise<TokenPair> {
  const secret = new TextEncoder().encode(process.env.JWT_SECRET)

  // Access Token
  const accessToken = await new jose.SignJWT({
    userId: user.id,
    tenantId: user.tenantId,
    email: user.email,
    roles: user.roles,
    permissions: user.permissions,
  })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('15m')
    .sign(secret)

  // Refresh Token
  const tokenId = uuidv4()
  const refreshToken = await new jose.SignJWT({
    userId: user.id,
    tenantId: user.tenantId,
    tokenId,
  })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(secret)

  // 儲存 Refresh Token 到資料庫 (用於撤銷)
  await db.refreshTokens.create({
    id: tokenId,
    userId: user.id,
    token: refreshToken,
    expiresAt: new Date(Date.now() + 7 * 86400 * 1000),
  })

  return { accessToken, refreshToken }
}

Token 驗證與刷新:

// Fastify 中介軟體
fastify.decorateRequest('user', null)

fastify.addHook('onRequest', async (request, reply) => {
  const authHeader = request.headers.authorization

  if (!authHeader?.startsWith('Bearer ')) {
    throw new UnauthorizedError('Missing or invalid authorization header')
  }

  const token = authHeader.slice(7)

  try {
    const secret = new TextEncoder().encode(process.env.JWT_SECRET)
    const { payload } = await jose.jwtVerify(token, secret)

    // 檢查 token 是否被撤銷 (使用 Redis 黑名單)
    const revoked = await redis.get(`token:revoked:${payload.jti}`)
    if (revoked) {
      throw new UnauthorizedError('Token has been revoked')
    }

    // 將用戶資訊附加到 request
    request.user = payload as AccessTokenPayload
  } catch (error) {
    if (error instanceof jose.errors.JWTExpired) {
      throw new UnauthorizedError('Token expired')
    }
    throw new UnauthorizedError('Invalid token')
  }
})

// Token 刷新 API
fastify.post('/auth/refresh', async (request, reply) => {
  const { refreshToken } = request.body

  // 驗證 Refresh Token
  const secret = new TextEncoder().encode(process.env.JWT_SECRET)
  const { payload } = await jose.jwtVerify(refreshToken, secret)

  // 檢查 Refresh Token 是否存在於資料庫
  const tokenRecord = await db.refreshTokens.findOne({
    where: {
      id: payload.tokenId,
      userId: payload.userId,
    },
  })

  if (!tokenRecord || tokenRecord.revoked) {
    throw new UnauthorizedError('Refresh token invalid or revoked')
  }

  // 獲取最新的用戶資訊
  const user = await db.users.findById(payload.userId)

  // 生成新的 Access Token
  const newAccessToken = await generateAccessToken(user)

  return { accessToken: newAccessToken }
})

// 撤銷 Token (登出)
fastify.post('/auth/logout', async (request, reply) => {
  const { refreshToken } = request.body
  const user = request.user

  // 撤銷 Refresh Token
  await db.refreshTokens.update({
    where: { userId: user.userId },
    data: { revoked: true },
  })

  // 將 Access Token 加入黑名單 (直到過期)
  const ttl = user.exp - Math.floor(Date.now() / 1000)
  if (ttl > 0) {
    await redis.setex(`token:revoked:${user.jti}`, ttl, '1')
  }

  return { success: true }
})

3. API 速率限制實作

Sliding Window 演算法:

// Redis + Sorted Set 實現
async function slidingWindowRateLimit(
  key: string,
  limit: number,
  windowSeconds: number
): Promise<{ allowed: boolean; remaining: number }> {
  const now = Date.now()
  const windowStart = now - windowSeconds * 1000

  const pipeline = redis.pipeline()

  // 1. 移除過期的請求記錄
  pipeline.zremrangebyscore(key, 0, windowStart)

  // 2. 計算當前視窗內的請求數
  pipeline.zcard(key)

  // 3. 加入當前請求
  pipeline.zadd(key, now, `${now}:${uuidv4()}`)

  // 4. 設定 key 過期時間
  pipeline.expire(key, windowSeconds)

  const results = await pipeline.exec()
  const count = (results?.[1]?.[1] as number) || 0

  const allowed = count < limit
  const remaining = Math.max(0, limit - count - 1)

  return { allowed, remaining }
}

// Fastify Plugin
const rateLimitPlugin: FastifyPluginAsync = async (fastify, options) => {
  fastify.addHook('onRequest', async (request, reply) => {
    const userId = request.user?.userId || request.ip
    const tenantId = request.user?.tenantId || 'anonymous'

    // 租戶級別限制: 1000 req/min
    const tenantKey = `ratelimit:tenant:${tenantId}:${Math.floor(Date.now() / 60000)}`
    const tenantLimit = await slidingWindowRateLimit(tenantKey, 1000, 60)

    if (!tenantLimit.allowed) {
      reply.status(429).send({
        error: 'Too Many Requests',
        message: 'Tenant rate limit exceeded',
        retryAfter: 60,
      })
      return
    }

    // 用戶級別限制: 100 req/min
    const userKey = `ratelimit:user:${userId}:${Math.floor(Date.now() / 60000)}`
    const userLimit = await slidingWindowRateLimit(userKey, 100, 60)

    if (!userLimit.allowed) {
      reply.status(429).send({
        error: 'Too Many Requests',
        message: 'User rate limit exceeded',
        retryAfter: 60,
      })
      return
    }

    // 設定回應 Header
    reply.header('X-RateLimit-Limit', '100')
    reply.header('X-RateLimit-Remaining', userLimit.remaining.toString())
    reply.header('X-RateLimit-Reset', (Math.floor(Date.now() / 60000) + 1) * 60)
  })
}

Token Bucket 演算法:

// 更平滑的速率限制,適合 burst traffic
class TokenBucket {
  private tokens: number
  private lastRefill: number

  constructor(
    private capacity: number,
    private refillRate: number // tokens per second
  ) {
    this.tokens = capacity
    this.lastRefill = Date.now()
  }

  async consume(tokens: number = 1): Promise<boolean> {
    // 補充 token
    const now = Date.now()
    const timePassed = (now - this.lastRefill) / 1000
    const tokensToAdd = timePassed * this.refillRate

    this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd)
    this.lastRefill = now

    // 檢查是否有足夠的 token
    if (this.tokens >= tokens) {
      this.tokens -= tokens
      return true
    }

    return false
  }

  getRemaining(): number {
    return Math.floor(this.tokens)
  }
}

// Redis 實現 (分散式)
async function tokenBucketRateLimit(
  key: string,
  capacity: number,
  refillRate: number
): Promise<{ allowed: boolean; remaining: number }> {
  const lua = `
    local key = KEYS[1]
    local capacity = tonumber(ARGV[1])
    local refillRate = tonumber(ARGV[2])
    local tokens = tonumber(ARGV[3])
    local now = tonumber(ARGV[4])

    local bucket = redis.call('hmget', key, 'tokens', 'lastRefill')
    local currentTokens = tonumber(bucket[1]) or capacity
    local lastRefill = tonumber(bucket[2]) or now

    local timePassed = (now - lastRefill) / 1000
    local tokensToAdd = timePassed * refillRate
    currentTokens = math.min(capacity, currentTokens + tokensToAdd)

    if currentTokens >= tokens then
      currentTokens = currentTokens - tokens
      redis.call('hmset', key, 'tokens', currentTokens, 'lastRefill', now)
      redis.call('expire', key, 3600)
      return {1, math.floor(currentTokens)}
    else
      return {0, math.floor(currentTokens)}
    end
  `

  const result = await redis.eval(
    lua,
    1,
    key,
    capacity,
    refillRate,
    1,
    Date.now()
  ) as [number, number]

  return {
    allowed: result[0] === 1,
    remaining: result[1],
  }
}

4. 審計日誌與合規追蹤

結構化日誌設計:

// 日誌 Schema
interface AuditLog {
  id: string
  tenantId: string
  userId?: string
  action: string // 'user.login', 'otp.send', 'settings.update'
  resourceType: string // 'user', 'otp', 'settings'
  resourceId?: string
  status: 'success' | 'failure'
  ipAddress: string
  userAgent: string
  metadata: Record<string, any>
  createdAt: Date
}

// 日誌記錄函數
async function logAudit(log: Omit<AuditLog, 'id' | 'createdAt'>) {
  await db.auditLogs.create({
    id: uuidv4(),
    ...log,
    createdAt: new Date(),
  })

  // 同時寫入 Elasticsearch 供快速查詢
  await esClient.index({
    index: 'audit-logs',
    document: {
      ...log,
      '@timestamp': new Date(),
    },
  })
}

// Fastify Hook 自動記錄
fastify.addHook('onResponse', async (request, reply) => {
  // 只記錄重要的操作
  const loggableActions = ['POST', 'PUT', 'PATCH', 'DELETE']

  if (!loggableActions.includes(request.method)) {
    return
  }

  const user = request.user
  const action = `${request.routerPath}.${request.method.toLowerCase()}`

  await logAudit({
    tenantId: user?.tenantId || 'anonymous',
    userId: user?.userId,
    action,
    resourceType: getResourceType(request.routerPath),
    resourceId: request.params.id,
    status: reply.statusCode < 400 ? 'success' : 'failure',
    ipAddress: request.ip,
    userAgent: request.headers['user-agent'] || '',
    metadata: {
      method: request.method,
      url: request.url,
      statusCode: reply.statusCode,
      responseTime: reply.getResponseTime(),
    },
  })
})

合規性查詢 API:

// GDPR: 用戶數據匯出
fastify.get('/compliance/gdpr/export', async (request, reply) => {
  const userId = request.user.userId

  // 收集所有相關數據
  const userData = await db.users.findById(userId)
  const otpRecords = await db.otpRequests.find({ userId })
  const auditLogs = await db.auditLogs.find({ userId })

  const exportData = {
    user: {
      id: userData.id,
      email: userData.email,
      name: userData.name,
      phone: userData.phone,
      createdAt: userData.createdAt,
    },
    otpRequests: otpRecords.map((r) => ({
      phoneNumber: r.phoneNumber,
      sentAt: r.sentAt,
      status: r.status,
    })),
    auditLogs: auditLogs.map((l) => ({
      action: l.action,
      ipAddress: l.ipAddress,
      createdAt: l.createdAt,
    })),
    exportedAt: new Date(),
  }

  // 記錄匯出操作
  await logAudit({
    tenantId: request.user.tenantId,
    userId,
    action: 'gdpr.data_export',
    resourceType: 'user',
    resourceId: userId,
    status: 'success',
    ipAddress: request.ip,
    userAgent: request.headers['user-agent'] || '',
    metadata: {},
  })

  // 返回 JSON 檔案
  reply.header('Content-Disposition', `attachment; filename="user-data-${userId}.json"`)
  return exportData
})

// GDPR: 用戶數據刪除
fastify.post('/compliance/gdpr/delete', async (request, reply) => {
  const userId = request.user.userId

  // 軟刪除 (保留日誌用於合規)
  await db.users.update({
    where: { id: userId },
    data: {
      email: `deleted_${userId}@deleted.com`,
      name: 'Deleted User',
      phone: null,
      status: 'deleted',
      deletedAt: new Date(),
    },
  })

  // 匿名化審計日誌
  await db.auditLogs.update({
    where: { userId },
    data: { userId: null },
  })

  // 記錄刪除操作
  await logAudit({
    tenantId: request.user.tenantId,
    userId,
    action: 'gdpr.data_deletion',
    resourceType: 'user',
    resourceId: userId,
    status: 'success',
    ipAddress: request.ip,
    userAgent: request.headers['user-agent'] || '',
    metadata: {},
  })

  return { success: true, message: 'User data deleted' }
})

5. 微服務通訊與事件驅動

事件總線設計:

// Event Schema
interface DomainEvent {
  id: string
  type: string // 'otp.sent', 'user.created'
  aggregateId: string // 資源 ID
  aggregateType: string // 'otp', 'user'
  payload: Record<string, any>
  metadata: {
    tenantId: string
    userId?: string
    timestamp: Date
    version: number
  }
}

// Event Publisher
class EventPublisher {
  constructor(private queue: Queue) {}

  async publish(event: Omit<DomainEvent, 'id' | 'metadata.timestamp'>) {
    const domainEvent: DomainEvent = {
      id: uuidv4(),
      ...event,
      metadata: {
        ...event.metadata,
        timestamp: new Date(),
      },
    }

    // 發布到 BullMQ
    await this.queue.add('domain-event', domainEvent, {
      priority: this.getPriority(event.type),
    })

    // 同時寫入事件儲存 (Event Store)
    await db.events.create(domainEvent)

    return domainEvent
  }

  private getPriority(eventType: string): number {
    const highPriority = ['user.created', 'payment.completed']
    return highPriority.includes(eventType) ? 1 : 10
  }
}

// Event Consumer
class EventConsumer {
  private handlers = new Map<string, EventHandler[]>()

  on(eventType: string, handler: EventHandler) {
    const handlers = this.handlers.get(eventType) || []
    handlers.push(handler)
    this.handlers.set(eventType, handlers)
  }

  async handle(event: DomainEvent) {
    const handlers = this.handlers.get(event.type) || []

    // 並行執行所有 handler
    await Promise.allSettled(
      handlers.map((handler) => handler(event))
    )
  }
}

// 使用範例
const eventPublisher = new EventPublisher(eventQueue)
const eventConsumer = new EventConsumer()

// 註冊事件處理器
eventConsumer.on('otp.sent', async (event) => {
  // 發送郵件通知
  await emailQueue.add('otp-notification', {
    userId: event.metadata.userId,
    phoneNumber: event.payload.phoneNumber,
  })
})

eventConsumer.on('otp.sent', async (event) => {
  // 更新統計數據
  await redis.incr(`stats:otp:sent:${event.metadata.tenantId}`)
})

eventConsumer.on('otp.verified', async (event) => {
  // 記錄成功驗證
  await logAudit({
    tenantId: event.metadata.tenantId,
    userId: event.metadata.userId,
    action: 'otp.verified',
    resourceType: 'otp',
    resourceId: event.aggregateId,
    status: 'success',
    ipAddress: event.payload.ipAddress,
    userAgent: event.payload.userAgent,
    metadata: {},
  })
})

// Worker 處理事件
const eventWorker = new Worker(
  'events',
  async (job) => {
    const event = job.data as DomainEvent
    await eventConsumer.handle(event)
  },
  {
    connection: redisConnection,
    concurrency: 10,
  }
)

6. Saga Pattern 分散式交易

// Saga Orchestrator
interface SagaStep {
  name: string
  execute: () => Promise<any>
  compensate: () => Promise<void>
}

class Saga {
  private steps: SagaStep[] = []
  private executedSteps: string[] = []

  addStep(step: SagaStep) {
    this.steps.push(step)
    return this
  }

  async execute(): Promise<any> {
    try {
      for (const step of this.steps) {
        console.log(`Executing step: ${step.name}`)
        await step.execute()
        this.executedSteps.push(step.name)
      }
      return { success: true }
    } catch (error) {
      console.error(`Saga failed at step: ${this.executedSteps[this.executedSteps.length - 1]}`)
      await this.rollback()
      throw error
    }
  }

  private async rollback() {
    console.log('Rolling back saga...')

    // 反向執行補償操作
    for (const stepName of this.executedSteps.reverse()) {
      const step = this.steps.find((s) => s.name === stepName)
      if (step) {
        console.log(`Compensating step: ${step.name}`)
        try {
          await step.compensate()
        } catch (error) {
          console.error(`Compensation failed for ${step.name}:`, error)
          // 記錄補償失敗,需要人工介入
          await logAlert({
            level: 'critical',
            message: `Saga compensation failed: ${step.name}`,
            error,
          })
        }
      }
    }
  }
}

// 使用範例: 用戶註冊流程
async function registerUserSaga(data: RegisterUserData) {
  const saga = new Saga()

  let userId: string
  let emailJobId: string
  let tenantId: string

  saga
    .addStep({
      name: 'create-user',
      execute: async () => {
        const user = await db.users.create({
          email: data.email,
          password: await hashPassword(data.password),
          name: data.name,
        })
        userId = user.id
        tenantId = user.tenantId
      },
      compensate: async () => {
        if (userId) {
          await db.users.delete({ where: { id: userId } })
        }
      },
    })
    .addStep({
      name: 'create-default-settings',
      execute: async () => {
        await db.settings.create({
          userId,
          theme: 'light',
          language: 'zh-TW',
        })
      },
      compensate: async () => {
        if (userId) {
          await db.settings.delete({ where: { userId } })
        }
      },
    })
    .addStep({
      name: 'send-welcome-email',
      execute: async () => {
        const job = await emailQueue.add('welcome', {
          userId,
          email: data.email,
          name: data.name,
        })
        emailJobId = job.id
      },
      compensate: async () => {
        if (emailJobId) {
          await emailQueue.remove(emailJobId)
        }
      },
    })
    .addStep({
      name: 'publish-user-created-event',
      execute: async () => {
        await eventPublisher.publish({
          type: 'user.created',
          aggregateId: userId,
          aggregateType: 'user',
          payload: {
            email: data.email,
            name: data.name,
          },
          metadata: {
            tenantId,
            userId,
            version: 1,
          },
        })
      },
      compensate: async () => {
        // 發布補償事件
        await eventPublisher.publish({
          type: 'user.creation_failed',
          aggregateId: userId,
          aggregateType: 'user',
          payload: {},
          metadata: {
            tenantId,
            userId,
            version: 1,
          },
        })
      },
    })

  await saga.execute()

  return { userId, tenantId }
}

架構決策回顧

Kyo System 核心架構特色

在開始深入探討各項技術決策之前,讓我們先回顧 Kyo System 的兩大核心架構特色:

1. 微服務架構 (Microservices Architecture)

Day 2: 系統架構規劃 開始,Kyo System 就確定採用微服務架構,而非傳統的單體應用:

為什麼選擇微服務架構?

✅ 獨立部署與擴展
   - 每個服務可以獨立更新,不影響其他服務
   - 根據負載為特定服務進行水平擴展

✅ 技術多樣性
   - 可以為不同服務選擇最適合的技術棧
   - OTP Service 專注於高效能,Analytics Service 專注於數據處理

✅ 故障隔離
   - 單一服務故障不會導致整個系統崩潰
   - 透過 Circuit Breaker 模式提升韌性

✅ 團隊自主性
   - 不同團隊可以獨立開發維護各自的服務
   - 加速開發迭代速度

Kyo System 微服務生態系:

├─ Kyo OTP Service (首發產品)
│  ├─ OTP 發送與驗證
│  ├─ 簡訊模板管理
│  └─ Rate Limiting
│
├─ Kyo Auth Service (認證服務)
│  ├─ JWT Token 管理
│  ├─ 多租戶認證
│  └─ OAuth 整合
│
├─ Kyo Analytics Service (分析服務)
│  ├─ 數據收集
│  ├─ 報表生成
│  └─ 儀表板 API
│
└─ Future Services (未來擴展)
   ├─ Payment Service
   ├─ Notification Service
   └─ AI/ML Service

2. Database-per-Tenant 多租戶策略

Day 14: 多租戶架構實作 開始,Kyo System 採用 Database-per-Tenant 策略,確保每個租戶擁有完全獨立的資料庫:

為什麼選擇 Database-per-Tenant?

✅ 完全資料隔離
   - 每個租戶資料庫完全獨立,零資料洩漏風險
   - 滿足 GDPR/SOC2 等合規要求

✅ 高度客製化
   - 可為特定租戶調整 Schema 或索引
   - 支援租戶特定的功能需求

✅ 獨立備份恢復
   - 租戶資料可以獨立備份與恢復
   - 不影響其他租戶的運作

✅ 效能隔離
   - 租戶間完全獨立,互不影響
   - 大客戶可以有獨立的資料庫實例

✅ 獨立擴展
   - 可為特定租戶升級資料庫規格
   - 靈活的擴展策略

微服務 + Database-per-Tenant 組合優勢:

這兩大架構特色的組合,為 Kyo System 帶來:

1. 極致的資料安全性
   - 微服務層級的訪問控制
   - 資料庫層級的完全隔離

2. 靈活的擴展能力
   - 服務層面可以水平擴展
   - 資料庫層面可以獨立擴展

3. 高度的可維護性
   - 服務間低耦合,易於維護
   - 資料庫獨立,問題容易定位

4. 符合企業級需求
   - 滿足大型客戶的合規要求
   - 支援複雜的 SLA 協議

為什麼選擇 Database-per-Tenant 多租戶架構?

多租戶策略比較:

策略 優點 缺點 Kyo System 選擇
Shared Database 成本低、維護簡單 資料隔離弱、難以客製化 ❌ 不適合
Shared Schema 平衡成本與隔離 跨租戶查詢風險 ❌ 不適合
Database-per-Tenant 完全隔離、高安全性 成本較高、管理複雜 我們的選擇

Kyo System 採用 Database-per-Tenant 策略:

Tenant A (健身房 A)
└─ RDS Instance: kyo_tenant_a
   ├─ 完全資料隔離
   ├─ 獨立備份/恢復
   ├─ 客製化 Schema
   └─ 獨立擴展能力

Tenant B (健身房 B)
└─ RDS Instance: kyo_tenant_b
   ├─ 完全資料隔離
   ├─ 獨立備份/恢復
   ├─ 客製化 Schema
   └─ 獨立擴展能力

Master Database (系統層)
└─ RDS Instance: kyo_master
   ├─ 租戶元資料
   ├─ 帳號管理
   └─ 系統設定

Database-per-Tenant 優勢:
完全資料隔離: 每個租戶資料庫完全獨立,零風險
高度客製化: 可為特定租戶調整 Schema 或索引
獨立備份恢復: 不影響其他租戶
效能隔離: 租戶間互不影響
符合合規要求: 滿足 GDPR/SOC2 等資料隔離需求

Row-Level Security (RLS) 實作:

-- PostgreSQL RLS
ALTER TABLE otp_requests ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation_policy ON otp_requests
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

-- 在查詢前設定租戶 ID
SET app.current_tenant = 'tenant-uuid';

-- 之後的查詢會自動過濾
SELECT * FROM otp_requests; -- 只返回當前租戶的數據

應用層隔離:

// Fastify Plugin
const tenantPlugin: FastifyPluginAsync = async (fastify, options) => {
  fastify.decorateRequest('tenant', null)

  fastify.addHook('onRequest', async (request, reply) => {
    const user = request.user

    if (!user) {
      throw new UnauthorizedError('User not authenticated')
    }

    // 從 JWT 取得租戶 ID
    request.tenant = {
      id: user.tenantId,
    }

    // 設定 PostgreSQL session variable
    await db.query('SET app.current_tenant = $1', [user.tenantId])
  })
}

// 使用
fastify.register(tenantPlugin)

fastify.get('/otp/requests', async (request, reply) => {
  // 不需要手動過濾 tenantId,RLS 自動處理
  const requests = await db.otpRequests.findAll()
  return requests
})

微服務架構策略

Kyo System 採用微服務架構 (Microservices Architecture)

從 Day 2 的系統設計開始,Kyo System 就確定採用微服務策略,以支援未來的擴展性和獨立部署需求:

微服務生態系:
├─ Kyo OTP Service (首發產品)
│  ├─ OTP 發送與驗證
│  ├─ 簡訊模板管理
│  ├─ Rate Limiting
│  └─ 獨立部署與擴展
│
├─ Kyo Auth Service (認證服務)
│  ├─ JWT Token 管理
│  ├─ 多租戶認證
│  ├─ MFA 驗證
│  └─ OAuth 整合
│
├─ Kyo Analytics Service (分析服務)
│  ├─ 數據收集與聚合
│  ├─ 報表生成
│  ├─ 儀表板 API
│  └─ 數據匯出
│
└─ Future Services (未來擴展)
   ├─ Payment Service
   ├─ Notification Service
   └─ AI/ML Service

微服務架構優勢:
獨立部署: 每個服務可以獨立更新,不影響其他服務
技術多樣性: 可為不同服務選擇最適合的技術棧
故障隔離: 單一服務故障不會影響整個系統
獨立擴展: 根據負載為特定服務擴容
團隊自主: 不同團隊可以獨立開發維護各自的服務

微服務通訊架構:

┌─────────────────┐
│   API Gateway   │ ◄─── 統一入口
└────────┬────────┘
         │
    ┌────┴────┐
    │         │
    ▼         ▼
┌─────────┐ ┌─────────┐
│   OTP   │ │  Auth   │
│ Service │ │ Service │
└────┬────┘ └────┬────┘
     │           │
     └─────┬─────┘
           ▼
    ┌──────────────┐
    │  Event Bus   │ ◄─── Redis Pub/Sub
    │ (Redis/Kafka)│
    └──────────────┘

服務間通訊策略:

  • 同步通訊: REST API (用於即時查詢)
  • 異步通訊: Event Bus (用於事件驅動)
  • 資料一致性: Saga Pattern (分散式交易)

模組化架構:

src/
├── modules/
│   ├── auth/           # 認證模組
│   │   ├── auth.service.ts
│   │   ├── auth.controller.ts
│   │   ├── auth.repository.ts
│   │   └── auth.types.ts
│   │
│   ├── otp/            # OTP 模組
│   │   ├── otp.service.ts
│   │   ├── otp.controller.ts
│   │   ├── otp.repository.ts
│   │   └── otp.types.ts
│   │
│   ├── email/          # Email 模組
│   │   ├── email.service.ts
│   │   ├── email.worker.ts
│   │   └── email.types.ts
│   │
│   └── analytics/      # 分析模組
│       ├── analytics.service.ts
│       ├── analytics.controller.ts
│       └── analytics.types.ts
│
├── shared/             # 共用模組
│   ├── database/
│   ├── redis/
│   ├── events/
│   └── logger/
│
└── server.ts           # 應用進入點

最大的挑戰

挑戰 1: 分散式交易處理

問題: OTP 發送涉及多個步驟,任何一步失敗都需要正確處理

OTP 發送流程:
1. 檢查速率限制
2. 生成 OTP code
3. 儲存到資料庫
4. 呼叫 SMS API
5. 記錄審計日誌
6. 發布事件

任何一步失敗,如何保證資料一致性?

解決方案: Saga Pattern + Event Sourcing

async function sendOTPWithSaga(phoneNumber: string, tenantId: string) {
  const saga = new Saga()

  let requestId: string
  let code: string

  saga
    .addStep({
      name: 'check-rate-limit',
      execute: async () => {
        const allowed = await checkRateLimit(`otp:${phoneNumber}`, 5, 3600)
        if (!allowed) {
          throw new RateLimitError('Too many OTP requests')
        }
      },
      compensate: async () => {
        // 速率限制檢查失敗,無需補償
      },
    })
    .addStep({
      name: 'generate-otp',
      execute: async () => {
        code = generateOTPCode()
        requestId = uuidv4()
      },
      compensate: async () => {
        // 生成 code 失敗,無需補償
      },
    })
    .addStep({
      name: 'save-to-database',
      execute: async () => {
        await db.otpRequests.create({
          id: requestId,
          tenantId,
          phoneNumber,
          code,
          status: 'pending',
          expiresAt: new Date(Date.now() + 5 * 60 * 1000),
        })
      },
      compensate: async () => {
        if (requestId) {
          await db.otpRequests.delete({ where: { id: requestId } })
        }
      },
    })
    .addStep({
      name: 'send-sms',
      execute: async () => {
        await mitakeSMS.send(phoneNumber, `您的驗證碼是: ${code}`)
      },
      compensate: async () => {
        // SMS 已發送,無法撤回,標記為失敗
        if (requestId) {
          await db.otpRequests.update({
            where: { id: requestId },
            data: { status: 'failed' },
          })
        }
      },
    })

  await saga.execute()

  return { requestId, expiresAt: new Date(Date.now() + 5 * 60 * 1000) }
}

挑戰 2: 速率限制演算法選擇

問題: 不同場景需要不同的速率限制策略

場景 1: 登入 API
需求: 防止暴力破解
策略: Fixed Window (簡單有效)

場景 2: OTP 發送
需求: 防止濫用,但允許短時間 burst
策略: Token Bucket (允許 burst)

場景 3: 查詢 API
需求: 平滑限制,避免突然的流量
策略: Sliding Window (更精確)

最終決策: 混合使用

// 根據路由選擇演算法
const rateLimitStrategies = {
  '/auth/login': { algorithm: 'fixed-window', limit: 5, window: 900 },
  '/otp/send': { algorithm: 'token-bucket', capacity: 10, refillRate: 0.1 },
  '/api/*': { algorithm: 'sliding-window', limit: 100, window: 60 },
}

挑戰 3: 日誌系統效能優化

問題: 審計日誌寫入量大,影響 API 效能

初版實作: 同步寫入資料庫
問題:
❌ API 回應時間增加 50-100ms
❌ 資料庫 CPU 使用率高
❌ 寫入失敗會影響業務邏輯

優化方案:

  1. 異步寫入:
// 使用 BullMQ 異步寫入
fastify.addHook('onResponse', async (request, reply) => {
  // 不等待日誌寫入完成
  auditLogQueue.add('log', {
    tenantId: request.user?.tenantId,
    userId: request.user?.userId,
    action: getAction(request),
    // ...
  }).catch((error) => {
    console.error('Failed to queue audit log:', error)
  })
})
  1. 批次寫入:
// Worker 批次寫入資料庫
const auditLogWorker = new Worker(
  'audit-logs',
  async (job) => {
    const logs = await job.getJobsWaiting(100) // 取 100 筆

    if (logs.length > 0) {
      // 批次寫入
      await db.auditLogs.createMany(logs.map((l) => l.data))
    }
  },
  {
    concurrency: 1,
    limiter: {
      max: 1,
      duration: 1000, // 每秒最多處理一次
    },
  }
)
  1. 分區表:
-- 月度分區,自動清理舊資料
CREATE TABLE audit_logs_2025_01 PARTITION OF audit_logs
  FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

優化結果:

  • API 回應時間恢復正常 (< 10ms)
  • 資料庫 CPU 降低 40%
  • 日誌寫入失敗不影響業務

開發建議

後端架構設計原則

1. SOLID 原則

// Single Responsibility (單一職責)
❌ 不好:
class UserService {
  async createUser(data) { /* ... */ }
  async sendWelcomeEmail(user) { /* ... */ }
  async generateToken(user) { /* ... */ }
}

✅ 好:
class UserService {
  async createUser(data) { /* ... */ }
}

class EmailService {
  async sendWelcomeEmail(user) { /* ... */ }
}

class AuthService {
  async generateToken(user) { /* ... */ }
}
// Open/Closed (開放封閉)
// 對擴展開放,對修改封閉

// 使用策略模式
interface SMSProvider {
  send(phoneNumber: string, message: string): Promise<void>
}

class MitakeSMSProvider implements SMSProvider {
  async send(phoneNumber: string, message: string) {
    // Mitake API
  }
}

class TwilioSMSProvider implements SMSProvider {
  async send(phoneNumber: string, message: string) {
    // Twilio API
  }
}

class SMSService {
  constructor(private provider: SMSProvider) {}

  async send(phoneNumber: string, message: string) {
    await this.provider.send(phoneNumber, message)
  }
}

// 新增提供商時,不需要修改 SMSService
const smsService = new SMSService(new TwilioSMSProvider())

2. DRY (Don't Repeat Yourself)

// 使用 Repository Pattern 避免重複的 CRUD 程式碼
abstract class BaseRepository<T> {
  constructor(protected tableName: string) {}

  async findById(id: string): Promise<T | null> {
    const result = await db.query(
      `SELECT * FROM ${this.tableName} WHERE id = $1`,
      [id]
    )
    return result.rows[0] || null
  }

  async create(data: Partial<T>): Promise<T> {
    const keys = Object.keys(data)
    const values = Object.values(data)
    const placeholders = keys.map((_, i) => `$${i + 1}`).join(', ')

    const result = await db.query(
      `INSERT INTO ${this.tableName} (${keys.join(', ')}) VALUES (${placeholders}) RETURNING *`,
      values
    )
    return result.rows[0]
  }

  // ... update, delete
}

// 具體 Repository
class UserRepository extends BaseRepository<User> {
  constructor() {
    super('users')
  }

  async findByEmail(email: string): Promise<User | null> {
    const result = await db.query(
      'SELECT * FROM users WHERE email = $1',
      [email]
    )
    return result.rows[0] || null
  }
}

3. 防禦式程式

// 輸入驗證
async function sendOTP(phoneNumber: string) {
  // 1. 檢查參數
  if (!phoneNumber) {
    throw new ValidationError('Phone number is required')
  }

  // 2. 格式驗證
  if (!isValidPhoneNumber(phoneNumber)) {
    throw new ValidationError('Invalid phone number format')
  }

  // 3. 業務規則驗證
  const recentOTP = await getRecentOTP(phoneNumber)
  if (recentOTP && !isExpired(recentOTP)) {
    throw new BusinessError('OTP already sent, please wait')
  }

  // 4. 速率限制
  const allowed = await checkRateLimit(phoneNumber)
  if (!allowed) {
    throw new RateLimitError('Too many requests')
  }

  // 執行業務邏輯
  // ...
}

如何平衡複雜度與可維護性

原則: YAGNI (You Aren't Gonna Need It)

❌ 過度設計:
// 為了「未來可能需要」,設計複雜的抽象層
interface DataStore {
  get(key: string): Promise<any>
  set(key: string, value: any): Promise<void>
}

class RedisStore implements DataStore { /* ... */ }
class MemcachedStore implements DataStore { /* ... */ }
class MongoStore implements DataStore { /* ... */ }

class DataStoreFactory {
  create(type: string): DataStore { /* ... */ }
}

// 實際上只需要 Redis
const store = new DataStoreFactory().create('redis')

✅ 簡單實用:
// 直接使用 Redis
import Redis from 'ioredis'
const redis = new Redis()

// 需要切換時再重構

何時增加抽象層?

✅ 應該抽象:
- 已經有重複程式碼 (至少 3 次)
- 確定會有多種實作
- 需要 mock 測試

❌ 不應該抽象:
- 「可能未來會需要」
- 只有一種實作
- 過度工程化

技術債務管理

// 使用 TODO 註解追蹤技術債
/**
 * TODO: [TECH-DEBT] 2025-02-01
 * 現況: 使用同步寫入日誌,影響效能
 * 計畫: 改為異步寫入 + 批次處理
 * 優先級: P2
 * 預估工時: 3 天
 */
async function logAudit(log: AuditLog) {
  await db.auditLogs.create(log)
}

// 定期 review 技術債,排入開發計畫

Kyo System 後端架構總結

最終架構圖 - 微服務 + Database-per-Tenant

                                   Internet
                                      │
                           ┌──────────▼──────────┐
                           │   CloudFront CDN    │
                           └──────────┬──────────┘
                                      │
                           ┌──────────▼──────────┐
                           │    WAF / Shield     │
                           └──────────┬──────────┘
                                      │
                           ┌──────────▼──────────┐
                           │   ALB (Gateway)     │
                           └──────────┬──────────┘
                                      │
                    ┌─────────────────┼─────────────────┐
                    │                 │                 │
                    ▼                 ▼                 ▼
            ┌──────────────┐  ┌──────────────┐  ┌──────────────┐
            │  Kyo OTP     │  │  Kyo Auth    │  │ Kyo Analytics│
            │  Service     │  │  Service     │  │  Service     │
            │ (ECS Fargate)│  │ (ECS Fargate)│  │ (ECS Fargate)│
            └──────┬───────┘  └──────┬───────┘  └──────┬───────┘
                   │                 │                 │
                   └─────────────────┼─────────────────┘
                                     │
            ┌────────────────────────┼────────────────────────┐
            │                        │                        │
            ▼                        ▼                        ▼
    ┌──────────────┐        ┌──────────────┐       ┌──────────────┐
    │ Master DB    │        │  Tenant A DB │       │  Tenant B DB │
    │ (RDS Aurora) │        │ (RDS Aurora) │       │ (RDS Aurora) │
    │              │        │              │       │              │
    │ - Tenants    │        │ - Users      │       │ - Users      │
    │ - Accounts   │        │ - OTP Logs   │       │ - OTP Logs   │
    │ - Settings   │        │ - Analytics  │       │ - Analytics  │
    └──────────────┘        └──────────────┘       └──────────────┘
            │
            ▼
    ┌──────────────┐        ┌──────────────┐       ┌──────────────┐
    │    Redis     │        │   BullMQ     │       │ CloudWatch   │
    │ (ElastiCache)│◄──────►│   Workers    │──────►│  Monitoring  │
    │              │        │              │       │              │
    │ - Cache      │        │ - Email      │       │ - Logs       │
    │ - Sessions   │        │ - SMS        │       │ - Metrics    │
    │ - Rate Limit │        │ - Events     │       │ - Alerts     │
    └──────────────┘        └──────┬───────┘       └──────────────┘
                                   │
                    ┌──────────────┼──────────────┐
                    │              │              │
                    ▼              ▼              ▼
            ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
            │ Email Worker │ │  SMS Worker  │ │ Event Worker │
            │   (SES)      │ │   (Mitake)   │ │   (Pub/Sub)  │
            └──────────────┘ └──────────────┘ └──────────────┘

關鍵架構特點:
✅ 微服務架構: OTP Service、Auth Service、Analytics Service 獨立部署
✅ Database-per-Tenant: 每個租戶獨立資料庫,完全隔離
✅ 事件驅動: BullMQ + Redis Pub/Sub 處理異步任務
✅ 高可用性: Multi-AZ、Auto Scaling、Health Check
✅ 安全性: WAF、Shield、VPC、Security Groups

微服務拆分策略

Kyo OTP Service (Port: 3001):
├─ OTP 發送與驗證 API
│  ├─ POST /api/otp/send
│  ├─ POST /api/otp/verify
│  └─ GET /api/otp/status/:requestId
│
├─ 簡訊模板管理
│  ├─ GET /api/templates
│  ├─ POST /api/templates
│  └─ PUT /api/templates/:id
│
├─ Rate Limiting 中間層軟體
│  ├─ Sliding Window 演算法
│  ├─ Token Bucket 演算法
│  └─ Redis 快取整合
│
└─ 獨立資料庫
   ├─ otp_requests (OTP 請求記錄)
   ├─ templates (簡訊模板)
   └─ rate_limit_logs (速率限制日誌)

Kyo Auth Service (Port: 3002):
├─ 認證 API
│  ├─ POST /api/auth/register
│  ├─ POST /api/auth/login
│  ├─ POST /api/auth/refresh
│  └─ POST /api/auth/logout
│
├─ JWT Token 管理
│  ├─ Access Token (15 分鐘)
│  ├─ Refresh Token (7 天)
│  └─ Token 撤銷黑名單
│
├─ MFA 驗證
│  ├─ TOTP 二次驗證
│  ├─ QR Code 生成
│  └─ Backup Codes
│
├─ OAuth 整合
│  ├─ Google OAuth
│  ├─ LINE Login
│  └─ Microsoft SSO
│
└─ 獨立資料庫
   ├─ users (用戶資料)
   ├─ refresh_tokens (Refresh Token)
   ├─ mfa_settings (MFA 設定)
   └─ oauth_connections (OAuth 連結)

Kyo Analytics Service (Port: 3003):
├─ 數據收集 API
│  ├─ POST /api/analytics/events
│  ├─ POST /api/analytics/metrics
│  └─ POST /api/analytics/logs
│
├─ 報表生成 API
│  ├─ GET /api/analytics/dashboard
│  ├─ GET /api/analytics/reports/:type
│  └─ POST /api/analytics/export
│
├─ 即時統計
│  ├─ WebSocket 連線
│  ├─ Server-Sent Events
│  └─ Redis Pub/Sub 整合
│
└─ 獨立資料庫
   ├─ events (事件記錄)
   ├─ metrics (指標數據)
   ├─ reports (報表快取)
   └─ aggregations (聚合統計)

背景服務 (Workers):
├─ Email Worker
│  ├─ 歡迎信件 (AWS SES)
│  ├─ OTP 通知信件
│  ├─ 報表郵件
│  └─ 密碼重設信件
│
├─ SMS Worker
│  ├─ OTP 簡訊 (Mitake API)
│  ├─ 通知簡訊
│  └─ 行銷簡訊
│
└─ Event Worker
   ├─ 事件分發 (Event Bus)
   ├─ Webhook 觸發
   ├─ 審計日誌寫入
   └─ 數據同步

微服務通訊模式:

  • 同步通訊: REST API (服務間直接呼叫)
  • 異步通訊: Redis Pub/Sub + BullMQ (事件驅動)
  • API Gateway: ALB 統一路由到各微服務
  • 服務發現: AWS ECS Service Discovery
  • 健康檢查: /health endpoint + ALB Target Group

可擴展性設計

1. 水平擴展

# 使用 Docker Compose / ECS 輕鬆擴展
docker-compose up --scale api=5 --scale worker=3

2. 垂直擴展

// 使用 Cluster 模式充分利用 CPU
import cluster from 'cluster'
import os from 'os'

if (cluster.isPrimary) {
  const numCPUs = os.cpus().length

  for (let i = 0; i < numCPUs; i++) {
    cluster.fork()
  }

  cluster.on('exit', (worker, code, signal) => {
    console.log(`Worker ${worker.process.pid} died`)
    cluster.fork() // 重啟 worker
  })
} else {
  // Worker 進程運行 Fastify
  startServer()
}

3. 資料庫擴展

讀寫分離:
┌──────────────┐
│    Master    │ ◄─── 寫入
│  (RDS Primary)│
└──────┬───────┘
       │ 複製
       ├─────────────┐
       │             │
       ▼             ▼
┌──────────────┐ ┌──────────────┐
│   Replica 1  │ │   Replica 2  │ ◄─── 讀取
└──────────────┘ └──────────────┘
// 使用 pg-pool 實現讀寫分離
const writePo = new Pool({
  host: process.env.DB_WRITE_HOST,
  // ...
})

const readPool = new Pool({
  host: process.env.DB_READ_HOST,
  // ...
})

class Database {
  async query(sql: string, params: any[], readOnly = false) {
    const pool = readOnly ? readPool : writePool
    return pool.query(sql, params)
  }
}

未來展望

效能優化方向

1. GraphQL 整合

// 讓前端更靈活地查詢資料
type Query {
  user(id: ID!): User
  otpRequests(limit: Int, offset: Int): [OTPRequest]
}

type User {
  id: ID!
  email: String!
  name: String!
  otpRequests: [OTPRequest]
}

2. gRPC 微服務通訊

// 高效能的內部服務通訊
service OTPService {
  rpc SendOTP (SendOTPRequest) returns (SendOTPResponse);
  rpc VerifyOTP (VerifyOTPRequest) returns (VerifyOTPResponse);
}

3. 邊緣運算 (Edge Computing)

// 使用 Cloudflare Workers 在邊緣處理請求
export default {
  async fetch(request) {
    // 在邊緣驗證 JWT
    // 快取常用資料
    // 路由到最近的後端
  }
}

新功能模組規劃

Q4 2025

  • [ ] WebAuthn / Passkey 支援
  • [ ] 多通道 OTP (Email, WhatsApp)
  • [ ] 進階分析儀表板
  • [ ] API SDK (Python, Ruby, PHP)

Q1 2026

  • [ ] 機器學習異常偵測
  • [ ] 自動化詐騙防護
  • [ ] 即時監控告警
  • [ ] GraphQL API

Q2 2026

  • [ ] 微服務拆分
  • [ ] Service Mesh (Istio)
  • [ ] Serverless Functions
  • [ ] Global CDN

心得

30 天的鐵人賽挑戰終於完成了!

回顧這段旅程,從第一天的系統設計,到最後完成一個可以實際運作的 SaaS 產品後端,這個過程充滿了挑戰與學習。

雖然我已經有八年的開發經驗,但這次從零開始設計後端架構的經驗,讓我對系統設計有了更深刻的理解。以前在公司工作時,框架和架構都已經建立好了,這次自己從頭設計加上第一次使用TS開發後端,才真正體會到:

  • 認證系統不只是簡單的 JWT,還要考慮安全性、可擴展性、使用者體驗
  • 速率限制有多種演算法,需要根據業務場景選擇最適合的
  • 日誌系統不只是記錄,還要考慮效能、合規性、查詢效率
  • 微服務架構需要處理分散式交易、事件驅動、服務通訊等複雜問題
  • AWS 部署以前只部署過簡單應用,這次學到了完整的雲端架構設計

**最重要的是,成功地為工作室打造出一套可以實際


上一篇
Day 29: 30天打造SaaS產品後端篇-微服務整合與事件驅動架構
系列文
30 天打造工作室 SaaS 產品 (後端篇)30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言