diff --git a/docs/20260309_푸시딥링크검증.md b/docs/20260309_푸시딥링크검증.md new file mode 100644 index 00000000..b58d99a5 --- /dev/null +++ b/docs/20260309_푸시딥링크검증.md @@ -0,0 +1,16 @@ +- [x] deep_link 파라미터 추가 여부를 푸시 발송 코드 기준으로 확인한다. +- [x] deep_link 값이 `voiceon://community/345` 형태인지 생성 규칙을 확인한다. +- [x] 검증 결과를 문서 하단에 기록한다. + +## 검증 기록 + +### 1차 확인 +- 무엇을: 푸시 발송 시 FCM payload에 `deep_link` 파라미터가 실제로 추가되는지와 커뮤니티 알림 형식이 `voiceon://community/{id}`인지 확인했다. +- 왜: 서버 구현이 문서 설명과 일치하는지, 그리고 앱이 기대하는 딥링크 문자열을 실제로 내려주는지 검증하기 위해서다. +- 어떻게: + - `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt` 확인: `createDeepLink(deepLinkValue, deepLinkId)` 결과가 null이 아니면 `multicastMessage.putData("deep_link", deepLink)`로 payload에 추가됨. + - `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt` 확인: 생성 규칙은 `server.env == voiceon`일 때 `voiceon://{deepLinkValue.value}/{deepLinkId}`, 그 외 환경은 `voiceon-test://{deepLinkValue.value}/{deepLinkId}`임. + - `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` 확인: 커뮤니티 새 글 알림은 `deepLinkValue = FcmDeepLinkValue.COMMUNITY`, `deepLinkId = member.id!!`를 전달하므로 운영 환경 기준 최종 값은 `voiceon://community/{creatorId}` 형식임. + - `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityController.kt` 확인: 커뮤니티 목록 조회 API가 `creatorId`를 받으므로 커뮤니티 딥링크의 식별자도 크리에이터 ID 기준과 일치함. + - `./gradlew build` 실행(성공) + - 코드 수정은 하지 않음(확인 작업만 수행). diff --git a/docs/20260309_푸시딥링크파라미터추가.md b/docs/20260309_푸시딥링크파라미터추가.md new file mode 100644 index 00000000..338ec01f --- /dev/null +++ b/docs/20260309_푸시딥링크파라미터추가.md @@ -0,0 +1,29 @@ +- [x] FCM 푸시 생성 경로에서 딥링크 파라미터 추가 위치 확정 +- [x] `server.env` 기반 URI scheme(`voiceon://`, `voiceon-test://`) 분기 로직 구현 +- [x] `deep_link_value` 매핑 규칙(`live`, `channel`, `content`, `series`, `audition`, `community`) 반영 +- [x] FCM payload에 최종 딥링크 문자열(`{URISCHEME}://{deep_link_value}/{ID}`) 주입 +- [x] 관련 테스트/검증 수행 후 결과 기록 + +## 검증 기록 + +### 1차 구현 +- 무엇을: FCM 이벤트에 딥링크 메타(`deepLinkValue`, `deepLinkId`)를 추가하고, `FcmService`에서 `deep_link` payload(`{URISCHEME}://{deep_link_value}/{ID}`)를 생성하도록 구현했다. +- 왜: 푸시 수신 시 앱이 직접 딥링크로 진입하도록 서버에서 일관된 규칙으로 URL을 포함하기 위해서다. +- 어떻게: + - `./gradlew test` 실행(성공) + - `./gradlew build` 실행(초기 실패: import 정렬 ktlint 위반) + - `./gradlew ktlintFormat` 실행(성공) + - `./gradlew test && ./gradlew build` 재실행(성공) + - LSP 진단은 Kotlin LSP 미구성 환경으로 실행 불가(Gradle 컴파일/테스트/ktlint로 대체 검증) + +### 2차 수정 +- 무엇을: 오디션 푸시의 `deepLinkId`를 `-1` 대체값이 아닌 실제 `audition.id` nullable 값으로 조정했다. +- 왜: ID가 null일 때 비정상 딥링크(`/audition/-1`)가 생성되는 가능성을 제거하기 위해서다. +- 어떻게: + - `./gradlew test && ./gradlew build` 실행(성공) + +### 3차 수정 +- 무엇을: `server.env` 값 해석 기준을 `voiceon`(프로덕션), `voiceon_test` 및 그 외(개발/기타)로 조정했다. +- 왜: 실제 운영 환경 변수 규칙과 딥링크 URI scheme 선택 조건을 일치시키기 위해서다. +- 어떻게: + - `./gradlew test && ./gradlew build` 실행(성공) 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/docs/20260312_푸시알림조회쿼리오류수정.md b/docs/20260312_푸시알림조회쿼리오류수정.md new file mode 100644 index 00000000..8c5550e5 --- /dev/null +++ b/docs/20260312_푸시알림조회쿼리오류수정.md @@ -0,0 +1,17 @@ +# 푸시 알림 조회 쿼리 오류 수정 + +- [x] `PushNotificationController` 연계 조회 API에서 발생한 DB 조회 오류 재현 경로와 실제 실패 쿼리 식별 +- [x] `QuerySyntaxException` 원인인 JPQL/HQL 함수 사용 구문을 코드베이스 패턴에 맞게 수정 +- [x] 수정 코드 정적 진단 및 테스트/빌드 검증 수행 +- [x] 검증 결과를 문서 하단에 기록 + +## 검증 기록 + +### 1차 수정 +- 무엇을: `PushNotificationListRepository.recipientContainsMember`의 QueryDSL 템플릿을 `JSON_CONTAINS({0}, JSON_ARRAY({1}), '$')`에서 `function('JSON_CONTAINS', {0}, function('JSON_ARRAY', {1}), '$') = 1`로 수정했다. +- 왜: Hibernate JPQL/HQL 파서는 MySQL 함수명(`JSON_CONTAINS`, `JSON_ARRAY`) 직접 호출 구문을 인식하지 못해 `QuerySyntaxException`이 발생하므로, JPQL 표준 함수 호출 래퍼(`function`)로 감싸 파싱 가능하도록 변경이 필요했다. +- 어떻게: + - 검색: `grep`/AST/Explore/Librarian로 `PushNotificationController -> PushNotificationService -> PushNotificationListRepository` 호출 흐름과 문제 쿼리를 확인했다. + - 정적 진단: `lsp_diagnostics`로 Kotlin 파일 진단을 시도했으나 현재 환경에 `.kt` LSP 서버 미설정으로 실행 불가를 확인했다. + - 테스트: `./gradlew test --tests "kr.co.vividnext.sodalive.fcm.notification.PushNotificationServiceTest" --tests "kr.co.vividnext.sodalive.fcm.notification.PushNotificationControllerTest"` 실행 결과 `BUILD SUCCESSFUL`. + - 빌드: `./gradlew build -x test` 실행 결과 `BUILD SUCCESSFUL`. diff --git a/docs/20260313_라이브추천팔로잉전체채널조회그룹바이오류수정.md b/docs/20260313_라이브추천팔로잉전체채널조회그룹바이오류수정.md new file mode 100644 index 00000000..2e58ee4f --- /dev/null +++ b/docs/20260313_라이브추천팔로잉전체채널조회그룹바이오류수정.md @@ -0,0 +1,14 @@ +- [x] getFollowingAllChannelList 오류 재현 경로와 원인 쿼리 위치를 확인한다. +- [x] only_full_group_by 호환 방식으로 조회 쿼리를 수정한다. +- [x] 관련 응답/페이징 동작이 유지되는지 확인한다. +- [x] 변경 파일 진단과 테스트/빌드를 수행한다. + +## 검증 기록 + +### 1차 구현 +- 무엇을: `getCreatorFollowingAllList` 쿼리의 `groupBy` 컬럼을 `member.id`, `member.nickname`, `member.profileImage`, `creatorFollowing.isNotify`로 확장하고, 회귀 방지를 위해 `LiveRecommendRepositoryTest.shouldReturnFollowingCreatorListWithNotifyFlag` 테스트를 추가했다. +- 왜: `only_full_group_by` 모드에서 SELECT에 포함된 비집계 컬럼(`creatorFollowing.isNotify`)이 GROUP BY에 없어 발생하는 SQL 오류를 제거하고, 팔로잉 목록 응답(`isNotify` 포함) 동작을 재검증하기 위해서다. +- 어떻게: + - 명령: `./gradlew test --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendRepositoryTest.shouldReturnFollowingCreatorListWithNotifyFlag"` / 결과: 성공 + - 명령: `./gradlew build` / 결과: 성공 + - 명령: `lsp_diagnostics` / 결과: `.kt` 확장 LSP 미구성으로 실행 불가(대신 Gradle 컴파일/테스트 성공으로 검증) diff --git a/docs/20260313_크리에이터커뮤니티댓글알림딥링크적용.md b/docs/20260313_크리에이터커뮤니티댓글알림딥링크적용.md new file mode 100644 index 00000000..f2ecbd0e --- /dev/null +++ b/docs/20260313_크리에이터커뮤니티댓글알림딥링크적용.md @@ -0,0 +1,36 @@ +- [x] 요구사항/기존 패턴 확정: 크리에이터 커뮤니티 댓글 등록 시점에 푸시 발송 + 알림 리스트 저장 경로를 기존 FCM 이벤트 파이프라인으로 연결한다. + - QA: `CreatorCommunityService#createCommunityPostComment`, `FcmEvent`, `FcmSendListener`, `PushNotificationService` 흐름을 코드로 확인한다. +- [x] 딥링크 규칙 확정: 댓글 알림의 딥링크를 `voiceon://community/{creatorId}?postId={postId}`(테스트 환경은 `voiceon-test://community/{creatorId}?postId={postId}`)로 생성되도록 이벤트 메타를 설정한다. + - QA: `FcmService.buildDeepLink(serverEnv, deepLinkValue, deepLinkId, deepLinkCommentPostId)` 규칙과 `creatorId/postId` 매핑을 확인한다. +- [x] 댓글 등록 시 알림 이벤트 구현: 댓글 작성자가 크리에이터 본인이 아닌 경우에만 크리에이터 대상 `INDIVIDUAL` 이벤트를 발행한다. + - QA: 이벤트에 `category=COMMUNITY`, `deepLinkValue=COMMUNITY`, `deepLinkId=creatorId`, `deepLinkCommentPostId=postId`, `recipients=[creatorId]`가 포함되는지 확인한다. +- [x] 알림 문구 메시지 키 추가: 크리에이터 커뮤니티 댓글 알림용 다국어 키를 `SodaMessageSource`에 추가한다. + - QA: KO/EN/JA 값이 모두 존재하고 `messageKey`로 조회 가능해야 한다. +- [x] 검증 실행: 수정 파일 LSP 진단, 관련 테스트, 전체 빌드 실행 후 결과를 기록한다. + - QA: `./gradlew test`, `./gradlew build` 성공. + +## 완료 기준 (Acceptance Criteria) +- [x] 댓글 등록 API 호출 후(작성자 != 크리에이터) `FcmEvent`가 발행되어 크리에이터에게 푸시 전송 대상이 생성된다. +- [x] 동일 이벤트로 저장되는 알림 리스트의 `deepLink` 값이 푸시 payload `deep_link`와 동일 규칙으로 생성된다. +- [x] 댓글 알림 딥링크는 커뮤니티 전체보기 진입 경로(`community/{creatorId}`)를 유지하면서 대상 게시글 식별자(`postId`)를 포함한다. +- [x] 기존 커뮤니티 새 글 알림 및 다른 도메인 푸시 딥링크 동작에 회귀 영향이 없다. + +## 검증 기록 + +### 1차 구현 +- 무엇을: `CreatorCommunityService#createCommunityPostComment`에 댓글 등록 직후 크리에이터 대상 `FcmEventType.INDIVIDUAL` 이벤트 발행 로직을 추가했다. 이벤트에는 `category=COMMUNITY`, `messageKey=creator.community.fcm.new_comment`, `deepLinkValue=COMMUNITY`, `deepLinkId=creatorId`, `recipients=[creatorId]`를 설정했고, 크리에이터 본인 댓글은 알림을 발행하지 않도록 제외했다. 또한 `SodaMessageSource`에 `creator.community.fcm.new_comment` 다국어 메시지를 추가했다. +- 왜: 댓글 알림 수신자가 푸시 터치/알림 리스트 터치 시 동일 딥링크(`community/{creatorId}`)로 이동하도록, 기존 FCM 이벤트-알림 저장 공통 경로를 그대로 재사용하기 위해서다. +- 어떻게: + - `lsp_diagnostics` 실행 시도: Kotlin LSP 미구성으로 실행 불가(환경 한계) + - `./gradlew test --tests "*CreatorCommunityServiceTest"` 실행(성공) + - `./gradlew test` 실행(성공) + - `./gradlew build` 실행(성공) + +### 2차 수정 +- 무엇을: 커뮤니티 댓글 알림 딥링크에 `postId`를 함께 전달하도록 `FcmEvent`에 `deepLinkCommentPostId`를 추가하고, `FcmService.buildDeepLink`에서 커뮤니티 딥링크일 때 `?postId={postId}`를 붙이도록 수정했다. 이에 맞춰 `CreatorCommunityService`에서 댓글 등록 이벤트 발행 시 `deepLinkCommentPostId = postId`를 설정했고, `PushNotificationService`도 동일 딥링크 문자열을 알림 리스트에 저장하도록 반영했다. 테스트는 `CreatorCommunityServiceTest`, `PushNotificationServiceTest`를 보강했다. +- 왜: 기존 `community/{creatorId}`만으로는 어떤 게시글의 댓글 리스트를 열어야 하는지 식별할 수 없어, 커뮤니티 전체보기 진입은 유지하면서 대상 게시글 식별자를 함께 전달하기 위해서다. +- 어떻게: + - `lsp_diagnostics` 실행 시도: Kotlin LSP 미구성으로 실행 불가(환경 한계) + - `./gradlew test --tests "*CreatorCommunityServiceTest" --tests "*PushNotificationServiceTest"` 실행(성공) + - `./gradlew test` 실행(성공) + - `./gradlew build` 실행(성공) diff --git a/docs/20260313_푸시알림조회기간타임존정합성수정.md b/docs/20260313_푸시알림조회기간타임존정합성수정.md new file mode 100644 index 00000000..76ebfcd8 --- /dev/null +++ b/docs/20260313_푸시알림조회기간타임존정합성수정.md @@ -0,0 +1,19 @@ +## 작업 개요 + +- [x] `PushNotificationService`의 1주 조회 시작 시각 계산 기준을 저장 시각(`BaseEntity.createdAt`)과 동일한 시스템 기본 타임존으로 통일한다. +- [x] `getNotificationList` 및 `getAvailableCategories`가 동일한 1주일 범위를 유지하는지 확인한다. +- [x] 관련 import/함수명을 정리해 코드 가독성과 의도를 명확히 한다. +- [x] 변경 파일 진단과 Gradle 검증(`test`, `build`)을 수행하고 결과를 기록한다. + +--- + +## 검증 기록 + +### 1차 구현 + +- 무엇을: `PushNotificationService`의 조회 기간 계산을 UTC 기준에서 시스템 기본 타임존 기준으로 변경. +- 왜: `createdAt` 저장 시각이 시스템 기본 타임존(`LocalDateTime.now()`)이므로 조회 기준만 UTC를 사용하면 서버 타임존이 UTC가 아닐 때 실제 조회 기간이 7일과 어긋날 수 있음. +- 어떻게: + - `lsp_diagnostics` 실행: `.kt` 확장자용 LSP 서버 미설정으로 도구 진단 불가(환경 제약 확인). + - `./gradlew test` 실행: 성공(BUILD SUCCESSFUL). + - `./gradlew build` 실행: 성공(BUILD SUCCESSFUL). 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 a3b6c07d..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 @@ -5,8 +5,10 @@ import kr.co.vividnext.sodalive.admin.audition.role.AdminAuditionRoleRepository import kr.co.vividnext.sodalive.audition.AuditionStatus import kr.co.vividnext.sodalive.aws.s3.S3Uploader 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 @@ -91,11 +93,14 @@ 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), isAuth = audition.isAdult, - auditionId = audition.id ?: -1 + auditionId = audition.id ?: -1, + deepLinkValue = FcmDeepLinkValue.AUDITION, + deepLinkId = audition.id ) ) } 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 35cb6232..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 @@ -13,8 +13,10 @@ import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus 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 @@ -328,10 +330,15 @@ 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 + pushTokens = pushTokens, + roomId = room.id, + deepLinkValue = FcmDeepLinkValue.LIVE, + deepLinkId = 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 033bb73b..c6a457b9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -28,8 +28,10 @@ import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository import kr.co.vividnext.sodalive.content.translation.TranslatedContent import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository 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 @@ -459,11 +461,15 @@ 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 + contentId = contentId, + deepLinkValue = FcmDeepLinkValue.CONTENT, + deepLinkId = contentId ) ) @@ -473,12 +479,16 @@ 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, - creatorId = audioContent.member!!.id + creatorId = audioContent.member!!.id, + deepLinkValue = FcmDeepLinkValue.CONTENT, + deepLinkId = contentId ) ) } @@ -495,12 +505,16 @@ 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!!, - creatorId = audioContent.member!!.id + creatorId = audioContent.member!!.id, + deepLinkValue = FcmDeepLinkValue.CONTENT, + deepLinkId = 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 5413e497..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 @@ -5,8 +5,10 @@ import kr.co.vividnext.sodalive.content.AudioContentRepository import kr.co.vividnext.sodalive.content.LanguageDetectEvent import kr.co.vividnext.sodalive.content.LanguageDetectTargetType 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 @@ -78,6 +80,7 @@ class AudioContentCommentService( applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.CREATE_CONTENT_COMMENT, + category = PushNotificationCategory.CONTENT, title = if (parent != null) { parent.member!!.nickname } else { @@ -89,9 +92,12 @@ class AudioContentCommentService( "content.comment.notification.new" }, args = listOf(audioContent.title), + senderMemberId = member.id, contentId = audioContentId, commentParentId = parentId, - myMemberId = member.id + myMemberId = member.id, + deepLinkValue = FcmDeepLinkValue.CONTENT, + deepLinkId = audioContentId ) ) 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 f5d39792..4e7c555e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -19,8 +19,10 @@ import kr.co.vividnext.sodalive.explorer.profile.PostWriteCheersRequest import kr.co.vividnext.sodalive.explorer.profile.PutWriteCheersRequest import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationService import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService +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 @@ -665,9 +667,13 @@ class ExplorerService( applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.CHANGE_NOTICE, + category = PushNotificationCategory.COMMUNITY, title = member.nickname, messageKey = "explorer.notice.fcm.message", - creatorId = member.id!! + 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 8e827e69..5a07bf67 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 @@ -17,8 +17,10 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCo import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.PostCommunityPostLikeRequest import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.PostCommunityPostLikeResponse 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 @@ -122,9 +124,13 @@ class CreatorCommunityService( applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.CHANGE_NOTICE, + category = PushNotificationCategory.COMMUNITY, title = member.nickname, messageKey = "creator.community.fcm.new_post", - creatorId = member.id!! + senderMemberId = member.id, + creatorId = member.id!!, + deepLinkValue = FcmDeepLinkValue.COMMUNITY, + deepLinkId = member.id!! ) ) } @@ -376,7 +382,9 @@ class CreatorCommunityService( val post = repository.findByIdOrNull(id = postId) ?: throw SodaException(messageKey = "creator.community.invalid_post_retry") - if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = post.member!!.id!!)) { + val creatorId = post.member!!.id!! + + if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) { throw SodaException(messageKey = "creator.community.invalid_access_retry") } @@ -401,6 +409,22 @@ class CreatorCommunityService( } commentRepository.save(postComment) + + if (member.id != creatorId) { + applicationEventPublisher.publishEvent( + FcmEvent( + type = FcmEventType.INDIVIDUAL, + category = PushNotificationCategory.COMMUNITY, + title = member.nickname, + messageKey = "creator.community.fcm.new_comment", + senderMemberId = member.id, + recipients = listOf(creatorId), + deepLinkValue = FcmDeepLinkValue.COMMUNITY, + deepLinkId = creatorId, + deepLinkCommentPostId = postId + ) + ) + } } @Transactional 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 bbf0b7f2..c747b300 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 @@ -16,12 +18,25 @@ enum class FcmEventType { CREATE_CONTENT_COMMENT, IN_PROGRESS_AUDITION } +enum class FcmDeepLinkValue(val value: String) { + LIVE("live"), + CHANNEL("channel"), + CONTENT("content"), + SERIES("series"), + AUDITION("audition"), + COMMUNITY("community") +} + 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(), @@ -32,6 +47,9 @@ class FcmEvent( val messageId: Long? = null, val creatorId: Long? = null, val auditionId: Long? = null, + val deepLinkValue: FcmDeepLinkValue? = null, + val deepLinkId: Long? = null, + val deepLinkCommentPostId: Long? = null, val commentParentId: Long? = null, val myMemberId: Long? = null, val isAvailableJoinCreator: Boolean? = null, @@ -43,7 +61,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 @@ -154,6 +173,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 -> @@ -166,7 +192,10 @@ class FcmSendListener( contentId = contentId ?: fcmEvent.contentId, messageId = messageId ?: fcmEvent.messageId, creatorId = creatorId ?: fcmEvent.creatorId, - auditionId = auditionId ?: fcmEvent.auditionId + auditionId = auditionId ?: fcmEvent.auditionId, + deepLinkValue = fcmEvent.deepLinkValue, + deepLinkId = fcmEvent.deepLinkId, + deepLinkCommentPostId = fcmEvent.deepLinkCommentPostId ) } } 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 68e05701..f4824489 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt @@ -8,11 +8,16 @@ import com.google.firebase.messaging.MessagingErrorCode import com.google.firebase.messaging.MulticastMessage import com.google.firebase.messaging.Notification import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value import org.springframework.scheduling.annotation.Async import org.springframework.stereotype.Service @Service -class FcmService(private val pushTokenService: PushTokenService) { +class FcmService( + private val pushTokenService: PushTokenService, + @Value("\${server.env}") + private val serverEnv: String +) { private val logger = LoggerFactory.getLogger(this::class.java) @Async @@ -25,7 +30,10 @@ class FcmService(private val pushTokenService: PushTokenService) { messageId: Long? = null, contentId: Long? = null, creatorId: Long? = null, - auditionId: Long? = null + auditionId: Long? = null, + deepLinkValue: FcmDeepLinkValue? = null, + deepLinkId: Long? = null, + deepLinkCommentPostId: Long? = null ) { if (tokens.isEmpty()) return logger.info("os: $container") @@ -82,6 +90,11 @@ class FcmService(private val pushTokenService: PushTokenService) { multicastMessage.putData("audition_id", auditionId.toString()) } + val deepLink = createDeepLink(deepLinkValue, deepLinkId, deepLinkCommentPostId) + if (deepLink != null) { + multicastMessage.putData("deep_link", deepLink) + } + val response = FirebaseMessaging.getInstance().sendEachForMulticast(multicastMessage.build()) val failedTokens = mutableListOf() @@ -115,6 +128,14 @@ class FcmService(private val pushTokenService: PushTokenService) { } } + private fun createDeepLink( + deepLinkValue: FcmDeepLinkValue?, + deepLinkId: Long?, + deepLinkCommentPostId: Long? + ): String? { + return buildDeepLink(serverEnv, deepLinkValue, deepLinkId, deepLinkCommentPostId) + } + fun sendPointGranted(tokens: List, point: Int) { if (tokens.isEmpty()) return val data = mapOf( @@ -180,4 +201,30 @@ class FcmService(private val pushTokenService: PushTokenService) { logger.error("[FCM] ❌ 최종 실패 대상 ${targets.size}명 → $targets") } } + + companion object { + fun buildDeepLink( + serverEnv: String, + deepLinkValue: FcmDeepLinkValue?, + deepLinkId: Long?, + deepLinkCommentPostId: Long? = null + ): String? { + if (deepLinkValue == null || deepLinkId == null) { + return null + } + + val uriScheme = if (serverEnv.equals("voiceon", ignoreCase = true)) { + "voiceon" + } else { + "voiceon-test" + } + + val baseDeepLink = "$uriScheme://${deepLinkValue.value}/$deepLinkId" + if (deepLinkValue == FcmDeepLinkValue.COMMUNITY && deepLinkCommentPostId != null) { + return "$baseDeepLink?postId=$deepLinkCommentPostId" + } + + return baseDeepLink + } + } } 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..701516d6 --- /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( + "function('JSON_CONTAINS', {0}, function('JSON_ARRAY', {1}), '$') = 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..ba4ce54c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationService.kt @@ -0,0 +1,222 @@ +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 + +@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 = serverEnv, + deepLinkValue = fcmEvent.deepLinkValue, + deepLinkId = fcmEvent.deepLinkId, + deepLinkCommentPostId = fcmEvent.deepLinkCommentPostId + ) + + 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 = oneWeekAgo() + + 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 = oneWeekAgo() + ).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 oneWeekAgo(): LocalDateTime { + return LocalDateTime.now().minusWeeks(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..dcfdb908 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 "제목을 입력하세요.", @@ -2234,6 +2272,11 @@ class SodaMessageSource { Lang.EN to "A new post has been added.", Lang.JA to "新しい投稿が登録されました。" ), + "creator.community.fcm.new_comment" to mapOf( + Lang.KO to "커뮤니티 게시글에 새 댓글이 등록되었습니다.", + Lang.EN to "A new comment has been added to your community post.", + Lang.JA to "コミュニティ投稿に新しいコメントが登録されました。" + ), "creator.community.invalid_request_retry" to mapOf( Lang.KO to "잘못된 요청입니다.\n다시 시도해 주세요.", Lang.EN to "Invalid request.\ntry again.", @@ -2313,6 +2356,7 @@ class SodaMessageSource { adminPointPolicyMessages, adminMemberStatisticsMessages, messageMessages, + pushNotificationMessages, noticeMessages, reportMessages, imageValidationMessages, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt index e7592698..50901f77 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt @@ -262,7 +262,12 @@ class LiveRecommendRepository( .from(creatorFollowing) .innerJoin(creatorFollowing.creator, member) .where(where) - .groupBy(member.id) + .groupBy( + member.id, + member.nickname, + member.profileImage, + creatorFollowing.isNotify + ) .offset(offset) .limit(limit) .fetch() 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 d5d0e574..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 @@ -19,9 +19,11 @@ import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository 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.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 @@ -478,17 +480,21 @@ 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, roomId = createdRoom.id, creatorId = createdRoom.member!!.id, + deepLinkValue = FcmDeepLinkValue.LIVE, + deepLinkId = createdRoom.id, genderRestriction = createdRoom.genderRestriction ) ) @@ -655,13 +661,17 @@ 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, roomId = room.id, creatorId = room.member!!.id, + deepLinkValue = FcmDeepLinkValue.LIVE, + deepLinkId = room.id, genderRestriction = room.genderRestriction ) ) @@ -726,10 +736,15 @@ 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 + pushTokens = pushTokens, + roomId = room.id, + deepLinkValue = FcmDeepLinkValue.LIVE, + deepLinkId = 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/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt new file mode 100644 index 00000000..f3f53dae --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt @@ -0,0 +1,134 @@ +package kr.co.vividnext.sodalive.explorer.profile.creatorCommunity + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.can.use.UseCanRepository +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityCommentRepository +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository +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 +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.context.ApplicationEventPublisher +import java.util.Optional + +class CreatorCommunityServiceTest { + private lateinit var repository: CreatorCommunityRepository + private lateinit var blockMemberRepository: BlockMemberRepository + private lateinit var commentRepository: CreatorCommunityCommentRepository + private lateinit var useCanRepository: UseCanRepository + private lateinit var applicationEventPublisher: ApplicationEventPublisher + private lateinit var service: CreatorCommunityService + + @BeforeEach + fun setup() { + repository = Mockito.mock(CreatorCommunityRepository::class.java) + blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java) + commentRepository = Mockito.mock(CreatorCommunityCommentRepository::class.java) + useCanRepository = Mockito.mock(UseCanRepository::class.java) + applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + + service = CreatorCommunityService( + canPaymentService = Mockito.mock(CanPaymentService::class.java), + repository = repository, + blockMemberRepository = blockMemberRepository, + likeRepository = Mockito.mock(CreatorCommunityLikeRepository::class.java), + commentRepository = commentRepository, + useCanRepository = useCanRepository, + s3Uploader = Mockito.mock(S3Uploader::class.java), + objectMapper = ObjectMapper(), + audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java), + applicationEventPublisher = applicationEventPublisher, + messageSource = SodaMessageSource(), + langContext = LangContext(), + imageBucket = "image-bucket", + contentBucket = "content-bucket", + imageHost = "https://cdn.test" + ) + } + + @Test + @DisplayName("크리에이터가 아닌 사용자가 댓글 작성 시 크리에이터 대상 커뮤니티 딥링크 알림 이벤트를 발행한다") + fun shouldPublishCreatorCommunityCommentNotificationEventWhenCommenterIsNotCreator() { + val creator = createMember(id = 11L, role = MemberRole.CREATOR, nickname = "creator") + val commenter = createMember(id = 22L, role = MemberRole.USER, nickname = "viewer") + val post = CreatorCommunity(content = "post", price = 0, isCommentAvailable = true, isAdult = false) + post.id = 301L + post.member = creator + + Mockito.`when`(repository.findById(post.id!!)).thenReturn(Optional.of(post)) + Mockito.`when`(useCanRepository.isExistCommunityPostOrdered(post.id!!, commenter.id!!)).thenReturn(false) + Mockito.`when`(commentRepository.save(Mockito.any(CreatorCommunityComment::class.java))) + .thenAnswer { invocation -> invocation.getArgument(0) } + + service.createCommunityPostComment( + member = commenter, + comment = "새 댓글", + postId = post.id!!, + parentId = null, + isSecret = false + ) + + val captor = ArgumentCaptor.forClass(FcmEvent::class.java) + Mockito.verify(applicationEventPublisher).publishEvent(captor.capture()) + val event = captor.value + + assertEquals(FcmEventType.INDIVIDUAL, event.type) + assertEquals(PushNotificationCategory.COMMUNITY, event.category) + assertEquals("creator.community.fcm.new_comment", event.messageKey) + assertEquals(commenter.id, event.senderMemberId) + assertEquals(listOf(creator.id!!), event.recipients) + assertEquals(FcmDeepLinkValue.COMMUNITY, event.deepLinkValue) + assertEquals(creator.id, event.deepLinkId) + assertEquals(post.id, event.deepLinkCommentPostId) + } + + @Test + @DisplayName("크리에이터 본인이 댓글 작성 시 자기 자신 대상 알림 이벤트를 발행하지 않는다") + fun shouldNotPublishNotificationEventWhenCreatorCommentsOwnPost() { + val creator = createMember(id = 44L, role = MemberRole.CREATOR, nickname = "creator") + val post = CreatorCommunity(content = "post", price = 0, isCommentAvailable = true, isAdult = false) + post.id = 401L + post.member = creator + + Mockito.`when`(repository.findById(post.id!!)).thenReturn(Optional.of(post)) + Mockito.`when`(useCanRepository.isExistCommunityPostOrdered(post.id!!, creator.id!!)).thenReturn(false) + Mockito.`when`(commentRepository.save(Mockito.any(CreatorCommunityComment::class.java))) + .thenAnswer { invocation -> invocation.getArgument(0) } + + service.createCommunityPostComment( + member = creator, + comment = "내가 단 댓글", + postId = post.id!!, + parentId = null, + isSecret = false + ) + + Mockito.verify(applicationEventPublisher, Mockito.never()).publishEvent(Mockito.any()) + } + + 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/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..8848711d --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationServiceTest.kt @@ -0,0 +1,368 @@ +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 shouldAppendPostIdQueryForCommunityCommentDeepLinkWhenSavingNotification() { + val event = FcmEvent( + type = FcmEventType.INDIVIDUAL, + category = PushNotificationCategory.COMMUNITY, + senderMemberId = 500L, + recipients = listOf(10L), + deepLinkValue = FcmDeepLinkValue.COMMUNITY, + deepLinkId = 77L, + deepLinkCommentPostId = 999L + ) + val pushTokens = listOf(PushTokenInfo(token = "token-1", deviceType = "aos", languageCode = "ko")) + val sender = createMember(id = 500L, role = MemberRole.CREATOR, nickname = "creator") + + Mockito.`when`(pushTokenRepository.findMemberIdsByTokenIn(listOf("token-1"))).thenReturn(listOf(10L)) + Mockito.`when`(memberRepository.findById(500L)).thenReturn(Optional.of(sender)) + Mockito.`when`(pushNotificationListRepository.save(Mockito.any(PushNotificationList::class.java))) + .thenAnswer { invocation -> invocation.getArgument(0) } + + service.saveNotification( + fcmEvent = event, + languageCode = "ko", + translatedMessage = "새 댓글이 등록되었습니다.", + recipientPushTokens = pushTokens + ) + + val captor = ArgumentCaptor.forClass(PushNotificationList::class.java) + Mockito.verify(pushNotificationListRepository).save(captor.capture()) + val saved = captor.value + assertEquals("voiceon://community/77?postId=999", saved.deepLink) + } + + @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 + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt index bdd50ee4..f0a75628 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt @@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.block.BlockMember import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import kr.co.vividnext.sodalive.member.following.CreatorFollowing import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -72,12 +73,38 @@ class LiveRecommendRepositoryTest @Autowired constructor( assertEquals(creator.id, result[0].creatorId) } + @Test + fun shouldReturnFollowingCreatorListWithNotifyFlag() { + val viewer = saveMember(nickname = "viewer-following", role = MemberRole.USER) + val creatorA = saveMember(nickname = "creator-following-a", role = MemberRole.CREATOR) + val creatorB = saveMember(nickname = "creator-following-b", role = MemberRole.CREATOR) + + saveFollowing(member = viewer, creator = creatorA, isNotify = true) + saveFollowing(member = viewer, creator = creatorB, isNotify = false) + + entityManager.flush() + entityManager.clear() + + val result = liveRecommendRepository.getCreatorFollowingAllList( + memberId = viewer.id!!, + offset = 0, + limit = 20, + isBlocked = { false } + ) + + assertEquals(2, result.size) + val isNotifyByCreatorId = result.associate { it.creatorId to it.isNotify } + assertEquals(true, isNotifyByCreatorId[creatorA.id]) + assertEquals(false, isNotifyByCreatorId[creatorB.id]) + } + private fun saveMember(nickname: String, role: MemberRole): Member { return memberRepository.saveAndFlush( Member( email = "$nickname@test.com", password = "password", nickname = nickname, + profileImage = "profile/default-profile.png", role = role ) ) @@ -101,4 +128,14 @@ class LiveRecommendRepositoryTest @Autowired constructor( block.blockedMember = blockedMember blockMemberRepository.saveAndFlush(block) } + + private fun saveFollowing(member: Member, creator: Member, isNotify: Boolean) { + val following = CreatorFollowing( + isNotify = isNotify, + isActive = true + ) + following.member = member + following.creator = creator + entityManager.persist(following) + } }