30 天的鐵人賽挑戰終於畫下句點,這是一段充滿挑戰與收穫的旅程。
我從事軟體開發已經八年了,從最初的後端工程師起步,後來成為全端工程師,再轉向區塊鏈開發,也成立了自己的工作室,接了不少專案。但這次的鐵人賽挑戰,讓我有了一個全新的體悟:以往在公司工作時,大多是進入一個已經建立好框架和架構的環境,專注在功能開發和優化上。
這次從零開始設計 Kyo System 的後端架構,才真正理解到架構設計的複雜性與重要性。從最基礎的認證系統、API 速率限制、到審計日誌、微服務通訊,每一個環節都需要深入研究,做出最適合的技術決策。
在這個過程中,我學到了:
最重要的是,成功地為工作室打造出一套可以實際應用的 SaaS 產品後端架構。這套架構具備高可用性、可擴展性、安全性,未來可以用在更多的產品上。
在開發 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 對後端開發的價值,遠超過我的預期:
// 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 // 編譯錯誤!不能返回密碼
}
}
// 修改介面定義
interface OTPRequest {
phoneNumber: string
templateId?: string
// 新增欄位
language?: 'zh-TW' | 'en-US'
}
// TypeScript 會自動提示所有需要更新的地方
// 1. API handler
// 2. 資料庫查詢
// 3. 測試案例
// 4. 文件
// 使用 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> {
// ...
}
-- 多租戶架構設計
-- 每個租戶 (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)
資料庫優化策略:
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()
}
}
-- 使用 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();
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
}
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 },
})
})
學到的架構模式:
// 生產者-消費者模式
// 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')
}
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 }
})
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],
}
}
結構化日誌設計:
// 日誌 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' }
})
事件總線設計:
// 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,
}
)
// 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 的兩大核心架構特色:
從 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
從 Day 14: 多租戶架構實作 開始,Kyo System 採用 Database-per-Tenant 策略,確保每個租戶擁有完全獨立的資料庫:
為什麼選擇 Database-per-Tenant?
✅ 完全資料隔離
- 每個租戶資料庫完全獨立,零資料洩漏風險
- 滿足 GDPR/SOC2 等合規要求
✅ 高度客製化
- 可為特定租戶調整 Schema 或索引
- 支援租戶特定的功能需求
✅ 獨立備份恢復
- 租戶資料可以獨立備份與恢復
- 不影響其他租戶的運作
✅ 效能隔離
- 租戶間完全獨立,互不影響
- 大客戶可以有獨立的資料庫實例
✅ 獨立擴展
- 可為特定租戶升級資料庫規格
- 靈活的擴展策略
微服務 + Database-per-Tenant 組合優勢:
這兩大架構特色的組合,為 Kyo System 帶來:
1. 極致的資料安全性
- 微服務層級的訪問控制
- 資料庫層級的完全隔離
2. 靈活的擴展能力
- 服務層面可以水平擴展
- 資料庫層面可以獨立擴展
3. 高度的可維護性
- 服務間低耦合,易於維護
- 資料庫獨立,問題容易定位
4. 符合企業級需求
- 滿足大型客戶的合規要求
- 支援複雜的 SLA 協議
多租戶策略比較:
策略 | 優點 | 缺點 | 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)│
└──────────────┘
服務間通訊策略:
模組化架構:
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 # 應用進入點
問題: 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) }
}
問題: 不同場景需要不同的速率限制策略
場景 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 },
}
問題: 審計日誌寫入量大,影響 API 效能
初版實作: 同步寫入資料庫
問題:
❌ API 回應時間增加 50-100ms
❌ 資料庫 CPU 使用率高
❌ 寫入失敗會影響業務邏輯
優化方案:
// 使用 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)
})
})
// 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, // 每秒最多處理一次
},
}
)
-- 月度分區,自動清理舊資料
CREATE TABLE audit_logs_2025_01 PARTITION OF audit_logs
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
優化結果:
// 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())
// 使用 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
}
}
// 輸入驗證
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 技術債,排入開發計畫
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 觸發
├─ 審計日誌寫入
└─ 數據同步
微服務通訊模式:
/health
endpoint + ALB Target Group# 使用 Docker Compose / ECS 輕鬆擴展
docker-compose up --scale api=5 --scale worker=3
// 使用 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()
}
讀寫分離:
┌──────────────┐
│ 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)
}
}
// 讓前端更靈活地查詢資料
type Query {
user(id: ID!): User
otpRequests(limit: Int, offset: Int): [OTPRequest]
}
type User {
id: ID!
email: String!
name: String!
otpRequests: [OTPRequest]
}
// 高效能的內部服務通訊
service OTPService {
rpc SendOTP (SendOTPRequest) returns (SendOTPResponse);
rpc VerifyOTP (VerifyOTPRequest) returns (VerifyOTPResponse);
}
// 使用 Cloudflare Workers 在邊緣處理請求
export default {
async fetch(request) {
// 在邊緣驗證 JWT
// 快取常用資料
// 路由到最近的後端
}
}
30 天的鐵人賽挑戰終於完成了!
回顧這段旅程,從第一天的系統設計,到最後完成一個可以實際運作的 SaaS 產品後端,這個過程充滿了挑戰與學習。
雖然我已經有八年的開發經驗,但這次從零開始設計後端架構的經驗,讓我對系統設計有了更深刻的理解。以前在公司工作時,框架和架構都已經建立好了,這次自己從頭設計加上第一次使用TS開發後端,才真正體會到:
**最重要的是,成功地為工作室打造出一套可以實際