16
docs/20260309_푸시딥링크검증.md
Normal file
16
docs/20260309_푸시딥링크검증.md
Normal file
@@ -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` 실행(성공)
|
||||||
|
- 코드 수정은 하지 않음(확인 작업만 수행).
|
||||||
29
docs/20260309_푸시딥링크파라미터추가.md
Normal file
29
docs/20260309_푸시딥링크파라미터추가.md
Normal file
@@ -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` 실행(성공)
|
||||||
179
docs/20260311_푸시알림리스트구현.md
Normal file
179
docs/20260311_푸시알림리스트구현.md
Normal 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` 실행(성공)
|
||||||
17
docs/20260312_푸시알림조회쿼리오류수정.md
Normal file
17
docs/20260312_푸시알림조회쿼리오류수정.md
Normal file
@@ -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`.
|
||||||
14
docs/20260313_라이브추천팔로잉전체채널조회그룹바이오류수정.md
Normal file
14
docs/20260313_라이브추천팔로잉전체채널조회그룹바이오류수정.md
Normal file
@@ -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 컴파일/테스트 성공으로 검증)
|
||||||
36
docs/20260313_크리에이터커뮤니티댓글알림딥링크적용.md
Normal file
36
docs/20260313_크리에이터커뮤니티댓글알림딥링크적용.md
Normal file
@@ -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` 실행(성공)
|
||||||
19
docs/20260313_푸시알림조회기간타임존정합성수정.md
Normal file
19
docs/20260313_푸시알림조회기간타임존정합성수정.md
Normal file
@@ -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).
|
||||||
@@ -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.audition.AuditionStatus
|
||||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
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.FcmEvent
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||||
|
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
@@ -91,11 +93,14 @@ class AdminAuditionService(
|
|||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.IN_PROGRESS_AUDITION,
|
type = FcmEventType.IN_PROGRESS_AUDITION,
|
||||||
|
category = PushNotificationCategory.AUDITION,
|
||||||
titleKey = "admin.audition.fcm.title.new",
|
titleKey = "admin.audition.fcm.title.new",
|
||||||
messageKey = "admin.audition.fcm.message.new",
|
messageKey = "admin.audition.fcm.message.new",
|
||||||
args = listOf(audition.title),
|
args = listOf(audition.title),
|
||||||
isAuth = audition.isAdult,
|
isAuth = audition.isAdult,
|
||||||
auditionId = audition.id ?: -1
|
auditionId = audition.id ?: -1,
|
||||||
|
deepLinkValue = FcmDeepLinkValue.AUDITION,
|
||||||
|
deepLinkId = audition.id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.UseCanCalculateRepository
|
||||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
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.FcmEvent
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
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.LangContext
|
||||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBanner
|
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBanner
|
||||||
@@ -328,10 +330,15 @@ class AdminLiveService(
|
|||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.CANCEL_LIVE,
|
type = FcmEventType.CANCEL_LIVE,
|
||||||
|
category = PushNotificationCategory.LIVE,
|
||||||
title = room.member!!.nickname,
|
title = room.member!!.nickname,
|
||||||
messageKey = "live.room.fcm.message.canceled",
|
messageKey = "live.room.fcm.message.canceled",
|
||||||
|
senderMemberId = room.member!!.id,
|
||||||
args = listOf(room.title),
|
args = listOf(room.title),
|
||||||
pushTokens = pushTokens
|
pushTokens = pushTokens,
|
||||||
|
roomId = room.id,
|
||||||
|
deepLinkValue = FcmDeepLinkValue.LIVE,
|
||||||
|
deepLinkId = room.id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
|||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
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.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
import kr.co.vividnext.sodalive.member.auth.AuthRepository
|
import kr.co.vividnext.sodalive.member.auth.AuthRepository
|
||||||
@@ -78,6 +79,7 @@ class ChargeEventService(
|
|||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.INDIVIDUAL,
|
type = FcmEventType.INDIVIDUAL,
|
||||||
|
category = PushNotificationCategory.MESSAGE,
|
||||||
title = chargeEvent.title,
|
title = chargeEvent.title,
|
||||||
messageKey = "can.charge.event.additional_can_paid",
|
messageKey = "can.charge.event.additional_can_paid",
|
||||||
args = listOf(additionalCan),
|
args = listOf(additionalCan),
|
||||||
@@ -101,6 +103,7 @@ class ChargeEventService(
|
|||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.INDIVIDUAL,
|
type = FcmEventType.INDIVIDUAL,
|
||||||
|
category = PushNotificationCategory.MESSAGE,
|
||||||
titleKey = "can.charge.event.first_title",
|
titleKey = "can.charge.event.first_title",
|
||||||
messageKey = "can.charge.event.additional_can_paid",
|
messageKey = "can.charge.event.additional_can_paid",
|
||||||
args = listOf(additionalCan),
|
args = listOf(additionalCan),
|
||||||
|
|||||||
@@ -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.content.translation.TranslatedContent
|
||||||
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
||||||
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
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.FcmEvent
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
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.LangContext
|
||||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||||
@@ -459,11 +461,15 @@ class AudioContentService(
|
|||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.INDIVIDUAL,
|
type = FcmEventType.INDIVIDUAL,
|
||||||
|
category = PushNotificationCategory.CONTENT,
|
||||||
titleKey = "content.notification.upload_complete_title",
|
titleKey = "content.notification.upload_complete_title",
|
||||||
message = audioContent.title,
|
message = audioContent.title,
|
||||||
|
senderMemberId = audioContent.member!!.id,
|
||||||
recipients = listOf(audioContent.member!!.id!!),
|
recipients = listOf(audioContent.member!!.id!!),
|
||||||
isAuth = null,
|
isAuth = null,
|
||||||
contentId = contentId
|
contentId = contentId,
|
||||||
|
deepLinkValue = FcmDeepLinkValue.CONTENT,
|
||||||
|
deepLinkId = contentId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -473,12 +479,16 @@ class AudioContentService(
|
|||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.UPLOAD_CONTENT,
|
type = FcmEventType.UPLOAD_CONTENT,
|
||||||
|
category = PushNotificationCategory.CONTENT,
|
||||||
title = audioContent.member!!.nickname,
|
title = audioContent.member!!.nickname,
|
||||||
messageKey = "content.notification.uploaded_message",
|
messageKey = "content.notification.uploaded_message",
|
||||||
|
senderMemberId = audioContent.member!!.id,
|
||||||
args = listOf(audioContent.title),
|
args = listOf(audioContent.title),
|
||||||
isAuth = audioContent.isAdult,
|
isAuth = audioContent.isAdult,
|
||||||
contentId = contentId,
|
contentId = contentId,
|
||||||
creatorId = audioContent.member!!.id
|
creatorId = audioContent.member!!.id,
|
||||||
|
deepLinkValue = FcmDeepLinkValue.CONTENT,
|
||||||
|
deepLinkId = contentId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -495,12 +505,16 @@ class AudioContentService(
|
|||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.UPLOAD_CONTENT,
|
type = FcmEventType.UPLOAD_CONTENT,
|
||||||
|
category = PushNotificationCategory.CONTENT,
|
||||||
title = audioContent.member!!.nickname,
|
title = audioContent.member!!.nickname,
|
||||||
messageKey = "content.notification.uploaded_message",
|
messageKey = "content.notification.uploaded_message",
|
||||||
|
senderMemberId = audioContent.member!!.id,
|
||||||
args = listOf(audioContent.title),
|
args = listOf(audioContent.title),
|
||||||
isAuth = audioContent.isAdult,
|
isAuth = audioContent.isAdult,
|
||||||
contentId = audioContent.id!!,
|
contentId = audioContent.id!!,
|
||||||
creatorId = audioContent.member!!.id
|
creatorId = audioContent.member!!.id,
|
||||||
|
deepLinkValue = FcmDeepLinkValue.CONTENT,
|
||||||
|
deepLinkId = audioContent.id!!
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import kr.co.vividnext.sodalive.content.AudioContentRepository
|
|||||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||||
import kr.co.vividnext.sodalive.content.order.OrderRepository
|
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.FcmEvent
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
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.LangContext
|
||||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
@@ -78,6 +80,7 @@ class AudioContentCommentService(
|
|||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.CREATE_CONTENT_COMMENT,
|
type = FcmEventType.CREATE_CONTENT_COMMENT,
|
||||||
|
category = PushNotificationCategory.CONTENT,
|
||||||
title = if (parent != null) {
|
title = if (parent != null) {
|
||||||
parent.member!!.nickname
|
parent.member!!.nickname
|
||||||
} else {
|
} else {
|
||||||
@@ -89,9 +92,12 @@ class AudioContentCommentService(
|
|||||||
"content.comment.notification.new"
|
"content.comment.notification.new"
|
||||||
},
|
},
|
||||||
args = listOf(audioContent.title),
|
args = listOf(audioContent.title),
|
||||||
|
senderMemberId = member.id,
|
||||||
contentId = audioContentId,
|
contentId = audioContentId,
|
||||||
commentParentId = parentId,
|
commentParentId = parentId,
|
||||||
myMemberId = member.id
|
myMemberId = member.id,
|
||||||
|
deepLinkValue = FcmDeepLinkValue.CONTENT,
|
||||||
|
deepLinkId = audioContentId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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.PutWriteCheersRequest
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationService
|
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationService
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService
|
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.FcmEvent
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
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.Lang
|
||||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
@@ -665,9 +667,13 @@ class ExplorerService(
|
|||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.CHANGE_NOTICE,
|
type = FcmEventType.CHANGE_NOTICE,
|
||||||
|
category = PushNotificationCategory.COMMUNITY,
|
||||||
title = member.nickname,
|
title = member.nickname,
|
||||||
messageKey = "explorer.notice.fcm.message",
|
messageKey = "explorer.notice.fcm.message",
|
||||||
creatorId = member.id!!
|
senderMemberId = member.id,
|
||||||
|
creatorId = member.id!!,
|
||||||
|
deepLinkValue = FcmDeepLinkValue.CHANNEL,
|
||||||
|
deepLinkId = member.id!!
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.PostCommunityPostLikeRequest
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.PostCommunityPostLikeResponse
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.PostCommunityPostLikeResponse
|
||||||
import kr.co.vividnext.sodalive.extensions.getTimeAgoString
|
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.FcmEvent
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
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.LangContext
|
||||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
@@ -122,9 +124,13 @@ class CreatorCommunityService(
|
|||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.CHANGE_NOTICE,
|
type = FcmEventType.CHANGE_NOTICE,
|
||||||
|
category = PushNotificationCategory.COMMUNITY,
|
||||||
title = member.nickname,
|
title = member.nickname,
|
||||||
messageKey = "creator.community.fcm.new_post",
|
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)
|
val post = repository.findByIdOrNull(id = postId)
|
||||||
?: throw SodaException(messageKey = "creator.community.invalid_post_retry")
|
?: 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")
|
throw SodaException(messageKey = "creator.community.invalid_access_retry")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,6 +409,22 @@ class CreatorCommunityService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
commentRepository.save(postComment)
|
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
|
@Transactional
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.fcm
|
package kr.co.vividnext.sodalive.fcm
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
@@ -21,6 +22,7 @@ class FcmController(private val applicationEventPublisher: ApplicationEventPubli
|
|||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.INDIVIDUAL,
|
type = FcmEventType.INDIVIDUAL,
|
||||||
|
category = PushNotificationCategory.SYSTEM,
|
||||||
title = request.title,
|
title = request.title,
|
||||||
isAuth = request.isAuth,
|
isAuth = request.isAuth,
|
||||||
message = request.message,
|
message = request.message,
|
||||||
@@ -31,6 +33,7 @@ class FcmController(private val applicationEventPublisher: ApplicationEventPubli
|
|||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.ALL,
|
type = FcmEventType.ALL,
|
||||||
|
category = PushNotificationCategory.SYSTEM,
|
||||||
title = request.title,
|
title = request.title,
|
||||||
message = request.message,
|
message = request.message,
|
||||||
isAuth = request.isAuth
|
isAuth = request.isAuth
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package kr.co.vividnext.sodalive.fcm
|
package kr.co.vividnext.sodalive.fcm
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
|
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.Lang
|
||||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
import kr.co.vividnext.sodalive.live.room.GenderRestriction
|
import kr.co.vividnext.sodalive.live.room.GenderRestriction
|
||||||
@@ -16,12 +18,25 @@ enum class FcmEventType {
|
|||||||
CREATE_CONTENT_COMMENT, IN_PROGRESS_AUDITION
|
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(
|
class FcmEvent(
|
||||||
val type: FcmEventType,
|
val type: FcmEventType,
|
||||||
|
val category: PushNotificationCategory? = null,
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
val message: String = "",
|
val message: String = "",
|
||||||
val titleKey: String? = null,
|
val titleKey: String? = null,
|
||||||
val messageKey: String? = null,
|
val messageKey: String? = null,
|
||||||
|
val senderMemberId: Long? = null,
|
||||||
|
val senderNicknameSnapshot: String? = null,
|
||||||
|
val senderProfileImageSnapshot: String? = null,
|
||||||
val args: List<Any> = listOf(),
|
val args: List<Any> = listOf(),
|
||||||
val container: String = "",
|
val container: String = "",
|
||||||
val recipients: List<Long> = listOf(),
|
val recipients: List<Long> = listOf(),
|
||||||
@@ -32,6 +47,9 @@ class FcmEvent(
|
|||||||
val messageId: Long? = null,
|
val messageId: Long? = null,
|
||||||
val creatorId: Long? = null,
|
val creatorId: Long? = null,
|
||||||
val auditionId: Long? = null,
|
val auditionId: Long? = null,
|
||||||
|
val deepLinkValue: FcmDeepLinkValue? = null,
|
||||||
|
val deepLinkId: Long? = null,
|
||||||
|
val deepLinkCommentPostId: Long? = null,
|
||||||
val commentParentId: Long? = null,
|
val commentParentId: Long? = null,
|
||||||
val myMemberId: Long? = null,
|
val myMemberId: Long? = null,
|
||||||
val isAvailableJoinCreator: Boolean? = null,
|
val isAvailableJoinCreator: Boolean? = null,
|
||||||
@@ -43,7 +61,8 @@ class FcmSendListener(
|
|||||||
private val pushService: FcmService,
|
private val pushService: FcmService,
|
||||||
private val memberRepository: MemberRepository,
|
private val memberRepository: MemberRepository,
|
||||||
private val contentCommentRepository: AudioContentCommentRepository,
|
private val contentCommentRepository: AudioContentCommentRepository,
|
||||||
private val messageSource: SodaMessageSource
|
private val messageSource: SodaMessageSource,
|
||||||
|
private val pushNotificationService: PushNotificationService
|
||||||
) {
|
) {
|
||||||
@Async
|
@Async
|
||||||
@TransactionalEventListener
|
@TransactionalEventListener
|
||||||
@@ -154,6 +173,13 @@ class FcmSendListener(
|
|||||||
val title = translate(fcmEvent.titleKey, fcmEvent.title, lang, fcmEvent.args)
|
val title = translate(fcmEvent.titleKey, fcmEvent.title, lang, fcmEvent.args)
|
||||||
val message = translate(fcmEvent.messageKey, fcmEvent.message, 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 }
|
val tokensByOS = tokens.groupBy { it.deviceType }
|
||||||
for ((os, osTokens) in tokensByOS) {
|
for ((os, osTokens) in tokensByOS) {
|
||||||
osTokens.map { it.token }.distinct().chunked(500).forEach { batch ->
|
osTokens.map { it.token }.distinct().chunked(500).forEach { batch ->
|
||||||
@@ -166,7 +192,10 @@ class FcmSendListener(
|
|||||||
contentId = contentId ?: fcmEvent.contentId,
|
contentId = contentId ?: fcmEvent.contentId,
|
||||||
messageId = messageId ?: fcmEvent.messageId,
|
messageId = messageId ?: fcmEvent.messageId,
|
||||||
creatorId = creatorId ?: fcmEvent.creatorId,
|
creatorId = creatorId ?: fcmEvent.creatorId,
|
||||||
auditionId = auditionId ?: fcmEvent.auditionId
|
auditionId = auditionId ?: fcmEvent.auditionId,
|
||||||
|
deepLinkValue = fcmEvent.deepLinkValue,
|
||||||
|
deepLinkId = fcmEvent.deepLinkId,
|
||||||
|
deepLinkCommentPostId = fcmEvent.deepLinkCommentPostId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,16 @@ import com.google.firebase.messaging.MessagingErrorCode
|
|||||||
import com.google.firebase.messaging.MulticastMessage
|
import com.google.firebase.messaging.MulticastMessage
|
||||||
import com.google.firebase.messaging.Notification
|
import com.google.firebase.messaging.Notification
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.scheduling.annotation.Async
|
import org.springframework.scheduling.annotation.Async
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
@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)
|
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||||
|
|
||||||
@Async
|
@Async
|
||||||
@@ -25,7 +30,10 @@ class FcmService(private val pushTokenService: PushTokenService) {
|
|||||||
messageId: Long? = null,
|
messageId: Long? = null,
|
||||||
contentId: Long? = null,
|
contentId: Long? = null,
|
||||||
creatorId: Long? = null,
|
creatorId: Long? = null,
|
||||||
auditionId: Long? = null
|
auditionId: Long? = null,
|
||||||
|
deepLinkValue: FcmDeepLinkValue? = null,
|
||||||
|
deepLinkId: Long? = null,
|
||||||
|
deepLinkCommentPostId: Long? = null
|
||||||
) {
|
) {
|
||||||
if (tokens.isEmpty()) return
|
if (tokens.isEmpty()) return
|
||||||
logger.info("os: $container")
|
logger.info("os: $container")
|
||||||
@@ -82,6 +90,11 @@ class FcmService(private val pushTokenService: PushTokenService) {
|
|||||||
multicastMessage.putData("audition_id", auditionId.toString())
|
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 response = FirebaseMessaging.getInstance().sendEachForMulticast(multicastMessage.build())
|
||||||
val failedTokens = mutableListOf<String>()
|
val failedTokens = mutableListOf<String>()
|
||||||
|
|
||||||
@@ -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<String>, point: Int) {
|
fun sendPointGranted(tokens: List<String>, point: Int) {
|
||||||
if (tokens.isEmpty()) return
|
if (tokens.isEmpty()) return
|
||||||
val data = mapOf(
|
val data = mapOf(
|
||||||
@@ -180,4 +201,30 @@ class FcmService(private val pushTokenService: PushTokenService) {
|
|||||||
logger.error("[FCM] ❌ 최종 실패 대상 ${targets.size}명 → $targets")
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface PushTokenQueryRepository {
|
|||||||
fun findByToken(token: String): PushToken?
|
fun findByToken(token: String): PushToken?
|
||||||
fun findByMemberId(memberId: Long): List<PushToken>
|
fun findByMemberId(memberId: Long): List<PushToken>
|
||||||
fun findByMemberIds(memberIds: List<Long>): List<PushToken>
|
fun findByMemberIds(memberIds: List<Long>): List<PushToken>
|
||||||
|
fun findMemberIdsByTokenIn(tokens: List<String>): List<Long>
|
||||||
}
|
}
|
||||||
|
|
||||||
class PushTokenQueryRepositoryImpl(
|
class PushTokenQueryRepositoryImpl(
|
||||||
@@ -36,4 +37,19 @@ class PushTokenQueryRepositoryImpl(
|
|||||||
.where(pushToken.member.id.`in`(memberIds))
|
.where(pushToken.member.id.`in`(memberIds))
|
||||||
.fetch()
|
.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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
"function('JSON_CONTAINS', {0}, function('JSON_ARRAY', {1}), '$') = 1",
|
||||||
|
recipientChunk.recipientMemberIds,
|
||||||
|
memberId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.exists()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
@@ -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<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 = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
private val noticeMessages = mapOf(
|
||||||
"notice.error.title_required" to mapOf(
|
"notice.error.title_required" to mapOf(
|
||||||
Lang.KO to "제목을 입력하세요.",
|
Lang.KO to "제목을 입력하세요.",
|
||||||
@@ -2234,6 +2272,11 @@ class SodaMessageSource {
|
|||||||
Lang.EN to "A new post has been added.",
|
Lang.EN to "A new post has been added.",
|
||||||
Lang.JA to "新しい投稿が登録されました。"
|
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(
|
"creator.community.invalid_request_retry" to mapOf(
|
||||||
Lang.KO to "잘못된 요청입니다.\n다시 시도해 주세요.",
|
Lang.KO to "잘못된 요청입니다.\n다시 시도해 주세요.",
|
||||||
Lang.EN to "Invalid request.\ntry again.",
|
Lang.EN to "Invalid request.\ntry again.",
|
||||||
@@ -2313,6 +2356,7 @@ class SodaMessageSource {
|
|||||||
adminPointPolicyMessages,
|
adminPointPolicyMessages,
|
||||||
adminMemberStatisticsMessages,
|
adminMemberStatisticsMessages,
|
||||||
messageMessages,
|
messageMessages,
|
||||||
|
pushNotificationMessages,
|
||||||
noticeMessages,
|
noticeMessages,
|
||||||
reportMessages,
|
reportMessages,
|
||||||
imageValidationMessages,
|
imageValidationMessages,
|
||||||
|
|||||||
@@ -262,7 +262,12 @@ class LiveRecommendRepository(
|
|||||||
.from(creatorFollowing)
|
.from(creatorFollowing)
|
||||||
.innerJoin(creatorFollowing.creator, member)
|
.innerJoin(creatorFollowing.creator, member)
|
||||||
.where(where)
|
.where(where)
|
||||||
.groupBy(member.id)
|
.groupBy(
|
||||||
|
member.id,
|
||||||
|
member.nickname,
|
||||||
|
member.profileImage,
|
||||||
|
creatorFollowing.isNotify
|
||||||
|
)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.fetch()
|
.fetch()
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
|||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
||||||
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
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.FcmEvent
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||||
import kr.co.vividnext.sodalive.fcm.PushTokenRepository
|
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.LangContext
|
||||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository
|
import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository
|
||||||
@@ -478,17 +480,21 @@ class LiveRoomService(
|
|||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.CREATE_LIVE,
|
type = FcmEventType.CREATE_LIVE,
|
||||||
|
category = PushNotificationCategory.LIVE,
|
||||||
title = createdRoom.member!!.nickname,
|
title = createdRoom.member!!.nickname,
|
||||||
messageKey = if (createdRoom.channelName != null) {
|
messageKey = if (createdRoom.channelName != null) {
|
||||||
"live.room.fcm.message.started"
|
"live.room.fcm.message.started"
|
||||||
} else {
|
} else {
|
||||||
"live.room.fcm.message.reserved"
|
"live.room.fcm.message.reserved"
|
||||||
},
|
},
|
||||||
|
senderMemberId = createdRoom.member!!.id,
|
||||||
args = listOf(createdRoom.title),
|
args = listOf(createdRoom.title),
|
||||||
isAuth = createdRoom.isAdult,
|
isAuth = createdRoom.isAdult,
|
||||||
isAvailableJoinCreator = createdRoom.isAvailableJoinCreator,
|
isAvailableJoinCreator = createdRoom.isAvailableJoinCreator,
|
||||||
roomId = createdRoom.id,
|
roomId = createdRoom.id,
|
||||||
creatorId = createdRoom.member!!.id,
|
creatorId = createdRoom.member!!.id,
|
||||||
|
deepLinkValue = FcmDeepLinkValue.LIVE,
|
||||||
|
deepLinkId = createdRoom.id,
|
||||||
genderRestriction = createdRoom.genderRestriction
|
genderRestriction = createdRoom.genderRestriction
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -655,13 +661,17 @@ class LiveRoomService(
|
|||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.START_LIVE,
|
type = FcmEventType.START_LIVE,
|
||||||
|
category = PushNotificationCategory.LIVE,
|
||||||
title = room.member!!.nickname,
|
title = room.member!!.nickname,
|
||||||
messageKey = "live.room.fcm.message.started",
|
messageKey = "live.room.fcm.message.started",
|
||||||
|
senderMemberId = room.member!!.id,
|
||||||
args = listOf(room.title),
|
args = listOf(room.title),
|
||||||
isAuth = room.isAdult,
|
isAuth = room.isAdult,
|
||||||
isAvailableJoinCreator = room.isAvailableJoinCreator,
|
isAvailableJoinCreator = room.isAvailableJoinCreator,
|
||||||
roomId = room.id,
|
roomId = room.id,
|
||||||
creatorId = room.member!!.id,
|
creatorId = room.member!!.id,
|
||||||
|
deepLinkValue = FcmDeepLinkValue.LIVE,
|
||||||
|
deepLinkId = room.id,
|
||||||
genderRestriction = room.genderRestriction
|
genderRestriction = room.genderRestriction
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -726,10 +736,15 @@ class LiveRoomService(
|
|||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.CANCEL_LIVE,
|
type = FcmEventType.CANCEL_LIVE,
|
||||||
|
category = PushNotificationCategory.LIVE,
|
||||||
title = room.member!!.nickname,
|
title = room.member!!.nickname,
|
||||||
messageKey = "live.room.fcm.message.canceled",
|
messageKey = "live.room.fcm.message.canceled",
|
||||||
|
senderMemberId = room.member!!.id,
|
||||||
args = listOf(room.title),
|
args = listOf(room.title),
|
||||||
pushTokens = pushTokens
|
pushTokens = pushTokens,
|
||||||
|
roomId = room.id,
|
||||||
|
deepLinkValue = FcmDeepLinkValue.LIVE,
|
||||||
|
deepLinkId = room.id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
|||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
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.LangContext
|
||||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
@@ -72,8 +73,10 @@ class MessageService(
|
|||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.SEND_MESSAGE,
|
type = FcmEventType.SEND_MESSAGE,
|
||||||
|
category = PushNotificationCategory.MESSAGE,
|
||||||
titleKey = "message.fcm.title",
|
titleKey = "message.fcm.title",
|
||||||
messageKey = "message.fcm.text_received",
|
messageKey = "message.fcm.text_received",
|
||||||
|
senderMemberId = sender.id,
|
||||||
args = listOf(sender.nickname),
|
args = listOf(sender.nickname),
|
||||||
messageId = message.id
|
messageId = message.id
|
||||||
)
|
)
|
||||||
@@ -145,8 +148,10 @@ class MessageService(
|
|||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.SEND_MESSAGE,
|
type = FcmEventType.SEND_MESSAGE,
|
||||||
|
category = PushNotificationCategory.MESSAGE,
|
||||||
titleKey = "message.fcm.title",
|
titleKey = "message.fcm.title",
|
||||||
messageKey = "message.fcm.voice_received",
|
messageKey = "message.fcm.voice_received",
|
||||||
|
senderMemberId = sender.id,
|
||||||
args = listOf(sender.nickname),
|
args = listOf(sender.nickname),
|
||||||
messageId = message.id
|
messageId = message.id
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>(0)
|
||||||
|
val lang = invocation.getArgument<Lang>(1)
|
||||||
|
messages[key]?.get(lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun anyLang(): Lang {
|
||||||
|
Mockito.any(Lang::class.java)
|
||||||
|
return Lang.KO
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.member.MemberRepository
|
|||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
import kr.co.vividnext.sodalive.member.block.BlockMember
|
import kr.co.vividnext.sodalive.member.block.BlockMember
|
||||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
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.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.BeforeEach
|
import org.junit.jupiter.api.BeforeEach
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
@@ -72,12 +73,38 @@ class LiveRecommendRepositoryTest @Autowired constructor(
|
|||||||
assertEquals(creator.id, result[0].creatorId)
|
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 {
|
private fun saveMember(nickname: String, role: MemberRole): Member {
|
||||||
return memberRepository.saveAndFlush(
|
return memberRepository.saveAndFlush(
|
||||||
Member(
|
Member(
|
||||||
email = "$nickname@test.com",
|
email = "$nickname@test.com",
|
||||||
password = "password",
|
password = "password",
|
||||||
nickname = nickname,
|
nickname = nickname,
|
||||||
|
profileImage = "profile/default-profile.png",
|
||||||
role = role
|
role = role
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -101,4 +128,14 @@ class LiveRecommendRepositoryTest @Autowired constructor(
|
|||||||
block.blockedMember = blockedMember
|
block.blockedMember = blockedMember
|
||||||
blockMemberRepository.saveAndFlush(block)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user