經過 Day 12 的 LINE Login + AWS Cognito 認證系統建立,我們的健身房 SaaS 已經可以讓用戶透過 LINE 帳號快速登入。今天我們要建立完整的檔案管理系統,重點處理 LINE 用戶頭像同步、會員照片、課程影片、教練證照等檔案,使用 S3 儲存和 CloudFront 全球內容分發網路,確保每個租戶的檔案安全隔離。
這樣當使用者透過 LINE Login 登入後,我們可以自動同步他們的頭像,並提供完整的檔案上傳體驗。
我們採用 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/ # 品牌資源
每個租戶只能存取自己的檔案:
// 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)));
});
}
}
使用 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>
);
};
// 自動化儲存類別轉換
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,
},
};
// 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(),
},
],
}));
}
}
// 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;
}
}
// 動態存取 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 小時
});
}
我們今天建立了完整的多租戶檔案儲存系統: