diff options
Diffstat (limited to 'lib/file-download-log')
| -rw-r--r-- | lib/file-download-log/service.ts | 353 | ||||
| -rw-r--r-- | lib/file-download-log/validation.ts | 37 |
2 files changed, 390 insertions, 0 deletions
diff --git a/lib/file-download-log/service.ts b/lib/file-download-log/service.ts new file mode 100644 index 00000000..b2350782 --- /dev/null +++ b/lib/file-download-log/service.ts @@ -0,0 +1,353 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { headers } from 'next/headers'; +import db from '@/db/db'; +import { fileDownloadLogs, userDownloadStats, rfqAttachments } from '@/db/schema'; +import { eq, and, gte, sql } from 'drizzle-orm'; +import { + createFileDownloadLogSchema, + type CreateFileDownloadLogInput, + type FileDownloadLog +} from './validation'; +import { z } from 'zod'; + +// 요청 정보 타입 +interface RequestInfo { + ip?: string; + userAgent?: string; + referer?: string; + sessionId?: string; +} + +// 헤더에서 요청 정보 추출 +async function getRequestInfo(): Promise<RequestInfo> { + const headersList = await headers(); + + return { + ip: headersList.get('x-forwarded-for')?.split(',')[0] || + headersList.get('x-real-ip') || + headersList.get('cf-connecting-ip') || // Cloudflare + undefined, + userAgent: headersList.get('user-agent') || undefined, + referer: headersList.get('referer') || undefined, + sessionId: headersList.get('x-session-id') || undefined, // 커스텀 세션 ID + }; + } + +// 파일 다운로드 로그 생성 (메인 서버 액션) +export async function createFileDownloadLog( + input: CreateFileDownloadLogInput + ): Promise<{ success: boolean; error?: string; logId?: number }> { + try { + const validatedInput = createFileDownloadLogSchema.parse(input); + + const session = await getServerSession(authOptions); + if (!session?.user) { + return { success: false, error: 'Unauthorized' }; + } + + const requestInfo = await getRequestInfo(); + + const fileInfo = validatedInput.fileInfo; + + + // 다운로드 로그 생성 + const [newLog] = await db + .insert(fileDownloadLogs) + .values({ + fileId: validatedInput.fileId, + userId: session.user.id, + userEmail: session.user.email || undefined, + userName: session.user.name || undefined, + userIP: requestInfo.ip, + userAgent: requestInfo.userAgent, + fileName: fileInfo?.fileName, + filePath: fileInfo?.filePath, + fileSize: fileInfo?.fileSize, + success: validatedInput.success, + errorMessage: validatedInput.errorMessage, + sessionId: requestInfo.sessionId, + requestId: validatedInput.requestId, + referer: requestInfo.referer, + downloadDurationMs: validatedInput.downloadDurationMs, + downloadedAt: new Date(), + }) + .returning({ id: fileDownloadLogs.id }); + + // 성공한 다운로드인 경우 통계 업데이트 + if (validatedInput.success && fileInfo?.fileSize) { + await updateUserDownloadStats( + session.user.id, + fileInfo.fileSize, + validatedInput.fileId + ); + } + + return { success: true, logId: newLog.id }; + + } catch (error) { + console.error('파일 다운로드 로그 생성 실패:', error); + + if (error instanceof z.ZodError) { + return { + success: false, + error: `입력값 검증 실패: ${error.errors.map(e => e.message).join(', ')}` + }; + } + + return { success: false, error: '로그 생성 중 오류가 발생했습니다.' }; + } + } +// 사용자 다운로드 통계 업데이트 +async function updateUserDownloadStats( + userId: string, + fileSize: number, + fileId: number +): Promise<void> { + try { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // 오늘 날짜의 통계가 있는지 확인 + const [existingStats] = await db + .select() + .from(userDownloadStats) + .where( + and( + eq(userDownloadStats.userId, userId), + gte(userDownloadStats.date, today) + ) + ) + .limit(1); + + if (existingStats) { + // 기존 통계 업데이트 + await db + .update(userDownloadStats) + .set({ + totalDownloads: sql`${userDownloadStats.totalDownloads} + 1`, + totalBytes: sql`${userDownloadStats.totalBytes} + ${fileSize}`, + lastDownloadAt: new Date(), + // uniqueFiles는 별도 로직으로 계산 (복잡하므로 생략) + }) + .where(eq(userDownloadStats.id, existingStats.id)); + } else { + // 새 통계 생성 + await db.insert(userDownloadStats).values({ + userId, + date: today, + totalDownloads: 1, + totalBytes: fileSize, + uniqueFiles: 1, + lastDownloadAt: new Date(), + }); + } + } catch (error) { + console.error('다운로드 통계 업데이트 실패:', error); + // 통계 실패가 메인 기능에 영향을 주지 않도록 에러를 던지지 않음 + } +} + +// 사용자 다운로드 이력 조회 +export async function getUserDownloadHistory( + page: number = 1, + limit: number = 20 +): Promise<{ + logs: FileDownloadLog[], + total: number, + hasMore: boolean +}> { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + throw new Error('Unauthorized'); + } + + const offset = (page - 1) * limit; + + // 총 개수 조회 + const [{ count }] = await db + .select({ count: sql<number>`count(*)` }) + .from(fileDownloadLogs) + .where(eq(fileDownloadLogs.userId, session.user.id)); + + // 로그 조회 + const logs = await db + .select() + .from(fileDownloadLogs) + .where(eq(fileDownloadLogs.userId, session.user.id)) + .orderBy(sql`${fileDownloadLogs.downloadedAt} DESC`) + .limit(limit) + .offset(offset); + + return { + logs, + total: count, + hasMore: offset + logs.length < count, + }; + + } catch (error) { + console.error('다운로드 이력 조회 실패:', error); + throw new Error('다운로드 이력을 가져올 수 없습니다.'); + } +} + +// 사용자 다운로드 통계 조회 +export async function getUserDownloadStats(days: number = 30): Promise<{ + totalDownloads: number; + totalBytes: number; + uniqueFiles: number; + dailyStats: Array<{ + date: string; + downloads: number; + bytes: number; + }>; +}> { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + throw new Error('Unauthorized'); + } + + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + + // 기간별 통계 조회 + const dailyStats = await db + .select({ + date: userDownloadStats.date, + downloads: userDownloadStats.totalDownloads, + bytes: userDownloadStats.totalBytes, + }) + .from(userDownloadStats) + .where( + and( + eq(userDownloadStats.userId, session.user.id), + gte(userDownloadStats.date, startDate) + ) + ) + .orderBy(userDownloadStats.date); + + // 총합 계산 + const totals = dailyStats.reduce( + (acc, stat) => ({ + totalDownloads: acc.totalDownloads + stat.downloads, + totalBytes: acc.totalBytes + stat.bytes, + }), + { totalDownloads: 0, totalBytes: 0 } + ); + + // 고유 파일 수 계산 (기간 내) + const [{ uniqueFiles }] = await db + .select({ + uniqueFiles: sql<number>`count(distinct ${fileDownloadLogs.fileId})` + }) + .from(fileDownloadLogs) + .where( + and( + eq(fileDownloadLogs.userId, session.user.id), + eq(fileDownloadLogs.success, true), + gte(fileDownloadLogs.downloadedAt, startDate) + ) + ); + + return { + ...totals, + uniqueFiles: uniqueFiles || 0, + dailyStats: dailyStats.map(stat => ({ + date: stat.date.toISOString().split('T')[0], + downloads: stat.downloads, + bytes: stat.bytes, + })), + }; + + } catch (error) { + console.error('다운로드 통계 조회 실패:', error); + throw new Error('다운로드 통계를 가져올 수 없습니다.'); + } +} + +// 관리자용: 전체 다운로드 로그 조회 (권한 확인 필요) +export async function getAllDownloadLogs( + page: number = 1, + limit: number = 50, + filters?: { + userId?: string; + success?: boolean; + startDate?: Date; + endDate?: Date; + } +): Promise<{ + logs: FileDownloadLog[], + total: number, + hasMore: boolean +}> { + try { + const session = await getServerSession(authOptions); + if (!session?.user || !session.user.roles.includes('admin') ) { + throw new Error('관리자 권한이 필요합니다.'); + } + + const offset = (page - 1) * limit; + + // 필터 조건 구성 + const conditions = []; + if (filters?.userId) { + conditions.push(eq(fileDownloadLogs.userId, filters.userId)); + } + if (filters?.success !== undefined) { + conditions.push(eq(fileDownloadLogs.success, filters.success)); + } + if (filters?.startDate) { + conditions.push(gte(fileDownloadLogs.downloadedAt, filters.startDate)); + } + if (filters?.endDate) { + conditions.push(sql`${fileDownloadLogs.downloadedAt} <= ${filters.endDate}`); + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + // 총 개수 조회 + const [{ count }] = await db + .select({ count: sql<number>`count(*)` }) + .from(fileDownloadLogs) + .where(whereClause); + + // 로그 조회 + const logs = await db + .select() + .from(fileDownloadLogs) + .where(whereClause) + .orderBy(sql`${fileDownloadLogs.downloadedAt} DESC`) + .limit(limit) + .offset(offset); + + return { + logs, + total: count, + hasMore: offset + logs.length < count, + }; + + } catch (error) { + console.error('전체 다운로드 로그 조회 실패:', error); + throw new Error('다운로드 로그를 가져올 수 없습니다.'); + } +} + +// 편의 함수: 간단한 로그 생성 +export async function logDownloadAttempt( + fileId: number, + success: boolean, + errorMessage?: string, + requestId?: string +): Promise<void> { + await createFileDownloadLog({ + fileId, + success, + errorMessage, + requestId, + }); +}
\ No newline at end of file diff --git a/lib/file-download-log/validation.ts b/lib/file-download-log/validation.ts new file mode 100644 index 00000000..bf888e9c --- /dev/null +++ b/lib/file-download-log/validation.ts @@ -0,0 +1,37 @@ +import { fileDownloadLogs } from "@/db/schema"; +import { createInsertSchema, createSelectSchema } from "drizzle-zod"; +import { z } from 'zod'; + +// Zod 스키마 +export const fileDownloadLogInsertSchema = createInsertSchema(fileDownloadLogs); +export const fileDownloadLogSelectSchema = createSelectSchema(fileDownloadLogs); + +export type FileDownloadLog = typeof fileDownloadLogs.$inferSelect; +export type NewFileDownloadLog = typeof fileDownloadLogs.$inferInsert; + +// validation.ts에 추가할 타입 +interface FileInfo { + fileName?: string; + filePath?: string; + fileSize?: number; + } + + // 수정된 입력 스키마 +export const createFileDownloadLogSchema = z.object({ + fileId: z.number(), + success: z.boolean(), + errorMessage: z.string().optional(), + requestId: z.string().optional(), + downloadDurationMs: z.number().optional(), + // 파일 정보를 직접 받을 수 있도록 추가 + fileInfo: z.object({ + fileName: z.string().optional(), + filePath: z.string().optional(), + fileSize: z.number().optional(), + }).optional(), + // 또는 다른 테이블에서 조회할 수 있도록 + fileSource: z.enum(['rfqAttachments', 'documents', 'uploads']).optional(), + }); + + +export type CreateFileDownloadLogInput = z.infer<typeof createFileDownloadLogSchema>; |
