iT邦幫忙

2025 iThome 鐵人賽

DAY 14
0
Build on AWS

30 天將工作室 SaaS 產品部署起來系列 第 14

Day 14:30天部署SaaS產品到AWS-EventBridge + SNS 推播通知系統

  • 分享至 

  • xImage
  •  

前情提要

經過 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';

EventBridge 事件規則設計

// 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),
    });
  }
}

LINE 推播通知整合

延續 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),
    };
  }
}

成本優化與性能監控

1. SNS 成本優化

// 批次通知處理降低成本
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);
    }
  }
}

2. 性能監控

// 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;
  }
};

今日總結

我們今天建立了完整的事件驅動推播通知系統:

核心功能

  1. EventBridge 事件中心:統一的事件路由和處理機制
  2. LINE 推播整合:延續 Day 12,提供 LINE 原生通知體驗
  3. 多通道通知協調:智能選擇最佳通知管道
  4. 檔案處理通知:延續 Day 13,即時通知檔案狀態
  5. 效果追蹤分析:完整的通知效果監控和報告

技術特色

  • 事件驅動架構:鬆耦合、可擴展的通知系統
  • 智能通道選擇:根據優先級和用戶偏好自動路由
  • 批次處理優化:降低 SNS 成本和提升效能
  • 豐富的通知類型:文字、圖片、互動式卡片

商業價值

  • 即時性:檔案處理完成立即通知,提升用戶體驗
  • 個人化:根據用戶偏好選擇通知方式
  • 自動化:減少人工通知工作,提升營運效率
  • 數據洞察:通知效果分析,優化溝通策略

上一篇
Day 13: 30天部署SaaS產品到AWS-S3 + CloudFront 檔案管理 - LINE Login 頭像儲存
下一篇
Day 15: 30天部署SaaS產品到AWS-ECS、RDS 多租戶與 CI/CD 自動化
系列文
30 天將工作室 SaaS 產品部署起來15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言