diff --git a/docs/20260311_푸시알림리스트구현.md b/docs/20260311_푸시알림리스트구현.md new file mode 100644 index 00000000..fe778f95 --- /dev/null +++ b/docs/20260311_푸시알림리스트구현.md @@ -0,0 +1,179 @@ +- [x] 요구사항 확정: 푸시 발송 내용을 알림 리스트에 적재하고, 미수신 상황에서도 조회 가능하도록 범위를 고정한다. +- [x] 도메인 모델 설계: 알림 본문/발송자 스냅샷/카테고리/딥링크/언어코드/수신자 청크(JSON 배열) 저장 구조를 JPA 엔티티로 정의한다. +- [x] 푸시 적재 로직 구현: 수신자가 없으면 저장하지 않고, 언어별 데이터로 분리 저장하며 수신자 ID를 청크 단위(JSON 배열)로 기록한다. +- [x] 조회 기간 제한 구현: 알림 조회는 최근 1개월 데이터만 조회하도록 서비스/리포지토리에 공통 조건을 적용한다. +- [x] API 구현: 인증 사용자 기준 알림 목록 조회 API(전체/카테고리별)와 알림 존재 카테고리 조회 API를 구현한다. +- [x] 카테고리 다국어 응답 구현: 카테고리 조회 API 응답을 현재 기기 언어(ko/en/ja) 라벨로 반환한다. +- [x] 페이징 구현: Pageable 파라미터를 사용해 offset/limit 기반 조회를 적용한다. +- [x] 시간 포맷 구현: 발송시간을 UTC 기반 String으로 응답 DTO에 포함한다. +- [x] TDD 구현: 스프링 컨테이너 없이 실행 가능한 단위 테스트를 먼저 작성하고, 구현 후 테스트를 통과시킨다. +- [x] SQL 문서화: 신규 테이블 생성 SQL 및 추가 인덱스 SQL(MySQL, TIMESTAMP NOT NULL)을 문서 하단에 기록한다. + +## API 상세 작업 계획 + +### 1) GET `/push/notification/list` +- 목적: 인증 사용자의 알림 리스트를 현재 기기 언어 기준으로 조회한다. +- 요청 파라미터: + - `page`, `size`, `sort` (Pageable) + - `category` (선택, 없으면 전체 조회) +- 처리 규칙: + - 인증 사용자(`Member?`) null이면 `SodaException(messageKey = "common.error.bad_credentials")` + - 현재 요청 언어(`LangContext.lang.code`)와 일치하는 알림만 조회 + - 조회 범위는 `now(UTC) - 1개월` 이후 데이터만 허용 + - `category` 미지정 시 전체 카테고리 조회 + - `category`는 코드(`live`) 또는 다국어 라벨(`라이브`/`Live`/`ライブ`) 입력을 허용한다 + - `category`가 `전체`/`All`/`すべて`이면 전체 카테고리 조회로 처리한다 + - 수신자 청크(JSON 배열)에 인증 사용자 ID가 포함된 알림만 조회 +- 응답 항목: + - 발송자 스냅샷(닉네임, 프로필 이미지) + - 발송 메시지 + - 카테고리 + - 딥링크 + - 발송시간(UTC String) +- 구현 작업: + - [x] Controller: 인증/파라미터/ApiResponse 처리 + - [x] Service: 1개월/언어/카테고리/페이지 조건 조합 + - [x] Repository: 수신자 청크 JSON membership + pageable 조회 + totalCount + - [x] DTO: `GetPushNotificationListResponse`, `PushNotificationListItem` 정의 + +### 2) GET `/push/notification/categories` +- 목적: 인증 사용자 기준으로 알림 데이터가 실제 존재하는 카테고리만 조회한다. +- 요청 파라미터: 없음 +- 처리 규칙: + - 인증 필수 + - 현재 요청 언어 기준 데이터만 대상 + - 최근 1개월 데이터만 대상 + - 수신자 청크(JSON 배열)에 인증 사용자 ID가 포함된 데이터만 대상 +- 응답 항목: + - 카테고리 목록(현재 기기 언어 라벨) +- 구현 작업: + - [x] Controller: 인증/ApiResponse 처리 + - [x] Service: 중복 제거된 카테고리 목록 반환 + - [x] Repository: 사용자/언어/기간 기반 카테고리 distinct 조회 + - [x] DTO: `GetPushNotificationCategoryResponse` 정의 + +## 비API 작업 계획 +- [x] FCM 이벤트 모델 확장: 알림 리스트 적재에 필요한 카테고리/발송자 스냅샷 정보를 이벤트에 포함한다. +- [x] FCM 전송 리스너 연동: 언어별 푸시 전송 시점에 알림 리스트 저장 서비스를 호출한다. +- [x] 발송자 스냅샷 처리: 이벤트 스냅샷 우선 사용, 없으면 발송자 ID 기반 조회로 보완한다. +- [x] 딥링크 저장 처리: 현재 푸시 딥링크 규칙과 동일한 값으로 저장한다. +- [x] 수신자 청크 저장 처리: 수신자 ID를 고정 크기 청크로 분할해 JSON 배열로 저장한다. +- [x] 수신자 미존재 처리: 최종 수신자 ID가 비어 있으면 알림 자체를 저장하지 않는다. + +## 테스트(TDD) 계획 +- [x] 단위 테스트: 알림 저장 서비스가 수신자 없음/언어별 분리/청크 분할/스냅샷 저장을 정확히 처리하는지 검증한다. +- [x] 단위 테스트: 조회 서비스가 1개월 제한/언어 필터/카테고리 옵션/pageable 전달을 정확히 적용하는지 검증한다. +- [x] 단위 테스트: 카테고리 조회 서비스가 사용자/언어/기간 기준 distinct 결과를 반환하는지 검증한다. +- [x] 단위 테스트: 컨트롤러가 인증 실패 시 에러 응답을 반환하고, 정상 시 서비스 호출 파라미터를 올바르게 전달하는지 검증한다. + +## SQL 초안 (구현 확정) + +### 1) 신규 테이블 생성 SQL (MySQL) +```sql +CREATE TABLE push_notification_list +( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'PK', + sender_nickname_snapshot VARCHAR(255) NOT NULL COMMENT '발송자 닉네임 스냅샷', + sender_profile_image_snapshot VARCHAR(500) NULL COMMENT '발송자 프로필 이미지 스냅샷', + message TEXT NOT NULL COMMENT '발송 메시지', + category VARCHAR(20) NOT NULL COMMENT '발송 카테고리', + deep_link VARCHAR(500) NULL COMMENT '딥링크', + language_code VARCHAR(8) NOT NULL COMMENT '언어 코드', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각(UTC)', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각(UTC)' +) COMMENT ='푸시 알림 리스트'; + +CREATE TABLE push_notification_recipient_chunk +( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'PK', + notification_id BIGINT NOT NULL COMMENT '알림 ID', + recipient_member_ids JSON NOT NULL COMMENT '수신자 회원 ID 청크(JSON 배열)', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각(UTC)', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각(UTC)', + CONSTRAINT fk_push_notification_recipient_chunk_notification + FOREIGN KEY (notification_id) REFERENCES push_notification_list (id) +) COMMENT ='푸시 알림 수신자 청크'; +``` + +### 2) 추가 인덱스 SQL (MySQL) +```sql +ALTER TABLE push_notification_list + ADD INDEX idx_push_notification_list_language_created (language_code, created_at, id), + ADD INDEX idx_push_notification_list_category_language_created (category, language_code, created_at, id); + +ALTER TABLE push_notification_recipient_chunk + ADD INDEX idx_push_notification_recipient_chunk_notification (notification_id); + +-- MySQL 8.0.17+ 환경에서 JSON 배열 membership 최적화가 필요할 때 사용 +ALTER TABLE push_notification_recipient_chunk + ADD INDEX idx_push_notification_recipient_chunk_member_ids_mvi ((CAST(recipient_member_ids AS UNSIGNED ARRAY))); +``` + +#### MVI 조건부 적용 가이드 (짧게) +- MySQL 8.0.17+ 환경이면 인덱스를 먼저 추가해 둔다. +- 실제 사용 여부는 옵티마이저가 쿼리 조건과 비용을 보고 결정하므로 `EXPLAIN`으로 확인한다. +- 현재 조회 조건처럼 `JSON_CONTAINS(JSON컬럼, JSON_ARRAY(값), '$')` 형태일 때 사용 후보가 된다. +- 인덱스가 선택되지 않아도 기능 오동작은 없지만, 쓰기/저장공간 비용은 항상 발생한다. + +## 검증 기록 + +### 1차 구현 +- 무엇을: 푸시 발송 시 언어별 메시지를 알림 리스트로 적재하는 `PushNotificationService`와 관련 JPA 엔티티/리포지토리/조회 API 2종(`/push/notification/list`, `/push/notification/categories`)을 추가하고, 기존 `FcmEvent` 발행 지점에 카테고리/발송자 스냅샷 소스를 연결했다. +- 왜: 푸시를 놓친 사용자도 최근 1개월 내 알림을 현재 기기 언어 기준으로 확인하고, 카테고리별 필터/카테고리 존재 여부를 조회할 수 있어야 하기 때문이다. +- 어떻게: + - `./gradlew test --tests "*PushNotification*"` 실행(성공) + - `./gradlew test` 실행(성공) + - `./gradlew build` 실행(초기 실패: ktlint import 정렬 위반) + - `./gradlew ktlintFormat` 실행(성공) + - `./gradlew test` 재실행(성공) + - `./gradlew build` 재실행(성공) + - Kotlin LSP 미구성으로 `lsp_diagnostics`는 실행 불가, Gradle test/build/ktlint로 대체 검증 + +### 2차 수정 +- 무엇을: `PushNotificationRecipientChunk`의 `chunkOrder` 필드를 제거하고, 저장 로직/문서 SQL(컬럼 및 인덱스)을 함께 정리했다. +- 왜: 저장 시점에만 값이 할당되고 조회/정렬/필터에서 실제 사용되지 않아 불필요한 데이터였기 때문이다. +- 어떻게: + - `./gradlew test --tests "*PushNotification*"` 실행(성공) + - `./gradlew build` 실행(성공) + +### 3차 수정 +- 무엇을: 알림 리스트 조회를 `PushNotificationListRow -> service map` 구조에서 `PushNotificationListItem` 직접 프로젝션 구조로 변경하고, 조회/카운트 쿼리에서 `innerJoin + distinct/countDistinct`를 제거해 `EXISTS` 기반 JSON membership 필터로 최적화했다. +- 왜: 중간 변환 객체가 불필요하고, 조인 기반 중복 제거 비용(distinct/countDistinct)이 커질 수 있어 페이지 조회 성능을 개선하기 위해서다. +- 어떻게: + - `./gradlew test --tests "*PushNotification*"` 실행(성공) + - `./gradlew build` 실행(성공) + +### 4차 수정 +- 무엇을: `sentAt` 포맷을 DB `DATE_FORMAT` 문자열 생성 방식에서 `PushNotificationListItem` QueryProjection 생성자 기반 UTC Instant 문자열(`...Z`) 생성 방식으로 변경했다. +- 왜: `GetLatestFinishedLiveResponse.dateUtc`와 동일하게 애플리케이션 레이어에서 명시적 UTC 변환을 적용해 포맷/의미 일관성을 맞추기 위해서다. +- 어떻게: + - `./gradlew test --tests "*PushNotification*"` 실행(성공) + - `./gradlew build` 실행(성공) + +### 5차 수정 +- 무엇을: `getAvailableCategories`가 카테고리 코드를 그대로 반환하던 동작을, 현재 기기 언어(`ko/en/ja`)에 맞는 카테고리 라벨을 반환하도록 변경했다. +- 왜: 카테고리 조회 응답을 조회 기기 언어에 따라 한글/영어/일본어로 내려주어야 하기 때문이다. +- 어떻게: + - `./gradlew test --tests "*PushNotification*"` 실행(성공) + - `./gradlew build` 실행(성공) + +### 6차 수정 +- 무엇을: `getAvailableCategories` 응답 리스트 맨 앞에 `전체` 항목을 고정 추가하고, `ko/en/ja` 다국어 라벨로 반환하도록 변경했다. +- 왜: 카테고리 필터 UI에서 전체 조회 옵션이 항상 첫 번째로 필요하기 때문이다. +- 어떻게: + - `./gradlew test --tests "*PushNotification*"` 실행(성공) + - `./gradlew build` 실행(성공) + +### 7차 수정 +- 무엇을: `getNotificationList`의 `category` 입력이 한글/영어/일본어 라벨(`라이브`/`Live`/`ライブ` 등)도 파싱되도록 확장하고, `전체`/`All`/`すべて` 입력은 전체 조회로 처리하도록 수정했다. +- 왜: 카테고리 조회 API가 다국어 라벨을 반환하므로, 목록 조회 API도 동일 라벨 입력을 처리할 수 있어야 하기 때문이다. +- 어떻게: + - `./gradlew test --tests "*PushNotificationServiceTest"` 실행(성공) + - `./gradlew test --tests "*PushNotification*"` 실행(성공) + - `./gradlew build` 실행(성공) + +### 8차 수정 +- 무엇을: 추가 인덱스 SQL 하단에 MVI 인덱스의 조건부 사용 가이드를 짧게 추가했다. +- 왜: 인덱스는 선반영 가능하지만 실제 사용은 쿼리/옵티마이저 조건에 따라 달라진다는 점을 문서에 명시하기 위해서다. +- 어떻게: + - `./gradlew tasks --all` 실행(성공) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/AdminAuditionService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/AdminAuditionService.kt index be698587..7e852614 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/AdminAuditionService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/AdminAuditionService.kt @@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory import kr.co.vividnext.sodalive.utils.generateFileName import org.springframework.beans.factory.annotation.Value import org.springframework.context.ApplicationEventPublisher @@ -92,6 +93,7 @@ class AdminAuditionService( applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.IN_PROGRESS_AUDITION, + category = PushNotificationCategory.AUDITION, titleKey = "admin.audition.fcm.title.new", messageKey = "admin.audition.fcm.message.new", args = listOf(audition.title), diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt index 8fb97c9c..b9cdeb15 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt @@ -16,6 +16,7 @@ import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBanner @@ -329,8 +330,10 @@ class AdminLiveService( applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.CANCEL_LIVE, + category = PushNotificationCategory.LIVE, title = room.member!!.nickname, messageKey = "live.room.fcm.message.canceled", + senderMemberId = room.member!!.id, args = listOf(room.title), pushTokens = pushTokens, roomId = room.id, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventService.kt index b1fc20cf..8d71de0d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventService.kt @@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.can.payment.PaymentStatus import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.auth.AuthRepository @@ -78,6 +79,7 @@ class ChargeEventService( applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.INDIVIDUAL, + category = PushNotificationCategory.MESSAGE, title = chargeEvent.title, messageKey = "can.charge.event.additional_can_paid", args = listOf(additionalCan), @@ -101,6 +103,7 @@ class ChargeEventService( applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.INDIVIDUAL, + category = PushNotificationCategory.MESSAGE, titleKey = "can.charge.event.first_title", messageKey = "can.charge.event.additional_can_paid", args = listOf(additionalCan), diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt index 26c5b9e0..c6a457b9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -31,6 +31,7 @@ import kr.co.vividnext.sodalive.extensions.convertLocalDateTime import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent @@ -460,8 +461,10 @@ class AudioContentService( applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.INDIVIDUAL, + category = PushNotificationCategory.CONTENT, titleKey = "content.notification.upload_complete_title", message = audioContent.title, + senderMemberId = audioContent.member!!.id, recipients = listOf(audioContent.member!!.id!!), isAuth = null, contentId = contentId, @@ -476,8 +479,10 @@ class AudioContentService( applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.UPLOAD_CONTENT, + category = PushNotificationCategory.CONTENT, title = audioContent.member!!.nickname, messageKey = "content.notification.uploaded_message", + senderMemberId = audioContent.member!!.id, args = listOf(audioContent.title), isAuth = audioContent.isAdult, contentId = contentId, @@ -500,8 +505,10 @@ class AudioContentService( applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.UPLOAD_CONTENT, + category = PushNotificationCategory.CONTENT, title = audioContent.member!!.nickname, messageKey = "content.notification.uploaded_message", + senderMemberId = audioContent.member!!.id, args = listOf(audioContent.title), isAuth = audioContent.isAdult, contentId = audioContent.id!!, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt index 63e90011..4d4d3743 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt @@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.content.order.OrderRepository import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member @@ -79,6 +80,7 @@ class AudioContentCommentService( applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.CREATE_CONTENT_COMMENT, + category = PushNotificationCategory.CONTENT, title = if (parent != null) { parent.member!!.nickname } else { @@ -90,6 +92,7 @@ class AudioContentCommentService( "content.comment.notification.new" }, args = listOf(audioContent.title), + senderMemberId = member.id, contentId = audioContentId, commentParentId = parentId, myMemberId = member.id, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index c326a210..4e7c555e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -22,6 +22,7 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommuni import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource @@ -666,8 +667,10 @@ class ExplorerService( applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.CHANGE_NOTICE, + category = PushNotificationCategory.COMMUNITY, title = member.nickname, messageKey = "explorer.notice.fcm.message", + senderMemberId = member.id, creatorId = member.id!!, deepLinkValue = FcmDeepLinkValue.CHANNEL, deepLinkId = member.id!! diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt index 71491071..8b2c5214 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt @@ -20,6 +20,7 @@ import kr.co.vividnext.sodalive.extensions.getTimeAgoString import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member @@ -123,8 +124,10 @@ class CreatorCommunityService( applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.CHANGE_NOTICE, + category = PushNotificationCategory.COMMUNITY, title = member.nickname, messageKey = "creator.community.fcm.new_post", + senderMemberId = member.id, creatorId = member.id!!, deepLinkValue = FcmDeepLinkValue.COMMUNITY, deepLinkId = member.id!! diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmController.kt index 6040d940..5f01b7df 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmController.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.fcm +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory import org.springframework.context.ApplicationEventPublisher import org.springframework.security.access.prepost.PreAuthorize import org.springframework.transaction.annotation.Transactional @@ -21,6 +22,7 @@ class FcmController(private val applicationEventPublisher: ApplicationEventPubli applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.INDIVIDUAL, + category = PushNotificationCategory.SYSTEM, title = request.title, isAuth = request.isAuth, message = request.message, @@ -31,6 +33,7 @@ class FcmController(private val applicationEventPublisher: ApplicationEventPubli applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.ALL, + category = PushNotificationCategory.SYSTEM, title = request.title, message = request.message, isAuth = request.isAuth diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt index 74eeca2b..2cbda86e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt @@ -1,6 +1,8 @@ package kr.co.vividnext.sodalive.fcm import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationService import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.live.room.GenderRestriction @@ -27,10 +29,14 @@ enum class FcmDeepLinkValue(val value: String) { class FcmEvent( val type: FcmEventType, + val category: PushNotificationCategory? = null, val title: String = "", val message: String = "", val titleKey: String? = null, val messageKey: String? = null, + val senderMemberId: Long? = null, + val senderNicknameSnapshot: String? = null, + val senderProfileImageSnapshot: String? = null, val args: List = listOf(), val container: String = "", val recipients: List = listOf(), @@ -54,7 +60,8 @@ class FcmSendListener( private val pushService: FcmService, private val memberRepository: MemberRepository, private val contentCommentRepository: AudioContentCommentRepository, - private val messageSource: SodaMessageSource + private val messageSource: SodaMessageSource, + private val pushNotificationService: PushNotificationService ) { @Async @TransactionalEventListener @@ -165,6 +172,13 @@ class FcmSendListener( val title = translate(fcmEvent.titleKey, fcmEvent.title, lang, fcmEvent.args) val message = translate(fcmEvent.messageKey, fcmEvent.message, lang, fcmEvent.args) + pushNotificationService.saveNotification( + fcmEvent = fcmEvent, + languageCode = lang.code, + translatedMessage = message, + recipientPushTokens = tokens + ) + val tokensByOS = tokens.groupBy { it.deviceType } for ((os, osTokens) in tokensByOS) { osTokens.map { it.token }.distinct().chunked(500).forEach { batch -> diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt index 02f726e1..2e7e0a3d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt @@ -128,17 +128,7 @@ class FcmService( } private fun createDeepLink(deepLinkValue: FcmDeepLinkValue?, deepLinkId: Long?): String? { - if (deepLinkValue == null || deepLinkId == null) { - return null - } - - val uriScheme = if (serverEnv.equals("voiceon", ignoreCase = true)) { - "voiceon" - } else { - "voiceon-test" - } - - return "$uriScheme://${deepLinkValue.value}/$deepLinkId" + return buildDeepLink(serverEnv, deepLinkValue, deepLinkId) } fun sendPointGranted(tokens: List, point: Int) { @@ -206,4 +196,24 @@ class FcmService( logger.error("[FCM] ❌ 최종 실패 대상 ${targets.size}명 → $targets") } } + + companion object { + fun buildDeepLink( + serverEnv: String, + deepLinkValue: FcmDeepLinkValue?, + deepLinkId: Long? + ): String? { + if (deepLinkValue == null || deepLinkId == null) { + return null + } + + val uriScheme = if (serverEnv.equals("voiceon", ignoreCase = true)) { + "voiceon" + } else { + "voiceon-test" + } + + return "$uriScheme://${deepLinkValue.value}/$deepLinkId" + } + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/PushTokenRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/PushTokenRepository.kt index 637cb922..8958fd11 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/PushTokenRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/PushTokenRepository.kt @@ -10,6 +10,7 @@ interface PushTokenQueryRepository { fun findByToken(token: String): PushToken? fun findByMemberId(memberId: Long): List fun findByMemberIds(memberIds: List): List + fun findMemberIdsByTokenIn(tokens: List): List } class PushTokenQueryRepositoryImpl( @@ -36,4 +37,19 @@ class PushTokenQueryRepositoryImpl( .where(pushToken.member.id.`in`(memberIds)) .fetch() } + + override fun findMemberIdsByTokenIn(tokens: List): List { + if (tokens.isEmpty()) return emptyList() + + return queryFactory + .select(pushToken.member.id) + .from(pushToken) + .where( + pushToken.token.`in`(tokens) + .and(pushToken.member.id.isNotNull) + ) + .fetch() + .filterNotNull() + .distinct() + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationCategory.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationCategory.kt new file mode 100644 index 00000000..72b1a4b0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationCategory.kt @@ -0,0 +1,16 @@ +package kr.co.vividnext.sodalive.fcm.notification + +enum class PushNotificationCategory(val code: String) { + LIVE("live"), + CONTENT("content"), + COMMUNITY("community"), + MESSAGE("message"), + AUDITION("audition"), + SYSTEM("system"); + + companion object { + fun fromCode(code: String): PushNotificationCategory? { + return values().find { it.code == code.lowercase() } + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationController.kt new file mode 100644 index 00000000..d8c7ef18 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationController.kt @@ -0,0 +1,43 @@ +package kr.co.vividnext.sodalive.fcm.notification + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import org.springframework.data.domain.Pageable +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/push/notification") +class PushNotificationController( + private val pushNotificationService: PushNotificationService +) { + @GetMapping("/list") + fun getNotificationList( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable, + @RequestParam("category", required = false) category: String? + ) = run { + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + + ApiResponse.ok( + pushNotificationService.getNotificationList( + member = member, + pageable = pageable, + category = category + ) + ) + } + + @GetMapping("/categories") + fun getAvailableCategories( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + + ApiResponse.ok(pushNotificationService.getAvailableCategories(member)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationList.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationList.kt new file mode 100644 index 00000000..2a3a66db --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationList.kt @@ -0,0 +1,78 @@ +package kr.co.vividnext.sodalive.fcm.notification + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.AttributeConverter +import javax.persistence.CascadeType +import javax.persistence.Column +import javax.persistence.Convert +import javax.persistence.Converter +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.OneToMany +import javax.persistence.Table + +@Entity +@Table(name = "push_notification_list") +class PushNotificationList( + @Column(nullable = false) + var senderNicknameSnapshot: String, + + @Column(nullable = true) + var senderProfileImageSnapshot: String? = null, + + @Column(columnDefinition = "TEXT", nullable = false) + var message: String, + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + var category: PushNotificationCategory, + + @Column(nullable = true) + var deepLink: String? = null, + + @Column(nullable = false, length = 8) + var languageCode: String +) : BaseEntity() { + @OneToMany(mappedBy = "notification", cascade = [CascadeType.ALL], orphanRemoval = true) + val recipientChunks: MutableList = mutableListOf() + + fun addRecipientChunk(chunk: PushNotificationRecipientChunk) { + chunk.notification = this + recipientChunks.add(chunk) + } +} + +@Entity +@Table(name = "push_notification_recipient_chunk") +class PushNotificationRecipientChunk( + @Column(columnDefinition = "json", nullable = false) + @Convert(converter = PushNotificationRecipientChunkConverter::class) + var recipientMemberIds: List +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "notification_id", nullable = false) + var notification: PushNotificationList? = null +} + +@Converter(autoApply = false) +class PushNotificationRecipientChunkConverter : AttributeConverter, String> { + override fun convertToDatabaseColumn(attribute: List?): String { + if (attribute == null) return "[]" + return objectMapper.writeValueAsString(attribute) + } + + override fun convertToEntityAttribute(dbData: String?): List { + if (dbData.isNullOrBlank()) return emptyList() + return objectMapper.readValue(dbData) + } + + companion object { + private val objectMapper = jacksonObjectMapper() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationListRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationListRepository.kt new file mode 100644 index 00000000..90fa306c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationListRepository.kt @@ -0,0 +1,135 @@ +package kr.co.vividnext.sodalive.fcm.notification + +import com.querydsl.core.types.dsl.BooleanExpression +import com.querydsl.core.types.dsl.Expressions +import com.querydsl.jpa.JPAExpressions +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.fcm.notification.QPushNotificationList.pushNotificationList +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +interface PushNotificationListRepository : JpaRepository, PushNotificationListQueryRepository + +interface PushNotificationListQueryRepository { + fun getNotificationList( + memberId: Long, + languageCode: String, + category: PushNotificationCategory?, + fromDateTime: LocalDateTime, + pageable: Pageable + ): List + + fun getNotificationCount( + memberId: Long, + languageCode: String, + category: PushNotificationCategory?, + fromDateTime: LocalDateTime + ): Long + + fun getAvailableCategories( + memberId: Long, + languageCode: String, + fromDateTime: LocalDateTime + ): List +} + +@Repository +class PushNotificationListQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory +) : PushNotificationListQueryRepository { + override fun getNotificationList( + memberId: Long, + languageCode: String, + category: PushNotificationCategory?, + fromDateTime: LocalDateTime, + pageable: Pageable + ): List { + return queryFactory + .select( + QPushNotificationListItem( + pushNotificationList.id, + pushNotificationList.senderNicknameSnapshot, + pushNotificationList.senderProfileImageSnapshot, + pushNotificationList.message, + pushNotificationList.category, + pushNotificationList.deepLink, + pushNotificationList.createdAt + ) + ) + .from(pushNotificationList) + .where( + pushNotificationList.languageCode.eq(languageCode), + pushNotificationList.createdAt.goe(fromDateTime), + recipientContainsMember(memberId), + categoryEq(category) + ) + .orderBy(pushNotificationList.createdAt.desc(), pushNotificationList.id.desc()) + .offset(pageable.offset) + .limit(pageable.pageSize.toLong()) + .fetch() + } + + override fun getNotificationCount( + memberId: Long, + languageCode: String, + category: PushNotificationCategory?, + fromDateTime: LocalDateTime + ): Long { + return queryFactory + .select(pushNotificationList.id.count()) + .from(pushNotificationList) + .where( + pushNotificationList.languageCode.eq(languageCode), + pushNotificationList.createdAt.goe(fromDateTime), + recipientContainsMember(memberId), + categoryEq(category) + ) + .fetchOne() ?: 0L + } + + override fun getAvailableCategories( + memberId: Long, + languageCode: String, + fromDateTime: LocalDateTime + ): List { + return queryFactory + .select(pushNotificationList.category) + .distinct() + .from(pushNotificationList) + .where( + pushNotificationList.languageCode.eq(languageCode), + pushNotificationList.createdAt.goe(fromDateTime), + recipientContainsMember(memberId) + ) + .fetch() + } + + private fun categoryEq(category: PushNotificationCategory?): BooleanExpression? { + return if (category == null) { + null + } else { + pushNotificationList.category.eq(category) + } + } + + private fun recipientContainsMember(memberId: Long): BooleanExpression { + val recipientChunk = QPushNotificationRecipientChunk("recipientChunk") + return JPAExpressions + .selectOne() + .from(recipientChunk) + .where( + recipientChunk.notification.id.eq(pushNotificationList.id) + .and( + Expressions.booleanTemplate( + "JSON_CONTAINS({0}, JSON_ARRAY({1}), '$')", + recipientChunk.recipientMemberIds, + memberId + ) + ) + ) + .exists() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationResponse.kt new file mode 100644 index 00000000..36ca61af --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationResponse.kt @@ -0,0 +1,46 @@ +package kr.co.vividnext.sodalive.fcm.notification + +import com.querydsl.core.annotations.QueryProjection +import java.time.LocalDateTime +import java.time.ZoneId + +data class GetPushNotificationListResponse( + val totalCount: Long, + val items: List +) + +data class PushNotificationListItem( + val id: Long, + val senderNickname: String, + val senderProfileImage: String?, + val message: String, + val category: String, + val deepLink: String?, + val sentAt: String +) { + @QueryProjection + constructor( + id: Long, + senderNickname: String, + senderProfileImage: String?, + message: String, + category: PushNotificationCategory, + deepLink: String?, + sentAt: LocalDateTime + ) : this( + id = id, + senderNickname = senderNickname, + senderProfileImage = senderProfileImage, + message = message, + category = category.code, + deepLink = deepLink, + sentAt = sentAt + .atZone(ZoneId.of("UTC")) + .toInstant() + .toString() + ) +} + +data class GetPushNotificationCategoryResponse( + val categories: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationService.kt new file mode 100644 index 00000000..d02a983e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationService.kt @@ -0,0 +1,218 @@ +package kr.co.vividnext.sodalive.fcm.notification + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.fcm.FcmEvent +import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.fcm.FcmService +import kr.co.vividnext.sodalive.fcm.PushTokenInfo +import kr.co.vividnext.sodalive.fcm.PushTokenRepository +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.Pageable +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.ZoneOffset + +@Service +@Transactional(readOnly = true) +class PushNotificationService( + private val pushNotificationListRepository: PushNotificationListRepository, + private val pushTokenRepository: PushTokenRepository, + private val memberRepository: MemberRepository, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String, + @Value("\${server.env}") + private val serverEnv: String +) { + @Transactional + fun saveNotification( + fcmEvent: FcmEvent, + languageCode: String, + translatedMessage: String, + recipientPushTokens: List + ) { + if (recipientPushTokens.isEmpty()) return + + val recipientMemberIds = pushTokenRepository + .findMemberIdsByTokenIn(recipientPushTokens.map { it.token }) + .distinct() + + // 최종 수신자 ID가 없으면 알림 리스트를 저장하지 않는다. + if (recipientMemberIds.isEmpty()) return + + val category = resolveCategory(fcmEvent) ?: return + val senderSnapshot = resolveSenderSnapshot(fcmEvent) + val deepLink = FcmService.buildDeepLink(serverEnv, fcmEvent.deepLinkValue, fcmEvent.deepLinkId) + + val notification = PushNotificationList( + senderNicknameSnapshot = senderSnapshot.nickname, + senderProfileImageSnapshot = senderSnapshot.profileImage, + message = translatedMessage, + category = category, + deepLink = deepLink, + languageCode = languageCode + ) + + recipientMemberIds + .chunked(RECIPIENT_CHUNK_SIZE) + .forEach { chunk -> + // 수신자는 JSON 배열 청크 단위로 분할 저장한다. + notification.addRecipientChunk( + PushNotificationRecipientChunk( + recipientMemberIds = chunk + ) + ) + } + + pushNotificationListRepository.save(notification) + } + + fun getNotificationList( + member: Member, + pageable: Pageable, + category: String? + ): GetPushNotificationListResponse { + val parsedCategory = parseCategory(category) + val languageCode = langContext.lang.code + val fromDateTime = oneMonthAgoUtc() + + val totalCount = pushNotificationListRepository.getNotificationCount( + memberId = member.id!!, + languageCode = languageCode, + category = parsedCategory, + fromDateTime = fromDateTime + ) + + val items = pushNotificationListRepository.getNotificationList( + memberId = member.id!!, + languageCode = languageCode, + category = parsedCategory, + fromDateTime = fromDateTime, + pageable = pageable + ) + + return GetPushNotificationListResponse( + totalCount = totalCount, + items = items + ) + } + + fun getAvailableCategories(member: Member): GetPushNotificationCategoryResponse { + val lang = langContext.lang + val localizedCategories = pushNotificationListRepository.getAvailableCategories( + memberId = member.id!!, + languageCode = lang.code, + fromDateTime = oneMonthAgoUtc() + ).map { category -> + messageSource.getMessage("push.notification.category.${category.code}", lang) ?: category.code + } + val allCategoryLabel = messageSource.getMessage("push.notification.category.all", lang) ?: "전체" + + val categories = listOf(allCategoryLabel) + localizedCategories + + return GetPushNotificationCategoryResponse(categories = categories) + } + + private fun parseCategory(category: String?): PushNotificationCategory? { + if (category.isNullOrBlank()) return null + + val normalizedCategory = category.trim() + if (isAllCategory(normalizedCategory)) { + return null + } + + PushNotificationCategory.fromCode(normalizedCategory)?.let { return it } + + val parsedCategory = PushNotificationCategory.values().firstOrNull { pushCategory -> + supportedCategoryInputLangs.any { lang -> + val localizedLabel = messageSource.getMessage("push.notification.category.${pushCategory.code}", lang) + localizedLabel.equals(normalizedCategory, ignoreCase = true) + } + } + + return parsedCategory ?: throw SodaException(messageKey = "common.error.invalid_request") + } + + private fun isAllCategory(category: String): Boolean { + if (category.equals("all", ignoreCase = true)) return true + + return supportedCategoryInputLangs.any { lang -> + val localizedLabel = messageSource.getMessage("push.notification.category.all", lang) + localizedLabel.equals(category, ignoreCase = true) + } + } + + private fun oneMonthAgoUtc(): LocalDateTime { + return LocalDateTime.now(ZoneOffset.UTC).minusMonths(1) + } + + private fun resolveCategory(fcmEvent: FcmEvent): PushNotificationCategory? { + // 이벤트에서 명시한 카테고리가 있으면 우선 사용하고, 없으면 타입 기반 기본값으로 보정한다. + return fcmEvent.category ?: when (fcmEvent.type) { + FcmEventType.CREATE_LIVE, + FcmEventType.START_LIVE, + FcmEventType.CANCEL_LIVE -> PushNotificationCategory.LIVE + + FcmEventType.UPLOAD_CONTENT, + FcmEventType.CREATE_CONTENT_COMMENT -> PushNotificationCategory.CONTENT + + FcmEventType.CHANGE_NOTICE -> PushNotificationCategory.COMMUNITY + + FcmEventType.SEND_MESSAGE -> PushNotificationCategory.MESSAGE + FcmEventType.IN_PROGRESS_AUDITION -> PushNotificationCategory.AUDITION + FcmEventType.ALL, + FcmEventType.INDIVIDUAL -> PushNotificationCategory.SYSTEM + } + } + + private fun resolveSenderSnapshot(fcmEvent: FcmEvent): SenderSnapshot { + if (!fcmEvent.senderNicknameSnapshot.isNullOrBlank()) { + return SenderSnapshot( + nickname = fcmEvent.senderNicknameSnapshot, + profileImage = fcmEvent.senderProfileImageSnapshot ?: defaultProfileImageUrl() + ) + } + + val sender = fcmEvent.senderMemberId?.let { memberRepository.findByIdOrNull(it) } + if (sender != null) { + return SenderSnapshot( + nickname = sender.nickname, + profileImage = toProfileImageUrl(sender.profileImage) + ) + } + + val fallbackNickname = fcmEvent.title.ifBlank { "" } + + return SenderSnapshot( + nickname = fallbackNickname, + profileImage = defaultProfileImageUrl() + ) + } + + private fun toProfileImageUrl(profileImage: String?): String { + if (profileImage.isNullOrBlank()) return defaultProfileImageUrl() + return "$cloudFrontHost/$profileImage" + } + + private fun defaultProfileImageUrl(): String { + return "$cloudFrontHost/profile/default-profile.png" + } + + data class SenderSnapshot( + val nickname: String, + val profileImage: String + ) + + companion object { + private const val RECIPIENT_CHUNK_SIZE = 500 + private val supportedCategoryInputLangs = listOf(Lang.KO, Lang.EN, Lang.JA) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index 0637b66d..114138c0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -1114,6 +1114,44 @@ class SodaMessageSource { ) ) + private val pushNotificationMessages = mapOf( + "push.notification.category.all" to mapOf( + Lang.KO to "전체", + Lang.EN to "All", + Lang.JA to "すべて" + ), + "push.notification.category.live" to mapOf( + Lang.KO to "라이브", + Lang.EN to "Live", + Lang.JA to "ライブ" + ), + "push.notification.category.content" to mapOf( + Lang.KO to "콘텐츠", + Lang.EN to "Content", + Lang.JA to "コンテンツ" + ), + "push.notification.category.community" to mapOf( + Lang.KO to "커뮤니티", + Lang.EN to "Community", + Lang.JA to "コミュニティ" + ), + "push.notification.category.message" to mapOf( + Lang.KO to "메시지", + Lang.EN to "Message", + Lang.JA to "メッセージ" + ), + "push.notification.category.audition" to mapOf( + Lang.KO to "오디션", + Lang.EN to "Audition", + Lang.JA to "オーディション" + ), + "push.notification.category.system" to mapOf( + Lang.KO to "시스템", + Lang.EN to "System", + Lang.JA to "システム" + ) + ) + private val noticeMessages = mapOf( "notice.error.title_required" to mapOf( Lang.KO to "제목을 입력하세요.", @@ -2313,6 +2351,7 @@ class SodaMessageSource { adminPointPolicyMessages, adminMemberStatisticsMessages, messageMessages, + pushNotificationMessages, noticeMessages, reportMessages, imageValidationMessages, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt index ef4f5a6a..34e8b621 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt @@ -23,6 +23,7 @@ import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType import kr.co.vividnext.sodalive.fcm.PushTokenRepository +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository @@ -479,12 +480,14 @@ class LiveRoomService( applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.CREATE_LIVE, + category = PushNotificationCategory.LIVE, title = createdRoom.member!!.nickname, messageKey = if (createdRoom.channelName != null) { "live.room.fcm.message.started" } else { "live.room.fcm.message.reserved" }, + senderMemberId = createdRoom.member!!.id, args = listOf(createdRoom.title), isAuth = createdRoom.isAdult, isAvailableJoinCreator = createdRoom.isAvailableJoinCreator, @@ -658,8 +661,10 @@ class LiveRoomService( applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.START_LIVE, + category = PushNotificationCategory.LIVE, title = room.member!!.nickname, messageKey = "live.room.fcm.message.started", + senderMemberId = room.member!!.id, args = listOf(room.title), isAuth = room.isAdult, isAvailableJoinCreator = room.isAvailableJoinCreator, @@ -731,8 +736,10 @@ class LiveRoomService( applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.CANCEL_LIVE, + category = PushNotificationCategory.LIVE, title = room.member!!.nickname, messageKey = "live.room.fcm.message.canceled", + senderMemberId = room.member!!.id, args = listOf(room.title), pushTokens = pushTokens, roomId = room.id, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/message/MessageService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/message/MessageService.kt index 7a486cec..78449f37 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/message/MessageService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/message/MessageService.kt @@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member @@ -72,8 +73,10 @@ class MessageService( applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.SEND_MESSAGE, + category = PushNotificationCategory.MESSAGE, titleKey = "message.fcm.title", messageKey = "message.fcm.text_received", + senderMemberId = sender.id, args = listOf(sender.nickname), messageId = message.id ) @@ -145,8 +148,10 @@ class MessageService( applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.SEND_MESSAGE, + category = PushNotificationCategory.MESSAGE, titleKey = "message.fcm.title", messageKey = "message.fcm.voice_received", + senderMemberId = sender.id, args = listOf(sender.nickname), messageId = message.id ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationControllerTest.kt new file mode 100644 index 00000000..d1925943 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationControllerTest.kt @@ -0,0 +1,128 @@ +package kr.co.vividnext.sodalive.fcm.notification + +import kr.co.vividnext.sodalive.common.SodaExceptionHandler +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.data.domain.PageRequest +import org.springframework.data.web.PageableHandlerMethodArgumentResolver +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders + +class PushNotificationControllerTest { + private lateinit var pushNotificationService: PushNotificationService + private lateinit var controller: PushNotificationController + private lateinit var mockMvc: MockMvc + + @BeforeEach + fun setup() { + pushNotificationService = Mockito.mock(PushNotificationService::class.java) + controller = PushNotificationController(pushNotificationService) + + mockMvc = MockMvcBuilders + .standaloneSetup(controller) + .setControllerAdvice(SodaExceptionHandler(LangContext(), SodaMessageSource())) + .setCustomArgumentResolvers( + AuthenticationPrincipalArgumentResolver(), + PageableHandlerMethodArgumentResolver() + ) + .build() + } + + @Test + fun shouldReturnErrorResponseWhenRequesterIsAnonymous() { + // given/when: 인증 없이 알림 목록 API를 호출한다. + mockMvc.perform( + get("/push/notification/list") + .param("page", "0") + .param("size", "5") + ) + // then: 공통 인증 실패 응답이 반환되어야 한다. + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("로그인 정보를 확인해주세요.")) + } + + @Test + fun shouldForwardPageableAndCategoryToServiceWhenListApiIsCalled() { + // given: 인증 사용자와 서비스 응답을 준비한다. + val member = createMember(id = 8L, role = MemberRole.USER, nickname = "viewer") + val response = GetPushNotificationListResponse( + totalCount = 1L, + items = listOf( + PushNotificationListItem( + id = 10L, + senderNickname = "creator", + senderProfileImage = "https://cdn.test/profile/default-profile.png", + message = "새 알림", + category = "live", + deepLink = "voiceon://live/10", + sentAt = "2026-03-11T10:00:00" + ) + ) + ) + + Mockito.`when`( + pushNotificationService.getNotificationList( + member = member, + pageable = PageRequest.of(2, 5), + category = "live" + ) + ).thenReturn(response) + + // when: 컨트롤러 메서드를 직접 호출한다. + val apiResponse = controller.getNotificationList( + member = member, + pageable = PageRequest.of(2, 5), + category = "live" + ) + + // then: pageable/category/member가 그대로 서비스에 전달되어야 한다. + assertEquals(true, apiResponse.success) + assertEquals(1L, apiResponse.data!!.totalCount) + assertEquals("live", apiResponse.data!!.items[0].category) + + Mockito.verify(pushNotificationService).getNotificationList( + member = member, + pageable = PageRequest.of(2, 5), + category = "live" + ) + } + + @Test + fun shouldForwardMemberToCategoryApiService() { + // given: 인증 사용자와 카테고리 응답을 준비한다. + val member = createMember(id = 21L, role = MemberRole.USER, nickname = "user") + val response = GetPushNotificationCategoryResponse(categories = listOf("live", "content")) + + Mockito.`when`(pushNotificationService.getAvailableCategories(member)).thenReturn(response) + + // when: 컨트롤러 메서드를 직접 호출한다. + val apiResponse = controller.getAvailableCategories(member) + + // then: 서비스 응답이 ApiResponse.ok로 반환되어야 한다. + assertEquals(true, apiResponse.success) + assertEquals(listOf("live", "content"), apiResponse.data!!.categories) + Mockito.verify(pushNotificationService).getAvailableCategories(member) + } + + private fun createMember(id: Long, role: MemberRole, nickname: String): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role + ) + member.id = id + return member + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationServiceTest.kt new file mode 100644 index 00000000..bdab6ea6 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationServiceTest.kt @@ -0,0 +1,336 @@ +package kr.co.vividnext.sodalive.fcm.notification + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue +import kr.co.vividnext.sodalive.fcm.FcmEvent +import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.fcm.PushTokenInfo +import kr.co.vividnext.sodalive.fcm.PushTokenRepository +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import java.time.LocalDateTime +import java.util.Optional + +class PushNotificationServiceTest { + private lateinit var pushNotificationListRepository: PushNotificationListRepository + private lateinit var pushTokenRepository: PushTokenRepository + private lateinit var memberRepository: MemberRepository + private lateinit var messageSource: SodaMessageSource + private lateinit var langContext: LangContext + private lateinit var service: PushNotificationService + + @BeforeEach + fun setup() { + pushNotificationListRepository = Mockito.mock(PushNotificationListRepository::class.java) + pushTokenRepository = Mockito.mock(PushTokenRepository::class.java) + memberRepository = Mockito.mock(MemberRepository::class.java) + messageSource = Mockito.mock(SodaMessageSource::class.java) + langContext = LangContext() + + mockPushNotificationCategoryMessages() + + service = PushNotificationService( + pushNotificationListRepository = pushNotificationListRepository, + pushTokenRepository = pushTokenRepository, + memberRepository = memberRepository, + messageSource = messageSource, + langContext = langContext, + cloudFrontHost = "https://cdn.test", + serverEnv = "voiceon" + ) + } + + @Test + fun shouldNotSaveWhenRecipientMemberIdsAreEmpty() { + // given: 언어별 발송 대상 토큰은 있으나 회원 ID 매핑 결과가 비어있는 상황을 준비한다. + val event = FcmEvent( + type = FcmEventType.SEND_MESSAGE, + category = PushNotificationCategory.MESSAGE, + senderMemberId = 10L, + deepLinkValue = FcmDeepLinkValue.CONTENT, + deepLinkId = 77L + ) + val pushTokens = listOf(PushTokenInfo(token = "token-1", deviceType = "aos", languageCode = "ko")) + + Mockito.`when`(pushTokenRepository.findMemberIdsByTokenIn(listOf("token-1"))).thenReturn(emptyList()) + + // when: 알림 적재를 실행한다. + service.saveNotification( + fcmEvent = event, + languageCode = "ko", + translatedMessage = "테스트 메시지", + recipientPushTokens = pushTokens + ) + + // then: 수신자 없음 규칙에 따라 저장이 발생하지 않아야 한다. + Mockito.verify(pushNotificationListRepository, Mockito.never()).save(Mockito.any(PushNotificationList::class.java)) + } + + @Test + fun shouldSaveChunkedRecipientsAndSenderSnapshotWhenEventIsValid() { + // given: 1001명의 수신자를 가진 유효 이벤트를 준비한다. + val event = FcmEvent( + type = FcmEventType.START_LIVE, + category = PushNotificationCategory.LIVE, + senderMemberId = 500L, + deepLinkValue = FcmDeepLinkValue.LIVE, + deepLinkId = 300L + ) + val pushTokens = listOf( + PushTokenInfo(token = "token-a", deviceType = "aos", languageCode = "ko"), + PushTokenInfo(token = "token-b", deviceType = "ios", languageCode = "ko") + ) + val recipientMemberIds = (1L..1001L).toList() + val sender = createMember(id = 500L, role = MemberRole.CREATOR, nickname = "creator") + sender.profileImage = "profile/creator.png" + + Mockito.`when`(pushTokenRepository.findMemberIdsByTokenIn(listOf("token-a", "token-b"))) + .thenReturn(recipientMemberIds) + Mockito.`when`(memberRepository.findById(500L)).thenReturn(Optional.of(sender)) + Mockito.`when`(pushNotificationListRepository.save(Mockito.any(PushNotificationList::class.java))) + .thenAnswer { invocation -> invocation.getArgument(0) } + + // when: 알림 적재를 실행한다. + service.saveNotification( + fcmEvent = event, + languageCode = "ko", + translatedMessage = "라이브가 시작되었습니다.", + recipientPushTokens = pushTokens + ) + + // then: 발송자 스냅샷/딥링크/카테고리/언어와 수신자 청크가 정확히 저장되어야 한다. + val captor = ArgumentCaptor.forClass(PushNotificationList::class.java) + Mockito.verify(pushNotificationListRepository).save(captor.capture()) + val saved = captor.value + + assertEquals("creator", saved.senderNicknameSnapshot) + assertEquals("https://cdn.test/profile/creator.png", saved.senderProfileImageSnapshot) + assertEquals("라이브가 시작되었습니다.", saved.message) + assertEquals(PushNotificationCategory.LIVE, saved.category) + assertEquals("voiceon://live/300", saved.deepLink) + assertEquals("ko", saved.languageCode) + assertEquals(3, saved.recipientChunks.size) + assertEquals(500, saved.recipientChunks[0].recipientMemberIds.size) + assertEquals(500, saved.recipientChunks[1].recipientMemberIds.size) + assertEquals(1, saved.recipientChunks[2].recipientMemberIds.size) + } + + @Test + fun shouldApplyLanguageAndOptionalCategoryWhenGettingNotificationList() { + // given: 현재 기기 언어를 EN으로 설정하고 목록 조회 결과를 준비한다. + langContext.setLang(Lang.EN) + val member = createMember(id = 7L, role = MemberRole.USER, nickname = "viewer") + val pageable = PageRequest.of(1, 2) + val rows = listOf( + PushNotificationListItem( + id = 100L, + senderNickname = "creator", + senderProfileImage = "https://cdn.test/profile/default-profile.png", + message = "new content", + category = "content", + deepLink = "voiceon://content/1", + sentAt = "2026-03-11T10:00:00" + ) + ) + + Mockito.`when`( + pushNotificationListRepository.getNotificationList( + Mockito.anyLong(), + Mockito.anyString(), + Mockito.isNull(), + anyLocalDateTime(), + anyPageable() + ) + ).thenReturn(rows) + Mockito.`when`( + pushNotificationListRepository.getNotificationCount( + Mockito.anyLong(), + Mockito.anyString(), + Mockito.isNull(), + anyLocalDateTime() + ) + ).thenReturn(1L) + + // when: 카테고리 미지정 상태로 목록 조회를 실행한다. + val response = service.getNotificationList(member = member, pageable = pageable, category = null) + + // then: 언어 필터가 적용되고 전체 카테고리 기준으로 결과가 반환되어야 한다. + assertEquals(1L, response.totalCount) + assertEquals(1, response.items.size) + assertEquals("content", response.items[0].category) + } + + @Test + fun shouldThrowWhenCategoryCodeIsInvalid() { + // given: 인증 사용자를 준비한다. + val member = createMember(id = 9L, role = MemberRole.USER, nickname = "member") + + // when & then: 정의되지 않은 카테고리 코드는 예외를 발생시켜야 한다. + assertThrows(SodaException::class.java) { + service.getNotificationList(member = member, pageable = PageRequest.of(0, 10), category = "unknown") + } + } + + @Test + fun shouldParseLocalizedCategoryLabelsWhenGettingNotificationList() { + // given: 다국어 카테고리 문자열 입력과 빈 조회 결과를 준비한다. + langContext.setLang(Lang.KO) + val member = createMember(id = 30L, role = MemberRole.USER, nickname = "user") + val pageable = PageRequest.of(0, 10) + + Mockito.`when`( + pushNotificationListRepository.getNotificationCount( + Mockito.anyLong(), + Mockito.anyString(), + anyCategory(), + anyLocalDateTime() + ) + ).thenReturn(0L) + Mockito.`when`( + pushNotificationListRepository.getNotificationList( + Mockito.anyLong(), + Mockito.anyString(), + anyCategory(), + anyLocalDateTime(), + anyPageable() + ) + ).thenReturn(emptyList()) + + // when: ko/en/ja 카테고리 라벨을 각각 전달해 조회를 실행한다. + listOf("라이브", "Live", "ライブ").forEach { localizedCategory -> + service.getNotificationList(member = member, pageable = pageable, category = localizedCategory) + } + + // then: 모두 LIVE 카테고리로 파싱되어 조회되어야 한다. + Mockito.verify(pushNotificationListRepository, Mockito.times(3)).getNotificationCount( + Mockito.anyLong(), + Mockito.anyString(), + Mockito.eq(PushNotificationCategory.LIVE), + anyLocalDateTime() + ) + } + + @Test + fun shouldTreatLocalizedAllCategoryAsNoFilterWhenGettingNotificationList() { + // given: 다국어 전체 카테고리 입력을 준비한다. + langContext.setLang(Lang.KO) + val member = createMember(id = 40L, role = MemberRole.USER, nickname = "user") + val pageable = PageRequest.of(0, 10) + + Mockito.`when`( + pushNotificationListRepository.getNotificationCount( + Mockito.anyLong(), + Mockito.anyString(), + Mockito.isNull(), + anyLocalDateTime() + ) + ).thenReturn(0L) + Mockito.`when`( + pushNotificationListRepository.getNotificationList( + Mockito.anyLong(), + Mockito.anyString(), + Mockito.isNull(), + anyLocalDateTime(), + anyPageable() + ) + ).thenReturn(emptyList()) + + // when: ko/en/ja 전체 라벨로 조회를 실행한다. + listOf("전체", "All", "すべて").forEach { localizedAllCategory -> + service.getNotificationList(member = member, pageable = pageable, category = localizedAllCategory) + } + + // then: 카테고리 필터 없이 전체 조회로 처리되어야 한다. + Mockito.verify(pushNotificationListRepository, Mockito.times(3)).getNotificationCount( + Mockito.anyLong(), + Mockito.anyString(), + Mockito.isNull(), + anyLocalDateTime() + ) + } + + @Test + fun shouldReturnAvailableCategoryLabelsForCurrentLanguage() { + // given: 현재 기기 언어를 JA로 설정하고 카테고리 조회 결과를 준비한다. + langContext.setLang(Lang.JA) + val member = createMember(id = 3L, role = MemberRole.USER, nickname = "user") + + Mockito.`when`( + pushNotificationListRepository.getAvailableCategories( + Mockito.anyLong(), + Mockito.anyString(), + anyLocalDateTime() + ) + ).thenReturn(listOf(PushNotificationCategory.LIVE, PushNotificationCategory.MESSAGE)) + Mockito.`when`(messageSource.getMessage("push.notification.category.all", Lang.JA)).thenReturn("すべて") + Mockito.`when`(messageSource.getMessage("push.notification.category.live", Lang.JA)).thenReturn("ライブ") + Mockito.`when`(messageSource.getMessage("push.notification.category.message", Lang.JA)).thenReturn("メッセージ") + + // when: 카테고리 조회를 실행한다. + val response = service.getAvailableCategories(member) + + // then: 현재 언어 기준 라벨 목록이 반환되어야 한다. + assertEquals(listOf("すべて", "ライブ", "メッセージ"), response.categories) + } + + private fun createMember(id: Long, role: MemberRole, nickname: String): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role + ) + member.id = id + return member + } + + private fun anyLocalDateTime(): LocalDateTime { + Mockito.any(LocalDateTime::class.java) + return LocalDateTime.MIN + } + + private fun anyPageable(): Pageable { + Mockito.any(Pageable::class.java) + return PageRequest.of(0, 1) + } + + private fun anyCategory(): PushNotificationCategory { + Mockito.any(PushNotificationCategory::class.java) + return PushNotificationCategory.LIVE + } + + private fun mockPushNotificationCategoryMessages() { + val messages = mapOf( + "push.notification.category.all" to mapOf(Lang.KO to "전체", Lang.EN to "All", Lang.JA to "すべて"), + "push.notification.category.live" to mapOf(Lang.KO to "라이브", Lang.EN to "Live", Lang.JA to "ライブ"), + "push.notification.category.content" to mapOf(Lang.KO to "콘텐츠", Lang.EN to "Content", Lang.JA to "コンテンツ"), + "push.notification.category.community" to mapOf(Lang.KO to "커뮤니티", Lang.EN to "Community", Lang.JA to "コミュニティ"), + "push.notification.category.message" to mapOf(Lang.KO to "메시지", Lang.EN to "Message", Lang.JA to "メッセージ"), + "push.notification.category.audition" to mapOf(Lang.KO to "오디션", Lang.EN to "Audition", Lang.JA to "オーディション"), + "push.notification.category.system" to mapOf(Lang.KO to "시스템", Lang.EN to "System", Lang.JA to "システム") + ) + + Mockito.`when`(messageSource.getMessage(Mockito.anyString(), anyLang())).thenAnswer { invocation -> + val key = invocation.getArgument(0) + val lang = invocation.getArgument(1) + messages[key]?.get(lang) + } + } + + private fun anyLang(): Lang { + Mockito.any(Lang::class.java) + return Lang.KO + } +}