test #399

Merged
klaus merged 6 commits from test into main 2026-03-13 13:18:26 +00:00
32 changed files with 1761 additions and 15 deletions

View 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` 실행(성공)
- 코드 수정은 하지 않음(확인 작업만 수행).

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

View File

@@ -0,0 +1,179 @@
- [x] 요구사항 확정: 푸시 발송 내용을 알림 리스트에 적재하고, 미수신 상황에서도 조회 가능하도록 범위를 고정한다.
- [x] 도메인 모델 설계: 알림 본문/발송자 스냅샷/카테고리/딥링크/언어코드/수신자 청크(JSON 배열) 저장 구조를 JPA 엔티티로 정의한다.
- [x] 푸시 적재 로직 구현: 수신자가 없으면 저장하지 않고, 언어별 데이터로 분리 저장하며 수신자 ID를 청크 단위(JSON 배열)로 기록한다.
- [x] 조회 기간 제한 구현: 알림 조회는 최근 1개월 데이터만 조회하도록 서비스/리포지토리에 공통 조건을 적용한다.
- [x] API 구현: 인증 사용자 기준 알림 목록 조회 API(전체/카테고리별)와 알림 존재 카테고리 조회 API를 구현한다.
- [x] 카테고리 다국어 응답 구현: 카테고리 조회 API 응답을 현재 기기 언어(ko/en/ja) 라벨로 반환한다.
- [x] 페이징 구현: Pageable 파라미터를 사용해 offset/limit 기반 조회를 적용한다.
- [x] 시간 포맷 구현: 발송시간을 UTC 기반 String으로 응답 DTO에 포함한다.
- [x] TDD 구현: 스프링 컨테이너 없이 실행 가능한 단위 테스트를 먼저 작성하고, 구현 후 테스트를 통과시킨다.
- [x] SQL 문서화: 신규 테이블 생성 SQL 및 추가 인덱스 SQL(MySQL, TIMESTAMP NOT NULL)을 문서 하단에 기록한다.
## API 상세 작업 계획
### 1) GET `/push/notification/list`
- 목적: 인증 사용자의 알림 리스트를 현재 기기 언어 기준으로 조회한다.
- 요청 파라미터:
- `page`, `size`, `sort` (Pageable)
- `category` (선택, 없으면 전체 조회)
- 처리 규칙:
- 인증 사용자(`Member?`) null이면 `SodaException(messageKey = "common.error.bad_credentials")`
- 현재 요청 언어(`LangContext.lang.code`)와 일치하는 알림만 조회
- 조회 범위는 `now(UTC) - 1개월` 이후 데이터만 허용
- `category` 미지정 시 전체 카테고리 조회
- `category`는 코드(`live`) 또는 다국어 라벨(`라이브`/`Live`/`ライブ`) 입력을 허용한다
- `category``전체`/`All`/`すべて`이면 전체 카테고리 조회로 처리한다
- 수신자 청크(JSON 배열)에 인증 사용자 ID가 포함된 알림만 조회
- 응답 항목:
- 발송자 스냅샷(닉네임, 프로필 이미지)
- 발송 메시지
- 카테고리
- 딥링크
- 발송시간(UTC String)
- 구현 작업:
- [x] Controller: 인증/파라미터/ApiResponse 처리
- [x] Service: 1개월/언어/카테고리/페이지 조건 조합
- [x] Repository: 수신자 청크 JSON membership + pageable 조회 + totalCount
- [x] DTO: `GetPushNotificationListResponse`, `PushNotificationListItem` 정의
### 2) GET `/push/notification/categories`
- 목적: 인증 사용자 기준으로 알림 데이터가 실제 존재하는 카테고리만 조회한다.
- 요청 파라미터: 없음
- 처리 규칙:
- 인증 필수
- 현재 요청 언어 기준 데이터만 대상
- 최근 1개월 데이터만 대상
- 수신자 청크(JSON 배열)에 인증 사용자 ID가 포함된 데이터만 대상
- 응답 항목:
- 카테고리 목록(현재 기기 언어 라벨)
- 구현 작업:
- [x] Controller: 인증/ApiResponse 처리
- [x] Service: 중복 제거된 카테고리 목록 반환
- [x] Repository: 사용자/언어/기간 기반 카테고리 distinct 조회
- [x] DTO: `GetPushNotificationCategoryResponse` 정의
## 비API 작업 계획
- [x] FCM 이벤트 모델 확장: 알림 리스트 적재에 필요한 카테고리/발송자 스냅샷 정보를 이벤트에 포함한다.
- [x] FCM 전송 리스너 연동: 언어별 푸시 전송 시점에 알림 리스트 저장 서비스를 호출한다.
- [x] 발송자 스냅샷 처리: 이벤트 스냅샷 우선 사용, 없으면 발송자 ID 기반 조회로 보완한다.
- [x] 딥링크 저장 처리: 현재 푸시 딥링크 규칙과 동일한 값으로 저장한다.
- [x] 수신자 청크 저장 처리: 수신자 ID를 고정 크기 청크로 분할해 JSON 배열로 저장한다.
- [x] 수신자 미존재 처리: 최종 수신자 ID가 비어 있으면 알림 자체를 저장하지 않는다.
## 테스트(TDD) 계획
- [x] 단위 테스트: 알림 저장 서비스가 수신자 없음/언어별 분리/청크 분할/스냅샷 저장을 정확히 처리하는지 검증한다.
- [x] 단위 테스트: 조회 서비스가 1개월 제한/언어 필터/카테고리 옵션/pageable 전달을 정확히 적용하는지 검증한다.
- [x] 단위 테스트: 카테고리 조회 서비스가 사용자/언어/기간 기준 distinct 결과를 반환하는지 검증한다.
- [x] 단위 테스트: 컨트롤러가 인증 실패 시 에러 응답을 반환하고, 정상 시 서비스 호출 파라미터를 올바르게 전달하는지 검증한다.
## SQL 초안 (구현 확정)
### 1) 신규 테이블 생성 SQL (MySQL)
```sql
CREATE TABLE push_notification_list
(
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'PK',
sender_nickname_snapshot VARCHAR(255) NOT NULL COMMENT '발송자 닉네임 스냅샷',
sender_profile_image_snapshot VARCHAR(500) NULL COMMENT '발송자 프로필 이미지 스냅샷',
message TEXT NOT NULL COMMENT '발송 메시지',
category VARCHAR(20) NOT NULL COMMENT '발송 카테고리',
deep_link VARCHAR(500) NULL COMMENT '딥링크',
language_code VARCHAR(8) NOT NULL COMMENT '언어 코드',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각(UTC)',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각(UTC)'
) COMMENT ='푸시 알림 리스트';
CREATE TABLE push_notification_recipient_chunk
(
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'PK',
notification_id BIGINT NOT NULL COMMENT '알림 ID',
recipient_member_ids JSON NOT NULL COMMENT '수신자 회원 ID 청크(JSON 배열)',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각(UTC)',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각(UTC)',
CONSTRAINT fk_push_notification_recipient_chunk_notification
FOREIGN KEY (notification_id) REFERENCES push_notification_list (id)
) COMMENT ='푸시 알림 수신자 청크';
```
### 2) 추가 인덱스 SQL (MySQL)
```sql
ALTER TABLE push_notification_list
ADD INDEX idx_push_notification_list_language_created (language_code, created_at, id),
ADD INDEX idx_push_notification_list_category_language_created (category, language_code, created_at, id);
ALTER TABLE push_notification_recipient_chunk
ADD INDEX idx_push_notification_recipient_chunk_notification (notification_id);
-- MySQL 8.0.17+ 환경에서 JSON 배열 membership 최적화가 필요할 때 사용
ALTER TABLE push_notification_recipient_chunk
ADD INDEX idx_push_notification_recipient_chunk_member_ids_mvi ((CAST(recipient_member_ids AS UNSIGNED ARRAY)));
```
#### MVI 조건부 적용 가이드 (짧게)
- MySQL 8.0.17+ 환경이면 인덱스를 먼저 추가해 둔다.
- 실제 사용 여부는 옵티마이저가 쿼리 조건과 비용을 보고 결정하므로 `EXPLAIN`으로 확인한다.
- 현재 조회 조건처럼 `JSON_CONTAINS(JSON컬럼, JSON_ARRAY(값), '$')` 형태일 때 사용 후보가 된다.
- 인덱스가 선택되지 않아도 기능 오동작은 없지만, 쓰기/저장공간 비용은 항상 발생한다.
## 검증 기록
### 1차 구현
- 무엇을: 푸시 발송 시 언어별 메시지를 알림 리스트로 적재하는 `PushNotificationService`와 관련 JPA 엔티티/리포지토리/조회 API 2종(`/push/notification/list`, `/push/notification/categories`)을 추가하고, 기존 `FcmEvent` 발행 지점에 카테고리/발송자 스냅샷 소스를 연결했다.
- 왜: 푸시를 놓친 사용자도 최근 1개월 내 알림을 현재 기기 언어 기준으로 확인하고, 카테고리별 필터/카테고리 존재 여부를 조회할 수 있어야 하기 때문이다.
- 어떻게:
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
- `./gradlew test` 실행(성공)
- `./gradlew build` 실행(초기 실패: ktlint import 정렬 위반)
- `./gradlew ktlintFormat` 실행(성공)
- `./gradlew test` 재실행(성공)
- `./gradlew build` 재실행(성공)
- Kotlin LSP 미구성으로 `lsp_diagnostics`는 실행 불가, Gradle test/build/ktlint로 대체 검증
### 2차 수정
- 무엇을: `PushNotificationRecipientChunk``chunkOrder` 필드를 제거하고, 저장 로직/문서 SQL(컬럼 및 인덱스)을 함께 정리했다.
- 왜: 저장 시점에만 값이 할당되고 조회/정렬/필터에서 실제 사용되지 않아 불필요한 데이터였기 때문이다.
- 어떻게:
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
- `./gradlew build` 실행(성공)
### 3차 수정
- 무엇을: 알림 리스트 조회를 `PushNotificationListRow -> service map` 구조에서 `PushNotificationListItem` 직접 프로젝션 구조로 변경하고, 조회/카운트 쿼리에서 `innerJoin + distinct/countDistinct`를 제거해 `EXISTS` 기반 JSON membership 필터로 최적화했다.
- 왜: 중간 변환 객체가 불필요하고, 조인 기반 중복 제거 비용(distinct/countDistinct)이 커질 수 있어 페이지 조회 성능을 개선하기 위해서다.
- 어떻게:
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
- `./gradlew build` 실행(성공)
### 4차 수정
- 무엇을: `sentAt` 포맷을 DB `DATE_FORMAT` 문자열 생성 방식에서 `PushNotificationListItem` QueryProjection 생성자 기반 UTC Instant 문자열(`...Z`) 생성 방식으로 변경했다.
- 왜: `GetLatestFinishedLiveResponse.dateUtc`와 동일하게 애플리케이션 레이어에서 명시적 UTC 변환을 적용해 포맷/의미 일관성을 맞추기 위해서다.
- 어떻게:
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
- `./gradlew build` 실행(성공)
### 5차 수정
- 무엇을: `getAvailableCategories`가 카테고리 코드를 그대로 반환하던 동작을, 현재 기기 언어(`ko/en/ja`)에 맞는 카테고리 라벨을 반환하도록 변경했다.
- 왜: 카테고리 조회 응답을 조회 기기 언어에 따라 한글/영어/일본어로 내려주어야 하기 때문이다.
- 어떻게:
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
- `./gradlew build` 실행(성공)
### 6차 수정
- 무엇을: `getAvailableCategories` 응답 리스트 맨 앞에 `전체` 항목을 고정 추가하고, `ko/en/ja` 다국어 라벨로 반환하도록 변경했다.
- 왜: 카테고리 필터 UI에서 전체 조회 옵션이 항상 첫 번째로 필요하기 때문이다.
- 어떻게:
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
- `./gradlew build` 실행(성공)
### 7차 수정
- 무엇을: `getNotificationList``category` 입력이 한글/영어/일본어 라벨(`라이브`/`Live`/`ライブ` 등)도 파싱되도록 확장하고, `전체`/`All`/`すべて` 입력은 전체 조회로 처리하도록 수정했다.
- 왜: 카테고리 조회 API가 다국어 라벨을 반환하므로, 목록 조회 API도 동일 라벨 입력을 처리할 수 있어야 하기 때문이다.
- 어떻게:
- `./gradlew test --tests "*PushNotificationServiceTest"` 실행(성공)
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
- `./gradlew build` 실행(성공)
### 8차 수정
- 무엇을: 추가 인덱스 SQL 하단에 MVI 인덱스의 조건부 사용 가이드를 짧게 추가했다.
- 왜: 인덱스는 선반영 가능하지만 실제 사용은 쿼리/옵티마이저 조건에 따라 달라진다는 점을 문서에 명시하기 위해서다.
- 어떻게:
- `./gradlew tasks --all` 실행(성공)

View File

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

View 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 컴파일/테스트 성공으로 검증)

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

View 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).

View File

@@ -5,8 +5,10 @@ import kr.co.vividnext.sodalive.admin.audition.role.AdminAuditionRoleRepository
import kr.co.vividnext.sodalive.audition.AuditionStatus
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
@@ -91,11 +93,14 @@ class AdminAuditionService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.IN_PROGRESS_AUDITION,
category = PushNotificationCategory.AUDITION,
titleKey = "admin.audition.fcm.title.new",
messageKey = "admin.audition.fcm.message.new",
args = listOf(audition.title),
isAuth = audition.isAdult,
auditionId = audition.id ?: -1
auditionId = audition.id ?: -1,
deepLinkValue = FcmDeepLinkValue.AUDITION,
deepLinkId = audition.id
)
)
}

View File

@@ -13,8 +13,10 @@ import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBanner
@@ -328,10 +330,15 @@ class AdminLiveService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.CANCEL_LIVE,
category = PushNotificationCategory.LIVE,
title = room.member!!.nickname,
messageKey = "live.room.fcm.message.canceled",
senderMemberId = room.member!!.id,
args = listOf(room.title),
pushTokens = pushTokens
pushTokens = pushTokens,
roomId = room.id,
deepLinkValue = FcmDeepLinkValue.LIVE,
deepLinkId = room.id
)
)
}

View File

@@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.auth.AuthRepository
@@ -78,6 +79,7 @@ class ChargeEventService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.INDIVIDUAL,
category = PushNotificationCategory.MESSAGE,
title = chargeEvent.title,
messageKey = "can.charge.event.additional_can_paid",
args = listOf(additionalCan),
@@ -101,6 +103,7 @@ class ChargeEventService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.INDIVIDUAL,
category = PushNotificationCategory.MESSAGE,
titleKey = "can.charge.event.first_title",
messageKey = "can.charge.event.additional_can_paid",
args = listOf(additionalCan),

View File

@@ -28,8 +28,10 @@ import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.content.translation.TranslatedContent
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
@@ -459,11 +461,15 @@ class AudioContentService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.INDIVIDUAL,
category = PushNotificationCategory.CONTENT,
titleKey = "content.notification.upload_complete_title",
message = audioContent.title,
senderMemberId = audioContent.member!!.id,
recipients = listOf(audioContent.member!!.id!!),
isAuth = null,
contentId = contentId
contentId = contentId,
deepLinkValue = FcmDeepLinkValue.CONTENT,
deepLinkId = contentId
)
)
@@ -473,12 +479,16 @@ class AudioContentService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.UPLOAD_CONTENT,
category = PushNotificationCategory.CONTENT,
title = audioContent.member!!.nickname,
messageKey = "content.notification.uploaded_message",
senderMemberId = audioContent.member!!.id,
args = listOf(audioContent.title),
isAuth = audioContent.isAdult,
contentId = contentId,
creatorId = audioContent.member!!.id
creatorId = audioContent.member!!.id,
deepLinkValue = FcmDeepLinkValue.CONTENT,
deepLinkId = contentId
)
)
}
@@ -495,12 +505,16 @@ class AudioContentService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.UPLOAD_CONTENT,
category = PushNotificationCategory.CONTENT,
title = audioContent.member!!.nickname,
messageKey = "content.notification.uploaded_message",
senderMemberId = audioContent.member!!.id,
args = listOf(audioContent.title),
isAuth = audioContent.isAdult,
contentId = audioContent.id!!,
creatorId = audioContent.member!!.id
creatorId = audioContent.member!!.id,
deepLinkValue = FcmDeepLinkValue.CONTENT,
deepLinkId = audioContent.id!!
)
)
}

View File

@@ -5,8 +5,10 @@ import kr.co.vividnext.sodalive.content.AudioContentRepository
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
import kr.co.vividnext.sodalive.content.order.OrderRepository
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
@@ -78,6 +80,7 @@ class AudioContentCommentService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.CREATE_CONTENT_COMMENT,
category = PushNotificationCategory.CONTENT,
title = if (parent != null) {
parent.member!!.nickname
} else {
@@ -89,9 +92,12 @@ class AudioContentCommentService(
"content.comment.notification.new"
},
args = listOf(audioContent.title),
senderMemberId = member.id,
contentId = audioContentId,
commentParentId = parentId,
myMemberId = member.id
myMemberId = member.id,
deepLinkValue = FcmDeepLinkValue.CONTENT,
deepLinkId = audioContentId
)
)

View File

@@ -19,8 +19,10 @@ import kr.co.vividnext.sodalive.explorer.profile.PostWriteCheersRequest
import kr.co.vividnext.sodalive.explorer.profile.PutWriteCheersRequest
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationService
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
@@ -665,9 +667,13 @@ class ExplorerService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.CHANGE_NOTICE,
category = PushNotificationCategory.COMMUNITY,
title = member.nickname,
messageKey = "explorer.notice.fcm.message",
creatorId = member.id!!
senderMemberId = member.id,
creatorId = member.id!!,
deepLinkValue = FcmDeepLinkValue.CHANNEL,
deepLinkId = member.id!!
)
)
}

View File

@@ -17,8 +17,10 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCo
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.PostCommunityPostLikeRequest
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.PostCommunityPostLikeResponse
import kr.co.vividnext.sodalive.extensions.getTimeAgoString
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
@@ -122,9 +124,13 @@ class CreatorCommunityService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.CHANGE_NOTICE,
category = PushNotificationCategory.COMMUNITY,
title = member.nickname,
messageKey = "creator.community.fcm.new_post",
creatorId = member.id!!
senderMemberId = member.id,
creatorId = member.id!!,
deepLinkValue = FcmDeepLinkValue.COMMUNITY,
deepLinkId = member.id!!
)
)
}
@@ -376,7 +382,9 @@ class CreatorCommunityService(
val post = repository.findByIdOrNull(id = postId)
?: throw SodaException(messageKey = "creator.community.invalid_post_retry")
if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = post.member!!.id!!)) {
val creatorId = post.member!!.id!!
if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) {
throw SodaException(messageKey = "creator.community.invalid_access_retry")
}
@@ -401,6 +409,22 @@ class CreatorCommunityService(
}
commentRepository.save(postComment)
if (member.id != creatorId) {
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.INDIVIDUAL,
category = PushNotificationCategory.COMMUNITY,
title = member.nickname,
messageKey = "creator.community.fcm.new_comment",
senderMemberId = member.id,
recipients = listOf(creatorId),
deepLinkValue = FcmDeepLinkValue.COMMUNITY,
deepLinkId = creatorId,
deepLinkCommentPostId = postId
)
)
}
}
@Transactional

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.fcm
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import org.springframework.context.ApplicationEventPublisher
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.transaction.annotation.Transactional
@@ -21,6 +22,7 @@ class FcmController(private val applicationEventPublisher: ApplicationEventPubli
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.INDIVIDUAL,
category = PushNotificationCategory.SYSTEM,
title = request.title,
isAuth = request.isAuth,
message = request.message,
@@ -31,6 +33,7 @@ class FcmController(private val applicationEventPublisher: ApplicationEventPubli
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.ALL,
category = PushNotificationCategory.SYSTEM,
title = request.title,
message = request.message,
isAuth = request.isAuth

View File

@@ -1,6 +1,8 @@
package kr.co.vividnext.sodalive.fcm
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationService
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.live.room.GenderRestriction
@@ -16,12 +18,25 @@ enum class FcmEventType {
CREATE_CONTENT_COMMENT, IN_PROGRESS_AUDITION
}
enum class FcmDeepLinkValue(val value: String) {
LIVE("live"),
CHANNEL("channel"),
CONTENT("content"),
SERIES("series"),
AUDITION("audition"),
COMMUNITY("community")
}
class FcmEvent(
val type: FcmEventType,
val category: PushNotificationCategory? = null,
val title: String = "",
val message: String = "",
val titleKey: String? = null,
val messageKey: String? = null,
val senderMemberId: Long? = null,
val senderNicknameSnapshot: String? = null,
val senderProfileImageSnapshot: String? = null,
val args: List<Any> = listOf(),
val container: String = "",
val recipients: List<Long> = listOf(),
@@ -32,6 +47,9 @@ class FcmEvent(
val messageId: Long? = null,
val creatorId: Long? = null,
val auditionId: Long? = null,
val deepLinkValue: FcmDeepLinkValue? = null,
val deepLinkId: Long? = null,
val deepLinkCommentPostId: Long? = null,
val commentParentId: Long? = null,
val myMemberId: Long? = null,
val isAvailableJoinCreator: Boolean? = null,
@@ -43,7 +61,8 @@ class FcmSendListener(
private val pushService: FcmService,
private val memberRepository: MemberRepository,
private val contentCommentRepository: AudioContentCommentRepository,
private val messageSource: SodaMessageSource
private val messageSource: SodaMessageSource,
private val pushNotificationService: PushNotificationService
) {
@Async
@TransactionalEventListener
@@ -154,6 +173,13 @@ class FcmSendListener(
val title = translate(fcmEvent.titleKey, fcmEvent.title, lang, fcmEvent.args)
val message = translate(fcmEvent.messageKey, fcmEvent.message, lang, fcmEvent.args)
pushNotificationService.saveNotification(
fcmEvent = fcmEvent,
languageCode = lang.code,
translatedMessage = message,
recipientPushTokens = tokens
)
val tokensByOS = tokens.groupBy { it.deviceType }
for ((os, osTokens) in tokensByOS) {
osTokens.map { it.token }.distinct().chunked(500).forEach { batch ->
@@ -166,7 +192,10 @@ class FcmSendListener(
contentId = contentId ?: fcmEvent.contentId,
messageId = messageId ?: fcmEvent.messageId,
creatorId = creatorId ?: fcmEvent.creatorId,
auditionId = auditionId ?: fcmEvent.auditionId
auditionId = auditionId ?: fcmEvent.auditionId,
deepLinkValue = fcmEvent.deepLinkValue,
deepLinkId = fcmEvent.deepLinkId,
deepLinkCommentPostId = fcmEvent.deepLinkCommentPostId
)
}
}

View File

@@ -8,11 +8,16 @@ import com.google.firebase.messaging.MessagingErrorCode
import com.google.firebase.messaging.MulticastMessage
import com.google.firebase.messaging.Notification
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Service
@Service
class FcmService(private val pushTokenService: PushTokenService) {
class FcmService(
private val pushTokenService: PushTokenService,
@Value("\${server.env}")
private val serverEnv: String
) {
private val logger = LoggerFactory.getLogger(this::class.java)
@Async
@@ -25,7 +30,10 @@ class FcmService(private val pushTokenService: PushTokenService) {
messageId: Long? = null,
contentId: Long? = null,
creatorId: Long? = null,
auditionId: Long? = null
auditionId: Long? = null,
deepLinkValue: FcmDeepLinkValue? = null,
deepLinkId: Long? = null,
deepLinkCommentPostId: Long? = null
) {
if (tokens.isEmpty()) return
logger.info("os: $container")
@@ -82,6 +90,11 @@ class FcmService(private val pushTokenService: PushTokenService) {
multicastMessage.putData("audition_id", auditionId.toString())
}
val deepLink = createDeepLink(deepLinkValue, deepLinkId, deepLinkCommentPostId)
if (deepLink != null) {
multicastMessage.putData("deep_link", deepLink)
}
val response = FirebaseMessaging.getInstance().sendEachForMulticast(multicastMessage.build())
val failedTokens = mutableListOf<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) {
if (tokens.isEmpty()) return
val data = mapOf(
@@ -180,4 +201,30 @@ class FcmService(private val pushTokenService: PushTokenService) {
logger.error("[FCM] ❌ 최종 실패 대상 ${targets.size}명 → $targets")
}
}
companion object {
fun buildDeepLink(
serverEnv: String,
deepLinkValue: FcmDeepLinkValue?,
deepLinkId: Long?,
deepLinkCommentPostId: Long? = null
): String? {
if (deepLinkValue == null || deepLinkId == null) {
return null
}
val uriScheme = if (serverEnv.equals("voiceon", ignoreCase = true)) {
"voiceon"
} else {
"voiceon-test"
}
val baseDeepLink = "$uriScheme://${deepLinkValue.value}/$deepLinkId"
if (deepLinkValue == FcmDeepLinkValue.COMMUNITY && deepLinkCommentPostId != null) {
return "$baseDeepLink?postId=$deepLinkCommentPostId"
}
return baseDeepLink
}
}
}

View File

@@ -10,6 +10,7 @@ interface PushTokenQueryRepository {
fun findByToken(token: String): PushToken?
fun findByMemberId(memberId: Long): List<PushToken>
fun findByMemberIds(memberIds: List<Long>): List<PushToken>
fun findMemberIdsByTokenIn(tokens: List<String>): List<Long>
}
class PushTokenQueryRepositoryImpl(
@@ -36,4 +37,19 @@ class PushTokenQueryRepositoryImpl(
.where(pushToken.member.id.`in`(memberIds))
.fetch()
}
override fun findMemberIdsByTokenIn(tokens: List<String>): List<Long> {
if (tokens.isEmpty()) return emptyList()
return queryFactory
.select(pushToken.member.id)
.from(pushToken)
.where(
pushToken.token.`in`(tokens)
.and(pushToken.member.id.isNotNull)
)
.fetch()
.filterNotNull()
.distinct()
}
}

View File

@@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.fcm.notification
enum class PushNotificationCategory(val code: String) {
LIVE("live"),
CONTENT("content"),
COMMUNITY("community"),
MESSAGE("message"),
AUDITION("audition"),
SYSTEM("system");
companion object {
fun fromCode(code: String): PushNotificationCategory? {
return values().find { it.code == code.lowercase() }
}
}
}

View File

@@ -0,0 +1,43 @@
package kr.co.vividnext.sodalive.fcm.notification
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/push/notification")
class PushNotificationController(
private val pushNotificationService: PushNotificationService
) {
@GetMapping("/list")
fun getNotificationList(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable,
@RequestParam("category", required = false) category: String?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(
pushNotificationService.getNotificationList(
member = member,
pageable = pageable,
category = category
)
)
}
@GetMapping("/categories")
fun getAvailableCategories(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(pushNotificationService.getAvailableCategories(member))
}
}

View File

@@ -0,0 +1,78 @@
package kr.co.vividnext.sodalive.fcm.notification
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.AttributeConverter
import javax.persistence.CascadeType
import javax.persistence.Column
import javax.persistence.Convert
import javax.persistence.Converter
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.OneToMany
import javax.persistence.Table
@Entity
@Table(name = "push_notification_list")
class PushNotificationList(
@Column(nullable = false)
var senderNicknameSnapshot: String,
@Column(nullable = true)
var senderProfileImageSnapshot: String? = null,
@Column(columnDefinition = "TEXT", nullable = false)
var message: String,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
var category: PushNotificationCategory,
@Column(nullable = true)
var deepLink: String? = null,
@Column(nullable = false, length = 8)
var languageCode: String
) : BaseEntity() {
@OneToMany(mappedBy = "notification", cascade = [CascadeType.ALL], orphanRemoval = true)
val recipientChunks: MutableList<PushNotificationRecipientChunk> = mutableListOf()
fun addRecipientChunk(chunk: PushNotificationRecipientChunk) {
chunk.notification = this
recipientChunks.add(chunk)
}
}
@Entity
@Table(name = "push_notification_recipient_chunk")
class PushNotificationRecipientChunk(
@Column(columnDefinition = "json", nullable = false)
@Convert(converter = PushNotificationRecipientChunkConverter::class)
var recipientMemberIds: List<Long>
) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "notification_id", nullable = false)
var notification: PushNotificationList? = null
}
@Converter(autoApply = false)
class PushNotificationRecipientChunkConverter : AttributeConverter<List<Long>, String> {
override fun convertToDatabaseColumn(attribute: List<Long>?): String {
if (attribute == null) return "[]"
return objectMapper.writeValueAsString(attribute)
}
override fun convertToEntityAttribute(dbData: String?): List<Long> {
if (dbData.isNullOrBlank()) return emptyList()
return objectMapper.readValue(dbData)
}
companion object {
private val objectMapper = jacksonObjectMapper()
}
}

View File

@@ -0,0 +1,135 @@
package kr.co.vividnext.sodalive.fcm.notification
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.jpa.JPAExpressions
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.fcm.notification.QPushNotificationList.pushNotificationList
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@Repository
interface PushNotificationListRepository : JpaRepository<PushNotificationList, Long>, PushNotificationListQueryRepository
interface PushNotificationListQueryRepository {
fun getNotificationList(
memberId: Long,
languageCode: String,
category: PushNotificationCategory?,
fromDateTime: LocalDateTime,
pageable: Pageable
): List<PushNotificationListItem>
fun getNotificationCount(
memberId: Long,
languageCode: String,
category: PushNotificationCategory?,
fromDateTime: LocalDateTime
): Long
fun getAvailableCategories(
memberId: Long,
languageCode: String,
fromDateTime: LocalDateTime
): List<PushNotificationCategory>
}
@Repository
class PushNotificationListQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : PushNotificationListQueryRepository {
override fun getNotificationList(
memberId: Long,
languageCode: String,
category: PushNotificationCategory?,
fromDateTime: LocalDateTime,
pageable: Pageable
): List<PushNotificationListItem> {
return queryFactory
.select(
QPushNotificationListItem(
pushNotificationList.id,
pushNotificationList.senderNicknameSnapshot,
pushNotificationList.senderProfileImageSnapshot,
pushNotificationList.message,
pushNotificationList.category,
pushNotificationList.deepLink,
pushNotificationList.createdAt
)
)
.from(pushNotificationList)
.where(
pushNotificationList.languageCode.eq(languageCode),
pushNotificationList.createdAt.goe(fromDateTime),
recipientContainsMember(memberId),
categoryEq(category)
)
.orderBy(pushNotificationList.createdAt.desc(), pushNotificationList.id.desc())
.offset(pageable.offset)
.limit(pageable.pageSize.toLong())
.fetch()
}
override fun getNotificationCount(
memberId: Long,
languageCode: String,
category: PushNotificationCategory?,
fromDateTime: LocalDateTime
): Long {
return queryFactory
.select(pushNotificationList.id.count())
.from(pushNotificationList)
.where(
pushNotificationList.languageCode.eq(languageCode),
pushNotificationList.createdAt.goe(fromDateTime),
recipientContainsMember(memberId),
categoryEq(category)
)
.fetchOne() ?: 0L
}
override fun getAvailableCategories(
memberId: Long,
languageCode: String,
fromDateTime: LocalDateTime
): List<PushNotificationCategory> {
return queryFactory
.select(pushNotificationList.category)
.distinct()
.from(pushNotificationList)
.where(
pushNotificationList.languageCode.eq(languageCode),
pushNotificationList.createdAt.goe(fromDateTime),
recipientContainsMember(memberId)
)
.fetch()
}
private fun categoryEq(category: PushNotificationCategory?): BooleanExpression? {
return if (category == null) {
null
} else {
pushNotificationList.category.eq(category)
}
}
private fun recipientContainsMember(memberId: Long): BooleanExpression {
val recipientChunk = QPushNotificationRecipientChunk("recipientChunk")
return JPAExpressions
.selectOne()
.from(recipientChunk)
.where(
recipientChunk.notification.id.eq(pushNotificationList.id)
.and(
Expressions.booleanTemplate(
"function('JSON_CONTAINS', {0}, function('JSON_ARRAY', {1}), '$') = 1",
recipientChunk.recipientMemberIds,
memberId
)
)
)
.exists()
}
}

View File

@@ -0,0 +1,46 @@
package kr.co.vividnext.sodalive.fcm.notification
import com.querydsl.core.annotations.QueryProjection
import java.time.LocalDateTime
import java.time.ZoneId
data class GetPushNotificationListResponse(
val totalCount: Long,
val items: List<PushNotificationListItem>
)
data class PushNotificationListItem(
val id: Long,
val senderNickname: String,
val senderProfileImage: String?,
val message: String,
val category: String,
val deepLink: String?,
val sentAt: String
) {
@QueryProjection
constructor(
id: Long,
senderNickname: String,
senderProfileImage: String?,
message: String,
category: PushNotificationCategory,
deepLink: String?,
sentAt: LocalDateTime
) : this(
id = id,
senderNickname = senderNickname,
senderProfileImage = senderProfileImage,
message = message,
category = category.code,
deepLink = deepLink,
sentAt = sentAt
.atZone(ZoneId.of("UTC"))
.toInstant()
.toString()
)
}
data class GetPushNotificationCategoryResponse(
val categories: List<String>
)

View File

@@ -0,0 +1,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)
}
}

View File

@@ -1114,6 +1114,44 @@ class SodaMessageSource {
)
)
private val pushNotificationMessages = mapOf(
"push.notification.category.all" to mapOf(
Lang.KO to "전체",
Lang.EN to "All",
Lang.JA to "すべて"
),
"push.notification.category.live" to mapOf(
Lang.KO to "라이브",
Lang.EN to "Live",
Lang.JA to "ライブ"
),
"push.notification.category.content" to mapOf(
Lang.KO to "콘텐츠",
Lang.EN to "Content",
Lang.JA to "コンテンツ"
),
"push.notification.category.community" to mapOf(
Lang.KO to "커뮤니티",
Lang.EN to "Community",
Lang.JA to "コミュニティ"
),
"push.notification.category.message" to mapOf(
Lang.KO to "메시지",
Lang.EN to "Message",
Lang.JA to "メッセージ"
),
"push.notification.category.audition" to mapOf(
Lang.KO to "오디션",
Lang.EN to "Audition",
Lang.JA to "オーディション"
),
"push.notification.category.system" to mapOf(
Lang.KO to "시스템",
Lang.EN to "System",
Lang.JA to "システム"
)
)
private val noticeMessages = mapOf(
"notice.error.title_required" to mapOf(
Lang.KO to "제목을 입력하세요.",
@@ -2234,6 +2272,11 @@ class SodaMessageSource {
Lang.EN to "A new post has been added.",
Lang.JA to "新しい投稿が登録されました。"
),
"creator.community.fcm.new_comment" to mapOf(
Lang.KO to "커뮤니티 게시글에 새 댓글이 등록되었습니다.",
Lang.EN to "A new comment has been added to your community post.",
Lang.JA to "コミュニティ投稿に新しいコメントが登録されました。"
),
"creator.community.invalid_request_retry" to mapOf(
Lang.KO to "잘못된 요청입니다.\n다시 시도해 주세요.",
Lang.EN to "Invalid request.\ntry again.",
@@ -2313,6 +2356,7 @@ class SodaMessageSource {
adminPointPolicyMessages,
adminMemberStatisticsMessages,
messageMessages,
pushNotificationMessages,
noticeMessages,
reportMessages,
imageValidationMessages,

View File

@@ -262,7 +262,12 @@ class LiveRecommendRepository(
.from(creatorFollowing)
.innerJoin(creatorFollowing.creator, member)
.where(where)
.groupBy(member.id)
.groupBy(
member.id,
member.nickname,
member.profileImage,
creatorFollowing.isNotify
)
.offset(offset)
.limit(limit)
.fetch()

View File

@@ -19,9 +19,11 @@ import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.PushTokenRepository
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository
@@ -478,17 +480,21 @@ class LiveRoomService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.CREATE_LIVE,
category = PushNotificationCategory.LIVE,
title = createdRoom.member!!.nickname,
messageKey = if (createdRoom.channelName != null) {
"live.room.fcm.message.started"
} else {
"live.room.fcm.message.reserved"
},
senderMemberId = createdRoom.member!!.id,
args = listOf(createdRoom.title),
isAuth = createdRoom.isAdult,
isAvailableJoinCreator = createdRoom.isAvailableJoinCreator,
roomId = createdRoom.id,
creatorId = createdRoom.member!!.id,
deepLinkValue = FcmDeepLinkValue.LIVE,
deepLinkId = createdRoom.id,
genderRestriction = createdRoom.genderRestriction
)
)
@@ -655,13 +661,17 @@ class LiveRoomService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.START_LIVE,
category = PushNotificationCategory.LIVE,
title = room.member!!.nickname,
messageKey = "live.room.fcm.message.started",
senderMemberId = room.member!!.id,
args = listOf(room.title),
isAuth = room.isAdult,
isAvailableJoinCreator = room.isAvailableJoinCreator,
roomId = room.id,
creatorId = room.member!!.id,
deepLinkValue = FcmDeepLinkValue.LIVE,
deepLinkId = room.id,
genderRestriction = room.genderRestriction
)
)
@@ -726,10 +736,15 @@ class LiveRoomService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.CANCEL_LIVE,
category = PushNotificationCategory.LIVE,
title = room.member!!.nickname,
messageKey = "live.room.fcm.message.canceled",
senderMemberId = room.member!!.id,
args = listOf(room.title),
pushTokens = pushTokens
pushTokens = pushTokens,
roomId = room.id,
deepLinkValue = FcmDeepLinkValue.LIVE,
deepLinkId = room.id
)
)
}

View File

@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
@@ -72,8 +73,10 @@ class MessageService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.SEND_MESSAGE,
category = PushNotificationCategory.MESSAGE,
titleKey = "message.fcm.title",
messageKey = "message.fcm.text_received",
senderMemberId = sender.id,
args = listOf(sender.nickname),
messageId = message.id
)
@@ -145,8 +148,10 @@ class MessageService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.SEND_MESSAGE,
category = PushNotificationCategory.MESSAGE,
titleKey = "message.fcm.title",
messageKey = "message.fcm.voice_received",
senderMemberId = sender.id,
args = listOf(sender.nickname),
messageId = message.id
)

View File

@@ -0,0 +1,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
}
}

View File

@@ -0,0 +1,128 @@
package kr.co.vividnext.sodalive.fcm.notification
import kr.co.vividnext.sodalive.common.SodaExceptionHandler
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.data.domain.PageRequest
import org.springframework.data.web.PageableHandlerMethodArgumentResolver
import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.MockMvcBuilders
class PushNotificationControllerTest {
private lateinit var pushNotificationService: PushNotificationService
private lateinit var controller: PushNotificationController
private lateinit var mockMvc: MockMvc
@BeforeEach
fun setup() {
pushNotificationService = Mockito.mock(PushNotificationService::class.java)
controller = PushNotificationController(pushNotificationService)
mockMvc = MockMvcBuilders
.standaloneSetup(controller)
.setControllerAdvice(SodaExceptionHandler(LangContext(), SodaMessageSource()))
.setCustomArgumentResolvers(
AuthenticationPrincipalArgumentResolver(),
PageableHandlerMethodArgumentResolver()
)
.build()
}
@Test
fun shouldReturnErrorResponseWhenRequesterIsAnonymous() {
// given/when: 인증 없이 알림 목록 API를 호출한다.
mockMvc.perform(
get("/push/notification/list")
.param("page", "0")
.param("size", "5")
)
// then: 공통 인증 실패 응답이 반환되어야 한다.
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("로그인 정보를 확인해주세요."))
}
@Test
fun shouldForwardPageableAndCategoryToServiceWhenListApiIsCalled() {
// given: 인증 사용자와 서비스 응답을 준비한다.
val member = createMember(id = 8L, role = MemberRole.USER, nickname = "viewer")
val response = GetPushNotificationListResponse(
totalCount = 1L,
items = listOf(
PushNotificationListItem(
id = 10L,
senderNickname = "creator",
senderProfileImage = "https://cdn.test/profile/default-profile.png",
message = "새 알림",
category = "live",
deepLink = "voiceon://live/10",
sentAt = "2026-03-11T10:00:00"
)
)
)
Mockito.`when`(
pushNotificationService.getNotificationList(
member = member,
pageable = PageRequest.of(2, 5),
category = "live"
)
).thenReturn(response)
// when: 컨트롤러 메서드를 직접 호출한다.
val apiResponse = controller.getNotificationList(
member = member,
pageable = PageRequest.of(2, 5),
category = "live"
)
// then: pageable/category/member가 그대로 서비스에 전달되어야 한다.
assertEquals(true, apiResponse.success)
assertEquals(1L, apiResponse.data!!.totalCount)
assertEquals("live", apiResponse.data!!.items[0].category)
Mockito.verify(pushNotificationService).getNotificationList(
member = member,
pageable = PageRequest.of(2, 5),
category = "live"
)
}
@Test
fun shouldForwardMemberToCategoryApiService() {
// given: 인증 사용자와 카테고리 응답을 준비한다.
val member = createMember(id = 21L, role = MemberRole.USER, nickname = "user")
val response = GetPushNotificationCategoryResponse(categories = listOf("live", "content"))
Mockito.`when`(pushNotificationService.getAvailableCategories(member)).thenReturn(response)
// when: 컨트롤러 메서드를 직접 호출한다.
val apiResponse = controller.getAvailableCategories(member)
// then: 서비스 응답이 ApiResponse.ok로 반환되어야 한다.
assertEquals(true, apiResponse.success)
assertEquals(listOf("live", "content"), apiResponse.data!!.categories)
Mockito.verify(pushNotificationService).getAvailableCategories(member)
}
private fun createMember(id: Long, role: MemberRole, nickname: String): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = role
)
member.id = id
return member
}
}

View File

@@ -0,0 +1,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
}
}

View File

@@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -72,12 +73,38 @@ class LiveRecommendRepositoryTest @Autowired constructor(
assertEquals(creator.id, result[0].creatorId)
}
@Test
fun shouldReturnFollowingCreatorListWithNotifyFlag() {
val viewer = saveMember(nickname = "viewer-following", role = MemberRole.USER)
val creatorA = saveMember(nickname = "creator-following-a", role = MemberRole.CREATOR)
val creatorB = saveMember(nickname = "creator-following-b", role = MemberRole.CREATOR)
saveFollowing(member = viewer, creator = creatorA, isNotify = true)
saveFollowing(member = viewer, creator = creatorB, isNotify = false)
entityManager.flush()
entityManager.clear()
val result = liveRecommendRepository.getCreatorFollowingAllList(
memberId = viewer.id!!,
offset = 0,
limit = 20,
isBlocked = { false }
)
assertEquals(2, result.size)
val isNotifyByCreatorId = result.associate { it.creatorId to it.isNotify }
assertEquals(true, isNotifyByCreatorId[creatorA.id])
assertEquals(false, isNotifyByCreatorId[creatorB.id])
}
private fun saveMember(nickname: String, role: MemberRole): Member {
return memberRepository.saveAndFlush(
Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
profileImage = "profile/default-profile.png",
role = role
)
)
@@ -101,4 +128,14 @@ class LiveRecommendRepositoryTest @Autowired constructor(
block.blockedMember = blockedMember
blockMemberRepository.saveAndFlush(block)
}
private fun saveFollowing(member: Member, creator: Member, isNotify: Boolean) {
val following = CreatorFollowing(
isNotify = isNotify,
isActive = true
)
following.member = member
following.creator = creator
entityManager.persist(following)
}
}