iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0
Build on AWS

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

Day 13: 30天部署SaaS產品到AWS-S3 + CloudFront 檔案管理 - LINE Login 頭像儲存

  • 分享至 

  • xImage
  •  

前情提要

經過 Day 12 的 LINE Login + AWS Cognito 認證系統建立,我們的健身房 SaaS 已經可以讓用戶透過 LINE 帳號快速登入。今天我們要建立完整的檔案管理系統,重點處理 LINE 用戶頭像同步、會員照片、課程影片、教練證照等檔案,使用 S3 儲存和 CloudFront 全球內容分發網路,確保每個租戶的檔案安全隔離。

這樣當使用者透過 LINE Login 登入後,我們可以自動同步他們的頭像,並提供完整的檔案上傳體驗。

多租戶檔案儲存策略

1. 儲存架構設計

我們採用 Bucket-per-Environment + Prefix-per-Tenant 策略:

📁 kyo-files-production/
├── 📁 tenant-{tenant-id}/
│   ├── 📁 members/
│   │   ├── 📁 profiles/          # 會員大頭照
│   │   ├── 📁 progress/          # 健身進度照片
│   │   └── 📁 documents/         # 會員證件
│   ├── 📁 trainers/
│   │   ├── 📁 profiles/          # 教練照片
│   │   ├── 📁 certificates/      # 教練證照
│   │   └── 📁 videos/           # 教學影片
│   ├── 📁 courses/
│   │   ├── 📁 thumbnails/        # 課程縮圖
│   │   ├── 📁 videos/           # 課程影片
│   │   └── 📁 materials/        # 課程資料
│   └── 📁 gym/
│       ├── 📁 photos/           # 健身房照片
│       ├── 📁 equipment/        # 器材照片
│       └── 📁 branding/         # 品牌資源

2. 安全隔離機制

每個租戶只能存取自己的檔案:

// infrastructure/lib/s3-stack.ts
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';

export class S3FileStorageStack extends cdk.Stack {
  public readonly filesBucket: s3.Bucket;
  public readonly distribution: cloudfront.Distribution;

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // 主要檔案儲存 Bucket
    this.filesBucket = new s3.Bucket(this, 'KyoFilesBucket', {
      bucketName: 'kyo-files-production',
      // 版本控制 - 防止意外刪除
      versioned: true,
      // 生命週期管理
      lifecycleRules: [
        {
          id: 'DeleteOldVersions',
          enabled: true,
          noncurrentVersionExpiration: cdk.Duration.days(30),
        },
        {
          id: 'TransitionToIA',
          enabled: true,
          transitions: [
            {
              storageClass: s3.StorageClass.INFREQUENT_ACCESS,
              transitionAfter: cdk.Duration.days(30),
            },
            {
              storageClass: s3.StorageClass.GLACIER,
              transitionAfter: cdk.Duration.days(90),
            },
          ],
        },
      ],
      // 加密設定
      encryption: s3.BucketEncryption.S3_MANAGED,
      // 阻止公開存取
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      // 事件通知
      eventBridgeEnabled: true,
    });

    // CloudFront 原點存取身份
    const originAccessIdentity = new cloudfront.OriginAccessIdentity(
      this,
      'FilesBucketOAI',
      {
        comment: 'OAI for Kyo files bucket',
      }
    );

    // S3 Bucket 政策 - 只允許 CloudFront 存取
    this.filesBucket.addToResourcePolicy(
      new iam.PolicyStatement({
        actions: ['s3:GetObject'],
        resources: [this.filesBucket.arnForObjects('*')],
        principals: [originAccessIdentity.grantPrincipal],
      })
    );

    // CloudFront 分發
    this.distribution = new cloudfront.Distribution(this, 'FilesDistribution', {
      defaultBehavior: {
        origin: new origins.S3Origin(this.filesBucket, {
          originAccessIdentity,
        }),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD,
        compress: true,
      },
      // 不同檔案類型的快取策略
      additionalBehaviors: {
        '/*/members/profiles/*': {
          origin: new origins.S3Origin(this.filesBucket, {
            originAccessIdentity,
          }),
          cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
          viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        },
        '/*/courses/videos/*': {
          origin: new origins.S3Origin(this.filesBucket, {
            originAccessIdentity,
          }),
          cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED_FOR_UNCOMPRESSED_OBJECTS,
          viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        },
      },
      priceClass: cloudfront.PriceClass.PRICE_CLASS_100, // 只使用北美和歐洲的邊緣節點
      enableIpv6: true,
    });

    // 租戶檔案存取權限管理
    this.createTenantAccessPolicies();
  }

  private createTenantAccessPolicies() {
    // 為每個租戶建立 IAM 角色
    const tenantFileAccessRole = new iam.Role(this, 'TenantFileAccessRole', {
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
      description: 'Role for tenant-specific file access',
    });

    // 租戶檔案存取政策模板
    const tenantFilePolicy = new iam.Policy(this, 'TenantFileAccessPolicy', {
      statements: [
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: [
            's3:GetObject',
            's3:PutObject',
            's3:DeleteObject',
            's3:ListBucket',
          ],
          resources: [
            this.filesBucket.bucketArn,
            `${this.filesBucket.bucketArn}/tenant-\${aws:PrincipalTag/TenantId}/*`,
          ],
          conditions: {
            StringEquals: {
              's3:prefix': 'tenant-${aws:PrincipalTag/TenantId}/',
            },
          },
        }),
      ],
    });

    tenantFileAccessRole.attachInlinePolicy(tenantFilePolicy);
  }
}

檔案上傳服務

我們建立一個完整的檔案管理服務,支援安全上傳、自動處理和權限控制:

// packages/kyo-core/src/services/file-service.ts
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import sharp from 'sharp';
import { v4 as uuidv4 } from 'uuid';

export interface FileUploadRequest {
  tenantId: string;
  category: 'member' | 'trainer' | 'course' | 'gym';
  subcategory: string;
  fileName: string;
  fileType: string;
  fileSize: number;
}

export interface FileUploadResponse {
  fileId: string;
  uploadUrl: string;
  downloadUrl: string;
  expiresIn: number;
}

export class FileService {
  private s3Client: S3Client;
  private bucketName: string;
  private cloudFrontDomain: string;

  constructor() {
    this.s3Client = new S3Client({ region: process.env.AWS_REGION });
    this.bucketName = process.env.FILES_BUCKET_NAME || 'kyo-files-production';
    this.cloudFrontDomain = process.env.CLOUDFRONT_DOMAIN || 'files.kyo.app';
  }

  async generateUploadUrl(request: FileUploadRequest): Promise<FileUploadResponse> {
    // 驗證檔案類型和大小
    this.validateFileRequest(request);

    // 生成唯一檔案 ID
    const fileId = uuidv4();
    const fileExtension = this.getFileExtension(request.fileName);
    const s3Key = this.generateS3Key(request.tenantId, request.category, request.subcategory, fileId, fileExtension);

    // 生成預簽名上傳 URL (15 分鐘有效)
    const uploadCommand = new PutObjectCommand({
      Bucket: this.bucketName,
      Key: s3Key,
      ContentType: request.fileType,
      ContentLength: request.fileSize,
      Metadata: {
        tenantId: request.tenantId,
        category: request.category,
        subcategory: request.subcategory,
        originalName: request.fileName,
      },
      // 自動標籤
      Tagging: `TenantId=${request.tenantId}&Category=${request.category}`,
    });

    const uploadUrl = await getSignedUrl(this.s3Client, uploadCommand, {
      expiresIn: 900, // 15 分鐘
    });

    // 生成 CloudFront 下載 URL
    const downloadUrl = `https://${this.cloudFrontDomain}/${s3Key}`;

    return {
      fileId,
      uploadUrl,
      downloadUrl,
      expiresIn: 900,
    };
  }

  async processImageFile(s3Key: string, tenantId: string): Promise<void> {
    try {
      // 取得原始圖片
      const getCommand = new GetObjectCommand({
        Bucket: this.bucketName,
        Key: s3Key,
      });

      const response = await this.s3Client.send(getCommand);
      const imageBuffer = await this.streamToBuffer(response.Body);

      // 建立不同尺寸的縮圖
      const sizes = [
        { name: 'thumbnail', width: 150, height: 150 },
        { name: 'medium', width: 400, height: 400 },
        { name: 'large', width: 800, height: 600 },
      ];

      for (const size of sizes) {
        const resizedBuffer = await sharp(imageBuffer)
          .resize(size.width, size.height, {
            fit: 'cover',
            position: 'center',
          })
          .jpeg({ quality: 85 })
          .toBuffer();

        // 上傳處理後的圖片
        const resizedKey = s3Key.replace(/(\.[^.]+)$/, `_${size.name}$1`);

        await this.s3Client.send(new PutObjectCommand({
          Bucket: this.bucketName,
          Key: resizedKey,
          Body: resizedBuffer,
          ContentType: 'image/jpeg',
          Metadata: {
            tenantId,
            processedSize: size.name,
            originalKey: s3Key,
          },
        }));
      }
    } catch (error) {
      console.error('Image processing failed:', error);
      throw new Error('Failed to process image');
    }
  }

  async deleteFile(tenantId: string, fileId: string): Promise<void> {
    // 找出所有相關檔案 (包含縮圖)
    const prefix = `tenant-${tenantId}/`;
    const files = await this.listFiles(prefix, fileId);

    // 批量刪除
    for (const file of files) {
      await this.s3Client.send(new DeleteObjectCommand({
        Bucket: this.bucketName,
        Key: file.key,
      }));
    }
  }

  private validateFileRequest(request: FileUploadRequest): void {
    const allowedTypes = {
      member: ['image/jpeg', 'image/png', 'image/webp'],
      trainer: ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'],
      course: ['image/jpeg', 'image/png', 'video/mp4', 'video/webm'],
      gym: ['image/jpeg', 'image/png', 'image/webp'],
    };

    const maxSizes = {
      member: 5 * 1024 * 1024,     // 5MB
      trainer: 10 * 1024 * 1024,   // 10MB
      course: 100 * 1024 * 1024,   // 100MB
      gym: 5 * 1024 * 1024,        // 5MB
    };

    if (!allowedTypes[request.category]?.includes(request.fileType)) {
      throw new Error(`File type ${request.fileType} not allowed for category ${request.category}`);
    }

    if (request.fileSize > maxSizes[request.category]) {
      throw new Error(`File size exceeds limit for category ${request.category}`);
    }
  }

  private generateS3Key(tenantId: string, category: string, subcategory: string, fileId: string, extension: string): string {
    return `tenant-${tenantId}/${category}/${subcategory}/${fileId}.${extension}`;
  }

  private getFileExtension(fileName: string): string {
    return fileName.split('.').pop()?.toLowerCase() || '';
  }

  private async streamToBuffer(stream: any): Promise<Buffer> {
    const chunks: Uint8Array[] = [];
    return new Promise((resolve, reject) => {
      stream.on('data', (chunk: Uint8Array) => chunks.push(chunk));
      stream.on('error', reject);
      stream.on('end', () => resolve(Buffer.concat(chunks)));
    });
  }
}

Lambda 檔案處理觸發器

使用 EventBridge 和 Lambda 自動處理上傳的檔案:

// lambda/file-processor/index.ts
import { S3Event, EventBridgeEvent } from 'aws-lambda';
import { FileService } from './file-service';

const fileService = new FileService();

export async function handler(event: EventBridgeEvent<'Object Created', any>) {
  console.log('File processing event:', JSON.stringify(event, null, 2));

  try {
    const { source, detail } = event;

    if (source !== 'aws.s3') {
      return { statusCode: 200, body: 'Not an S3 event' };
    }

    const { bucket, object } = detail;
    const s3Key = object.key;

    // 解析檔案路徑取得租戶資訊
    const pathParts = s3Key.split('/');
    if (pathParts.length < 3 || !pathParts[0].startsWith('tenant-')) {
      return { statusCode: 200, body: 'Invalid file path structure' };
    }

    const tenantId = pathParts[0].replace('tenant-', '');
    const category = pathParts[1];
    const subcategory = pathParts[2];

    // 根據檔案類型決定處理方式
    if (category === 'members' || category === 'trainers' || category === 'gym') {
      // 圖片處理 - 生成縮圖
      if (object.key.match(/\.(jpg|jpeg|png|webp)$/i)) {
        await fileService.processImageFile(s3Key, tenantId);

        // 記錄檔案資訊到資料庫
        await recordFileInDatabase(tenantId, {
          fileId: extractFileId(s3Key),
          category,
          subcategory,
          originalKey: s3Key,
          fileSize: object.size,
          processedAt: new Date(),
        });
      }
    } else if (category === 'courses' && subcategory === 'videos') {
      // 影片處理 - 生成縮圖和轉碼 (未來功能)
      await processVideoFile(s3Key, tenantId);
    }

    return {
      statusCode: 200,
      body: JSON.stringify({ message: 'File processed successfully' }),
    };
  } catch (error) {
    console.error('File processing error:', error);
    return {
      statusCode: 500,
      body: JSON.stringify({ error: 'File processing failed' }),
    };
  }
}

async function recordFileInDatabase(tenantId: string, fileInfo: any) {
  // 記錄檔案資訊到租戶資料庫
  const pool = await tenantConnectionManager.getConnection(tenantId);

  await pool.query(`
    INSERT INTO files (id, category, subcategory, s3_key, file_size, created_at)
    VALUES ($1, $2, $3, $4, $5, $6)
  `, [
    fileInfo.fileId,
    fileInfo.category,
    fileInfo.subcategory,
    fileInfo.originalKey,
    fileInfo.fileSize,
    fileInfo.processedAt,
  ]);
}

async function processVideoFile(s3Key: string, tenantId: string) {
  // 未來實作:影片縮圖生成、轉碼、字幕提取等
  console.log(`Video processing for ${s3Key} in tenant ${tenantId}`);
}

function extractFileId(s3Key: string): string {
  const fileName = s3Key.split('/').pop() || '';
  return fileName.split('.')[0];
}

前端檔案上傳組件

建立 React 檔案上傳組件,支援拖拽、預覽和進度顯示:

// apps/kyo-dashboard/src/components/FileUpload/FileUploadZone.tsx
import React, { useState, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import {
  Group,
  Text,
  useMantineTheme,
  rem,
  Progress,
  Alert,
  Image,
  ActionIcon,
  Stack
} from '@mantine/core';
import { IconUpload, IconX, IconCheck, IconAlertCircle } from '@tabler/icons-react';
import { useTenant } from '@kyong/kyo-core/client';

interface FileUploadZoneProps {
  category: 'member' | 'trainer' | 'course' | 'gym';
  subcategory: string;
  onUploadComplete?: (fileUrl: string) => void;
  maxFiles?: number;
  accept?: string[];
}

interface UploadFile {
  id: string;
  file: File;
  progress: number;
  status: 'pending' | 'uploading' | 'completed' | 'error';
  downloadUrl?: string;
  error?: string;
}

export const FileUploadZone: React.FC<FileUploadZoneProps> = ({
  category,
  subcategory,
  onUploadComplete,
  maxFiles = 5,
  accept = ['image/jpeg', 'image/png'],
}) => {
  const theme = useMantineTheme();
  const { currentTenant } = useTenant();
  const [uploadFiles, setUploadFiles] = useState<UploadFile[]>([]);

  const onDrop = useCallback(async (acceptedFiles: File[]) => {
    const newFiles: UploadFile[] = acceptedFiles.map(file => ({
      id: Math.random().toString(36).substr(2, 9),
      file,
      progress: 0,
      status: 'pending',
    }));

    setUploadFiles(prev => [...prev, ...newFiles]);

    // 開始上傳
    for (const uploadFile of newFiles) {
      await uploadSingleFile(uploadFile);
    }
  }, [category, subcategory, currentTenant?.id]);

  const uploadSingleFile = async (uploadFile: UploadFile) => {
    if (!currentTenant?.id) return;

    try {
      // 更新狀態為上傳中
      setUploadFiles(prev => prev.map(f =>
        f.id === uploadFile.id
          ? { ...f, status: 'uploading' as const }
          : f
      ));

      // 1. 取得預簽名上傳 URL
      const uploadResponse = await fetch('/api/files/upload-url', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-Tenant-ID': currentTenant.id,
        },
        body: JSON.stringify({
          category,
          subcategory,
          fileName: uploadFile.file.name,
          fileType: uploadFile.file.type,
          fileSize: uploadFile.file.size,
        }),
      });

      if (!uploadResponse.ok) {
        throw new Error('Failed to get upload URL');
      }

      const { uploadUrl, downloadUrl } = await uploadResponse.json();

      // 2. 直接上傳到 S3
      const xhr = new XMLHttpRequest();

      xhr.upload.addEventListener('progress', (event) => {
        if (event.lengthComputable) {
          const progress = Math.round((event.loaded / event.total) * 100);
          setUploadFiles(prev => prev.map(f =>
            f.id === uploadFile.id
              ? { ...f, progress }
              : f
          ));
        }
      });

      xhr.addEventListener('load', () => {
        if (xhr.status === 200) {
          setUploadFiles(prev => prev.map(f =>
            f.id === uploadFile.id
              ? { ...f, status: 'completed' as const, downloadUrl }
              : f
          ));
          onUploadComplete?.(downloadUrl);
        } else {
          throw new Error('Upload failed');
        }
      });

      xhr.addEventListener('error', () => {
        throw new Error('Network error during upload');
      });

      xhr.open('PUT', uploadUrl);
      xhr.setRequestHeader('Content-Type', uploadFile.file.type);
      xhr.send(uploadFile.file);

    } catch (error) {
      setUploadFiles(prev => prev.map(f =>
        f.id === uploadFile.id
          ? { ...f, status: 'error' as const, error: error.message }
          : f
      ));
    }
  };

  const removeFile = (fileId: string) => {
    setUploadFiles(prev => prev.filter(f => f.id !== fileId));
  };

  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    accept: accept.reduce((acc, type) => ({ ...acc, [type]: [] }), {}),
    maxFiles: maxFiles - uploadFiles.length,
  });

  return (
    <Stack spacing="md">
      {/* 拖拽上傳區域 */}
      <div
        {...getRootProps()}
        style={{
          border: `2px dashed ${isDragActive ? theme.colors.blue[5] : theme.colors.gray[3]}`,
          borderRadius: theme.radius.md,
          padding: rem(40),
          textAlign: 'center',
          cursor: 'pointer',
          backgroundColor: isDragActive ? theme.colors.blue[0] : theme.colors.gray[0],
          transition: 'all 0.2s ease',
        }}
      >
        <input {...getInputProps()} />
        <Group position="center" spacing="sm">
          <IconUpload size={rem(50)} stroke={1.5} color={theme.colors.gray[5]} />
        </Group>
        <Text size="lg" mt="md">
          {isDragActive ? '放開檔案開始上傳' : '拖拽檔案到此處或點擊選擇'}
        </Text>
        <Text size="sm" color="dimmed" mt="xs">
          支援格式:{accept.join(', ')} | 最大 {maxFiles} 個檔案
        </Text>
      </div>

      {/* 上傳檔案列表 */}
      {uploadFiles.map((uploadFile) => (
        <FileUploadItem
          key={uploadFile.id}
          uploadFile={uploadFile}
          onRemove={() => removeFile(uploadFile.id)}
        />
      ))}
    </Stack>
  );
};

interface FileUploadItemProps {
  uploadFile: UploadFile;
  onRemove: () => void;
}

const FileUploadItem: React.FC<FileUploadItemProps> = ({ uploadFile, onRemove }) => {
  const getStatusIcon = () => {
    switch (uploadFile.status) {
      case 'completed':
        return <IconCheck size={20} color="green" />;
      case 'error':
        return <IconAlertCircle size={20} color="red" />;
      default:
        return null;
    }
  };

  const getStatusColor = () => {
    switch (uploadFile.status) {
      case 'completed':
        return 'green';
      case 'error':
        return 'red';
      case 'uploading':
        return 'blue';
      default:
        return 'gray';
    }
  };

  return (
    <Alert
      color={getStatusColor()}
      variant="light"
      icon={getStatusIcon()}
    >
      <Group position="apart">
        <div>
          <Text size="sm" weight={500}>
            {uploadFile.file.name}
          </Text>
          <Text size="xs" color="dimmed">
            {(uploadFile.file.size / 1024 / 1024).toFixed(2)} MB
          </Text>
          {uploadFile.status === 'uploading' && (
            <Progress
              value={uploadFile.progress}
              mt="xs"
              size="sm"
              color="blue"
            />
          )}
          {uploadFile.status === 'error' && (
            <Text size="xs" color="red" mt="xs">
              {uploadFile.error}
            </Text>
          )}
        </div>

        <Group spacing="xs">
          {uploadFile.downloadUrl && uploadFile.file.type.startsWith('image/') && (
            <Image
              src={uploadFile.downloadUrl}
              alt="Preview"
              width={50}
              height={50}
              fit="cover"
              radius="sm"
            />
          )}
          <ActionIcon onClick={onRemove} color="red" variant="subtle">
            <IconX size={16} />
          </ActionIcon>
        </Group>
      </Group>
    </Alert>
  );
};

成本優化策略

1. 儲存成本優化

// 自動化儲存類別轉換
const lifecycleRule = {
  id: 'CostOptimization',
  enabled: true,
  // 30 天後轉為 IA (Infrequent Access)
  transitions: [
    {
      days: 30,
      storageClass: 'STANDARD_IA',
    },
    {
      days: 90,
      storageClass: 'GLACIER',
    },
    {
      days: 365,
      storageClass: 'DEEP_ARCHIVE',
    },
  ],
  // 刪除未完成的多部分上傳
  abortIncompleteMultipartUpload: {
    daysAfterInitiation: 1,
  },
};

2. 流量成本優化

// CloudFront 快取策略優化
const cachePolicy = new cloudfront.CachePolicy(this, 'FilesCachePolicy', {
  cachePolicyName: 'kyo-files-cache-policy',
  defaultTtl: cdk.Duration.days(1),
  maxTtl: cdk.Duration.days(365),
  minTtl: cdk.Duration.seconds(0),
  keyPolicy: {
    headers: cloudfront.CacheHeaderBehavior.allowList('Authorization'),
    queryStrings: cloudfront.CacheQueryStringBehavior.allowList('v'),
    cookies: cloudfront.CacheCookieBehavior.none(),
  },
});

監控與分析

// CloudWatch 自訂指標
export class FileStorageMonitoring {
  static async trackFileUpload(tenantId: string, category: string, fileSize: number) {
    const cloudWatch = new CloudWatchClient({ region: process.env.AWS_REGION });

    await cloudWatch.send(new PutMetricDataCommand({
      Namespace: 'Kyo/FileStorage',
      MetricData: [
        {
          MetricName: 'FileUploaded',
          Dimensions: [
            { Name: 'TenantId', Value: tenantId },
            { Name: 'Category', Value: category },
          ],
          Value: 1,
          Unit: 'Count',
          Timestamp: new Date(),
        },
        {
          MetricName: 'FileSize',
          Dimensions: [
            { Name: 'TenantId', Value: tenantId },
            { Name: 'Category', Value: category },
          ],
          Value: fileSize,
          Unit: 'Bytes',
          Timestamp: new Date(),
        },
      ],
    }));
  }

  static async trackStorageUsage(tenantId: string, totalSize: number) {
    // 每日儲存使用量追蹤
    const cloudWatch = new CloudWatchClient({ region: process.env.AWS_REGION });

    await cloudWatch.send(new PutMetricDataCommand({
      Namespace: 'Kyo/FileStorage',
      MetricData: [
        {
          MetricName: 'TenantStorageUsage',
          Dimensions: [{ Name: 'TenantId', Value: tenantId }],
          Value: totalSize,
          Unit: 'Bytes',
          Timestamp: new Date(),
        },
      ],
    }));
  }
}

安全性考量

1. 檔案內容掃描

// Lambda 病毒掃描
import { ClamAV } from 'clamav.js';

export async function scanFile(s3Key: string, bucketName: string): Promise<boolean> {
  const clamav = new ClamAV();

  try {
    // 下載檔案進行掃描
    const fileStream = await getS3Object(bucketName, s3Key);
    const scanResult = await clamav.scanStream(fileStream);

    if (scanResult.isInfected) {
      // 隔離感染檔案
      await quarantineFile(s3Key, bucketName);
      return false;
    }

    return true;
  } catch (error) {
    console.error('File scan failed:', error);
    return false;
  }
}

2. 存取權限控制

// 動態存取 URL 生成
export async function generateSecureAccessUrl(
  tenantId: string,
  fileId: string,
  userId: string
): Promise<string> {
  // 驗證用戶權限
  const hasAccess = await checkFileAccess(tenantId, fileId, userId);
  if (!hasAccess) {
    throw new Error('Access denied');
  }

  // 生成限時存取 URL
  const command = new GetObjectCommand({
    Bucket: bucketName,
    Key: `tenant-${tenantId}/${fileId}`,
  });

  return await getSignedUrl(s3Client, command, {
    expiresIn: 3600, // 1 小時
  });
}

今日總結

我們今天建立了完整的多租戶檔案儲存系統:

核心功能

  1. S3 多租戶架構:安全隔離的檔案儲存策略
  2. CloudFront 全球分發:快速的檔案存取效能
  3. 自動檔案處理:圖片縮圖、影片轉碼
  4. React 上傳組件:支援拖拽、預覽、進度顯示
  5. 成本優化策略:生命週期管理、快取優化

技術特色

  • 安全性:租戶隔離、病毒掃描、存取控制
  • 效能:CloudFront 快取、多尺寸圖片
  • 可擴展性:事件驅動處理、自動化管道
  • 成本效益:儲存分層、流量優化

商業價值

  • 會員體驗:快速圖片載入、多尺寸適配
  • 營運效率:自動化檔案處理、統一管理
  • 數據安全:完全隔離、合規保護
  • 成本控制:智慧儲存、流量優化

上一篇
Day 12:30天部署SaaS產品到AWS-多元認證架構:LINE Login + AWS Cognito 整合實作
下一篇
Day 14:30天部署SaaS產品到AWS-EventBridge + SNS 推播通知系統
系列文
30 天將工作室 SaaS 產品部署起來15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言