feat(fcm): 푸시 알림함 저장 및 카테고리 조회를 지원한다

This commit is contained in:
2026-03-11 19:33:07 +09:00
parent f5c3c62e68
commit f69ace570a
23 changed files with 1309 additions and 12 deletions

View File

@@ -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` 실행(성공)

View File

@@ -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),

View File

@@ -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,

View File

@@ -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),

View File

@@ -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!!,

View File

@@ -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,

View File

@@ -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!!

View File

@@ -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!!

View File

@@ -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

View File

@@ -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<Any> = listOf(),
val container: String = "",
val recipients: List<Long> = 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 ->

View File

@@ -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<String>, 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"
}
}
}

View File

@@ -10,6 +10,7 @@ interface PushTokenQueryRepository {
fun findByToken(token: String): PushToken?
fun findByMemberId(memberId: Long): List<PushToken>
fun findByMemberIds(memberIds: List<Long>): List<PushToken>
fun findMemberIdsByTokenIn(tokens: List<String>): List<Long>
}
class PushTokenQueryRepositoryImpl(
@@ -36,4 +37,19 @@ class PushTokenQueryRepositoryImpl(
.where(pushToken.member.id.`in`(memberIds))
.fetch()
}
override fun findMemberIdsByTokenIn(tokens: List<String>): List<Long> {
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()
}
}

View File

@@ -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() }
}
}
}

View File

@@ -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))
}
}

View File

@@ -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<PushNotificationRecipientChunk> = 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<Long>
) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "notification_id", nullable = false)
var notification: PushNotificationList? = null
}
@Converter(autoApply = false)
class PushNotificationRecipientChunkConverter : AttributeConverter<List<Long>, String> {
override fun convertToDatabaseColumn(attribute: List<Long>?): String {
if (attribute == null) return "[]"
return objectMapper.writeValueAsString(attribute)
}
override fun convertToEntityAttribute(dbData: String?): List<Long> {
if (dbData.isNullOrBlank()) return emptyList()
return objectMapper.readValue(dbData)
}
companion object {
private val objectMapper = jacksonObjectMapper()
}
}

View File

@@ -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<PushNotificationList, Long>, PushNotificationListQueryRepository
interface PushNotificationListQueryRepository {
fun getNotificationList(
memberId: Long,
languageCode: String,
category: PushNotificationCategory?,
fromDateTime: LocalDateTime,
pageable: Pageable
): List<PushNotificationListItem>
fun getNotificationCount(
memberId: Long,
languageCode: String,
category: PushNotificationCategory?,
fromDateTime: LocalDateTime
): Long
fun getAvailableCategories(
memberId: Long,
languageCode: String,
fromDateTime: LocalDateTime
): List<PushNotificationCategory>
}
@Repository
class PushNotificationListQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : PushNotificationListQueryRepository {
override fun getNotificationList(
memberId: Long,
languageCode: String,
category: PushNotificationCategory?,
fromDateTime: LocalDateTime,
pageable: Pageable
): List<PushNotificationListItem> {
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<PushNotificationCategory> {
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()
}
}

View File

@@ -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<PushNotificationListItem>
)
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<String>
)

View File

@@ -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<PushTokenInfo>
) {
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)
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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
)

View File

@@ -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
}
}

View File

@@ -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<String>(0)
val lang = invocation.getArgument<Lang>(1)
messages[key]?.get(lang)
}
}
private fun anyLang(): Lang {
Mockito.any(Lang::class.java)
return Lang.KO
}
}