經過 Day 13 的 S3 + CloudFront 檔案管理系統建立,我們已經可以安全地儲存和分發多媒體檔案。今天我們要建立完整的事件驅動通知系統,當檔案處理完成、會員上傳照片、課程影片轉碼成功時,系統要能即時通知相關人員。
我們將使用 Amazon EventBridge + SNS 建立多通道推播系統,支援 LINE 通知、Email、SMS 和 Web Push,讓健身房管理更加即時效率。
// 健身房 SaaS 核心事件類型
interface GymEvent {
eventType:
// 檔案相關事件 (延續 Day 13)
| 'file.uploaded' // 檔案上傳完成
| 'file.processed' // 檔案處理完成 (縮圖、轉碼)
| 'file.virus_detected' // 檔案病毒檢測
// 會員相關事件
| 'member.registered' // 新會員註冊
| 'member.checked_in' // 會員報到
| 'member.membership_expiring' // 會籍即將到期
// 課程相關事件
| 'course.booking_confirmed' // 課程預約確認
| 'course.cancelled' // 課程取消
| 'course.reminder' // 課程提醒
// 付款相關事件
| 'payment.success' // 付款成功
| 'payment.failed' // 付款失敗
| 'invoice.generated' // 發票產生
// 系統相關事件
| 'system.backup_completed' // 資料備份完成
| 'system.maintenance' // 系統維護通知;
tenantId: string;
timestamp: string;
source: string;
data: Record<string, any>;
metadata: {
userId?: string;
priority: 'low' | 'medium' | 'high' | 'critical';
channels: NotificationChannel[];
};
}
type NotificationChannel = 'line' | 'email' | 'sms' | 'web_push' | 'slack';
// infrastructure/lib/eventbridge-stack.ts
import * as events from 'aws-cdk-lib/aws-events';
import * as targets from 'aws-cdk-lib/aws-events-targets';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as lambda from 'aws-cdk-lib/aws-lambda';
export class EventBridgeStack extends cdk.Stack {
public readonly eventBus: events.EventBus;
public readonly notificationTopics: Map<string, sns.Topic>;
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// 自訂事件匯流排
this.eventBus = new events.EventBus(this, 'GymEventBus', {
eventBusName: 'kyo-gym-events',
description: 'Event bus for gym SaaS system',
});
// 建立通知主題
this.notificationTopics = new Map();
this.createNotificationTopics();
// 建立事件規則
this.createEventRules();
// 建立 Lambda 處理函數
this.createNotificationHandlers();
}
private createNotificationTopics() {
const topicConfigs = [
{ name: 'FileProcessing', description: '檔案處理通知' },
{ name: 'MemberActivity', description: '會員活動通知' },
{ name: 'CourseManagement', description: '課程管理通知' },
{ name: 'PaymentUpdates', description: '付款更新通知' },
{ name: 'SystemAlerts', description: '系統警報通知' },
];
topicConfigs.forEach(config => {
const topic = new sns.Topic(this, `${config.name}Topic`, {
topicName: `kyo-${config.name.toLowerCase()}`,
displayName: config.description,
// 加密靜態數據
masterKey: kms.Key.fromLookup(this, `${config.name}Key`, {
aliasName: 'alias/aws/sns',
}),
});
this.notificationTopics.set(config.name, topic);
});
}
private createEventRules() {
// 檔案處理完成事件規則 (延續 Day 13 的 S3 事件)
new events.Rule(this, 'FileProcessedRule', {
eventBus: this.eventBus,
eventPattern: {
source: ['kyo.files'],
detailType: ['File Processed'],
detail: {
status: ['completed', 'failed'],
},
},
targets: [
new targets.SnsTopic(this.notificationTopics.get('FileProcessing')!),
new targets.LambdaFunction(this.createFileNotificationHandler()),
],
});
// 會員活動事件規則
new events.Rule(this, 'MemberActivityRule', {
eventBus: this.eventBus,
eventPattern: {
source: ['kyo.members'],
detailType: [
'Member Registered',
'Member Checked In',
'Membership Expiring'
],
},
targets: [
new targets.SnsTopic(this.notificationTopics.get('MemberActivity')!),
new targets.LambdaFunction(this.createMemberNotificationHandler()),
],
});
// 課程相關事件規則
new events.Rule(this, 'CourseEventRule', {
eventBus: this.eventBus,
eventPattern: {
source: ['kyo.courses'],
detailType: [
'Course Booking Confirmed',
'Course Cancelled',
'Course Reminder'
],
},
targets: [
new targets.SnsTopic(this.notificationTopics.get('CourseManagement')!),
new targets.LambdaFunction(this.createCourseNotificationHandler()),
],
});
// 緊急事件規則 (高優先級)
new events.Rule(this, 'HighPriorityRule', {
eventBus: this.eventBus,
eventPattern: {
source: ['kyo.system', 'kyo.security'],
detail: {
priority: ['high', 'critical'],
},
},
targets: [
new targets.SnsTopic(this.notificationTopics.get('SystemAlerts')!),
// 立即發送 SMS 給管理員
new targets.LambdaFunction(this.createEmergencyNotificationHandler()),
],
});
}
private createFileNotificationHandler(): lambda.Function {
return new lambda.Function(this, 'FileNotificationHandler', {
runtime: lambda.Runtime.NODEJS_18_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('lambda/file-notification'),
environment: {
LINE_CHANNEL_ACCESS_TOKEN: process.env.LINE_CHANNEL_ACCESS_TOKEN || '',
LINE_NOTIFY_TOKEN: process.env.LINE_NOTIFY_TOKEN || '',
},
timeout: cdk.Duration.minutes(1),
});
}
private createMemberNotificationHandler(): lambda.Function {
return new lambda.Function(this, 'MemberNotificationHandler', {
runtime: lambda.Runtime.NODEJS_18_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('lambda/member-notification'),
environment: {
SES_FROM_EMAIL: 'noreply@kyo.app',
LINE_CHANNEL_ACCESS_TOKEN: process.env.LINE_CHANNEL_ACCESS_TOKEN || '',
},
timeout: cdk.Duration.minutes(2),
});
}
}
延續 Day 12 的 LINE Login 整合,我們現在要實作 LINE 推播通知:
// lambda/line-notification/index.ts
import { EventBridgeEvent } from 'aws-lambda';
import { Client, messagingApi } from '@line/bot-sdk';
interface LineNotificationConfig {
channelAccessToken: string;
channelSecret: string;
}
export class LineNotificationService {
private client: Client;
constructor(config: LineNotificationConfig) {
this.client = new Client(config);
}
async sendFileProcessedNotification(
userId: string,
tenantId: string,
fileInfo: {
fileName: string;
category: string;
status: 'completed' | 'failed';
downloadUrl?: string;
}
): Promise<void> {
try {
const message = this.createFileProcessedMessage(fileInfo);
await this.client.pushMessage(userId, message);
console.log(`LINE notification sent to user ${userId} for file ${fileInfo.fileName}`);
} catch (error) {
console.error('Failed to send LINE notification:', error);
throw error;
}
}
async sendMembershipExpiringNotification(
userId: string,
memberInfo: {
name: string;
membershipType: string;
expiryDate: string;
daysLeft: number;
}
): Promise<void> {
const message: messagingApi.FlexMessage = {
type: 'flex',
altText: '會籍即將到期提醒',
contents: {
type: 'bubble',
header: {
type: 'box',
layout: 'vertical',
contents: [
{
type: 'text',
text: '會籍到期提醒',
weight: 'bold',
color: '#FF5551',
size: 'lg',
},
],
backgroundColor: '#FFF1F0',
paddingAll: 'lg',
},
body: {
type: 'box',
layout: 'vertical',
contents: [
{
type: 'text',
text: `嗨 ${memberInfo.name}!`,
weight: 'bold',
size: 'md',
margin: 'none',
},
{
type: 'text',
text: `您的 ${memberInfo.membershipType} 會籍將在 ${memberInfo.daysLeft} 天後到期`,
size: 'sm',
color: '#666666',
margin: 'md',
wrap: true,
},
{
type: 'text',
text: `到期日期:${memberInfo.expiryDate}`,
size: 'sm',
color: '#999999',
margin: 'sm',
},
],
spacing: 'sm',
},
footer: {
type: 'box',
layout: 'vertical',
contents: [
{
type: 'button',
action: {
type: 'uri',
label: '立即續約',
uri: `https://gym.kyo.app/membership/renew?utm_source=line&utm_medium=notification`,
},
style: 'primary',
color: '#1DBF73',
},
{
type: 'button',
action: {
type: 'uri',
label: '查看會籍詳情',
uri: `https://gym.kyo.app/profile?utm_source=line&utm_medium=notification`,
},
style: 'secondary',
margin: 'sm',
},
],
},
},
};
await this.client.pushMessage(userId, message);
}
async sendCourseReminderNotification(
userId: string,
courseInfo: {
courseName: string;
instructorName: string;
startTime: string;
location: string;
remainingSlots: number;
}
): Promise<void> {
const message: messagingApi.TemplateMessage = {
type: 'template',
altText: '課程提醒',
template: {
type: 'buttons',
title: '課程提醒',
text: `${courseInfo.courseName}\n${courseInfo.startTime}\n教練:${courseInfo.instructorName}\n地點:${courseInfo.location}`,
actions: [
{
type: 'uri',
label: '查看課程詳情',
uri: `https://gym.kyo.app/courses?utm_source=line&utm_medium=reminder`,
},
{
type: 'uri',
label: '取消預約',
uri: `https://gym.kyo.app/courses/cancel?utm_source=line&utm_medium=reminder`,
},
],
},
};
await this.client.pushMessage(userId, message);
}
private createFileProcessedMessage(fileInfo: any): messagingApi.TextMessage {
if (fileInfo.status === 'completed') {
return {
type: 'text',
text: `🎉 您的檔案「${fileInfo.fileName}」已處理完成!\n\n您現在可以在健身房系統中查看或下載。`,
};
} else {
return {
type: 'text',
text: `❌ 檔案「${fileInfo.fileName}」處理失敗\n\n請重新上傳或聯繫客服協助。`,
};
}
}
}
// Lambda 處理函數
export async function handler(event: EventBridgeEvent<'File Processed', any>) {
const lineService = new LineNotificationService({
channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN!,
channelSecret: process.env.LINE_CHANNEL_SECRET!,
});
try {
const { detail } = event;
const { tenantId, fileInfo, userId } = detail;
// 檢查用戶是否有 LINE ID 綁定
const userLineId = await getUserLineId(tenantId, userId);
if (!userLineId) {
console.log(`User ${userId} has no LINE ID, skipping LINE notification`);
return;
}
// 發送 LINE 通知
await lineService.sendFileProcessedNotification(
userLineId,
tenantId,
fileInfo
);
return {
statusCode: 200,
body: JSON.stringify({ message: 'LINE notification sent successfully' }),
};
} catch (error) {
console.error('LINE notification error:', error);
return {
statusCode: 500,
body: JSON.stringify({ error: 'Failed to send LINE notification' }),
};
}
}
async function getUserLineId(tenantId: string, userId: string): Promise<string | null> {
// 從租戶資料庫查詢用戶的 LINE ID
const pool = await tenantConnectionManager.getConnection(tenantId);
const result = await pool.query(
'SELECT line_user_id FROM users WHERE id = $1 AND line_user_id IS NOT NULL',
[userId]
);
return result.rows[0]?.line_user_id || null;
}
建立智慧通知協調系統,根據用戶偏好和事件重要性選擇最佳通知通道:
// lambda/notification-coordinator/index.ts
export interface NotificationPreference {
userId: string;
tenantId: string;
channels: {
line: boolean;
email: boolean;
sms: boolean;
webPush: boolean;
};
schedule: {
quietHours: { start: string; end: string };
timezone: string;
workdays: boolean[];
};
priority: {
high: NotificationChannel[];
medium: NotificationChannel[];
low: NotificationChannel[];
};
}
export class NotificationCoordinator {
async processNotification(event: GymEvent): Promise<void> {
const { tenantId, eventType, data, metadata } = event;
// 1. 取得目標用戶列表
const targetUsers = await this.getTargetUsers(tenantId, eventType, data);
// 2. 對每個用戶決定通知策略
for (const user of targetUsers) {
const preferences = await this.getUserPreferences(tenantId, user.id);
const channels = this.selectNotificationChannels(
metadata.priority,
preferences,
new Date()
);
// 3. 發送通知到選定的通道
await this.sendMultiChannelNotification(user, channels, event);
}
}
private selectNotificationChannels(
priority: 'low' | 'medium' | 'high' | 'critical',
preferences: NotificationPreference,
currentTime: Date
): NotificationChannel[] {
// 檢查安靜時間
if (this.isQuietHour(currentTime, preferences.schedule)) {
// 安靜時間只發送高優先級通知
if (priority === 'high' || priority === 'critical') {
return preferences.priority.high;
}
return []; // 安靜時間不發送低/中優先級通知
}
// 根據優先級選擇通道
switch (priority) {
case 'critical':
return ['line', 'sms', 'email', 'web_push']; // 所有通道
case 'high':
return preferences.priority.high;
case 'medium':
return preferences.priority.medium;
case 'low':
return preferences.priority.low;
default:
return ['web_push']; // 預設只發 Web 推播
}
}
private async sendMultiChannelNotification(
user: any,
channels: NotificationChannel[],
event: GymEvent
): Promise<void> {
const promises = channels.map(channel => {
switch (channel) {
case 'line':
return this.sendLineNotification(user, event);
case 'email':
return this.sendEmailNotification(user, event);
case 'sms':
return this.sendSmsNotification(user, event);
case 'web_push':
return this.sendWebPushNotification(user, event);
default:
return Promise.resolve();
}
});
// 並行發送到所有通道
await Promise.allSettled(promises);
}
private async sendEmailNotification(user: any, event: GymEvent): Promise<void> {
const ses = new SESClient({ region: process.env.AWS_REGION });
const emailTemplate = this.generateEmailTemplate(event);
await ses.send(new SendEmailCommand({
Source: 'notifications@kyo.app',
Destination: { ToAddresses: [user.email] },
Message: {
Subject: { Data: emailTemplate.subject, Charset: 'UTF-8' },
Body: {
Html: { Data: emailTemplate.html, Charset: 'UTF-8' },
Text: { Data: emailTemplate.text, Charset: 'UTF-8' },
},
},
}));
}
private async sendSmsNotification(user: any, event: GymEvent): Promise<void> {
const sns = new SNSClient({ region: process.env.AWS_REGION });
const message = this.generateSmsMessage(event);
await sns.send(new PublishCommand({
PhoneNumber: user.phone,
Message: message,
MessageAttributes: {
'AWS.SNS.SMS.SenderID': {
DataType: 'String',
StringValue: 'KyoGym',
},
'AWS.SNS.SMS.SMSType': {
DataType: 'String',
StringValue: 'Transactional',
},
},
}));
}
private generateEmailTemplate(event: GymEvent): {
subject: string;
html: string;
text: string;
} {
switch (event.eventType) {
case 'file.processed':
return {
subject: '檔案處理完成通知',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #1DBF73;">檔案處理完成 ✅</h2>
<p>您的檔案「${event.data.fileName}」已經處理完成。</p>
<p>您現在可以在健身房系統中查看或下載這個檔案。</p>
<a href="${event.data.downloadUrl}"
style="background: #1DBF73; color: white; padding: 12px 24px;
text-decoration: none; border-radius: 6px; display: inline-block;">
查看檔案
</a>
</div>
`,
text: `檔案處理完成:${event.data.fileName}。請至健身房系統查看。`,
};
case 'member.membership_expiring':
return {
subject: '會籍即將到期提醒',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #FF5551;">會籍到期提醒 ⏰</h2>
<p>親愛的 ${event.data.memberName}:</p>
<p>您的 ${event.data.membershipType} 會籍將在 ${event.data.daysLeft} 天後到期。</p>
<p>到期日期:${event.data.expiryDate}</p>
<a href="https://gym.kyo.app/membership/renew"
style="background: #1DBF73; color: white; padding: 12px 24px;
text-decoration: none; border-radius: 6px; display: inline-block;">
立即續約
</a>
</div>
`,
text: `會籍提醒:您的會籍將在 ${event.data.daysLeft} 天後到期,請及時續約。`,
};
default:
return {
subject: '健身房系統通知',
html: '<p>您有新的通知,請登入系統查看。</p>',
text: '您有新的通知,請登入系統查看。',
};
}
}
}
// 通知效果追蹤
export class NotificationAnalytics {
static async trackNotificationSent(
tenantId: string,
userId: string,
channel: NotificationChannel,
eventType: string
): Promise<void> {
const cloudWatch = new CloudWatchClient({ region: process.env.AWS_REGION });
await cloudWatch.send(new PutMetricDataCommand({
Namespace: 'Kyo/Notifications',
MetricData: [
{
MetricName: 'NotificationSent',
Dimensions: [
{ Name: 'TenantId', Value: tenantId },
{ Name: 'Channel', Value: channel },
{ Name: 'EventType', Value: eventType },
],
Value: 1,
Unit: 'Count',
Timestamp: new Date(),
},
],
}));
}
static async trackNotificationClicked(
tenantId: string,
userId: string,
notificationId: string,
channel: NotificationChannel
): Promise<void> {
// 記錄點擊率和用戶參與度
const cloudWatch = new CloudWatchClient({ region: process.env.AWS_REGION });
await cloudWatch.send(new PutMetricDataCommand({
Namespace: 'Kyo/Notifications',
MetricData: [
{
MetricName: 'NotificationClicked',
Dimensions: [
{ Name: 'TenantId', Value: tenantId },
{ Name: 'Channel', Value: channel },
],
Value: 1,
Unit: 'Count',
Timestamp: new Date(),
},
],
}));
}
static async generateNotificationReport(tenantId: string): Promise<any> {
// 生成通知效果報告
const cloudWatch = new CloudWatchClient({ region: process.env.AWS_REGION });
const endTime = new Date();
const startTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 天前
const sentMetrics = await cloudWatch.send(new GetMetricStatisticsCommand({
Namespace: 'Kyo/Notifications',
MetricName: 'NotificationSent',
Dimensions: [{ Name: 'TenantId', Value: tenantId }],
StartTime: startTime,
EndTime: endTime,
Period: 86400, // 1 天
Statistics: ['Sum'],
}));
const clickedMetrics = await cloudWatch.send(new GetMetricStatisticsCommand({
Namespace: 'Kyo/Notifications',
MetricName: 'NotificationClicked',
Dimensions: [{ Name: 'TenantId', Value: tenantId }],
StartTime: startTime,
EndTime: endTime,
Period: 86400,
Statistics: ['Sum'],
}));
return {
totalSent: sentMetrics.Datapoints?.reduce((sum, dp) => sum + (dp.Sum || 0), 0),
totalClicked: clickedMetrics.Datapoints?.reduce((sum, dp) => sum + (dp.Sum || 0), 0),
clickRate: this.calculateClickRate(sentMetrics, clickedMetrics),
dailyBreakdown: this.mergeDailyData(sentMetrics, clickedMetrics),
};
}
}
// 批次通知處理降低成本
export class BatchNotificationProcessor {
private batchQueue: Map<string, GymEvent[]> = new Map();
private readonly batchSize = 100;
private readonly batchTimeout = 30000; // 30 秒
async queueNotification(event: GymEvent): Promise<void> {
const batchKey = `${event.tenantId}-${event.eventType}`;
if (!this.batchQueue.has(batchKey)) {
this.batchQueue.set(batchKey, []);
// 設定批次處理定時器
setTimeout(() => this.processBatch(batchKey), this.batchTimeout);
}
const batch = this.batchQueue.get(batchKey)!;
batch.push(event);
// 達到批次大小立即處理
if (batch.length >= this.batchSize) {
await this.processBatch(batchKey);
}
}
private async processBatch(batchKey: string): Promise<void> {
const batch = this.batchQueue.get(batchKey);
if (!batch || batch.length === 0) return;
try {
// 合併相同用戶的通知
const groupedByUser = this.groupNotificationsByUser(batch);
// 批次發送
await this.sendBatchNotifications(groupedByUser);
} finally {
this.batchQueue.delete(batchKey);
}
}
}
// Lambda 冷啟動優化和監控
export const warmupHandler = async (event: any) => {
// Lambda 預熱處理
if (event.source === 'aws.events' && event['detail-type'] === 'Scheduled Event') {
return { statusCode: 200, body: 'Warmed up' };
}
// 正常事件處理
const startTime = Date.now();
try {
await processNotificationEvent(event);
// 記錄處理時間
const processingTime = Date.now() - startTime;
await trackProcessingTime('notification_handler', processingTime);
} catch (error) {
await trackError('notification_handler', error);
throw error;
}
};
我們今天建立了完整的事件驅動推播通知系統: