From 2dc02ac247bf044235ed73d2cb1d4755676cff82 Mon Sep 17 00:00:00 2001 From: DongilMin Date: Tue, 29 Jul 2025 21:11:18 +0900 Subject: [PATCH 01/26] Fix: Remove CORS Error --- src/app.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app.ts b/src/app.ts index 38c56b9..680ce69 100644 --- a/src/app.ts +++ b/src/app.ts @@ -49,14 +49,14 @@ const corsOptions = { const allowedOrigins = [ //수정1 - undefined 값 필터링 address, - 'http://54.180.254.48:3000', // HTTP로 수정 + 'http://54.180.254.48:3000', // HTTP로 수정 'https://54.180.254.48:3000', //'https://your-frontend-domain.com', // 실제 프론트엔드 도메인으로 변경 //'https://onairmate.vercel.app', // 예시 도메인 'http://localhost:3000', // 로컬 개발용 'http://localhost:3001', // 로컬 개발용 ].filter(Boolean); // undefined나 null 값 제거 - + console.log('배포 주소', address); console.log('연결 origin:', origin); From 4935061c15cfe8e704a267ce628079958420bc41 Mon Sep 17 00:00:00 2001 From: DongilMin Date: Thu, 7 Aug 2025 01:02:32 +0900 Subject: [PATCH 02/26] =?UTF-8?q?Fix:=20CORS=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app.ts b/src/app.ts index c44d4fc..f0c71bc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -37,6 +37,7 @@ const corsOptions = { origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void, ) { + console.log('연결 origin:', origin); // 개발 환경에서는 모든 origin 허용 if (process.env.NODE_ENV === 'development') { callback(null, true); @@ -48,6 +49,7 @@ const corsOptions = { const allowedOrigins = [ //수정1 address, + 'http://54.180.254.48:3000', 'https://54.180.254.48:3000', //'https://your-frontend-domain.com', // 실제 프론트엔드 도메인으로 변경 //'https://onairmate.vercel.app', // 예시 도메인 From 63b6ca0eb21af2745f0aaf57c8155961ad26ebcb Mon Sep 17 00:00:00 2001 From: DongilMin Date: Thu, 7 Aug 2025 02:11:51 +0900 Subject: [PATCH 03/26] =?UTF-8?q?fix:=20=EA=B2=BD=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/youtubeRoute.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/youtubeRoute.ts b/src/routes/youtubeRoute.ts index 8a35214..fb89441 100644 --- a/src/routes/youtubeRoute.ts +++ b/src/routes/youtubeRoute.ts @@ -153,6 +153,6 @@ router.get('/search', requireAuth, searchYoutubeVideos); * 500: * description: 서버 에러 */ -router.get('/:videoId', requireAuth, getYoutubeVideoDetail); +router.get('/videos/:videoId', requireAuth, getYoutubeVideoDetail); export default router; From 138f54378742ece8e1de955f791b1158034f6065 Mon Sep 17 00:00:00 2001 From: DongilMin Date: Fri, 8 Aug 2025 01:58:44 +0900 Subject: [PATCH 04/26] Fix: Remove Auto Migration --- .github/workflows/deploy.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b87c568..b9bbe8f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -121,10 +121,6 @@ jobs: # Prisma 클라이언트 생성 npx prisma generate - # 데이터베이스 마이그레이션 실행 - echo "데이터베이스 마이그레이션 실행 중..." - npx prisma migrate deploy - # TypeScript 빌드 npm run build From 3c522e4497c826e13eadad79f592dc2c9f22de43 Mon Sep 17 00:00:00 2001 From: DongilMin Date: Sat, 9 Aug 2025 14:18:24 +0900 Subject: [PATCH 05/26] Refacot: Ai-prompt --- src/services/aiSummaryService.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/services/aiSummaryService.ts b/src/services/aiSummaryService.ts index 7155fd3..372e68f 100644 --- a/src/services/aiSummaryService.ts +++ b/src/services/aiSummaryService.ts @@ -137,21 +137,21 @@ ${chatContent} 위 채팅 내용을 분석하여 다음 두 가지를 한국어로 작성해주세요: -1. 전체 대화 주제 요약 (3-5문장으로 핵심 내용 정리) +1. 전체 대화 주제 요약 (1문장으로 간결하게 핵심 내용 정리) 2. 대화의 전반적인 감정 분석 - - 반드시 다음 6가지 중 하나를 선택: 기쁨, 슬픔, 분노, 혐오, 공포, 놀람 - - 기쁨: 즐겁고 유쾌한 분위기 - - 슬픔: 우울하거나 아쉬운 분위기 - - 분노: 화나거나 짜증나는 분위기 - - 혐오: 불쾌하거나 거부감이 드는 분위기 - - 공포: 무섭거나 불안한 분위기 - - 놀람: 놀랍거나 신기하거나 충격적인 분위기 - - 형식: "감정이름 - 이유 설명" +- 전체 대화를 100%로 보고, 특정 감정에 해당하는 대화 내용의 문장 수 또는 단어 수 기준으로 비율 계산 +- 기준: 문장 수, 단어 수, 글자 수 중 하나를 고정(추천: 문장 수) +- 주요 감정 3~5개를 선정 +- 감정 예시: 행복/즐거움, 슬픔, 분노, 놀람, 감동, 공감, 공포, 좌절, 절망 +- 감정 카테고리는 대화 특성에 맞춰 조정 가능 +- 각 감정에 해당하는 대화 문장을 지정하고, 전체 대비 비율(%) 계산 +- 감정 합계는 반드시 100%가 되도록 조정 +- 감정 분류가 애매한 경우 가장 유사한 카테고리에 포함 응답은 반드시 다음 JSON 형식으로만 해주세요: { "topicSummary": "요약 내용", - "emotionAnalysis": "기쁨 - 대화 전반적으로 즐거운 분위기가 지배적이었습니다" + "emotionAnalysis": "기쁨 n%, 슬픔 m%, ..." }`; try { From 605a787a8624802c3a06d73eb18f8f9aa6ed474d Mon Sep 17 00:00:00 2001 From: DongilMin Date: Sat, 9 Aug 2025 14:30:40 +0900 Subject: [PATCH 06/26] =?UTF-8?q?Refact:=20AI=20PROMPT=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/aiSummaryService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/aiSummaryService.ts b/src/services/aiSummaryService.ts index 372e68f..6402fe4 100644 --- a/src/services/aiSummaryService.ts +++ b/src/services/aiSummaryService.ts @@ -137,7 +137,7 @@ ${chatContent} 위 채팅 내용을 분석하여 다음 두 가지를 한국어로 작성해주세요: -1. 전체 대화 주제 요약 (1문장으로 간결하게 핵심 내용 정리) +1. 전체 대화 주제 요약 (1문장으로 간결하게 핵심 내용 정리), 딱딱한 AI스러운 말투사용 대신 "대부분 짜증이 난거 같아요!", "많이 슬픈가 보네요.." 등 친근한 형식으로 작성. 2. 대화의 전반적인 감정 분석 - 전체 대화를 100%로 보고, 특정 감정에 해당하는 대화 내용의 문장 수 또는 단어 수 기준으로 비율 계산 - 기준: 문장 수, 단어 수, 글자 수 중 하나를 고정(추천: 문장 수) From da960217b75a47134925d0a40a4cea2b984d1bf8 Mon Sep 17 00:00:00 2001 From: DongilMin Date: Sat, 9 Aug 2025 14:39:11 +0900 Subject: [PATCH 07/26] =?UTF-8?q?Refact:=20=ED=94=84=EB=A1=AC=ED=94=84?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/aiSummaryService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/aiSummaryService.ts b/src/services/aiSummaryService.ts index 6402fe4..d305201 100644 --- a/src/services/aiSummaryService.ts +++ b/src/services/aiSummaryService.ts @@ -142,7 +142,7 @@ ${chatContent} - 전체 대화를 100%로 보고, 특정 감정에 해당하는 대화 내용의 문장 수 또는 단어 수 기준으로 비율 계산 - 기준: 문장 수, 단어 수, 글자 수 중 하나를 고정(추천: 문장 수) - 주요 감정 3~5개를 선정 -- 감정 예시: 행복/즐거움, 슬픔, 분노, 놀람, 감동, 공감, 공포, 좌절, 절망 +- 감정 예시: 행복, 즐거움, 슬픔, 분노, 놀람, 감동, 공감, 공포, 좌절, 절망, 두려움 etc.. - 감정 카테고리는 대화 특성에 맞춰 조정 가능 - 각 감정에 해당하는 대화 문장을 지정하고, 전체 대비 비율(%) 계산 - 감정 합계는 반드시 100%가 되도록 조정 @@ -151,7 +151,7 @@ ${chatContent} 응답은 반드시 다음 JSON 형식으로만 해주세요: { "topicSummary": "요약 내용", - "emotionAnalysis": "기쁨 n%, 슬픔 m%, ..." + "emotionAnalysis": "감정1 n%, 감정2 m%, 감정3 k% ..." }`; try { From c2c34b80ce64038b5b31b0def7bc4c310e51f145 Mon Sep 17 00:00:00 2001 From: DongilMin Date: Sat, 9 Aug 2025 14:42:24 +0900 Subject: [PATCH 08/26] Refact: Prompt-ai --- src/services/aiSummaryService.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/services/aiSummaryService.ts b/src/services/aiSummaryService.ts index d305201..14f3402 100644 --- a/src/services/aiSummaryService.ts +++ b/src/services/aiSummaryService.ts @@ -139,14 +139,12 @@ ${chatContent} 1. 전체 대화 주제 요약 (1문장으로 간결하게 핵심 내용 정리), 딱딱한 AI스러운 말투사용 대신 "대부분 짜증이 난거 같아요!", "많이 슬픈가 보네요.." 등 친근한 형식으로 작성. 2. 대화의 전반적인 감정 분석 -- 전체 대화를 100%로 보고, 특정 감정에 해당하는 대화 내용의 문장 수 또는 단어 수 기준으로 비율 계산 -- 기준: 문장 수, 단어 수, 글자 수 중 하나를 고정(추천: 문장 수) -- 주요 감정 3~5개를 선정 -- 감정 예시: 행복, 즐거움, 슬픔, 분노, 놀람, 감동, 공감, 공포, 좌절, 절망, 두려움 etc.. -- 감정 카테고리는 대화 특성에 맞춰 조정 가능 -- 각 감정에 해당하는 대화 문장을 지정하고, 전체 대비 비율(%) 계산 -- 감정 합계는 반드시 100%가 되도록 조정 -- 감정 분류가 애매한 경우 가장 유사한 카테고리에 포함 +- 전체 대화를 100%로 보고, 감정별 "문장 수" 기준으로 비율 계산(기준 고정). +- 주요 감정 3~5개를 선정하고, 높은 비율부터 내림차순으로 나열. +- 표준 감정 카테고리: 기쁨, 슬픔, 분노, 놀람, 감동, 공감, 공포, 좌절, 절망, 당황 + (필요 시 가장 가까운 카테고리로 매핑) +- 각 감정에 해당하는 대표 문장 1~2개를 지정하고, 전체 대비 비율(%) 계산. +- 백분율은 정수(%)로 반올림하되, 마지막 항목에서 합계가 정확히 100%가 되도록 보정. 응답은 반드시 다음 JSON 형식으로만 해주세요: { From 708db470a90149ad140a2e2cdc5698eddbdab754 Mon Sep 17 00:00:00 2001 From: DongilMin Date: Sat, 9 Aug 2025 14:55:14 +0900 Subject: [PATCH 09/26] Fix: Add Friend route --- src/app.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app.ts b/src/app.ts index 2037bd4..98029db 100644 --- a/src/app.ts +++ b/src/app.ts @@ -160,6 +160,7 @@ app.use('/api/rooms', roomRoutes); app.use('/api/chat/direct', chatDirectRoutes); app.use('/api/youtube', youtubeRoutes); // youtubeRecommendationRoute와 youtubeSearchRoute 병합 app.use('/api/shared-collections', sharedCollectionRoute); +app.use('/api/friends', friendRoutes); app.use('/api/ai', aiSummaryRoutes); // 404 에러 핸들링 From 3bee22e619fabe074e53174113ae7eb03c50e37d Mon Sep 17 00:00:00 2001 From: DongilMin Date: Sat, 9 Aug 2025 15:00:19 +0900 Subject: [PATCH 10/26] =?UTF-8?q?Prisma=20=ED=8C=8C=EC=9D=BC=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20DB=EC=99=80=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/schema.prisma | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2fcf15a..5852e58 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -76,15 +76,15 @@ model Friendship { } model UserBlock { - blockId Int @id @default(autoincrement()) @map("block_id") - blockerUserId Int @map("blocker_user_id") - blockedUserId Int @map("blocked_user_id") - blockedAt DateTime @default(now()) @map("blocked_at") - isActive Boolean @default(true) @map("is_active") - blocked User @relation("BlockedUser", fields: [blockedUserId], references: [userId], onDelete: Cascade) - blocker User @relation("BlockerUser", fields: [blockerUserId], references: [userId], onDelete: Cascade) - customReason String? - reportReasons UserBlockReason[] + blockId Int @id @default(autoincrement()) @map("block_id") + blockerUserId Int @map("blocker_user_id") + blockedUserId Int @map("blocked_user_id") + blockedAt DateTime @default(now()) @map("blocked_at") + isActive Boolean @default(true) @map("is_active") + customReason String? + reportReasons UserBlockReason[] + blocked User @relation("BlockedUser", fields: [blockedUserId], references: [userId], onDelete: Cascade) + blocker User @relation("BlockerUser", fields: [blockerUserId], references: [userId], onDelete: Cascade) @@unique([blockerUserId, blockedUserId], name: "unique_block") @@index([blockedUserId], map: "user_blocks_blocked_user_id_fkey") @@ -92,11 +92,12 @@ model UserBlock { } model UserBlockReason { - userBlockReasonId Int @id @default(autoincrement())@map("user_block_reason_id") - block UserBlock @relation(fields: [blockId], references: [blockId], onDelete: Cascade) - blockId Int @map("block_id") - reason reportReason + userBlockReasonId Int @id @default(autoincrement()) @map("user_block_reason_id") + blockId Int @map("block_id") + reason reportReason + block UserBlock @relation(fields: [blockId], references: [blockId], onDelete: Cascade) + @@index([blockId], map: "user_block_reasons_block_id_fkey") @@map("user_block_reasons") } @@ -121,7 +122,6 @@ model Room { messages RoomMessage[] participants RoomParticipant[] host User @relation("HostUser", fields: [hostId], references: [userId], onDelete: Cascade) - video YoutubeVideo @relation("VideoOnRoom", fields: [videoId], references: [videoId]) @@index([hostId], map: "rooms_host_id_fkey") @@index([videoId], map: "rooms_video_id_fkey") @@ -304,7 +304,6 @@ model YoutubeVideo { duration String? @db.VarChar(20) uploadedAt DateTime? @map("uploaded_at") createdAt DateTime @default(now()) @map("created_at") - rooms Room[] @relation("VideoOnRoom") @@map("youtube_videos") } @@ -392,4 +391,4 @@ enum reportReason { PROFANITY HATE ETC -} \ No newline at end of file +} From 047a26ab6dfe6eacf2a9dc2e3b4a688dcdeaa240 Mon Sep 17 00:00:00 2001 From: DongilMin Date: Sat, 9 Aug 2025 18:43:50 +0900 Subject: [PATCH 11/26] Feat: Add Redirection HTTP to HTTPS Nginx System --- README.md | 501 +++++++++++++------------------------------------ src/app.ts | 3 +- src/swagger.ts | 2 +- 3 files changed, 133 insertions(+), 373 deletions(-) diff --git a/README.md b/README.md index cb523a5..a35fce1 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,61 @@ -# ON-AIR-mate Backend - -## 소개 - +ON-AIR-mate Backend +소개 ON-AIR-mate Backend(node.js) 레포지토리입니다. +🚀 배포 정보 (운영 중) +🌐 프로덕션 서버 -## 🚀 배포 정보 (운영 중) +서버 URL: https://54.180.254.48 +Swagger URL: https://54.180.254.48/api-docs +헬스체크: https://54.180.254.48/health +상태: 🟢 ONLINE (24시간 운영) +보안: 🔒 HTTPS 활성화 (자체 서명 인증서) -### **🌐 프로덕션 서버** -- **서버 URL**: http://54.180.254.48:3000 -- **Swagger URL**: http://54.180.254.48:3000/api-docs -- **헬스체크**: http://54.180.254.48:3000/health -- **상태**: 🟢 **ONLINE** (24시간 운영) +☁️ AWS 인프라 -### **☁️ AWS 인프라** -- **EC2**: i-0a91a4de26d731d88 (t2.micro, Amazon Linux 2023) -- **RDS**: MySQL 8.0 (db.t3.micro) -- **리전**: ap-northeast-2 (서울) -- **보안그룹**: HTTP(3000), SSH(22) 오픈 +EC2: i-0a91a4de26d731d88 (t2.micro, Amazon Linux 2023) +RDS: MySQL 8.0 (db.t3.micro) +리전: ap-northeast-2 (서울) +보안그룹: HTTP(80), HTTPS(443), SSH(22) 오픈 +웹서버: Nginx (리버스 프록시) -### **🔄 배포 상태** -- **프로세스 관리**: PM2 -- **자동 재시작**: 활성화 -- **로그 로테이션**: 활성화 -- **GitHub Actions**: 자동 배포 설정 완료 +🔄 배포 상태 ---- +프로세스 관리: PM2 +자동 재시작: 활성화 +로그 로테이션: 활성화 +GitHub Actions: 자동 배포 설정 완료 +HTTPS: 자체 서명 SSL 인증서 적용 -## 🛠️ 기술 스택 -- **Language**: TypeScript -- **Runtime**: Node.js 20.x -- **Framework**: Express.js -- **ORM**: Prisma -- **Database**: MySQL 8.0 (AWS RDS) -- **Process Manager**: PM2 -- **CI/CD**: GitHub Actions +🛠️ 기술 스택 -### **개발 도구** -- **Linting**: ESLint -- **Formatting**: Prettier -- **API 문서**: Swagger UI +Language: TypeScript +Runtime: Node.js 20.x +Framework: Express.js +ORM: Prisma +Database: MySQL 8.0 (AWS RDS) +Process Manager: PM2 +Web Server: Nginx (리버스 프록시) +CI/CD: GitHub Actions +개발 도구 ---- +Linting: ESLint +Formatting: Prettier +API 문서: Swagger UI -## 🚀 로컬 개발 환경 설정 -### **1. 프로젝트 클론** - -```bash -# 레포지토리 클론 +🚀 로컬 개발 환경 설정 +1. 프로젝트 클론 +bash# 레포지토리 클론 git clone https://github.com/ON-AIR-mate/Node.js.git cd Node.js # 의존성 설치 npm install -``` - -**`.env` 파일 내용 (팀원별로 별도 공유):** -```env -# 서버 설정 +2. 환경 변수 설정 +.env 파일 내용 (팀원별로 별도 공유): +env# 서버 설정 PORT=3000 NODE_ENV=development @@ -80,56 +76,44 @@ AWS_REGION=ap-northeast-2 AWS_ACCESS_KEY_ID=your_dev_access_key AWS_SECRET_ACCESS_KEY=your_dev_secret_key S3_BUCKET_NAME=your_dev_bucket -``` - -### **3. 개발 서버 실행** - -```bash -# 개발 서버 시작 (자동 재시작) +3. 개발 서버 실행 +bash# 개발 서버 시작 (자동 재시작) npm run dev -``` - -**접속 URL:** -- **API 서버**: http://localhost:3000 -- **API 문서**: http://localhost:3000/api-docs -- **헬스체크**: http://localhost:3000/health +접속 URL: ---- +API 서버: http://localhost:3000 +API 문서: http://localhost:3000/api-docs +헬스체크: http://localhost:3000/health -## 🔄 배포 가이드 -### **🤖 자동 배포 (권장)** - -**GitHub Actions 사용 - main 브랜치 push 시 자동 배포** - -```bash -# 로컬에서 작업 +🔄 배포 가이드 +🤖 자동 배포 (권장) +GitHub Actions 사용 - main 브랜치 push 시 자동 배포 +bash# 로컬에서 작업 git add . git commit -m "[feat] 새로운 기능 추가" git push origin main # 🚀 자동으로 EC2에 배포됩니다! -``` - -**배포 프로세스:** -1. ✅ 코드 품질 검사 (ESLint) -2. ✅ TypeScript 빌드 -3. ✅ EC2 SSH 접속 -4. ✅ 코드 pull 및 의존성 설치 -5. ✅ 프로덕션 빌드 -6. ✅ PM2 재시작 -7. ✅ 헬스체크 확인 - -**GitHub Actions 설정:** -- `Settings` → `Secrets and variables` → `Actions`에서 설정 완료 -- `EC2_KEY`: SSH 키 파일 설정 완료 ✅ -- `EC2_HOST`: `54.180.254.48` ✅ -- `EC2_USER`: `ec2-user` ✅ - -### **👨‍💻 수동 배포 (비상시)** - -```bash -# EC2 접속 +배포 프로세스: + +✅ 코드 품질 검사 (ESLint) +✅ TypeScript 빌드 +✅ EC2 SSH 접속 +✅ 코드 pull 및 의존성 설치 +✅ 프로덕션 빌드 +✅ PM2 재시작 +✅ 헬스체크 확인 + +GitHub Actions 설정: + +Settings → Secrets and variables → Actions에서 설정 완료 +EC2_KEY: SSH 키 파일 설정 완료 ✅ +EC2_HOST: 54.180.254.48 ✅ +EC2_USER: ec2-user ✅ + +👨‍💻 수동 배포 (비상시) +bash# EC2 접속 ssh -i your-key.pem ec2-user@54.180.254.48 # 프로젝트 디렉토리 이동 @@ -148,26 +132,42 @@ pm2 restart onairmate-api # 배포 확인 pm2 status -curl http://localhost:3000/health -``` +curl https://localhost:3000/health + +🔒 HTTPS/SSL 설정 +Nginx 리버스 프록시 구성 ---- +Nginx: 80/443 포트 → Node.js 3000 포트로 프록시 +SSL: 자체 서명 인증서 사용 (개발/테스트 환경) +위치: /etc/nginx/conf.d/app.conf -## 🔧 개발 도구 설정 +인증서 관리 +bash# 인증서 위치 +/etc/nginx/ssl/certificate.crt +/etc/nginx/ssl/private.key -### **VS Code 자동 포맷팅 (권장)** +# Nginx 재시작 (설정 변경 시) +sudo systemctl restart nginx +향후 계획 -프로젝트에 VS Code 설정이 포함되어 있어 **파일 저장 시 자동 포맷팅**됩니다: + Let's Encrypt 무료 인증서 적용 (도메인 구매 후) + AWS Certificate Manager + ALB 고려 -1. **필수 확장 프로그램**: - - `esbenp.prettier-vscode` (Prettier) - - `dbaeumer.vscode-eslint` (ESLint) -2. 저장 시 자동 적용 ✨ -### **코드 품질 관리** +🔧 개발 도구 설정 +VS Code 자동 포맷팅 (권장) +프로젝트에 VS Code 설정이 포함되어 있어 파일 저장 시 자동 포맷팅됩니다: -```bash -# 🔥 포맷팅 + 린팅 (한번에) +필수 확장 프로그램: + +esbenp.prettier-vscode (Prettier) +dbaeumer.vscode-eslint (ESLint) + + +저장 시 자동 적용 ✨ + +코드 품질 관리 +bash# 🔥 포맷팅 + 린팅 (한번에) npm run format # ✅ 체크만 (수정하지 않음) @@ -180,17 +180,11 @@ npm run build npm run dev # 🎯 프로덕션 실행 -npm start -``` - ---- - -## 🗄️ 데이터베이스 - -### **Prisma 설정** +NODE_ENV=production npm start -```bash -# 스키마 변경 후 마이그레이션 +🗄️ 데이터베이스 +Prisma 설정 +bash# 스키마 변경 후 마이그레이션 npm run db:migrate # 수동 마이그레이션 @@ -201,23 +195,18 @@ npx prisma generate # 데이터베이스 브라우저 npx prisma studio -``` +MySQL 연결 정보 -### **MySQL 연결 정보** -- **호스트**: AWS RDS (서울 리전) -- **엔진**: MySQL 8.0 (db.t3.micro) -- **포트**: 3306 -- **데이터베이스**: onairmate -- **연결 풀**: 10개 연결 제한 +호스트: AWS RDS (서울 리전) +엔진: MySQL 8.0 (db.t3.micro) +포트: 3306 +데이터베이스: onairmate +연결 풀: 10개 연결 제한 ---- -## 🎯 운영 관리 - -### **PM2 명령어** - -```bash -# 상태 확인 +🎯 운영 관리 +PM2 명령어 +bash# 상태 확인 pm2 status # 로그 확인 (실시간) @@ -235,261 +224,33 @@ pm2 start onairmate-api # 메모리 사용량 상세 pm2 show onairmate-api -``` -### **시스템 모니터링** +# 환경변수와 함께 시작 +NODE_ENV=production pm2 start ecosystem.config.js +Nginx 관리 +bash# 상태 확인 +sudo systemctl status nginx -```bash -# 서버 리소스 확인 +# 설정 테스트 +sudo nginx -t + +# 재시작 +sudo systemctl restart nginx + +# 로그 확인 +sudo tail -f /var/log/nginx/access.log +sudo tail -f /var/log/nginx/error.log +시스템 모니터링 +bash# 서버 리소스 확인 htop # CPU/메모리 실시간 free -h # 메모리 사용량 df -h # 디스크 사용량 # 네트워크 확인 -sudo ss -tulpn | grep :3000 # 포트 상태 -netstat -an | grep :3000 # 연결 상태 +sudo ss -tulpn | grep :3000 # Node.js 포트 +sudo ss -tulpn | grep :443 # HTTPS 포트 +netstat -an | grep :443 # HTTPS 연결 상태 # 프로세스 확인 ps aux | grep node -ps aux --sort=-%mem | head # 메모리 사용량 상위 -``` - ---- - -## 📋 API 엔드포인트 - -### **현재 구현된 엔드포인트** -- **헬스체크**: `GET /health` - 서버 상태 확인 ✅ -- **API 문서**: `GET /api-docs` - Swagger UI ✅ -- **루트**: `GET /` - Hello World ✅ -- **인증 테스트**: `GET /protected` - JWT 토큰 테스트 ✅ - -### **개발 예정 엔드포인트** -- `POST /api/auth/login` - 로그인 -- `POST /api/auth/register` - 회원가입 -- `POST /api/auth/refresh` - 토큰 갱신 -- `GET /api/users/profile` - 프로필 조회 -- `PUT /api/users/profile` - 프로필 수정 -- `GET /api/rooms` - 방 목록 -- `POST /api/rooms` - 방 생성 - ---- - -## 🧪 테스트 - -### **테스트 종류** -```bash -# 단위 테스트 (예정) -npm test - -# 통합 테스트 (예정) -npm run test:integration - -# API 테스트 (수동) -curl http://54.180.254.48:3000/health -curl http://localhost:3000/api-docs -``` - -**자세한 테스트 가이드**: [TESTING.md](./TESTING.md) - ---- - -## 🆘 트러블슈팅 - -### **자주 발생하는 문제 (Quick Fix)** - -#### **1. 서버 접속 불가** -```bash -# 🔍 진단 -pm2 status -curl http://localhost:3000/health - -# 🛠️ 해결 -pm2 restart onairmate-api -``` - -#### **2. 포맷팅 오류** -```bash -# 🔍 진단 -npm run check - -# 🛠️ 해결 -npm run format -``` - -#### **3. 빌드 실패** -```bash -# 🔍 진단 -npm run build - -# 🛠️ 해결 -rm -rf node_modules package-lock.json -npm install -npm run build -``` - -#### **4. GitHub Actions 실패** -```bash -# 🔍 GitHub → Actions → 워크플로우 로그 확인 -# 🛠️ SSH 키 및 Secrets 설정 재확인 -``` - -**전체 트러블슈팅 가이드**: [TROUBLESHOOTING.md](./TROUBLESHOOTING.md) - ---- - -## 📋 코드 컨벤션 - -### **포맷팅 규칙** -- **들여쓰기**: space 2 -- **따옴표**: single quote -- **세미콜론**: 필수 -- **줄바꿈**: LF (Unix) - -### **TypeScript 규칙** -- **interface** 사용 권장 (type 대신) -- **explicit return type** 권장 -- **any 사용 금지** (필요시 unknown) - -### **브랜치 전략 (GitHub Flow)** -``` -main (자동 배포) - ├── feature/user-auth - ├── feature/room-management - ├── fix/cors-error - └── docs/api-documentation -``` - -### **커밋 메시지 규칙** -```bash -[feat] 사용자 인증 API 추가 -[fix] CORS 에러 수정 -[docs] API 문서 업데이트 -[refactor] 데이터베이스 연결 로직 개선 -[chore] 의존성 업데이트 -[test] 회원가입 테스트 추가 -``` - ---- - -## 📞 팀원 가이드 - -### **새 팀원 온보딩 체크리스트** - -- [ ] **1. 저장소 클론 및 설정** -```bash -git clone https://github.com/ON-AIR-mate/Node.js.git -cd Node.js -npm install -cp .env.example .env -``` - -- [ ] **2. 환경변수 설정** (팀 리더에게 요청) -- [ ] **3. 개발 서버 실행** (`npm run dev`) -- [ ] **4. API 문서 확인** (http://localhost:3000/api-docs) -- [ ] **5. 첫 커밋 테스트** (feature 브랜치에서) - -### **운영 서버 접근** (필요시) -- [ ] EC2 SSH 키 요청 -- [ ] AWS IAM 권한 요청 -- [ ] RDS 접근 권한 확인 -- [ ] PM2 명령어 교육 - -### **개발 워크플로우** -1. **이슈 생성** → **브랜치 생성** → **개발** → **PR** → **리뷰** → **머지** -2. **main 브랜치**: 자동 배포됨 (신중하게!) -3. **feature 브랜치**: PR로만 머지 - ---- - -## 📊 명령어 치트시트 - -| 용도 | 명령어 | 설명 | -|------|--------|------| -| **개발** | `npm install` | 의존성 설치 | -| | `npm run dev` | 개발 서버 실행 | -| | `npm run format` | 코드 포맷팅 + 린팅 | -| | `npm run build` | TypeScript 컴파일 | -| | `npm start` | 프로덕션 실행 | -| **DB** | `npm run db:migrate` | DB 마이그레이션 | -| | `npx prisma studio` | DB 브라우저 | -| | `npx prisma generate` | 클라이언트 재생성 | -| **운영** | `pm2 status` | PM2 상태 확인 | -| | `pm2 logs onairmate-api` | 로그 확인 | -| | `pm2 restart onairmate-api` | 서버 재시작 | -| | `pm2 monit` | 실시간 모니터링 | -| **시스템** | `htop` | 시스템 리소스 | -| | `free -h` | 메모리 사용량 | -| | `df -h` | 디스크 사용량 | - ---- - -## ⚠️ 중요 주의사항 - -### **🔐 보안** -- ❌ `.env` 파일 절대 커밋 금지 -- ❌ AWS 키 절대 하드코딩 금지 -- ✅ 민감한 정보는 GitHub Secrets 사용 -- ✅ JWT Secret 정기적 교체 - -### **🚀 배포** -- ⚡ main 브랜치 push = 자동 배포 -- ✅ 배포 전 반드시 로컬 테스트 -- ✅ 배포 후 헬스체크 확인 필수 -- 🔄 롤백 준비 (이전 커밋 해시 기록) - -### **💾 데이터베이스** -- 📋 마이그레이션 전 백업 -- ❌ 운영 DB 직접 수정 금지 -- 📢 스키마 변경 시 팀 전체 공유 -- 🔍 쿼리 성능 모니터링 - -### **🔧 개발** -- ✅ 커밋 전 `npm run format` 실행 -- ✅ PR 생성 전 빌드 확인 -- ✅ 코드 리뷰 필수 -- 📝 의미 있는 커밋 메시지 작성 - ---- - -## 🎉 현재 상태 요약 - -### **✅ 완료된 설정** -- [x] **AWS 인프라**: EC2 + RDS 운영 중 -- [x] **자동 배포**: GitHub Actions 설정 완료 -- [x] **프로세스 관리**: PM2 설정 완료 -- [x] **도메인 설정**: IP 기반 접근 가능 -- [x] **보안 설정**: SSH, 방화벽, JWT 설정 -- [x] **모니터링**: 로그, 헬스체크 설정 -- [x] **개발 환경**: TypeScript, ESLint, Prettier - -### **🚧 진행 중인 작업** -- [ ] **API 개발**: 사용자, 방, 인증 API -- [ ] **데이터베이스**: 스키마 최적화 -- [ ] **테스트**: 단위/통합 테스트 추가 -- [ ] **문서화**: API 문서 상세화 - -### **🎯 다음 단계** -1. **API 라우터 구현** (auth, users, rooms) -2. **데이터베이스 스키마 마이그레이션** -3. **프론트엔드 연동 테스트** -4. **성능 최적화 및 모니터링** - ---- - -## 📞 연락처 및 리소스 - -### **프로덕션 정보** -- ** 서버**: http://54.180.254.48:3000 -- ** 헬스체크**: http://54.180.254.48:3000/health -- ** 상태**: 🟢 **ONLINE** (24시간 운영) - -### **개발 리소스** -- **GitHub**: https://github.com/ON-AIR-mate/Node.js -- **AWS 콘솔**: ap-northeast-2 (서울) -- **Prisma 문서**: https://www.prisma.io/docs/ -- **PM2 문서**: https://pm2.keymetrics.io/docs/ - ---- - -## Ready to Code! +ps aux | grep nginx \ No newline at end of file diff --git a/src/app.ts b/src/app.ts index 98029db..f35ca9a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -66,8 +66,7 @@ const corsOptions = { const allowedOrigins = [ //수정1 address, - 'http://54.180.254.48:3000', - 'https://54.180.254.48:3000', + 'https://54.180.254.48', //'https://your-frontend-domain.com', // 실제 프론트엔드 도메인으로 변경 //'https://onairmate.vercel.app', // 예시 도메인 'http://localhost:3000', // 로컬 개발용 diff --git a/src/swagger.ts b/src/swagger.ts index d5798cb..f29b399 100644 --- a/src/swagger.ts +++ b/src/swagger.ts @@ -1,6 +1,6 @@ import swaggerJsdoc from 'swagger-jsdoc'; const hostUrl = - process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : 'http://54.180.254.48:3000'; // 환경변수로 관리 + process.env.NODE_ENV === 'development' ? 'https://localhost:3000' : 'https://54.180.254.48'; // 환경변수로 관리 const isProduction = process.env.NODE_ENV === 'production'; From c455ba4b20db1736437bfb3bfaf83c09b968dfe5 Mon Sep 17 00:00:00 2001 From: DongilMin Date: Sat, 9 Aug 2025 18:50:27 +0900 Subject: [PATCH 12/26] chore: Revert schema.prisma to upstream version --- prisma/schema.prisma | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5852e58..2fcf15a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -76,15 +76,15 @@ model Friendship { } model UserBlock { - blockId Int @id @default(autoincrement()) @map("block_id") - blockerUserId Int @map("blocker_user_id") - blockedUserId Int @map("blocked_user_id") - blockedAt DateTime @default(now()) @map("blocked_at") - isActive Boolean @default(true) @map("is_active") - customReason String? - reportReasons UserBlockReason[] - blocked User @relation("BlockedUser", fields: [blockedUserId], references: [userId], onDelete: Cascade) - blocker User @relation("BlockerUser", fields: [blockerUserId], references: [userId], onDelete: Cascade) + blockId Int @id @default(autoincrement()) @map("block_id") + blockerUserId Int @map("blocker_user_id") + blockedUserId Int @map("blocked_user_id") + blockedAt DateTime @default(now()) @map("blocked_at") + isActive Boolean @default(true) @map("is_active") + blocked User @relation("BlockedUser", fields: [blockedUserId], references: [userId], onDelete: Cascade) + blocker User @relation("BlockerUser", fields: [blockerUserId], references: [userId], onDelete: Cascade) + customReason String? + reportReasons UserBlockReason[] @@unique([blockerUserId, blockedUserId], name: "unique_block") @@index([blockedUserId], map: "user_blocks_blocked_user_id_fkey") @@ -92,12 +92,11 @@ model UserBlock { } model UserBlockReason { - userBlockReasonId Int @id @default(autoincrement()) @map("user_block_reason_id") - blockId Int @map("block_id") - reason reportReason - block UserBlock @relation(fields: [blockId], references: [blockId], onDelete: Cascade) + userBlockReasonId Int @id @default(autoincrement())@map("user_block_reason_id") + block UserBlock @relation(fields: [blockId], references: [blockId], onDelete: Cascade) + blockId Int @map("block_id") + reason reportReason - @@index([blockId], map: "user_block_reasons_block_id_fkey") @@map("user_block_reasons") } @@ -122,6 +121,7 @@ model Room { messages RoomMessage[] participants RoomParticipant[] host User @relation("HostUser", fields: [hostId], references: [userId], onDelete: Cascade) + video YoutubeVideo @relation("VideoOnRoom", fields: [videoId], references: [videoId]) @@index([hostId], map: "rooms_host_id_fkey") @@index([videoId], map: "rooms_video_id_fkey") @@ -304,6 +304,7 @@ model YoutubeVideo { duration String? @db.VarChar(20) uploadedAt DateTime? @map("uploaded_at") createdAt DateTime @default(now()) @map("created_at") + rooms Room[] @relation("VideoOnRoom") @@map("youtube_videos") } @@ -391,4 +392,4 @@ enum reportReason { PROFANITY HATE ETC -} +} \ No newline at end of file From cb992decf3cc6c4b7bde2fb2daceb2b43e606209 Mon Sep 17 00:00:00 2001 From: DongilMin Date: Sat, 9 Aug 2025 19:29:58 +0900 Subject: [PATCH 13/26] fix: Add HTTPS redirect middleware for production --- src/app.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/app.ts b/src/app.ts index f35ca9a..e5ec2b8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -48,6 +48,17 @@ redis.on('connect', () => { const port = process.env.PORT || 3000; const address = process.env.ADDRESS; +app.enable('trust proxy'); + +if (process.env.NODE_ENV === 'production') { + app.use((req: Request, res: Response, next: NextFunction) => { + if (req.header('x-forwarded-proto') !== 'https') { + return res.redirect(301, `https://${req.header('host')}${req.url}`); + } + next(); + }); +} + // CORS 설정 const corsOptions = { origin: function ( @@ -67,8 +78,7 @@ const corsOptions = { //수정1 address, 'https://54.180.254.48', - //'https://your-frontend-domain.com', // 실제 프론트엔드 도메인으로 변경 - //'https://onairmate.vercel.app', // 예시 도메인 + 'https://onairmate.duckdns.org', 'http://localhost:3000', // 로컬 개발용 'http://localhost:3001', // 로컬 개발용 'https://29d0611ca9f9.ngrok-free.app', // ✅ ngrok 주소 @@ -161,6 +171,8 @@ app.use('/api/youtube', youtubeRoutes); // youtubeRecommendationRoute와 youtube app.use('/api/shared-collections', sharedCollectionRoute); app.use('/api/friends', friendRoutes); app.use('/api/ai', aiSummaryRoutes); +app.use('/api/notifications', notificationRoutes); + // 404 에러 핸들링 app.use((req: Request, res: Response, next: NextFunction) => { From 89ac1469bc186172597e493a80050f83c4c3ae7a Mon Sep 17 00:00:00 2001 From: DongilMin Date: Sat, 9 Aug 2025 19:33:10 +0900 Subject: [PATCH 14/26] Formatting --- src/app.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app.ts b/src/app.ts index e5ec2b8..041efee 100644 --- a/src/app.ts +++ b/src/app.ts @@ -173,7 +173,6 @@ app.use('/api/friends', friendRoutes); app.use('/api/ai', aiSummaryRoutes); app.use('/api/notifications', notificationRoutes); - // 404 에러 핸들링 app.use((req: Request, res: Response, next: NextFunction) => { console.error('app.ts에서 404 에러 발생:', req.originalUrl); From b352403ac5339b8142c987f7a39de3190561dfba Mon Sep 17 00:00:00 2001 From: DongilMin Date: Sat, 9 Aug 2025 19:50:08 +0900 Subject: [PATCH 15/26] =?UTF-8?q?DOCS:=20=EC=84=9C=EB=B2=84=20URL=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a35fce1..6cf6602 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,10 @@ ON-AIR-mate Backend 소개 ON-AIR-mate Backend(node.js) 레포지토리입니다. -🚀 배포 정보 (운영 중) -🌐 프로덕션 서버 -서버 URL: https://54.180.254.48 -Swagger URL: https://54.180.254.48/api-docs -헬스체크: https://54.180.254.48/health +- 서버 URL: https://onairmate.duckdns.org/ +- Swagger URL: https://onairmate.duckdns.org/ +- 헬스체크: https://onairmate.duckdns.org/health 상태: 🟢 ONLINE (24시간 운영) 보안: 🔒 HTTPS 활성화 (자체 서명 인증서) From 4be270bbca69137e0149a002f096f74f05b88eac Mon Sep 17 00:00:00 2001 From: DongilMin Date: Mon, 11 Aug 2025 23:27:17 +0900 Subject: [PATCH 16/26] Fix: swagger route --- src/swagger.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/swagger.ts b/src/swagger.ts index 4ca2805..c5ac3e5 100644 --- a/src/swagger.ts +++ b/src/swagger.ts @@ -1,6 +1,8 @@ import swaggerJsdoc from 'swagger-jsdoc'; const hostUrl = - process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : 'https://54.180.254.48'; // 환경변수로 관리 + process.env.NODE_ENV === 'development' + ? 'http://localhost:3000' // 개발: HTTP + : 'https://onairmate.duckdns.org'; const isProduction = process.env.NODE_ENV === 'production'; From 54de74eca1fe28c5cc34403ac0312a31995618a9 Mon Sep 17 00:00:00 2001 From: DongilMin Date: Mon, 11 Aug 2025 23:43:21 +0900 Subject: [PATCH 17/26] =?UTF-8?q?Fix:=20Prisma=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EB=B6=88=EC=9D=BC=EC=B9=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/socket/chatHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/socket/chatHandler.ts b/src/socket/chatHandler.ts index b332053..fd565b9 100644 --- a/src/socket/chatHandler.ts +++ b/src/socket/chatHandler.ts @@ -11,7 +11,7 @@ import { chatMessageType, MessageType } from '../dtos/messageDto.js'; export default function chatHandler(io: Server, socket: Socket) { const user = socket.data.user; - const userId = user.id; + const userId = user.userId; console.log(`✅ 인증된 사용자 접속: ${user.nickname} (${userId}) , socketId: ${socket.id}`); /** * room 소캣 이벤트 From 674f04307d81f653b559ca15f22dbf9c74493b27 Mon Sep 17 00:00:00 2001 From: DongilMin Date: Tue, 12 Aug 2025 00:11:33 +0900 Subject: [PATCH 18/26] =?UTF-8?q?Refact:=20ReceiverId=EB=A5=BC=20=EA=B0=9D?= =?UTF-8?q?=EC=B2=B4=20=ED=83=80=EC=9E=85=EC=9C=BC=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/socket/chatHandler.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/socket/chatHandler.ts b/src/socket/chatHandler.ts index fd565b9..196f335 100644 --- a/src/socket/chatHandler.ts +++ b/src/socket/chatHandler.ts @@ -171,23 +171,25 @@ export default function chatHandler(io: Server, socket: Socket) { */ //1:1 DM 방 입장 - socket.on('joinDM', async (receiverId: number) => { + socket.on('joinDM', async (data: { receiverId: number }) => { try { + const { receiverId } = data; + if (!receiverId) { socket.emit('error', { type: 'joinDM', message: 'Required fields are missing.' }); return; } - + const dmRoom = await getOrCreateChatRoom(userId, receiverId); const dmId = dmRoom.chatId; - + socket.join(dmId.toString()); console.log('[Socket] entered dm:', dmId); - + console.log(`[Socket] ${userId}님이 ${dmId} dm 방에 입장`); socket.emit('success', { type: 'joinDM', message: 'DM 입장 성공' }); } catch (err) { - console.error('[Socket] joinDM error:', err); // 서버 로그 확인용 + console.error('[Socket] joinDM error:', err); socket.emit('error', { type: 'joinDM', message: 'dm 입장 실패' }); } }); From adac8f3377d436f871fd682ded0d5e07fd1e000d Mon Sep 17 00:00:00 2001 From: DongilMin Date: Tue, 12 Aug 2025 00:12:18 +0900 Subject: [PATCH 19/26] Refact: Formatting --- src/socket/chatHandler.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/socket/chatHandler.ts b/src/socket/chatHandler.ts index 196f335..005dd39 100644 --- a/src/socket/chatHandler.ts +++ b/src/socket/chatHandler.ts @@ -174,18 +174,18 @@ export default function chatHandler(io: Server, socket: Socket) { socket.on('joinDM', async (data: { receiverId: number }) => { try { const { receiverId } = data; - + if (!receiverId) { socket.emit('error', { type: 'joinDM', message: 'Required fields are missing.' }); return; } - + const dmRoom = await getOrCreateChatRoom(userId, receiverId); const dmId = dmRoom.chatId; - + socket.join(dmId.toString()); console.log('[Socket] entered dm:', dmId); - + console.log(`[Socket] ${userId}님이 ${dmId} dm 방에 입장`); socket.emit('success', { type: 'joinDM', message: 'DM 입장 성공' }); } catch (err) { From 8e50435a03b8703b430836a9800de5b3e9a7145d Mon Sep 17 00:00:00 2001 From: DongilMin Date: Tue, 12 Aug 2025 00:42:00 +0900 Subject: [PATCH 20/26] =?UTF-8?q?refact:=20=EC=86=8C=EC=BC=93=EC=9D=B4=20?= =?UTF-8?q?=EB=8D=AE=EC=96=B4=EC=93=B0=EA=B8=B0=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/socket/redisManager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/socket/redisManager.ts b/src/socket/redisManager.ts index c187ea6..9383641 100644 --- a/src/socket/redisManager.ts +++ b/src/socket/redisManager.ts @@ -36,8 +36,8 @@ export const joinRoom = async (roomId: number, userId: number, socketId: string) console.log('[Redis] joinRoom 이벤트 처리'); const saddRes1 = await redis.sadd(ROOM_PARTICIPANTS_KEY(roomId), userId); const incrRes = await redis.incr(ROOM_PARTICIPANTS_COUNT_KEY(roomId)); - const setRes1 = await redis.set(USER_SOCKET_KEY(userId), socketId, 'NX'); // NX = Only if not exists - const setRes2 = await redis.set(SOCKET_USER_KEY(socketId), userId.toString(), 'NX'); + const setRes1 = await redis.set(USER_SOCKET_KEY(userId), socketId); + const setRes2 = await redis.set(SOCKET_USER_KEY(socketId), userId.toString()); const saddRes2 = await redis.sadd(USER_ROOMS_KEY(userId), roomId.toString()); // 검증: sadd/incr 결과가 0이나 null이거나, set 결과가 null이면 실패로 판단 if (saddRes1 === 0 && incrRes <= 0) { From b077a6c4b1d0da94c7245c92d360dde38eacba4e Mon Sep 17 00:00:00 2001 From: DongilMin Date: Thu, 14 Aug 2025 18:54:27 +0900 Subject: [PATCH 21/26] refact: Remove Token in upload image --- src/controllers/userController.ts | 34 ++++++++++++++++--------------- src/routes/friendRoutes.ts | 1 + src/routes/userRoutes.ts | 2 +- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/controllers/userController.ts b/src/controllers/userController.ts index 5df4ae9..233304d 100644 --- a/src/controllers/userController.ts +++ b/src/controllers/userController.ts @@ -71,9 +71,10 @@ export const uploadProfileImageHandler = [ try { const userId = req.user?.userId; - if (!userId) { - throw new AppError('AUTH_007'); - } + // 토큰 검증 제거 - userId 체크를 하지 않음 + // if (!userId) { + // throw new AppError('AUTH_007'); + // } // multer-s3를 사용하면 req.file에 location과 key가 추가됩니다 const file = req.file as Express.MulterS3.File; @@ -85,21 +86,22 @@ export const uploadProfileImageHandler = [ // S3에 업로드된 파일의 URL const profileImageUrl = file.location; - // DB에 URL 저장 - 실패시 S3 파일 롤백 - try { - await userService.updateUserProfile(userId, { - profileImage: profileImageUrl, - }); - } catch (dbError) { - // S3에서 파일 삭제 시도 + // DB 업데이트는 userId가 있을 때만 수행 + if (userId) { try { - await deleteS3Object(file.location); - console.log(`DB 업데이트 실패로 S3 파일 삭제: ${file.key}`); - } catch (s3Error) { - console.error('S3 파일 삭제 실패:', s3Error); - // 삭제 실패해도 원래 에러를 전달 + await userService.updateUserProfile(userId, { + profileImage: profileImageUrl, + }); + } catch (dbError) { + // S3에서 파일 삭제 시도 + try { + await deleteS3Object(file.location); + console.log(`DB 업데이트 실패로 S3 파일 삭제: ${file.key}`); + } catch (s3Error) { + console.error('S3 파일 삭제 실패:', s3Error); + } + throw dbError; } - throw dbError; // DB 에러를 다시 던짐 } sendSuccess(res, { diff --git a/src/routes/friendRoutes.ts b/src/routes/friendRoutes.ts index 6bb7c82..f7d0295 100644 --- a/src/routes/friendRoutes.ts +++ b/src/routes/friendRoutes.ts @@ -146,6 +146,7 @@ router.post('/request', sendFriendRequest); * format: date-time * example: 2025-01-01T12:00:00Z */ +router.get('/request', getFriendRequests); router.get('/requests', getFriendRequests); /** diff --git a/src/routes/userRoutes.ts b/src/routes/userRoutes.ts index 01b0782..d62626b 100644 --- a/src/routes/userRoutes.ts +++ b/src/routes/userRoutes.ts @@ -111,7 +111,7 @@ router.get('/test', (req, res) => { * 401: * description: 인증 실패 */ -router.post('/profile/image', requireAuth, uploadProfileImageHandler); +router.post('/profile/image', uploadProfileImageHandler); router.get('/profile', requireAuth, getProfile); From 1dea919a866cc1c4dd547e7c87e9608c84b50707 Mon Sep 17 00:00:00 2001 From: DongilMin Date: Fri, 15 Aug 2025 01:13:10 +0900 Subject: [PATCH 22/26] fix: ai summary && friend request --- src/constants/errorCodes.ts | 4 ++- src/controllers/friendController.ts | 13 ++++++++ src/services/aiSummaryService.ts | 52 +++++++++++++---------------- src/services/friendServices.ts | 50 +++++++++++++++++++++++++-- 4 files changed, 88 insertions(+), 31 deletions(-) diff --git a/src/constants/errorCodes.ts b/src/constants/errorCodes.ts index 321a9b6..42cfd7a 100644 --- a/src/constants/errorCodes.ts +++ b/src/constants/errorCodes.ts @@ -29,9 +29,11 @@ export const ERROR_CODES = { FRIEND_003: { message: '자신에게는 친구 요청을 보낼 수 없습니다.', statusCode: 400 }, FRIEND_004: { message: '차단된 사용자입니다.', statusCode: 403 }, FRIEND_005: { message: '존재하지 않는 사용자입니다.', statusCode: 404 }, - FRIEND_006: { message: '친구 요청을 찾을 수 없습니다.', statusCode: 404 }, + FRIEND_006: { message: '친구 요청이 존재하지 않습니다.', statusCode: 404 }, FRIEND_007: { message: '친구가 아닙니다.', statusCode: 403 }, FRIEND_008: { message: '방에 초대 권한이 없습니다.', statusCode: 403 }, + FRIEND_009: { message: '이미 처리된 친구 요청입니다.', statusCode: 409 }, + FRIEND_010: { message: '상대방이 친구 요청을 거절했습니다.', statusCode: 409 }, // 컬렉션 관련 (COLLECTION_XXX) COLLECTION_001: { message: '컬렉션이 존재하지 않습니다.', statusCode: 404 }, diff --git a/src/controllers/friendController.ts b/src/controllers/friendController.ts index ac66dc1..ed8b585 100644 --- a/src/controllers/friendController.ts +++ b/src/controllers/friendController.ts @@ -77,16 +77,29 @@ export const handleFriendRequest = async (req: Request, res: Response, next: Nex } if (isNaN(requestId)) { + console.error(`[친구 요청 처리] 유효하지 않은 요청 ID: ${req.params.requestId}`); throw new AppError('GENERAL_001', '유효하지 않은 요청 ID입니다.'); } if (!action || !['ACCEPT', 'REJECT'].includes(action)) { + console.error(`[친구 요청 처리] 유효하지 않은 액션: ${action}`); throw new AppError('GENERAL_001', 'action은 ACCEPT 또는 REJECT여야 합니다.'); } const message = await friendService.handleFriendRequest(userId, requestId, action); sendSuccess(res, { message }); } catch (error) { + if (error instanceof AppError) { + if (error.errorCode === 'FRIEND_010') { + console.error( + `[친구 거절 409 에러] 사용자 ${req.user?.userId}가 이미 거절된 요청 ${req.params.requestId}를 다시 거절 시도`, + ); + } else if (error.errorCode === 'FRIEND_006') { + console.error(`[친구 요청 404 에러] 존재하지 않는 친구 요청 ID: ${req.params.requestId}`); + } else if (error.errorCode === 'FRIEND_009') { + console.error(`[친구 요청 409 에러] 이미 처리된 요청 ID: ${req.params.requestId}`); + } + } next(error); } }; diff --git a/src/services/aiSummaryService.ts b/src/services/aiSummaryService.ts index 14f3402..4a25667 100644 --- a/src/services/aiSummaryService.ts +++ b/src/services/aiSummaryService.ts @@ -127,8 +127,7 @@ export class AiSummaryService { chatContent: string, videoTitle: string, ): Promise { - const systemPrompt = `당신은 채팅 내용을 분석하고 요약하는 전문가입니다. -응답은 반드시 유효한 JSON 형식으로만 해주세요.`; + const systemPrompt = `당신은 채팅 내용을 분석하고 요약하는 전문가입니다.`; const userPrompt = `다음은 "${videoTitle}" 영상을 함께 시청하며 나눈 채팅 내용입니다. @@ -138,19 +137,17 @@ ${chatContent} 위 채팅 내용을 분석하여 다음 두 가지를 한국어로 작성해주세요: 1. 전체 대화 주제 요약 (1문장으로 간결하게 핵심 내용 정리), 딱딱한 AI스러운 말투사용 대신 "대부분 짜증이 난거 같아요!", "많이 슬픈가 보네요.." 등 친근한 형식으로 작성. + 2. 대화의 전반적인 감정 분석 - 전체 대화를 100%로 보고, 감정별 "문장 수" 기준으로 비율 계산(기준 고정). - 주요 감정 3~5개를 선정하고, 높은 비율부터 내림차순으로 나열. - 표준 감정 카테고리: 기쁨, 슬픔, 분노, 놀람, 감동, 공감, 공포, 좌절, 절망, 당황 - (필요 시 가장 가까운 카테고리로 매핑) - 각 감정에 해당하는 대표 문장 1~2개를 지정하고, 전체 대비 비율(%) 계산. - 백분율은 정수(%)로 반올림하되, 마지막 항목에서 합계가 정확히 100%가 되도록 보정. -응답은 반드시 다음 JSON 형식으로만 해주세요: -{ - "topicSummary": "요약 내용", - "emotionAnalysis": "감정1 n%, 감정2 m%, 감정3 k% ..." -}`; +응답 형식: +첫 번째 줄: 주제 요약 +두 번째 줄: 감정 분석 (감정1 n%, 감정2 m%, 감정3 k% 형식)`; try { const input: InvokeModelCommandInput = { @@ -170,30 +167,29 @@ ${chatContent} ], }), }; - const command = new InvokeModelCommand(input); const response: InvokeModelCommandOutput = await bedrockClient.send(command); - try { - const responseBody = JSON.parse(new TextDecoder().decode(response.body)) as ClaudeResponse; - const responseText = responseBody.content[0]?.text || '{}'; - const result = JSON.parse(responseText); - - if (!result.topicSummary || !result.emotionAnalysis) { - throw new Error('응답 형식 오류'); - } - - return { - topicSummary: result.topicSummary, - emotionAnalysis: result.emotionAnalysis, - }; - } catch (parseError) { - console.error('Claude 응답 파싱 실패:', parseError); - throw new AppError('GENERAL_004', 'AI 응답 파싱에 실패했습니다.'); + const responseBody = JSON.parse(new TextDecoder().decode(response.body)) as ClaudeResponse; + const responseText = responseBody.content[0]?.text || ''; + + // AI 응답을 줄바꿈으로 분리 + const lines = responseText + .trim() + .split('\n') + .filter(line => line.trim()); + + if (lines.length < 2) { + throw new Error('응답 형식 오류'); } - } catch (error) { - console.error('Claude 모델 호출 실패:', error); - throw new AppError('GENERAL_004', 'AI 요약 생성에 실패했습니다.'); + + return { + topicSummary: lines[0].trim(), + emotionAnalysis: lines[1].trim(), + }; + } catch (parseError) { + console.error('Claude 응답 파싱 실패:', parseError); + throw new AppError('GENERAL_004', 'AI 응답 파싱에 실패했습니다.'); } } diff --git a/src/services/friendServices.ts b/src/services/friendServices.ts index 6b57ee3..a9057e3 100644 --- a/src/services/friendServices.ts +++ b/src/services/friendServices.ts @@ -145,9 +145,20 @@ export const sendFriendRequest = async ( throw new AppError('FRIEND_001'); } else if (existingFriendship.status === 'pending') { throw new AppError('FRIEND_002'); + } else if (existingFriendship.status === 'rejected') { + // 거절된 요청이 있는 경우 기존 요청을 삭제하고 새로운 요청 생성 + await prisma.friendship.delete({ + where: { friendshipId: existingFriendship.friendshipId }, + }); } } + // 요청자 정보 가져오기 + const requester = await prisma.user.findUnique({ + where: { userId: requesterId }, + select: { nickname: true }, + }); + // 트랜잭션으로 친구 요청과 알림을 함께 생성 await prisma.$transaction(async tx => { await tx.friendship.create({ @@ -163,7 +174,8 @@ export const sendFriendRequest = async ( fromUserId: requesterId, toUserId: targetUserId, type: 'friendRequest', - title: '새로운 친구 요청이 있습니다.', + title: `${requester?.nickname}님이 친구 요청을 보냈습니다.`, // content → title + // isRead와 status는 기본값 사용 (status: 'unread') }, }); }); @@ -211,23 +223,52 @@ export const handleFriendRequest = async ( requestId: number, action: 'ACCEPT' | 'REJECT', ): Promise => { + console.log(`[친구 요청 처리] 사용자 ${userId}, 요청 ${requestId}, 액션: ${action}`); + // 친구 요청 확인 const request = await prisma.friendship.findUnique({ where: { friendshipId: requestId }, + include: { + requester: { + select: { + nickname: true, + }, + }, + receiver: { + select: { + nickname: true, + }, + }, + }, }); if (!request) { + console.error(`[친구 요청 처리 실패] 요청 ID ${requestId}가 존재하지 않음`); throw new AppError('FRIEND_006'); } // 본인에게 온 요청인지 확인 if (request.requestedTo !== userId) { + console.error(`[친구 요청 처리 실패] 사용자 ${userId}는 요청 ${requestId}에 대한 권한이 없음`); throw new AppError('GENERAL_002'); } // 이미 처리된 요청인지 확인 if (request.status !== 'pending') { - throw new AppError('GENERAL_001'); + console.warn(`[친구 요청 처리 실패] 요청 ${requestId}는 이미 ${request.status} 상태임`); + + if (request.status === 'rejected' && action === 'REJECT') { + // 이미 거절된 요청을 다시 거절하려는 경우 + console.log(`[친구 요청] ${request.receiver.nickname}님이 이미 거절된 요청을 다시 거절 시도`); + throw new AppError('FRIEND_010'); + } else if (request.status === 'accepted') { + // 이미 수락된 요청 + console.log(`[친구 요청] 이미 수락된 요청에 대한 처리 시도`); + throw new AppError('FRIEND_001'); + } else { + // 기타 이미 처리된 요청 + throw new AppError('FRIEND_009'); + } } // 요청 처리 @@ -242,6 +283,11 @@ export const handleFriendRequest = async ( }, }); + const actionMessage = action === 'ACCEPT' ? '수락' : '거절'; + console.log( + `[친구 요청 처리 성공] ${request.receiver.nickname}님이 ${request.requester.nickname}님의 친구 요청을 ${actionMessage}함`, + ); + return action === 'ACCEPT' ? '친구 요청을 수락했습니다.' : '친구 요청을 거절했습니다.'; }; From e390e8793d02bf252feca68a675d22890a37d2f3 Mon Sep 17 00:00:00 2001 From: DongilMin Date: Sat, 16 Aug 2025 15:58:38 +0900 Subject: [PATCH 23/26] =?UTF-8?q?feat:=20=EC=B9=9C=EA=B5=AC=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20AI=20=EC=9A=94=EC=95=BD=20=EA=B8=B0=EB=8A=A5=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/aiSummaryService.ts | 146 ++++++++++++++++++++++++------ src/services/friendServices.ts | 151 +++++++++++++++++++++---------- 2 files changed, 223 insertions(+), 74 deletions(-) diff --git a/src/services/aiSummaryService.ts b/src/services/aiSummaryService.ts index 4a25667..66b37c8 100644 --- a/src/services/aiSummaryService.ts +++ b/src/services/aiSummaryService.ts @@ -130,28 +130,28 @@ export class AiSummaryService { const systemPrompt = `당신은 채팅 내용을 분석하고 요약하는 전문가입니다.`; const userPrompt = `다음은 "${videoTitle}" 영상을 함께 시청하며 나눈 채팅 내용입니다. - -채팅 내용: -${chatContent} - -위 채팅 내용을 분석하여 다음 두 가지를 한국어로 작성해주세요: - -1. 전체 대화 주제 요약 (1문장으로 간결하게 핵심 내용 정리), 딱딱한 AI스러운 말투사용 대신 "대부분 짜증이 난거 같아요!", "많이 슬픈가 보네요.." 등 친근한 형식으로 작성. - -2. 대화의 전반적인 감정 분석 -- 전체 대화를 100%로 보고, 감정별 "문장 수" 기준으로 비율 계산(기준 고정). -- 주요 감정 3~5개를 선정하고, 높은 비율부터 내림차순으로 나열. -- 표준 감정 카테고리: 기쁨, 슬픔, 분노, 놀람, 감동, 공감, 공포, 좌절, 절망, 당황 -- 각 감정에 해당하는 대표 문장 1~2개를 지정하고, 전체 대비 비율(%) 계산. -- 백분율은 정수(%)로 반올림하되, 마지막 항목에서 합계가 정확히 100%가 되도록 보정. - -응답 형식: -첫 번째 줄: 주제 요약 -두 번째 줄: 감정 분석 (감정1 n%, 감정2 m%, 감정3 k% 형식)`; + + 채팅 내용: + ${chatContent} + + 위 채팅 내용을 분석하여 다음 두 가지를 한국어로 작성해주세요: + + 1. 전체 대화 주제 요약 (1문장으로 간결하게 핵심 내용 정리), 딱딱한 AI스러운 말투사용 대신 "대부분 짜증이 난거 같아요!", "많이 슬픈가 보네요.." 등 친근한 형식으로 작성. + + 2. 대화의 전반적인 감정 분석 + - 전체 대화를 100%로 보고, 감정별 "문장 수" 기준으로 비율 계산(기준 고정). + - 주요 감정 3~5개를 선정하고, 높은 비율부터 내림차순으로 나열. + - 표준 감정 카테고리: 기쁨, 슬픔, 분노, 놀람, 감동, 공감, 공포, 좌절, 절망, 당황 + - 각 감정에 해당하는 대표 문장 1~2개를 지정하고, 전체 대비 비율(%) 계산. + - 백분율은 정수(%)로 반올림하되, 마지막 항목에서 합계가 정확히 100%가 되도록 보정. + + 응답 형식: + 첫 번째 줄: 주제 요약 + 두 번째 줄: 감정 분석 (감정1 n%, 감정2 m%, 감정3 k% 형식)`; try { const input: InvokeModelCommandInput = { - modelId: BEDROCK_MODEL_ID, // Claude 3.5 Sonnet + modelId: BEDROCK_MODEL_ID, contentType: 'application/json', accept: 'application/json', body: JSON.stringify({ @@ -167,29 +167,119 @@ ${chatContent} ], }), }; + const command = new InvokeModelCommand(input); const response: InvokeModelCommandOutput = await bedrockClient.send(command); - const responseBody = JSON.parse(new TextDecoder().decode(response.body)) as ClaudeResponse; const responseText = responseBody.content[0]?.text || ''; - // AI 응답을 줄바꿈으로 분리 + // ===== 개선된 파싱 로직 시작 ===== + console.log('[AI Summary] 원본 응답:', responseText); // 디버깅용 로그 + + let topicSummary = ''; + let emotionAnalysis = ''; + + // 줄바꿈 기준 파싱 시도 const lines = responseText .trim() .split('\n') .filter(line => line.trim()); - if (lines.length < 2) { - throw new Error('응답 형식 오류'); + if (lines.length >= 2) { + // 정상적으로 2줄 이상 응답이 온 경우 + topicSummary = lines[0].trim(); + emotionAnalysis = lines[1].trim(); + } else if (lines.length === 1) { + // 한 줄로 응답이 온 경우 - 패턴으로 분리 시도 + const singleLine = lines[0]; + + // 감정 분석 패턴 찾기 (예: "기쁨 30%", "슬픔 20%" 등) + const emotionPattern = /[가-힣]+\s*\d+%/; + const emotionMatch = singleLine.match(emotionPattern); + + if (emotionMatch) { + // 감정 분석 부분의 시작 위치 찾기 + const emotionStartIndex = singleLine.indexOf(emotionMatch[0]); + + if (emotionStartIndex > 0) { + topicSummary = singleLine.substring(0, emotionStartIndex).trim(); + emotionAnalysis = singleLine.substring(emotionStartIndex).trim(); + } else { + // 감정 분석이 처음부터 시작하는 경우 + topicSummary = '채팅 내용에 대한 요약을 생성할 수 없습니다.'; + emotionAnalysis = singleLine; + } + } else { + // 감정 패턴을 찾을 수 없는 경우 + topicSummary = singleLine; + emotionAnalysis = '감정 분석 없음'; + } } + // 키워드 기반 파싱 (fallback) + if (!topicSummary || !emotionAnalysis || emotionAnalysis === '감정 분석 없음') { + // 전체 텍스트에서 감정 키워드와 퍼센트를 찾기 + const emotionKeywords = [ + '기쁨', + '슬픔', + '분노', + '놀람', + '감동', + '공감', + '공포', + '좌절', + '절망', + '당황', + ]; + const percentPattern = new RegExp(`(${emotionKeywords.join('|')})\\s*\\d+%`, 'g'); + const matches = responseText.match(percentPattern); + + if (matches && matches.length > 0) { + emotionAnalysis = matches.join(', '); + + // 감정 분석 부분을 제외한 나머지를 주제 요약으로 + let tempText = responseText; + matches.forEach(match => { + tempText = tempText.replace(match, ''); + }); + topicSummary = tempText.trim() || '영상을 시청하며 다양한 의견을 나누었습니다.'; + } + } + + // 최종 검증 및 기본값 설정 + if (!topicSummary || topicSummary.length < 5) { + topicSummary = '영상을 보며 즐거운 시간을 보냈네요!'; + console.warn('[AI Summary] 주제 요약 생성 실패, 기본값 사용'); + } + + if ( + !emotionAnalysis || + emotionAnalysis === '감정 분석 없음' || + !emotionAnalysis.includes('%') + ) { + emotionAnalysis = '공감 40%, 기쁨 30%, 놀람 20%, 기타 10%'; + console.warn('[AI Summary] 감정 분석 생성 실패, 기본값 사용'); + } + + // 최종 결과 검증 + console.log('[AI Summary] 파싱 결과:', { + topicSummary, + emotionAnalysis, + }); + + return { + topicSummary: topicSummary.substring(0, 200), // 최대 200자 제한 + emotionAnalysis: emotionAnalysis.substring(0, 200), // 최대 200자 제한 + }; + // ===== 개선된 파싱 로직 끝 ===== + } catch (error) { + console.error('[AI Summary] Claude 모델 호출 실패:', error); + + // AI 호출 실패 시 기본 응답 반환 return { - topicSummary: lines[0].trim(), - emotionAnalysis: lines[1].trim(), + topicSummary: '채팅 내용을 분석 중 오류가 발생했습니다.', + emotionAnalysis: '감정 분석을 수행할 수 없습니다.', }; - } catch (parseError) { - console.error('Claude 응답 파싱 실패:', parseError); - throw new AppError('GENERAL_004', 'AI 응답 파싱에 실패했습니다.'); } } diff --git a/src/services/friendServices.ts b/src/services/friendServices.ts index a9057e3..d5dc185 100644 --- a/src/services/friendServices.ts +++ b/src/services/friendServices.ts @@ -1,11 +1,10 @@ -// src/services/friendService.ts import { FriendshipStatus } from '@prisma/client'; import AppError from '../middleware/errors/AppError.js'; import { prisma } from '../lib/prisma.js'; import { getIO } from '../socket/index.js'; import redis from '../redis.js'; -import { USER_SOCKET_KEY } from '../socket/redisManager.js'; import { saveDirectMessage } from './messageServices.js'; +import { USER_SOCKET_KEY } from '../socket/redisManager.js'; // 타입 정의 interface Friend { @@ -49,7 +48,7 @@ interface FriendLounge { */ export const getFriendsList = async (userId: number): Promise => { try { - // 양방향 친구 관계 조회 (requestedBy 또는 requestedTo가 userId이고 status가 accepted인 경우) + // 양방향 친구 관계 조회 const friendships = await prisma.friendship.findMany({ where: { OR: [ @@ -77,21 +76,32 @@ export const getFriendsList = async (userId: number): Promise => { }, }); - // 친구 목록 생성 - const friends = friendships.map(friendship => { - const friend = friendship.requestedBy === userId ? friendship.receiver : friendship.requester; - return { - userId: friend.userId, - nickname: friend.nickname, - profileImage: friend.profileImage, - popularity: friend.popularity, - isOnline: null, // 실시간 온라인 상태는 추후 구현 - }; - }); + // 친구 목록 생성 및 온라인 상태 확인 + const friends = await Promise.all( + friendships.map(async friendship => { + const friend = + friendship.requestedBy === userId ? friendship.receiver : friendship.requester; + + // Redis에서 socketId 존재 여부로 온라인 상태 확인 + const socketId = await redis.get(USER_SOCKET_KEY(friend.userId)); + const isOnline = !!socketId; + + return { + userId: friend.userId, + nickname: friend.nickname, + profileImage: friend.profileImage, + popularity: friend.popularity, + isOnline: isOnline, + }; + }), + ); return friends; - } catch { - throw new AppError('GENERAL_005'); + } catch (err) { + if (err instanceof AppError) { + throw err; + } + throw new AppError('GENERAL_005', '친구 목록을 조회하는 중 오류가 발생했습니다.'); } }; @@ -111,7 +121,6 @@ export const sendFriendRequest = async ( const targetUser = await prisma.user.findUnique({ where: { userId: targetUserId }, }); - if (!targetUser) { throw new AppError('FRIEND_005'); } @@ -125,41 +134,95 @@ export const sendFriendRequest = async ( ], }, }); - if (isBlocked) { throw new AppError('FRIEND_004'); } - // 기존 친구 관계 확인 (양방향) - const existingFriendship = await prisma.friendship.findFirst({ + // 1. 내가 상대방에게 보낸 요청이 있는지 확인 + const myRequest = await prisma.friendship.findFirst({ where: { - OR: [ - { requestedBy: requesterId, requestedTo: targetUserId }, - { requestedBy: targetUserId, requestedTo: requesterId }, - ], + requestedBy: requesterId, + requestedTo: targetUserId, }, }); - if (existingFriendship) { - if (existingFriendship.status === 'accepted') { - throw new AppError('FRIEND_001'); - } else if (existingFriendship.status === 'pending') { - throw new AppError('FRIEND_002'); - } else if (existingFriendship.status === 'rejected') { - // 거절된 요청이 있는 경우 기존 요청을 삭제하고 새로운 요청 생성 + if (myRequest) { + if (myRequest.status === 'accepted') { + throw new AppError('FRIEND_001'); // 이미 친구입니다. + } else if (myRequest.status === 'pending') { + throw new AppError('FRIEND_002'); // 이미 친구 요청을 보냈습니다. + } else if (myRequest.status === 'rejected') { + // 내가 보냈다가 거절된 요청이 있는 경우, 기존 기록을 삭제하고 새로 요청 await prisma.friendship.delete({ - where: { friendshipId: existingFriendship.friendshipId }, + where: { friendshipId: myRequest.friendshipId }, }); } } - // 요청자 정보 가져오기 + // 2. 상대방이 나에게 보낸 요청이 있는지 확인 + const theirRequest = await prisma.friendship.findFirst({ + where: { + requestedBy: targetUserId, + requestedTo: requesterId, + status: 'pending', // 'pending' 상태인 요청만 확인 + }, + }); + + if (theirRequest) { + // 상대방이 이미 나에게 요청을 보낸 경우 -> 자동 수락 처리 + console.log(`[친구 요청] 양방향 요청 감지: ${requesterId} ↔ ${targetUserId}, 자동 수락 처리`); + + await prisma.$transaction(async tx => { + // 기존 요청을 'accepted'로 업데이트 + await tx.friendship.update({ + where: { friendshipId: theirRequest.friendshipId }, + data: { + status: 'accepted', + acceptedAt: new Date(), + isAccepted: true, + }, + }); + + // 양쪽 사용자 정보 조회 + const requester = await tx.user.findUnique({ + where: { userId: requesterId }, + select: { nickname: true }, + }); + const target = await tx.user.findUnique({ + where: { userId: targetUserId }, + select: { nickname: true }, + }); + + // 원래 요청자(targetUser)에게 알림 + await tx.notification.create({ + data: { + fromUserId: requesterId, + toUserId: targetUserId, + type: 'friendRequest', + title: `${requester?.nickname}님과 친구가 되었습니다!`, + }, + }); + + // 현재 요청자(requester)에게 알림 + await tx.notification.create({ + data: { + fromUserId: targetUserId, + toUserId: requesterId, + type: 'friendRequest', + title: `${target?.nickname}님과 친구가 되었습니다!`, + }, + }); + }); + + return; // 자동 수락이 완료 + } + + // 3. 위 조건에 해당하지 않는 경우, 새로운 친구 요청 생성 const requester = await prisma.user.findUnique({ where: { userId: requesterId }, select: { nickname: true }, }); - // 트랜잭션으로 친구 요청과 알림을 함께 생성 await prisma.$transaction(async tx => { await tx.friendship.create({ data: { @@ -174,8 +237,7 @@ export const sendFriendRequest = async ( fromUserId: requesterId, toUserId: targetUserId, type: 'friendRequest', - title: `${requester?.nickname}님이 친구 요청을 보냈습니다.`, // content → title - // isRead와 status는 기본값 사용 (status: 'unread') + title: `${requester?.nickname}님이 친구 요청을 보냈습니다.`, }, }); }); @@ -258,15 +320,12 @@ export const handleFriendRequest = async ( console.warn(`[친구 요청 처리 실패] 요청 ${requestId}는 이미 ${request.status} 상태임`); if (request.status === 'rejected' && action === 'REJECT') { - // 이미 거절된 요청을 다시 거절하려는 경우 console.log(`[친구 요청] ${request.receiver.nickname}님이 이미 거절된 요청을 다시 거절 시도`); throw new AppError('FRIEND_010'); } else if (request.status === 'accepted') { - // 이미 수락된 요청 console.log(`[친구 요청] 이미 수락된 요청에 대한 처리 시도`); throw new AppError('FRIEND_001'); } else { - // 기타 이미 처리된 요청 throw new AppError('FRIEND_009'); } } @@ -444,7 +503,7 @@ export const inviteFriendToRoom = async ( const result = await prisma.$transaction(async tx => { // 1. 알림 생성 - const notification = await tx.notification.create({ + await tx.notification.create({ data: { fromUserId: userId, toUserId: friendId, @@ -472,7 +531,7 @@ export const inviteFriendToRoom = async ( }, }); - return { notification, inviter, video }; + return { inviter, video }; }); let message; @@ -499,22 +558,22 @@ export const inviteFriendToRoom = async ( // Redis에서 친구의 socketId 찾기 const friendSocketId = await redis.get(USER_SOCKET_KEY(friendId)); - if (friendSocketId) { + if (friendSocketId && result.inviter) { // 특정 소켓으로 방 초대 알림 전송 io.to(friendSocketId).emit('receiveDirectMessage', { type: 'receiveDirectMessage', data: { - senderId: result.inviter!.userId, + senderId: result.inviter.userId, receiverId: friendId, content: `${room.roomName} 방에 초대했습니다.`, messageType: 'roomInvite', //('general','roomInvite','bookmarkShare') createdAt: message?.createdAt, roomInvite: { inviter: { - userId: result.inviter!.userId, - nickname: result.inviter!.nickname, - profileImage: result.inviter!.profileImage, + userId: result.inviter.userId, + nickname: result.inviter.nickname, + profileImage: result.inviter.profileImage, }, room: { roomId: room.roomId, From 360e1217ac7a5b59c123889a444080d40f176491 Mon Sep 17 00:00:00 2001 From: DongilMin Date: Sat, 16 Aug 2025 16:35:40 +0900 Subject: [PATCH 24/26] =?UTF-8?q?feat:=20ai,=20friend=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/aiSummaryService.ts | 3 +- src/services/friendServices.ts | 140 ++++++++++++++++--------------- 2 files changed, 73 insertions(+), 70 deletions(-) diff --git a/src/services/aiSummaryService.ts b/src/services/aiSummaryService.ts index 66b37c8..7f912a9 100644 --- a/src/services/aiSummaryService.ts +++ b/src/services/aiSummaryService.ts @@ -173,7 +173,6 @@ export class AiSummaryService { const responseBody = JSON.parse(new TextDecoder().decode(response.body)) as ClaudeResponse; const responseText = responseBody.content[0]?.text || ''; - // ===== 개선된 파싱 로직 시작 ===== console.log('[AI Summary] 원본 응답:', responseText); // 디버깅용 로그 let topicSummary = ''; @@ -271,7 +270,7 @@ export class AiSummaryService { topicSummary: topicSummary.substring(0, 200), // 최대 200자 제한 emotionAnalysis: emotionAnalysis.substring(0, 200), // 최대 200자 제한 }; - // ===== 개선된 파싱 로직 끝 ===== + } catch (error) { console.error('[AI Summary] Claude 모델 호출 실패:', error); diff --git a/src/services/friendServices.ts b/src/services/friendServices.ts index d5dc185..ce5d52a 100644 --- a/src/services/friendServices.ts +++ b/src/services/friendServices.ts @@ -4,7 +4,10 @@ import { prisma } from '../lib/prisma.js'; import { getIO } from '../socket/index.js'; import redis from '../redis.js'; import { saveDirectMessage } from './messageServices.js'; -import { USER_SOCKET_KEY } from '../socket/redisManager.js'; +import { roomInfoService } from '../services/roomInfoService.js'; + +const USER_STATUS_KEY = (userId: number) => `user:${userId}:status`; +const USER_SOCKET_KEY = (userId: number) => `user:${userId}:socketId`; // 타입 정의 interface Friend { @@ -44,11 +47,11 @@ interface FriendLounge { } /** - * 친구 목록 조회 + * 친구 목록 조회 (온라인 상태 포함) */ export const getFriendsList = async (userId: number): Promise => { try { - // 양방향 친구 관계 조회 + // 양방향 친구 관계 조회 (requestedBy 또는 requestedTo가 userId이고 status가 accepted인 경우) const friendships = await prisma.friendship.findMany({ where: { OR: [ @@ -76,37 +79,35 @@ export const getFriendsList = async (userId: number): Promise => { }, }); - // 친구 목록 생성 및 온라인 상태 확인 + // 친구 목록 생성 시 Redis에서 온라인 상태 확인 const friends = await Promise.all( friendships.map(async friendship => { const friend = friendship.requestedBy === userId ? friendship.receiver : friendship.requester; - // Redis에서 socketId 존재 여부로 온라인 상태 확인 - const socketId = await redis.get(USER_SOCKET_KEY(friend.userId)); - const isOnline = !!socketId; + // Redis에서 온라인 상태 확인 + const status = await redis.get(USER_STATUS_KEY(friend.userId)); + const isOnline = status === 'online'; return { userId: friend.userId, nickname: friend.nickname, profileImage: friend.profileImage, popularity: friend.popularity, - isOnline: isOnline, + isOnline: isOnline, // Redis에서 실시간 상태 반영 }; }), ); return friends; - } catch (err) { - if (err instanceof AppError) { - throw err; - } - throw new AppError('GENERAL_005', '친구 목록을 조회하는 중 오류가 발생했습니다.'); + } catch (error) { + console.error('친구 목록 조회 실패:', error); + throw new AppError('GENERAL_005'); } }; /** - * 친구 요청 전송 + * 친구 요청 전송 (양방향 요청 자동 수락 포함) */ export const sendFriendRequest = async ( requesterId: number, @@ -121,6 +122,7 @@ export const sendFriendRequest = async ( const targetUser = await prisma.user.findUnique({ where: { userId: targetUserId }, }); + if (!targetUser) { throw new AppError('FRIEND_005'); } @@ -134,11 +136,12 @@ export const sendFriendRequest = async ( ], }, }); + if (isBlocked) { throw new AppError('FRIEND_004'); } - // 1. 내가 상대방에게 보낸 요청이 있는지 확인 + // 1. 내가 상대방에게 보낸 요청 확인 const myRequest = await prisma.friendship.findFirst({ where: { requestedBy: requesterId, @@ -148,32 +151,32 @@ export const sendFriendRequest = async ( if (myRequest) { if (myRequest.status === 'accepted') { - throw new AppError('FRIEND_001'); // 이미 친구입니다. + throw new AppError('FRIEND_001'); // 이미 친구입니다 } else if (myRequest.status === 'pending') { - throw new AppError('FRIEND_002'); // 이미 친구 요청을 보냈습니다. + throw new AppError('FRIEND_002'); // 이미 친구 요청을 보냈습니다 } else if (myRequest.status === 'rejected') { - // 내가 보냈다가 거절된 요청이 있는 경우, 기존 기록을 삭제하고 새로 요청 + // 거절된 요청이 있는 경우 삭제하고 새로 생성 await prisma.friendship.delete({ where: { friendshipId: myRequest.friendshipId }, }); } } - // 2. 상대방이 나에게 보낸 요청이 있는지 확인 + // 2. 상대방이 나에게 보낸 요청 확인 const theirRequest = await prisma.friendship.findFirst({ where: { requestedBy: targetUserId, requestedTo: requesterId, - status: 'pending', // 'pending' 상태인 요청만 확인 + status: 'pending', }, }); if (theirRequest) { - // 상대방이 이미 나에게 요청을 보낸 경우 -> 자동 수락 처리 + // 상대방이 이미 나에게 요청을 보낸 경우 → 자동 수락! console.log(`[친구 요청] 양방향 요청 감지: ${requesterId} ↔ ${targetUserId}, 자동 수락 처리`); await prisma.$transaction(async tx => { - // 기존 요청을 'accepted'로 업데이트 + // 기존 요청을 수락 처리 await tx.friendship.update({ where: { friendshipId: theirRequest.friendshipId }, data: { @@ -183,17 +186,18 @@ export const sendFriendRequest = async ( }, }); - // 양쪽 사용자 정보 조회 + // 양쪽에 알림 생성 const requester = await tx.user.findUnique({ where: { userId: requesterId }, select: { nickname: true }, }); + const target = await tx.user.findUnique({ where: { userId: targetUserId }, select: { nickname: true }, }); - // 원래 요청자(targetUser)에게 알림 + // 원래 요청자(targetUserId)에게 알림 await tx.notification.create({ data: { fromUserId: requesterId, @@ -203,7 +207,7 @@ export const sendFriendRequest = async ( }, }); - // 현재 요청자(requester)에게 알림 + // 현재 요청자(requesterId)에게 알림 await tx.notification.create({ data: { fromUserId: targetUserId, @@ -214,10 +218,10 @@ export const sendFriendRequest = async ( }); }); - return; // 자동 수락이 완료 + return; // 자동 수락 완료, 함수 종료 } - // 3. 위 조건에 해당하지 않는 경우, 새로운 친구 요청 생성 + // 3. 새로운 친구 요청 생성 const requester = await prisma.user.findUnique({ where: { userId: requesterId }, select: { nickname: true }, @@ -267,6 +271,8 @@ export const getFriendRequests = async (userId: number): Promise ({ requestId: request.friendshipId, userId: request.requester.userId, @@ -304,6 +310,8 @@ export const handleFriendRequest = async ( }, }); + console.log('요청 상태 확인:', request); + if (!request) { console.error(`[친구 요청 처리 실패] 요청 ID ${requestId}가 존재하지 않음`); throw new AppError('FRIEND_006'); @@ -333,7 +341,7 @@ export const handleFriendRequest = async ( // 요청 처리 const newStatus: FriendshipStatus = action === 'ACCEPT' ? 'accepted' : 'rejected'; - await prisma.friendship.update({ + const fres = await prisma.friendship.update({ where: { friendshipId: requestId }, data: { status: newStatus, @@ -341,6 +349,7 @@ export const handleFriendRequest = async ( isAccepted: action === 'ACCEPT', }, }); + console.log('친구 요청 db 처리: ', fres); const actionMessage = action === 'ACCEPT' ? '수락' : '거절'; console.log( @@ -466,6 +475,17 @@ export const inviteFriendToRoom = async ( throw new AppError('ROOM_001'); } + // 방장 정보 조회 + const host = await prisma.user.findUnique({ + where: { userId: room?.hostId }, + select: { + userId: true, + nickname: true, + profileImage: true, + popularity: true, + }, + }); + // 초대 권한 확인 (inviteAuth가 'host'인 경우 방장만 가능) if (room.inviteAuth === 'host' && room.hostId !== userId) { throw new AppError('FRIEND_008'); @@ -503,7 +523,7 @@ export const inviteFriendToRoom = async ( const result = await prisma.$transaction(async tx => { // 1. 알림 생성 - await tx.notification.create({ + const notification = await tx.notification.create({ data: { fromUserId: userId, toUserId: friendId, @@ -523,32 +543,21 @@ export const inviteFriendToRoom = async ( }); // 3. 방의 영상 정보 조회 - const video = await tx.youtubeVideo.findUnique({ - where: { videoId: room.videoId }, - select: { - title: true, - thumbnail: true, - }, - }); + const video = await roomInfoService.getRoomInfoById(roomId); - return { inviter, video }; + return { notification, inviter, video }; }); let message; - // 4. 1:1 채팅 메시지로도 저장 + // 4. DM으로 초대 메시지 전송 try { message = await saveDirectMessage(userId, { receiverId: friendId, - content: JSON.stringify({ - roomId: room.roomId, - roomName: room.roomName, - videoTitle: result.video?.title || '', - message: `${room.roomName} 방에 초대했습니다.`, - }), + content: `${room.roomName} 방에 초대했습니다.`, type: 'roomInvite', }); } catch (error) { - console.error('초대 채팅 메시지 저장 실패:', error); + console.error('초대 메시지 저장 실패:', error); } // 5. Socket.IO로 실시간 알림 전송 @@ -557,36 +566,31 @@ export const inviteFriendToRoom = async ( // Redis에서 친구의 socketId 찾기 const friendSocketId = await redis.get(USER_SOCKET_KEY(friendId)); + console.log('친구 소캣: ', friendSocketId); - if (friendSocketId && result.inviter) { + if (friendSocketId) { // 특정 소켓으로 방 초대 알림 전송 - io.to(friendSocketId).emit('receiveDirectMessage', { type: 'receiveDirectMessage', data: { - senderId: result.inviter.userId, + messageId: message?.messageId, + senderId: result.inviter!.userId, receiverId: friendId, content: `${room.roomName} 방에 초대했습니다.`, - messageType: 'roomInvite', //('general','roomInvite','bookmarkShare') - createdAt: message?.createdAt, - roomInvite: { - inviter: { - userId: result.inviter.userId, - nickname: result.inviter.nickname, - profileImage: result.inviter.profileImage, - }, - room: { - roomId: room.roomId, - roomName: room.roomName, - currentParticipants: currentParticipants, - maxParticipants: room.maxParticipants, - isPrivate: !room.isPublic, - }, - video: { - title: result.video?.title || '', - thumbnail: result.video?.thumbnail || '', - }, - invitedAt: new Date().toISOString(), + messageType: 'roomInvite', + timestamp: message, + room: { + roomId: room.roomId, + roomTitle: room.roomName, + hostNickname: host?.nickname, + hostProfileImage: host?.profileImage, + hostPopularity: host?.popularity, + currentParticipants: room.currentParticipants, + maxParticipants: room.maxParticipants, + videoTitle: result.video?.videoTitle || '', + videoThumbnail: result.video?.videoThumbnail || '', + duration: result.video?.duration, + isPrivate: result.video?.isPrivate, }, }, }); From 8ff63a34906ab6101e5933cc62c5e85abb488a80 Mon Sep 17 00:00:00 2001 From: DongilMin Date: Sat, 16 Aug 2025 16:36:17 +0900 Subject: [PATCH 25/26] Formatting --- src/services/aiSummaryService.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/services/aiSummaryService.ts b/src/services/aiSummaryService.ts index 7f912a9..52ab9a2 100644 --- a/src/services/aiSummaryService.ts +++ b/src/services/aiSummaryService.ts @@ -270,7 +270,6 @@ export class AiSummaryService { topicSummary: topicSummary.substring(0, 200), // 최대 200자 제한 emotionAnalysis: emotionAnalysis.substring(0, 200), // 최대 200자 제한 }; - } catch (error) { console.error('[AI Summary] Claude 모델 호출 실패:', error); From 5bbc89f899805cb67d9a7dface3b7801e81d53ae Mon Sep 17 00:00:00 2001 From: DongilMin Date: Mon, 18 Aug 2025 22:04:19 +0900 Subject: [PATCH 26/26] Refact str to list --- src/dtos/aiSummaryDto.ts | 12 ++- src/routes/aiSummaryRoutes.ts | 14 +++- src/services/aiSummaryService.ts | 136 ++++++++++++++++--------------- 3 files changed, 91 insertions(+), 71 deletions(-) diff --git a/src/dtos/aiSummaryDto.ts b/src/dtos/aiSummaryDto.ts index f4b2b05..9b562db 100644 --- a/src/dtos/aiSummaryDto.ts +++ b/src/dtos/aiSummaryDto.ts @@ -5,6 +5,14 @@ export interface GenerateSummaryRequestDto { roomId: number; } +/** + * 감정 분석 항목 + */ +export interface EmotionItem { + emotion: string; // 감정 이름 (기쁨, 슬픔, 분노 등) + percentage: number; // 퍼센트 값 (0-100) +} + /** * AI 채팅 요약 응답 DTO */ @@ -13,7 +21,7 @@ export interface GenerateSummaryResponseDto { roomTitle: string; videoTitle: string; topicSummary: string; - emotionAnalysis: string; // "기쁨", "슬픔", "분노", "혐오", "공포", "놀람" 중 하나 + emotionAnalysis: EmotionItem[]; // string에서 배열로 변경 timestamp: string; } @@ -46,7 +54,7 @@ export interface ChatMessageFormatDto { */ export interface ClaudeResponseDto { topicSummary: string; - emotionAnalysis: string; // EmotionType + 설명 + emotionAnalysis: EmotionItem[]; // string에서 배열로 변경 } /** diff --git a/src/routes/aiSummaryRoutes.ts b/src/routes/aiSummaryRoutes.ts index 15e8888..d3ac203 100644 --- a/src/routes/aiSummaryRoutes.ts +++ b/src/routes/aiSummaryRoutes.ts @@ -52,9 +52,17 @@ const router = express.Router(); * type: string * example: 전체 대화 주제 요약 * emotionAnalysis: - * type: string - * description: 6가지 기본 감정 중 하나와 설명 (기쁨/슬픔/분노/혐오/공포/놀람) - * example: 기쁨 - 영상을 보며 즐거워하는 반응이 많았습니다 + * type: array + * description: 감정별 분석 결과 + * example: + * - emotion: "기쁨" + * percentage: 40 + * - emotion: "공감" + * percentage: 30 + * - emotion: "놀람" + * percentage: 20 + * - emotion: "슬픔" + * percentage: 10 * timestamp: * type: string * format: date-time diff --git a/src/services/aiSummaryService.ts b/src/services/aiSummaryService.ts index 52ab9a2..bee4859 100644 --- a/src/services/aiSummaryService.ts +++ b/src/services/aiSummaryService.ts @@ -12,6 +12,7 @@ import { GenerateSummaryResponseDto, ClaudeResponseDto, AISummaryFeedbackData, + EmotionItem, } from '../dtos/aiSummaryDto.js'; const BEDROCK_MODEL_ID = 'anthropic.claude-3-5-sonnet-20240620-v1:0'; @@ -172,103 +173,59 @@ export class AiSummaryService { const response: InvokeModelCommandOutput = await bedrockClient.send(command); const responseBody = JSON.parse(new TextDecoder().decode(response.body)) as ClaudeResponse; const responseText = responseBody.content[0]?.text || ''; - - console.log('[AI Summary] 원본 응답:', responseText); // 디버깅용 로그 + console.log('[AI Summary] 원본 응답:', responseText); let topicSummary = ''; - let emotionAnalysis = ''; + let emotionAnalysis: EmotionItem[] = []; - // 줄바꿈 기준 파싱 시도 + // 줄바꿈 기준 파싱 const lines = responseText .trim() .split('\n') .filter(line => line.trim()); if (lines.length >= 2) { - // 정상적으로 2줄 이상 응답이 온 경우 topicSummary = lines[0].trim(); - emotionAnalysis = lines[1].trim(); + const emotionText = lines[1].trim(); + + // 감정 텍스트를 배열로 파싱 + emotionAnalysis = this.parseEmotionText(emotionText); } else if (lines.length === 1) { - // 한 줄로 응답이 온 경우 - 패턴으로 분리 시도 + // 한 줄로 응답이 온 경우 처리 const singleLine = lines[0]; - - // 감정 분석 패턴 찾기 (예: "기쁨 30%", "슬픔 20%" 등) const emotionPattern = /[가-힣]+\s*\d+%/; const emotionMatch = singleLine.match(emotionPattern); if (emotionMatch) { - // 감정 분석 부분의 시작 위치 찾기 const emotionStartIndex = singleLine.indexOf(emotionMatch[0]); - if (emotionStartIndex > 0) { topicSummary = singleLine.substring(0, emotionStartIndex).trim(); - emotionAnalysis = singleLine.substring(emotionStartIndex).trim(); - } else { - // 감정 분석이 처음부터 시작하는 경우 - topicSummary = '채팅 내용에 대한 요약을 생성할 수 없습니다.'; - emotionAnalysis = singleLine; + const emotionText = singleLine.substring(emotionStartIndex).trim(); + emotionAnalysis = this.parseEmotionText(emotionText); } - } else { - // 감정 패턴을 찾을 수 없는 경우 - topicSummary = singleLine; - emotionAnalysis = '감정 분석 없음'; - } - } - - // 키워드 기반 파싱 (fallback) - if (!topicSummary || !emotionAnalysis || emotionAnalysis === '감정 분석 없음') { - // 전체 텍스트에서 감정 키워드와 퍼센트를 찾기 - const emotionKeywords = [ - '기쁨', - '슬픔', - '분노', - '놀람', - '감동', - '공감', - '공포', - '좌절', - '절망', - '당황', - ]; - const percentPattern = new RegExp(`(${emotionKeywords.join('|')})\\s*\\d+%`, 'g'); - const matches = responseText.match(percentPattern); - - if (matches && matches.length > 0) { - emotionAnalysis = matches.join(', '); - - // 감정 분석 부분을 제외한 나머지를 주제 요약으로 - let tempText = responseText; - matches.forEach(match => { - tempText = tempText.replace(match, ''); - }); - topicSummary = tempText.trim() || '영상을 시청하며 다양한 의견을 나누었습니다.'; } } - // 최종 검증 및 기본값 설정 + // 검증 및 기본값 설정 if (!topicSummary || topicSummary.length < 5) { topicSummary = '영상을 보며 즐거운 시간을 보냈네요!'; console.warn('[AI Summary] 주제 요약 생성 실패, 기본값 사용'); } - if ( - !emotionAnalysis || - emotionAnalysis === '감정 분석 없음' || - !emotionAnalysis.includes('%') - ) { - emotionAnalysis = '공감 40%, 기쁨 30%, 놀람 20%, 기타 10%'; + if (emotionAnalysis.length === 0) { + // 기본값 설정 + emotionAnalysis = [ + { emotion: '공감', percentage: 40 }, + { emotion: '기쁨', percentage: 30 }, + { emotion: '놀람', percentage: 20 }, + { emotion: '기타', percentage: 10 }, + ]; console.warn('[AI Summary] 감정 분석 생성 실패, 기본값 사용'); } - // 최종 결과 검증 - console.log('[AI Summary] 파싱 결과:', { - topicSummary, - emotionAnalysis, - }); - return { - topicSummary: topicSummary.substring(0, 200), // 최대 200자 제한 - emotionAnalysis: emotionAnalysis.substring(0, 200), // 최대 200자 제한 + topicSummary: topicSummary.substring(0, 200), + emotionAnalysis, }; } catch (error) { console.error('[AI Summary] Claude 모델 호출 실패:', error); @@ -276,11 +233,58 @@ export class AiSummaryService { // AI 호출 실패 시 기본 응답 반환 return { topicSummary: '채팅 내용을 분석 중 오류가 발생했습니다.', - emotionAnalysis: '감정 분석을 수행할 수 없습니다.', + emotionAnalysis: [{ emotion: '분석 불가', percentage: 100 }], }; } } + /** + * 감정 텍스트를 구조화된 배열로 파싱 + * "기쁨 30%, 슬픔 20%, 분노 10%" -> [{emotion: "기쁨", percentage: 30}, ...] + */ + private parseEmotionText(emotionText: string): EmotionItem[] { + const emotionItems: EmotionItem[] = []; + + // 감정 키워드 정의 + const emotionKeywords = [ + '기쁨', + '슬픔', + '분노', + '놀람', + '감동', + '공감', + '공포', + '좌절', + '절망', + '당황', + '기타', + ]; + + // 정규식으로 "감정명 숫자%" 패턴 찾기 + const pattern = new RegExp(`(${emotionKeywords.join('|')})\\s*(\\d+)%`, 'g'); + let match; + + while ((match = pattern.exec(emotionText)) !== null) { + emotionItems.push({ + emotion: match[1], + percentage: parseInt(match[2], 10), + }); + } + + // 백분율 합계 검증 및 보정 + const total = emotionItems.reduce((sum, item) => sum + item.percentage, 0); + if (total !== 100 && emotionItems.length > 0) { + // 마지막 항목으로 보정 + const diff = 100 - total; + emotionItems[emotionItems.length - 1].percentage += diff; + } + + // 내림차순 정렬 + emotionItems.sort((a, b) => b.percentage - a.percentage); + + return emotionItems; + } + /** * AI 요약에 대한 피드백을 저장합니다. * 기존 UserFeedback 테이블을 활용하여 JSON 형태로 저장합니다.