@restnfeel/agentc-starter-kit
Version:
한국어 기업용 CMS 모듈 - Task Master AI와 함께 빠르게 웹사이트를 구현할 수 있는 재사용 가능한 컴포넌트 시스템
387 lines (338 loc) • 10.5 kB
text/typescript
import { CDNService, getCDNService } from "./cdn-service";
import { queueImageProcessing, getProcessingStatus } from "./image-worker";
export interface MediaUploadOptions {
enableCDN?: boolean;
enableProcessing?: boolean;
processingSizes?: string[];
processingFormats?: string[];
customMetadata?: Record<string, unknown>;
tags?: string[];
description?: string;
}
export interface MediaUploadResult {
id: string;
fileName: string;
originalName: string;
mimeType: string;
size: number;
url: string;
cdnUrl?: string;
thumbnailUrl?: string;
uploadedAt: Date;
processingJobId?: string;
metadata?: Record<string, unknown>;
tags?: string[];
description?: string;
}
export interface MediaFile {
id: string;
fileName: string;
originalName: string;
mimeType: string;
size: number;
url: string;
cdnUrl?: string;
thumbnailUrl?: string;
createdAt: string;
updatedAt: string;
downloadCount: number;
viewCount: number;
tags: string[];
description?: string;
dimensions?: {
width: number;
height: number;
};
metadata?: Record<string, unknown>;
processingStatus?: "pending" | "processing" | "completed" | "failed";
processingJobId?: string;
}
export class MediaService {
private cdnService: CDNService;
private uploadPath: string;
constructor() {
this.cdnService = getCDNService();
this.uploadPath = process.env.MEDIA_UPLOAD_PATH || "./uploads";
}
async uploadFile(
file: Buffer,
originalName: string,
mimeType: string,
options: MediaUploadOptions = {}
): Promise<MediaUploadResult> {
const fileId = this.generateFileId();
const fileName = this.generateFileName(originalName, fileId);
const fileKey = this.generateFileKey(fileName);
try {
// 로컬 저장소에 저장
const localUrl = await this.saveToLocal(file, fileName);
// CDN 업로드 (활성화된 경우)
let cdnUrl: string | undefined;
if (options.enableCDN !== false) {
try {
const cdnResult = await this.cdnService.upload(
file,
fileKey,
mimeType
);
cdnUrl = cdnResult.cdnUrl || cdnResult.url;
} catch (error) {
console.warn("CDN upload failed, using local storage:", error);
}
}
// 이미지 처리 큐에 추가 (이미지 파일인 경우)
let processingJobId: string | undefined;
if (options.enableProcessing !== false && mimeType.startsWith("image/")) {
try {
const jobId = await queueImageProcessing(fileId, localUrl, fileName, {
sizes: (options.processingSizes as
| ("thumbnail" | "small" | "medium" | "large" | "xlarge")[]
| undefined) || ["thumbnail", "small", "medium", "large"],
generateWebP: options.processingFormats?.includes("webp") || true,
generateAVIF: options.processingFormats?.includes("avif") || false,
quality: 85,
preserveMetadata: true,
optimize: true,
});
processingJobId = jobId;
} catch (error) {
console.warn("Image processing queue failed:", error);
}
}
// 메타데이터 저장
const mediaFile: MediaUploadResult = {
id: fileId,
fileName,
originalName,
mimeType,
size: file.length,
url: localUrl,
cdnUrl,
uploadedAt: new Date(),
processingJobId,
metadata: options.customMetadata,
tags: options.tags,
description: options.description,
};
await this.saveMetadata(mediaFile);
return mediaFile;
} catch (error) {
console.error("File upload failed:", error);
throw new Error(`파일 업로드 실패: ${error}`);
}
}
async getFile(fileId: string): Promise<MediaFile | null> {
try {
const metadata = await this.getMetadata(fileId);
if (!metadata) return null;
// 처리 상태 업데이트 (처리 중인 경우)
if (
metadata.processingJobId &&
metadata.processingStatus !== "completed"
) {
const processingStatus = await getProcessingStatus(
metadata.processingJobId
);
if (processingStatus) {
metadata.processingStatus = processingStatus.status;
await this.updateMetadata(fileId, {
processingStatus: processingStatus.status,
});
}
}
return metadata;
} catch (error) {
console.error("Failed to get file:", error);
return null;
}
}
async getFiles(
options: {
page?: number;
limit?: number;
mimeType?: string;
tags?: string[];
searchTerm?: string;
sortBy?: "name" | "date" | "size";
sortOrder?: "asc" | "desc";
} = {}
): Promise<{
files: MediaFile[];
total: number;
page: number;
limit: number;
totalPages: number;
}> {
// 실제 구현에서는 데이터베이스에서 조회
// 여기서는 Mock 데이터 반환
const mockFiles: MediaFile[] = [];
return {
files: mockFiles,
total: 0,
page: options.page || 1,
limit: options.limit || 20,
totalPages: 0,
};
}
async updateFile(
fileId: string,
updates: Partial<Pick<MediaFile, "tags" | "description" | "metadata">>
): Promise<MediaFile | null> {
try {
const existingFile = await this.getFile(fileId);
if (!existingFile) return null;
const updatedFile = {
...existingFile,
...updates,
updatedAt: new Date().toISOString(),
};
await this.updateMetadata(fileId, updatedFile);
// CDN에서 캐시 무효화 (메타데이터가 URL에 영향을 주는 경우)
if (existingFile.cdnUrl) {
try {
await this.cdnService.invalidate([
this.generateFileKey(existingFile.fileName),
]);
} catch (error) {
console.warn("CDN cache invalidation failed:", error);
}
}
return updatedFile;
} catch (error) {
console.error("Failed to update file:", error);
return null;
}
}
async deleteFile(fileId: string): Promise<boolean> {
try {
const file = await this.getFile(fileId);
if (!file) return false;
// 로컬 파일 삭제
await this.deleteFromLocal(file.fileName);
// CDN에서 삭제
if (file.cdnUrl) {
try {
await this.cdnService.delete(this.generateFileKey(file.fileName));
} catch (error) {
console.warn("CDN delete failed:", error);
}
}
// 메타데이터 삭제
await this.deleteMetadata(fileId);
return true;
} catch (error) {
console.error("Failed to delete file:", error);
return false;
}
}
async getFileUrl(
fileId: string,
options?: {
width?: number;
height?: number;
format?: string;
quality?: number;
preferCDN?: boolean;
}
): Promise<string | null> {
const file = await this.getFile(fileId);
if (!file) return null;
// CDN URL 사용 (가능한 경우)
if (options?.preferCDN !== false && file.cdnUrl) {
return this.cdnService.getUrl(
this.generateFileKey(file.fileName),
options
);
}
return file.url;
}
async incrementDownloadCount(fileId: string): Promise<void> {
const file = await this.getFile(fileId);
if (file) {
await this.updateMetadata(fileId, {
downloadCount: file.downloadCount + 1,
});
}
}
async incrementViewCount(fileId: string): Promise<void> {
const file = await this.getFile(fileId);
if (file) {
await this.updateMetadata(fileId, {
viewCount: file.viewCount + 1,
});
}
}
// CDN 통계 및 상태
async getCDNStats() {
return this.cdnService.checkHealth();
}
async refreshCDNCache(fileIds: string[]): Promise<boolean> {
try {
const keys = await Promise.all(
fileIds.map(async (fileId) => {
const file = await this.getFile(fileId);
return file ? this.generateFileKey(file.fileName) : null;
})
);
const validKeys = keys.filter((key): key is string => key !== null);
if (validKeys.length > 0) {
await this.cdnService.invalidate(validKeys);
}
return true;
} catch (error) {
console.error("Failed to refresh CDN cache:", error);
return false;
}
}
// 헬퍼 메서드들
private generateFileId(): string {
return Date.now().toString() + Math.random().toString(36).substr(2, 9);
}
private generateFileName(originalName: string, fileId: string): string {
const ext = originalName.split(".").pop();
const baseName = originalName.replace(/\.[^/.]+$/, "");
return `${fileId}_${baseName}.${ext}`;
}
private generateFileKey(fileName: string): string {
return `media/${new Date().getFullYear()}/${
new Date().getMonth() + 1
}/${fileName}`;
}
private async saveToLocal(file: Buffer, fileName: string): Promise<string> {
// 실제 구현에서는 파일 시스템에 저장
// 여기서는 Mock URL 반환
return `/uploads/${fileName}`;
}
private async saveMetadata(metadata: MediaUploadResult): Promise<void> {
// 실제 구현에서는 데이터베이스에 저장
console.log("Saving metadata:", metadata);
}
private async getMetadata(fileId: string): Promise<MediaFile | null> {
// 실제 구현에서는 데이터베이스에서 조회
console.log("Getting metadata for:", fileId);
return null;
}
private async updateMetadata(
fileId: string,
updates: Partial<MediaFile>
): Promise<void> {
// 실제 구현에서는 데이터베이스 업데이트
console.log("Updating metadata:", fileId, updates);
}
private async deleteMetadata(fileId: string): Promise<void> {
// 실제 구현에서는 데이터베이스에서 삭제
console.log("Deleting metadata:", fileId);
}
private async deleteFromLocal(fileName: string): Promise<void> {
// 실제 구현에서는 파일 시스템에서 삭제
console.log("Deleting local file:", fileName);
}
}
// 싱글톤 인스턴스
let mediaService: MediaService | null = null;
export function getMediaService(): MediaService {
if (!mediaService) {
mediaService = new MediaService();
}
return mediaService;
}