diff --git a/docs/api-specs/notification-api.md b/docs/api-specs/notification-api.md index 0d6069b..34be5a9 100644 --- a/docs/api-specs/notification-api.md +++ b/docs/api-specs/notification-api.md @@ -334,10 +334,231 @@ --- -## 6. 에러 코드 +## 6. 관리자 API + +모든 API는 `ROLE_ADMIN` 권한을 가진 계정만 호출 가능합니다 (`Authorization: Bearer {access_token}`, 403 시 `COMMON_403` 등 공통 권한 에러). + +### 6.1 공지/이벤트 (`/api/v1/admin/notices`) + +즉시 발송되는 공지사항/이벤트 알림을 관리합니다. + +| Method | Path | 설명 | +|---|---|---| +| `POST` | `/api/v1/admin/notices` | 공지사항/이벤트 작성 (즉시 전체 발송) | +| `GET` | `/api/v1/admin/notices/options` | 공지 작성 시 선택 가능한 `category` 목록 조회 | +| `GET` | `/api/v1/admin/notices/target-count` | 지금 등록하면 몇 대의 디바이스에 발송되는지 미리 조회 | +| `GET` | `/api/v1/admin/notices` | 공지 목록 조회 (`category`, `page`, `size` 쿼리) | +| `GET` | `/api/v1/admin/notices/{noticeId}` | 공지 상세 조회 | +| `PUT` | `/api/v1/admin/notices/{noticeId}` | 공지 수정 (제목/본문) | +| `DELETE` | `/api/v1/admin/notices/{noticeId}` | 공지 삭제 | +| `GET` | `/api/v1/admin/notices/{noticeId}/delivery-result` | 발송 결과 조회 (대상/성공/실패 건수) | +| `POST` | `/api/v1/admin/notices/test` | 특정 유저 대상 테스트 푸시 발송 (알림 설정 ON/OFF 무관) | + +**`POST /api/v1/admin/notices` 요청 바디** + +```json +{ + "category": "NOTICE", + "title": "서비스 점검 안내", + "body": "8/30 02:00~04:00 점검이 진행됩니다." +} +``` + +| 필드 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `category` | `string` | Y | `CONTENT` \| `NOTICE` \| `EVENT` | +| `title` | `string` | Y | 알림 제목 | +| `body` | `string` | Y | 알림 본문 | + +응답은 `AdminNoticeDetailResponse` (`notificationId`, `category`, `detailCode`, `title`, `body`, `referenceId`, `createdAt`). + +**`GET /api/v1/admin/notices/options`** + +공지 작성 폼의 카테고리 선택지를 서버 enum 기준으로 내려줍니다. `ALL`은 목록 조회 필터용이라 작성 옵션에는 포함되지 않습니다. + +성공 응답 `200 OK`: + +```json +{ + "statusCode": 200, + "data": { + "categories": ["CONTENT", "NOTICE", "EVENT"] + }, + "error": null +} +``` + +**`GET /api/v1/admin/notices/target-count`** + +`NOTICE`/`EVENT` 공지를 지금 등록하면 몇 대의 디바이스에 발송되는지 미리 조회합니다. `notifyAdminNotice`가 사용하는 것과 동일하게 '이벤트 및 소식 알림' 설정이 ON인 유저의 등록된 디바이스 수 기준입니다. `CONTENT` 카테고리 공지는 애초에 이 대상자 산정을 타지 않으므로 참고용으로만 사용하세요. + +성공 응답 `200 OK`: + +```json +{ + "statusCode": 200, + "data": { + "targetCount": 1200 + }, + "error": null +} +``` + +**`PUT /api/v1/admin/notices/{noticeId}` 요청 바디** + +```json +{ + "title": "서비스 점검 안내 (수정)", + "body": "8/30 02:00~05:00로 점검 시간이 변경되었습니다." +} +``` + +| 필드 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `title` | `string` | Y | 알림 제목 | +| `body` | `string` | Y | 알림 본문 | + +`category`/`detailCode`는 등록 시점에 확정되어 수정 대상이 아닙니다. 이미 발송된 알림함/푸시는 재발송되지 않고, 알림함에 남아있는 텍스트만 갱신됩니다. 응답은 등록 API와 동일한 `AdminNoticeDetailResponse`. + +**`DELETE /api/v1/admin/notices/{noticeId}`** + +성공 시 `200 OK`, `data: null`. 삭제된 공지는 알림함에서도 사라집니다. + +**`GET /api/v1/admin/notices/{noticeId}/delivery-result`** + +`CONTENT` 카테고리를 제외한 `NOTICE`/`EVENT` 공지처럼 실제로 푸시가 발송되는 알림에 한해, 발송 대상 디바이스 수와 성공/실패 건수를 조회합니다. 푸시 발송은 Android(FCM)는 동기, iOS(APNs)는 비동기로 처리되므로, 전체 발송이 끝나기 전에는 `pending: true`로 내려갑니다. + +성공 응답 `200 OK`: + +```json +{ + "statusCode": 200, + "data": { + "notificationId": 101, + "targetCount": 1200, + "successCount": 1180, + "failureCount": 20, + "pending": false + }, + "error": null +} +``` + +`failureCount`는 만료된 디바이스 토큰(`UNREGISTERED`, APNs의 `BadDeviceToken`/`Unregistered` 등) 정리 대상 건수를 포함한 전체 실패 건수이며, 실패 사유별 세부 분류는 제공하지 않습니다. + +예외 응답 `404 - 발송 결과 없음` (공지 자체가 없거나, `CONTENT` 카테고리처럼 관리자 발송 트리거를 거치지 않아 결과가 집계되지 않은 경우): + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "NOTIFICATION_404_DELIVERY_RESULT", + "message": "발송 결과가 집계되지 않은 알림입니다." + } +} +``` + +**`POST /api/v1/admin/notices/test` 요청 바디** + +```json +{ + "userId": 123, + "title": "테스트 알림", + "body": "테스트 발송입니다." +} +``` + +### 6.2 예약 알림 (`/api/v1/admin/notification-schedules`) + +매일 지정된 시각에 전체 유저에게 자동 발송되는 예약 알림(`NotificationSchedule`)을 관리합니다. 매분 `NotificationScheduleDispatcher`가 `enabled=true`인 예약 중 `sendTime`(시:분)이 현재 시각과 일치하는 건을 찾아 발송하며, 같은 날 중복 발송되지 않도록 `lastSentDate`로 발송 이력을 관리합니다. + +| Method | Path | 설명 | +|---|---|---| +| `POST` | `/api/v1/admin/notification-schedules` | 예약 알림 등록 | +| `GET` | `/api/v1/admin/notification-schedules` | 예약 알림 전체 목록 조회 | +| `GET` | `/api/v1/admin/notification-schedules/{scheduleId}` | 예약 알림 상세 조회 | +| `PUT` | `/api/v1/admin/notification-schedules/{scheduleId}` | 예약 알림 수정 (제목/부제목/발송시간/on-off 전체 교체) | +| `PATCH` | `/api/v1/admin/notification-schedules/{scheduleId}/toggle` | 예약 알림 On/Off만 전환 | +| `POST` | `/api/v1/admin/notification-schedules/{scheduleId}/test` | 저장된 제목/부제목으로 특정 유저에게 테스트 발송 | +| `DELETE` | `/api/v1/admin/notification-schedules/{scheduleId}` | 예약 알림 삭제 | + +**`POST` / `PUT` 요청 바디** + +```json +{ + "title": "오늘의 질문", + "subtitle": "지금 확인해보세요", + "sendTime": "19:00:00", + "enabled": true +} +``` + +| 필드 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `title` | `string` | Y | 알림 제목 | +| `subtitle` | `string` | Y | 알림 부제목(본문) | +| `sendTime` | `string` (`HH:mm:ss`) | Y | 매일 발송할 시각 (KST 기준, 분 단위로 매칭) | +| `enabled` | `boolean` | Y | 활성화 여부 | + +**`PATCH .../toggle` 요청 바디** + +```json +{ + "enabled": false +} +``` + +**`POST .../test` 요청 바디** + +```json +{ + "userId": 123 +} +``` + +저장되어 있는 예약 알림의 `title`/`subtitle`을 그대로 사용해, 지정한 유저의 등록된 디바이스로 알림 설정(ON/OFF) 무관하게 즉시 테스트 푸시를 발송합니다. 등록/수정 폼에서 저장 전 미리보기 용도로 쓰려면, 먼저 원하는 문구로 `POST`/`PUT`을 호출해 저장한 뒤 이 API로 확인하는 흐름을 권장합니다. + +**응답 (`AdminNotificationScheduleResponse`)** + +```json +{ + "statusCode": 200, + "data": { + "id": 3, + "title": "오늘의 질문", + "subtitle": "지금 확인해보세요", + "sendTime": "19:00:00", + "enabled": true, + "createdAt": "2026-08-20T10:00:00" + }, + "error": null +} +``` + +목록 조회(`GET /api/v1/admin/notification-schedules`) 응답은 `{ "schedules": [AdminNotificationScheduleResponse, ...] }` 형태입니다. + +예외 응답 `404 - 예약 알림 없음`: + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "NOTIFICATION_404_SCHEDULE", + "message": "존재하지 않는 알림 예약입니다." + } +} +``` + +--- + +## 7. 에러 코드 | Error Code | HTTP Status | 설명 | |---|:---:|---| | `COMMON_400` | `400` | 요청 파라미터가 잘못되었습니다. (예: `fcmToken`/`platform` 누락) | | `USER_404` | `404` | 존재하지 않는 사용자입니다. | | `NOTIFICATION_404` | `404` | 존재하지 않는 알림입니다. (본인 소유가 아닌 알림 포함) | +| `NOTIFICATION_404_SCHEDULE` | `404` | 존재하지 않는 예약 알림입니다. | +| `NOTIFICATION_404_DELIVERY_RESULT` | `404` | 발송 결과가 집계되지 않은 알림입니다. | diff --git a/docs/api-specs/user-api.md b/docs/api-specs/user-api.md index 78594f0..16c49d2 100644 --- a/docs/api-specs/user-api.md +++ b/docs/api-specs/user-api.md @@ -438,9 +438,47 @@ --- -## 4. 에러 코드 +## 4. 관리자 API -### 4.1 공통 에러 코드 +`ROLE_ADMIN` 권한을 가진 계정만 호출 가능합니다 (`Authorization: Bearer {access_token}`). + +### 4.1 `GET /api/v1/admin/users/search` + +닉네임 / 유저태그 / 이메일로 유저를 검색합니다. 어드민 페이지에서 특정 유저에게 테스트 알림을 보내는 등, 내부 `userId`를 알아야 하는 다른 관리자 API의 입력값을 찾기 위한 용도입니다. + +이메일은 소셜 로그인 유저의 `providerEmail`만 검색 대상이며, **로컬 로그인 유저는 이메일 필드 자체가 없어 닉네임/유저태그로만 검색**됩니다. + +쿼리 파라미터: + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `keyword` | `string` | Y | 닉네임/유저태그/이메일 중 하나라도 부분 일치하면 매칭 | +| `page` | `integer` | N | 페이지 번호 (기본값 `0`) | +| `size` | `integer` | N | 페이지 크기 (기본값 `20`) | + +성공 응답 `200 OK`: + +```json +{ + "statusCode": 200, + "data": { + "items": [ + { "userId": 123, "userTag": "picke_abcd", "nickname": "민초러버", "email": "user@gmail.com" }, + { "userId": 124, "userTag": "picke_efgh", "nickname": "로컬유저", "email": null } + ], + "hasNext": false + }, + "error": null +} +``` + +`email`은 소셜 계정이 없는 유저(로컬 로그인)의 경우 `null`로 내려갑니다. + +--- + +## 5. 에러 코드 + +### 5.1 공통 에러 코드 | Error Code | HTTP Status | 설명 | |------------|:-----------:|------| @@ -452,7 +490,7 @@ | `USER_SUSPENDED` | `403` | 일정 기간 이용 정지된 사용자 | | `INTERNAL_SERVER_ERROR` | `500` | 서버 오류 | -### 4.2 사용자 에러 코드 +### 5.2 사용자 에러 코드 | Error Code | HTTP Status | 설명 | |------------|:-----------:|------| diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java index c434a9c..b4d3b6b 100644 --- a/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java @@ -1,9 +1,13 @@ package com.swyp.picke.domain.admin.controller; import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeCreateRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeUpdateRequest; import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationTestRequest; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeDeliveryResultResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeDetailResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeListResponse; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeOptionsResponse; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeTargetCountResponse; import com.swyp.picke.domain.admin.service.AdminNotificationService; import com.swyp.picke.domain.notification.enums.NotificationCategory; import com.swyp.picke.domain.notification.service.NotificationDispatchService; @@ -13,9 +17,11 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -39,6 +45,12 @@ public ApiResponse createNotice( return ApiResponse.onSuccess(adminNotificationService.createNotice(request)); } + @Operation(summary = "공지사항 작성 옵션 조회", description = "공지 작성 시 선택 가능한 category 목록을 내려준다.") + @GetMapping("/options") + public ApiResponse getOptions() { + return ApiResponse.onSuccess(adminNotificationService.getOptions()); + } + @Operation(summary = "공지사항 목록 조회") @GetMapping public ApiResponse getNotices( @@ -49,12 +61,40 @@ public ApiResponse getNotices( return ApiResponse.onSuccess(adminNotificationService.getNotices(category, page, size)); } + @Operation(summary = "공지 발송 대상자 수 미리보기", description = "지금 NOTICE/EVENT 공지를 등록하면 몇 대의 디바이스에 발송되는지 미리 조회한다.") + @GetMapping("/target-count") + public ApiResponse getTargetCount() { + return ApiResponse.onSuccess(adminNotificationService.getTargetCount()); + } + @Operation(summary = "공지사항 상세 조회") @GetMapping("/{noticeId}") public ApiResponse getNoticeDetail(@PathVariable Long noticeId) { return ApiResponse.onSuccess(adminNotificationService.getNoticeDetail(noticeId)); } + @Operation(summary = "공지사항 수정", description = "이미 발송된 알림함/푸시는 재발송되지 않으며, 알림함에 남는 텍스트만 갱신된다.") + @PutMapping("/{noticeId}") + public ApiResponse updateNotice( + @PathVariable Long noticeId, + @RequestBody @Valid AdminNoticeUpdateRequest request + ) { + return ApiResponse.onSuccess(adminNotificationService.updateNotice(noticeId, request)); + } + + @Operation(summary = "공지사항 삭제") + @DeleteMapping("/{noticeId}") + public ApiResponse deleteNotice(@PathVariable Long noticeId) { + adminNotificationService.deleteNotice(noticeId); + return ApiResponse.onSuccess(null); + } + + @Operation(summary = "공지사항 발송 결과 조회", description = "대상 디바이스 수, 성공/실패 건수를 조회한다. 푸시 발송은 비동기로 이뤄지므로 완료 전에는 pending=true로 내려간다.") + @GetMapping("/{noticeId}/delivery-result") + public ApiResponse getDeliveryResult(@PathVariable Long noticeId) { + return ApiResponse.onSuccess(adminNotificationService.getDeliveryResult(noticeId)); + } + @Operation(summary = "푸시 알림 발송 테스트", description = "특정 유저의 등록된 디바이스로 알림 설정(ON/OFF) 무관하게 즉시 테스트 푸시를 발송한다.") @PostMapping("/test") public ApiResponse sendTestPush( diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationScheduleController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationScheduleController.java index 24b8770..ee2cb23 100644 --- a/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationScheduleController.java +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationScheduleController.java @@ -1,6 +1,8 @@ package com.swyp.picke.domain.admin.controller; import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleTestRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleToggleRequest; import com.swyp.picke.domain.admin.dto.notification.response.AdminNotificationScheduleListResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNotificationScheduleResponse; import com.swyp.picke.domain.admin.service.AdminNotificationScheduleService; @@ -14,6 +16,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -57,6 +60,25 @@ public ApiResponse update( return ApiResponse.onSuccess(adminNotificationScheduleService.update(scheduleId, request)); } + @Operation(summary = "예약 알림 On/Off 전환") + @PatchMapping("/{scheduleId}/toggle") + public ApiResponse toggle( + @PathVariable Long scheduleId, + @RequestBody @Valid AdminNotificationScheduleToggleRequest request + ) { + return ApiResponse.onSuccess(adminNotificationScheduleService.toggle(scheduleId, request)); + } + + @Operation(summary = "예약 알림 테스트 발송", description = "저장된 예약 알림의 제목/부제목을 특정 유저에게 알림 설정(ON/OFF) 무관하게 즉시 테스트 발송한다.") + @PostMapping("/{scheduleId}/test") + public ApiResponse sendTest( + @PathVariable Long scheduleId, + @RequestBody @Valid AdminNotificationScheduleTestRequest request + ) { + adminNotificationScheduleService.sendTest(scheduleId, request); + return ApiResponse.onSuccess(null); + } + @Operation(summary = "예약 알림 삭제") @DeleteMapping("/{scheduleId}") public ApiResponse delete(@PathVariable Long scheduleId) { diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminUserController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminUserController.java new file mode 100644 index 0000000..cdd5963 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminUserController.java @@ -0,0 +1,33 @@ +package com.swyp.picke.domain.admin.controller; + +import com.swyp.picke.domain.admin.dto.user.response.AdminUserSearchResponse; +import com.swyp.picke.domain.admin.service.AdminUserService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +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; + +@Tag(name = "관리자 유저 API", description = "관리자 페이지에서 유저를 조회하기 위한 API") +@RestController +@RequestMapping("/api/v1/admin/users") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminUserController { + + private final AdminUserService adminUserService; + + @Operation(summary = "유저 검색", description = "닉네임/유저태그/이메일(소셜 로그인 유저만 해당)로 유저를 검색한다. 로컬 로그인 유저는 닉네임/유저태그로만 검색된다.") + @GetMapping("/search") + public ApiResponse searchUsers( + @RequestParam String keyword, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ApiResponse.onSuccess(adminUserService.searchUsers(keyword, page, size)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNoticeUpdateRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNoticeUpdateRequest.java new file mode 100644 index 0000000..8def0bb --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNoticeUpdateRequest.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.dto.notification.request; + +import jakarta.validation.constraints.NotBlank; + +public record AdminNoticeUpdateRequest( + @NotBlank String title, + @NotBlank String body +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationScheduleTestRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationScheduleTestRequest.java new file mode 100644 index 0000000..52341bb --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationScheduleTestRequest.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.admin.dto.notification.request; + +import jakarta.validation.constraints.NotNull; + +public record AdminNotificationScheduleTestRequest( + @NotNull Long userId +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationScheduleToggleRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationScheduleToggleRequest.java new file mode 100644 index 0000000..db4b63f --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationScheduleToggleRequest.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.admin.dto.notification.request; + +import jakarta.validation.constraints.NotNull; + +public record AdminNotificationScheduleToggleRequest( + @NotNull Boolean enabled +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeDeliveryResultResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeDeliveryResultResponse.java new file mode 100644 index 0000000..49152ca --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeDeliveryResultResponse.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.admin.dto.notification.response; + +public record AdminNoticeDeliveryResultResponse( + Long notificationId, + int targetCount, + int successCount, + int failureCount, + boolean pending +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeOptionsResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeOptionsResponse.java new file mode 100644 index 0000000..ae906dc --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeOptionsResponse.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.admin.dto.notification.response; + +import java.util.List; + +public record AdminNoticeOptionsResponse( + List categories +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeTargetCountResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeTargetCountResponse.java new file mode 100644 index 0000000..b0f3edb --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeTargetCountResponse.java @@ -0,0 +1,5 @@ +package com.swyp.picke.domain.admin.dto.notification.response; + +public record AdminNoticeTargetCountResponse( + int targetCount +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/user/response/AdminUserSearchResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/user/response/AdminUserSearchResponse.java new file mode 100644 index 0000000..b0592ee --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/user/response/AdminUserSearchResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.dto.user.response; + +import java.util.List; + +public record AdminUserSearchResponse( + List items, + boolean hasNext +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/user/response/AdminUserSummaryResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/user/response/AdminUserSummaryResponse.java new file mode 100644 index 0000000..7273638 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/user/response/AdminUserSummaryResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.dto.user.response; + +public record AdminUserSummaryResponse( + Long userId, + String userTag, + String nickname, + String email +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleService.java index 7e07cc4..770915a 100644 --- a/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleService.java +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleService.java @@ -1,10 +1,13 @@ package com.swyp.picke.domain.admin.service; import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleTestRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleToggleRequest; import com.swyp.picke.domain.admin.dto.notification.response.AdminNotificationScheduleListResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNotificationScheduleResponse; import com.swyp.picke.domain.notification.entity.NotificationSchedule; import com.swyp.picke.domain.notification.repository.NotificationScheduleRepository; +import com.swyp.picke.domain.notification.service.NotificationDispatchService; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; import lombok.RequiredArgsConstructor; @@ -17,6 +20,7 @@ public class AdminNotificationScheduleService { private final NotificationScheduleRepository notificationScheduleRepository; + private final NotificationDispatchService notificationDispatchService; @Transactional public AdminNotificationScheduleResponse create(AdminNotificationScheduleRequest request) { @@ -49,6 +53,18 @@ public AdminNotificationScheduleResponse update(Long scheduleId, AdminNotificati return toResponse(schedule); } + @Transactional + public AdminNotificationScheduleResponse toggle(Long scheduleId, AdminNotificationScheduleToggleRequest request) { + NotificationSchedule schedule = getExistingSchedule(scheduleId); + schedule.changeEnabled(request.enabled()); + return toResponse(schedule); + } + + public void sendTest(Long scheduleId, AdminNotificationScheduleTestRequest request) { + NotificationSchedule schedule = getExistingSchedule(scheduleId); + notificationDispatchService.sendTestPush(request.userId(), schedule.getTitle(), schedule.getSubtitle()); + } + @Transactional public void delete(Long scheduleId) { NotificationSchedule schedule = getExistingSchedule(scheduleId); diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationService.java index c5f6605..d46663c 100644 --- a/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationService.java +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationService.java @@ -1,17 +1,24 @@ package com.swyp.picke.domain.admin.service; import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeCreateRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeUpdateRequest; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeDeliveryResultResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeDetailResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeListResponse; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeOptionsResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeSummaryResponse; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeTargetCountResponse; import com.swyp.picke.domain.notification.entity.Notification; +import com.swyp.picke.domain.notification.entity.NotificationDeliveryResult; import com.swyp.picke.domain.notification.enums.NotificationCategory; import com.swyp.picke.domain.notification.enums.NotificationDetailCode; +import com.swyp.picke.domain.notification.repository.NotificationDeliveryResultRepository; import com.swyp.picke.domain.notification.repository.NotificationRepository; import com.swyp.picke.domain.notification.service.NotificationDispatchService; import com.swyp.picke.domain.notification.service.NotificationService; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; @@ -28,6 +35,7 @@ public class AdminNotificationService { private final NotificationService notificationService; private final NotificationDispatchService notificationDispatchService; private final NotificationRepository notificationRepository; + private final NotificationDeliveryResultRepository notificationDeliveryResultRepository; @Transactional public AdminNoticeDetailResponse createNotice(AdminNoticeCreateRequest request) { @@ -41,7 +49,7 @@ public AdminNoticeDetailResponse createNotice(AdminNoticeCreateRequest request) if (detailCode.getCategory() == NotificationCategory.NOTICE || detailCode.getCategory() == NotificationCategory.EVENT) { - notificationDispatchService.notifyAdminNotice(detailCode, request.title(), request.body()); + notificationDispatchService.notifyAdminNotice(notification.getId(), detailCode, request.title(), request.body()); } return toDetailResponse(notification); @@ -66,11 +74,55 @@ public AdminNoticeListResponse getNotices(NotificationCategory category, int pag } public AdminNoticeDetailResponse getNoticeDetail(Long notificationId) { - Notification notification = notificationRepository.findById(notificationId) + Notification notification = notificationRepository.findByIdAndDeletedAtIsNull(notificationId) .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND)); return toDetailResponse(notification); } + @Transactional + public AdminNoticeDetailResponse updateNotice(Long notificationId, AdminNoticeUpdateRequest request) { + Notification notification = notificationRepository.findByIdAndDeletedAtIsNull(notificationId) + .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND)); + notification.updateContent(request.title(), request.body()); + return toDetailResponse(notification); + } + + @Transactional + public void deleteNotice(Long notificationId) { + Notification notification = notificationRepository.findByIdAndDeletedAtIsNull(notificationId) + .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND)); + notification.delete(); + } + + public AdminNoticeDeliveryResultResponse getDeliveryResult(Long notificationId) { + notificationRepository.findByIdAndDeletedAtIsNull(notificationId) + .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND)); + + NotificationDeliveryResult result = notificationDeliveryResultRepository.findByNotificationId(notificationId) + .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_DELIVERY_RESULT_NOT_FOUND)); + + return new AdminNoticeDeliveryResultResponse( + result.getNotificationId(), + result.getTargetCount(), + result.getSuccessCount(), + result.getFailureCount(), + result.isPending() + ); + } + + public AdminNoticeTargetCountResponse getTargetCount() { + return new AdminNoticeTargetCountResponse(notificationDispatchService.countAdminNoticeTargets()); + } + + public AdminNoticeOptionsResponse getOptions() { + List categories = List.of( + NotificationCategory.CONTENT.name(), + NotificationCategory.NOTICE.name(), + NotificationCategory.EVENT.name() + ); + return new AdminNoticeOptionsResponse(categories); + } + private NotificationCategory normalizeCategory(NotificationCategory category) { if (category == null || category == NotificationCategory.ALL) { return null; diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminUserService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminUserService.java new file mode 100644 index 0000000..67c1698 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminUserService.java @@ -0,0 +1,71 @@ +package com.swyp.picke.domain.admin.service; + +import com.swyp.picke.domain.admin.dto.user.response.AdminUserSearchResponse; +import com.swyp.picke.domain.admin.dto.user.response.AdminUserSummaryResponse; +import com.swyp.picke.domain.oauth.entity.UserSocialAccount; +import com.swyp.picke.domain.oauth.repository.UserSocialAccountRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.repository.UserRepository; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminUserService { + + private static final int DEFAULT_PAGE_SIZE = 20; + + private final UserRepository userRepository; + private final UserSocialAccountRepository userSocialAccountRepository; + + public AdminUserSearchResponse searchUsers(String keyword, int page, int size) { + int pageNumber = Math.max(0, page); + int pageSize = size <= 0 ? DEFAULT_PAGE_SIZE : size; + + // 닉네임/유저태그(로컬+소셜 유저 공통)와 이메일(소셜 로그인 유저만 보유)은 + // 서로 다른 테이블에서 나오는 결과라 각각 조회 후 userId 기준으로 합친다. + Slice byNicknameOrTag = userRepository.searchByNicknameOrUserTag( + keyword, PageRequest.of(pageNumber, pageSize)); + List byEmail = userSocialAccountRepository.findByProviderEmailContaining(keyword); + + Map merged = new LinkedHashMap<>(); + byNicknameOrTag.getContent().forEach(user -> merged.put(user.getId(), user)); + byEmail.forEach(socialAccount -> merged.put(socialAccount.getUser().getId(), socialAccount.getUser())); + + List users = merged.values().stream() + .sorted((a, b) -> Long.compare(b.getId(), a.getId())) + .limit(pageSize) + .toList(); + + Map emailByUserId = emailByUserId(users); + + List items = users.stream() + .map(user -> new AdminUserSummaryResponse( + user.getId(), + user.getUserTag(), + user.getNickname(), + emailByUserId.get(user.getId()) + )) + .toList(); + + boolean hasNext = byNicknameOrTag.hasNext() || merged.size() > pageSize; + + return new AdminUserSearchResponse(items, hasNext); + } + + private Map emailByUserId(List users) { + List userIds = users.stream().map(User::getId).toList(); + Map emailByUserId = new LinkedHashMap<>(); + for (UserSocialAccount socialAccount : userSocialAccountRepository.findByUser_IdIn(userIds)) { + emailByUserId.putIfAbsent(socialAccount.getUser().getId(), socialAccount.getProviderEmail()); + } + return emailByUserId; + } +} diff --git a/src/main/java/com/swyp/picke/domain/notification/entity/Notification.java b/src/main/java/com/swyp/picke/domain/notification/entity/Notification.java index e360140..7a773de 100644 --- a/src/main/java/com/swyp/picke/domain/notification/entity/Notification.java +++ b/src/main/java/com/swyp/picke/domain/notification/entity/Notification.java @@ -55,6 +55,9 @@ public class Notification extends BaseEntity { @Column(name = "read_at") private LocalDateTime readAt; + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + @Builder private Notification(User user, NotificationCategory category, NotificationDetailCode detailCode, String title, String body, Long referenceId, Long perspectiveId) { @@ -68,10 +71,19 @@ private Notification(User user, NotificationCategory category, NotificationDetai this.read = false; } + public void updateContent(String title, String body) { + this.title = title; + this.body = body; + } + public void markAsRead() { if (!this.read) { this.read = true; this.readAt = LocalDateTime.now(); } } + + public void delete() { + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/swyp/picke/domain/notification/entity/NotificationDeliveryResult.java b/src/main/java/com/swyp/picke/domain/notification/entity/NotificationDeliveryResult.java new file mode 100644 index 0000000..6129460 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/entity/NotificationDeliveryResult.java @@ -0,0 +1,41 @@ +package com.swyp.picke.domain.notification.entity; + +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "notification_delivery_results") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NotificationDeliveryResult extends BaseEntity { + + @Column(name = "notification_id", nullable = false, unique = true) + private Long notificationId; + + @Column(name = "target_count", nullable = false) + private int targetCount; + + @Column(name = "success_count", nullable = false) + private int successCount; + + @Column(name = "failure_count", nullable = false) + private int failureCount; + + @Builder + private NotificationDeliveryResult(Long notificationId, int targetCount) { + this.notificationId = notificationId; + this.targetCount = targetCount; + this.successCount = 0; + this.failureCount = 0; + } + + public boolean isPending() { + return successCount + failureCount < targetCount; + } +} diff --git a/src/main/java/com/swyp/picke/domain/notification/entity/NotificationSchedule.java b/src/main/java/com/swyp/picke/domain/notification/entity/NotificationSchedule.java index 6e94e7f..d17b69d 100644 --- a/src/main/java/com/swyp/picke/domain/notification/entity/NotificationSchedule.java +++ b/src/main/java/com/swyp/picke/domain/notification/entity/NotificationSchedule.java @@ -47,6 +47,10 @@ public void update(String title, String subtitle, LocalTime sendTime, boolean en this.enabled = enabled; } + public void changeEnabled(boolean enabled) { + this.enabled = enabled; + } + public boolean isDue(LocalTime now, LocalDate today) { return enabled && sendTime.getHour() == now.getHour() diff --git a/src/main/java/com/swyp/picke/domain/notification/repository/NotificationDeliveryResultRepository.java b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationDeliveryResultRepository.java new file mode 100644 index 0000000..f538f9e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationDeliveryResultRepository.java @@ -0,0 +1,25 @@ +package com.swyp.picke.domain.notification.repository; + +import com.swyp.picke.domain.notification.entity.NotificationDeliveryResult; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface NotificationDeliveryResultRepository extends JpaRepository { + + Optional findByNotificationId(Long notificationId); + + @Modifying + @Query(""" + update NotificationDeliveryResult r + set r.successCount = :successCount, r.failureCount = :failureCount + where r.notificationId = :notificationId + """) + void updateResult( + @Param("notificationId") Long notificationId, + @Param("successCount") int successCount, + @Param("failureCount") int failureCount + ); +} diff --git a/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java index 14bf3bd..129bd0d 100644 --- a/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java @@ -2,6 +2,7 @@ import com.swyp.picke.domain.notification.entity.Notification; import com.swyp.picke.domain.notification.enums.NotificationCategory; +import java.util.Optional; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; @@ -11,12 +12,15 @@ public interface NotificationRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + @Query(""" SELECT n FROM Notification n WHERE ( (n.user IS NOT NULL AND n.user.id = :userId) OR n.user IS NULL ) + AND n.deletedAt IS NULL AND (:category IS NULL OR n.category = :category) ORDER BY n.createdAt DESC """) @@ -30,6 +34,7 @@ Slice findVisibleNotifications( SELECT CASE WHEN COUNT(n) > 0 THEN true ELSE false END FROM Notification n WHERE n.user IS NULL + AND n.deletedAt IS NULL AND n.category = :category AND NOT EXISTS ( SELECT 1 FROM NotificationRead nr @@ -41,7 +46,8 @@ AND NOT EXISTS ( @Query(""" SELECT CASE WHEN COUNT(n) > 0 THEN true ELSE false END FROM Notification n - WHERE (:category IS NULL OR n.category = :category) + WHERE n.deletedAt IS NULL + AND (:category IS NULL OR n.category = :category) AND ( (n.user.id = :userId AND n.read = false) OR (n.user IS NULL AND NOT EXISTS ( @@ -61,7 +67,8 @@ SELECT CASE WHEN COUNT(n) > 0 THEN true ELSE false END @Query(""" SELECT n FROM Notification n - WHERE (:category IS NULL OR n.category = :category) + WHERE n.deletedAt IS NULL + AND (:category IS NULL OR n.category = :category) ORDER BY n.createdAt DESC """) Slice findNotificationsForAdmin( diff --git a/src/main/java/com/swyp/picke/domain/notification/repository/UserDeviceRepository.java b/src/main/java/com/swyp/picke/domain/notification/repository/UserDeviceRepository.java index e6f3869..f377bc4 100644 --- a/src/main/java/com/swyp/picke/domain/notification/repository/UserDeviceRepository.java +++ b/src/main/java/com/swyp/picke/domain/notification/repository/UserDeviceRepository.java @@ -15,6 +15,8 @@ public interface UserDeviceRepository extends JpaRepository { List findAllByUserIdIn(List userIds); + long countByUserIdIn(List userIds); + List findAllByUserIdAndPlatform(Long userId, DevicePlatform platform); void deleteByUserIdAndFcmToken(Long userId, String fcmToken); diff --git a/src/main/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcher.java b/src/main/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcher.java index 2c5148e..9fb70af 100644 --- a/src/main/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcher.java +++ b/src/main/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcher.java @@ -1,5 +1,6 @@ package com.swyp.picke.domain.notification.scheduler; +import com.swyp.picke.domain.notification.entity.Notification; import com.swyp.picke.domain.notification.entity.NotificationSchedule; import com.swyp.picke.domain.notification.enums.NotificationDetailCode; import com.swyp.picke.domain.notification.repository.NotificationScheduleRepository; @@ -40,10 +41,10 @@ public void dispatchDueSchedules() { .toList(); for (NotificationSchedule schedule : dueSchedules) { - notificationService.createBroadcastNotification( + Notification notification = notificationService.createBroadcastNotification( NotificationDetailCode.DAILY_MESSAGE, schedule.getTitle(), schedule.getSubtitle(), null); notificationDispatchService.notifyAdminNotice( - NotificationDetailCode.DAILY_MESSAGE, schedule.getTitle(), schedule.getSubtitle()); + notification.getId(), NotificationDetailCode.DAILY_MESSAGE, schedule.getTitle(), schedule.getSubtitle()); schedule.markSent(today); log.info("[NotificationScheduleDispatcher] sent scheduleId={}, title={}", schedule.getId(), schedule.getTitle()); } diff --git a/src/main/java/com/swyp/picke/domain/notification/service/NotificationDispatchService.java b/src/main/java/com/swyp/picke/domain/notification/service/NotificationDispatchService.java index 3a60177..eab6133 100644 --- a/src/main/java/com/swyp/picke/domain/notification/service/NotificationDispatchService.java +++ b/src/main/java/com/swyp/picke/domain/notification/service/NotificationDispatchService.java @@ -1,8 +1,10 @@ package com.swyp.picke.domain.notification.service; +import com.swyp.picke.domain.notification.entity.NotificationDeliveryResult; import com.swyp.picke.domain.notification.entity.UserDevice; import com.swyp.picke.domain.notification.enums.DevicePlatform; import com.swyp.picke.domain.notification.enums.NotificationDetailCode; +import com.swyp.picke.domain.notification.repository.NotificationDeliveryResultRepository; import com.swyp.picke.domain.notification.repository.UserDeviceRepository; import com.swyp.picke.domain.user.entity.UserSettings; import com.swyp.picke.domain.user.repository.UserSettingsRepository; @@ -10,6 +12,7 @@ import com.swyp.picke.global.infra.fcm.service.FcmPushService; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; import java.util.function.Predicate; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -27,6 +30,7 @@ public class NotificationDispatchService { private final NotificationService notificationService; private final UserDeviceRepository userDeviceRepository; private final UserSettingsRepository userSettingsRepository; + private final NotificationDeliveryResultRepository notificationDeliveryResultRepository; private final FcmPushService fcmPushService; private final ApnsPushService apnsPushService; @@ -83,15 +87,42 @@ public void notifyNewComment(Long perspectiveAuthorId, Long perspectiveId, Long } /** - * 관리자가 등록한 공지/이벤트 알림에 대해 '이벤트 및 소식 알림' 설정이 ON인 사용자에게 푸시를 발송한다. + * 관리자가 등록한 공지/이벤트 알림에 대해 '이벤트 및 소식 알림' 설정이 ON인 사용자에게 푸시를 발송하고, + * 발송 결과(성공/실패 건수)를 {@link NotificationDeliveryResult}에 기록한다. */ - public void notifyAdminNotice(NotificationDetailCode detailCode, String title, String body) { + public void notifyAdminNotice(Long notificationId, NotificationDetailCode detailCode, String title, String body) { Map data = Map.of("type", detailCode.getCategory().name()); List userIds = userSettingsRepository.findUserIdsByMarketingEventEnabledTrue(); - for (UserDevice device : userDeviceRepository.findAllByUserIdIn(userIds)) { - sendPush(device, title, body, data); - } + List devices = userDeviceRepository.findAllByUserIdIn(userIds); + + notificationDeliveryResultRepository.save( + NotificationDeliveryResult.builder() + .notificationId(notificationId) + .targetCount(devices.size()) + .build() + ); + + List> results = devices.stream() + .map(device -> sendPush(device, title, body, data)) + .toList(); + + CompletableFuture.allOf(results.toArray(new CompletableFuture[0])) + .whenComplete((ignored, throwable) -> { + long successCount = results.stream().filter(CompletableFuture::join).count(); + long failureCount = results.size() - successCount; + notificationDeliveryResultRepository.updateResult( + notificationId, (int) successCount, (int) failureCount); + }); + } + + /** + * 관리자가 공지/이벤트를 등록하기 전, 지금 등록하면 몇 명(디바이스 기준)에게 발송될지 미리 조회한다. + * {@link #notifyAdminNotice}와 동일한 대상자 산정 기준('이벤트 및 소식 알림' 설정 ON)을 사용한다. + */ + public int countAdminNoticeTargets() { + List userIds = userSettingsRepository.findUserIdsByMarketingEventEnabledTrue(); + return (int) userDeviceRepository.countByUserIdIn(userIds); } /** @@ -128,11 +159,10 @@ private void sendCommentPush(Long userId, NotificationDetailCode detailCode, Str } } - private void sendPush(UserDevice device, String title, String body, Map data) { + private CompletableFuture sendPush(UserDevice device, String title, String body, Map data) { if (device.getPlatform() == DevicePlatform.IOS) { - apnsPushService.send(device, title, body, data); - } else { - fcmPushService.send(device, title, body, data); + return apnsPushService.send(device, title, body, data); } + return fcmPushService.send(device, title, body, data); } } diff --git a/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java b/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java index ea74943..5055033 100644 --- a/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java +++ b/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java @@ -153,7 +153,7 @@ public NotificationUnreadResponse hasUnread(Long userId, NotificationCategory ca } private Notification getAccessibleNotification(Long userId, Long notificationId) { - Notification notification = notificationRepository.findById(notificationId) + Notification notification = notificationRepository.findByIdAndDeletedAtIsNull(notificationId) .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND)); boolean isAccessible = notification.getUser() == null diff --git a/src/main/java/com/swyp/picke/domain/oauth/repository/UserSocialAccountRepository.java b/src/main/java/com/swyp/picke/domain/oauth/repository/UserSocialAccountRepository.java index e1a75c9..b3c91ea 100644 --- a/src/main/java/com/swyp/picke/domain/oauth/repository/UserSocialAccountRepository.java +++ b/src/main/java/com/swyp/picke/domain/oauth/repository/UserSocialAccountRepository.java @@ -4,6 +4,7 @@ import com.swyp.picke.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface UserSocialAccountRepository extends JpaRepository { @@ -14,4 +15,8 @@ Optional findByProviderAndProviderUserId( Optional findByUser(User user); void deleteByUser(User user); + + List findByProviderEmailContaining(String keyword); + + List findByUser_IdIn(List userIds); } \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java b/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java index bc4a352..0911ecc 100644 --- a/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java +++ b/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java @@ -5,6 +5,8 @@ import com.swyp.picke.domain.user.enums.UserStatus; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -29,4 +31,12 @@ public interface UserRepository extends JpaRepository { List findAllByStatus(UserStatus status); List findAllByRole(UserRole role); + + @Query(""" + select u from User u + where u.nickname like concat('%', :keyword, '%') + or u.userTag like concat('%', :keyword, '%') + order by u.id desc + """) + Slice searchByNicknameOrUserTag(@Param("keyword") String keyword, Pageable pageable); } diff --git a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java index 277b2d5..415672d 100644 --- a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java +++ b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java @@ -50,6 +50,7 @@ public enum ErrorCode { // Notification NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION_404", "존재하지 않는 알림입니다."), NOTIFICATION_SCHEDULE_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION_404_SCHEDULE", "존재하지 않는 알림 예약입니다."), + NOTIFICATION_DELIVERY_RESULT_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION_404_DELIVERY_RESULT", "발송 결과가 집계되지 않은 알림입니다."), // TTS TTS_INVALID_VOICE_ID(HttpStatus.BAD_REQUEST, "TTS_400_VOICE", "TTS 보이스 ID가 유효하지 않습니다."), diff --git a/src/main/java/com/swyp/picke/global/infra/apns/service/ApnsPushService.java b/src/main/java/com/swyp/picke/global/infra/apns/service/ApnsPushService.java index 0133947..6aeddff 100644 --- a/src/main/java/com/swyp/picke/global/infra/apns/service/ApnsPushService.java +++ b/src/main/java/com/swyp/picke/global/infra/apns/service/ApnsPushService.java @@ -16,6 +16,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.concurrent.CompletableFuture; /** * iOS 디바이스에 FCM을 거치지 않고 APNs로 직접 푸시를 발송한다. @@ -35,10 +36,10 @@ public class ApnsPushService { @Value("${apns.bundle-id}") private String bundleId; - public void send(UserDevice device, String title, String body, Map data) { + public CompletableFuture send(UserDevice device, String title, String body, Map data) { if (apnsClient.isEmpty()) { log.warn("APNs가 설정되지 않아 푸시를 건너뜁니다. deviceId={}", device.getId()); - return; + return CompletableFuture.completedFuture(false); } ApnsPayloadBuilder payloadBuilder = new SimpleApnsPayloadBuilder() @@ -51,18 +52,18 @@ public void send(UserDevice device, String title, String body, Map { + return apnsClient.get().sendNotification(notification).handle((response, cause) -> { if (cause != null) { log.warn("APNs 푸시 전송 실패. deviceId={}, error={}", device.getId(), cause.getMessage()); - return; + return false; } - handleResponse(device, response); + return handleResponse(device, response); }); } - private void handleResponse(UserDevice device, PushNotificationResponse response) { + private boolean handleResponse(UserDevice device, PushNotificationResponse response) { if (response.isAccepted()) { - return; + return true; } String reason = response.getRejectionReason().orElse("UNKNOWN"); @@ -71,5 +72,6 @@ private void handleResponse(UserDevice device, PushNotificationResponse data) { + public CompletableFuture send(UserDevice device, String title, String body, Map data) { Map payload = new HashMap<>(data); payload.put("title", title); payload.put("body", body); @@ -41,11 +42,13 @@ public void send(UserDevice device, String title, String body, Map adminNotificationScheduleService.toggle(999L, new AdminNotificationScheduleToggleRequest(true))) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("예약 알림 저장 내용으로 특정 유저에게 테스트 발송한다") + void sendTest_dispatchesSavedScheduleContent() { + NotificationSchedule schedule = NotificationSchedule.builder() + .title("오늘의 질문") + .subtitle("지금 확인해보세요") + .sendTime(LocalTime.of(19, 0)) + .enabled(true) + .build(); + when(notificationScheduleRepository.findById(1L)).thenReturn(Optional.of(schedule)); + + adminNotificationScheduleService.sendTest(1L, new AdminNotificationScheduleTestRequest(123L)); + + verify(notificationDispatchService).sendTestPush(123L, "오늘의 질문", "지금 확인해보세요"); + } + + @Test + @DisplayName("존재하지 않는 예약 알림을 테스트 발송하면 예외를 던진다") + void sendTest_throws_whenNotFound() { + when(notificationScheduleRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminNotificationScheduleService.sendTest( + 999L, new AdminNotificationScheduleTestRequest(123L))) + .isInstanceOf(CustomException.class); + } + @Test @DisplayName("예약 알림을 삭제한다") void delete_removesExistingSchedule() { diff --git a/src/test/java/com/swyp/picke/domain/admin/service/AdminNotificationServiceTest.java b/src/test/java/com/swyp/picke/domain/admin/service/AdminNotificationServiceTest.java new file mode 100644 index 0000000..ad78960 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/service/AdminNotificationServiceTest.java @@ -0,0 +1,155 @@ +package com.swyp.picke.domain.admin.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeUpdateRequest; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeDetailResponse; +import com.swyp.picke.domain.notification.entity.Notification; +import com.swyp.picke.domain.notification.entity.NotificationDeliveryResult; +import com.swyp.picke.domain.notification.enums.NotificationCategory; +import com.swyp.picke.domain.notification.enums.NotificationDetailCode; +import com.swyp.picke.domain.notification.repository.NotificationDeliveryResultRepository; +import com.swyp.picke.domain.notification.repository.NotificationRepository; +import com.swyp.picke.domain.notification.service.NotificationDispatchService; +import com.swyp.picke.domain.notification.service.NotificationService; +import com.swyp.picke.global.common.exception.CustomException; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AdminNotificationServiceTest { + + @Mock + private NotificationService notificationService; + + @Mock + private NotificationDispatchService notificationDispatchService; + + @Mock + private NotificationRepository notificationRepository; + + @Mock + private NotificationDeliveryResultRepository notificationDeliveryResultRepository; + + @InjectMocks + private AdminNotificationService adminNotificationService; + + private Notification newNotice(String title, String body) { + return Notification.builder() + .user(null) + .category(NotificationCategory.NOTICE) + .detailCode(NotificationDetailCode.POLICY_CHANGE) + .title(title) + .body(body) + .build(); + } + + @Test + @DisplayName("공지사항을 수정한다") + void updateNotice_updatesExistingNotice() { + Notification notification = newNotice("원본 제목", "원본 본문"); + when(notificationRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(notification)); + + AdminNoticeDetailResponse response = adminNotificationService.updateNotice( + 1L, new AdminNoticeUpdateRequest("수정된 제목", "수정된 본문")); + + assertThat(response.title()).isEqualTo("수정된 제목"); + assertThat(response.body()).isEqualTo("수정된 본문"); + } + + @Test + @DisplayName("존재하지 않는 공지사항을 수정하면 예외를 던진다") + void updateNotice_throws_whenNotFound() { + when(notificationRepository.findByIdAndDeletedAtIsNull(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminNotificationService.updateNotice( + 999L, new AdminNoticeUpdateRequest("제목", "본문"))) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("공지사항을 삭제하면 deletedAt이 기록된다") + void deleteNotice_softDeletesExistingNotice() { + Notification notification = newNotice("제목", "본문"); + when(notificationRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(notification)); + + adminNotificationService.deleteNotice(1L); + + assertThat(notification.getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("존재하지 않는 공지사항을 삭제하면 예외를 던진다") + void deleteNotice_throws_whenNotFound() { + when(notificationRepository.findByIdAndDeletedAtIsNull(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminNotificationService.deleteNotice(999L)) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("공지사항 발송 결과를 조회한다") + void getDeliveryResult_returnsResult() { + Notification notification = newNotice("제목", "본문"); + when(notificationRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(notification)); + NotificationDeliveryResult deliveryResult = NotificationDeliveryResult.builder() + .notificationId(1L) + .targetCount(10) + .build(); + when(notificationDeliveryResultRepository.findByNotificationId(1L)) + .thenReturn(Optional.of(deliveryResult)); + + var response = adminNotificationService.getDeliveryResult(1L); + + assertThat(response.notificationId()).isEqualTo(1L); + assertThat(response.targetCount()).isEqualTo(10); + assertThat(response.successCount()).isEqualTo(0); + assertThat(response.failureCount()).isEqualTo(0); + assertThat(response.pending()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 공지사항의 발송 결과를 조회하면 예외를 던진다") + void getDeliveryResult_throws_whenNoticeNotFound() { + when(notificationRepository.findByIdAndDeletedAtIsNull(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminNotificationService.getDeliveryResult(999L)) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("발송 결과가 아직 집계되지 않은 공지사항을 조회하면 예외를 던진다") + void getDeliveryResult_throws_whenResultNotFound() { + Notification notification = newNotice("제목", "본문"); + when(notificationRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(notification)); + when(notificationDeliveryResultRepository.findByNotificationId(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminNotificationService.getDeliveryResult(1L)) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("공지 발송 대상자 수를 조회한다") + void getTargetCount_returnsCount() { + when(notificationDispatchService.countAdminNoticeTargets()).thenReturn(1200); + + var response = adminNotificationService.getTargetCount(); + + assertThat(response.targetCount()).isEqualTo(1200); + } + + @Test + @DisplayName("공지 작성 시 선택 가능한 카테고리 목록을 조회한다") + void getOptions_returnsCreatableCategories() { + var response = adminNotificationService.getOptions(); + + assertThat(response.categories()).containsExactly("CONTENT", "NOTICE", "EVENT"); + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/service/AdminUserServiceTest.java b/src/test/java/com/swyp/picke/domain/admin/service/AdminUserServiceTest.java new file mode 100644 index 0000000..2c959ab --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/service/AdminUserServiceTest.java @@ -0,0 +1,107 @@ +package com.swyp.picke.domain.admin.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.admin.dto.user.response.AdminUserSearchResponse; +import com.swyp.picke.domain.oauth.entity.UserSocialAccount; +import com.swyp.picke.domain.oauth.repository.UserSocialAccountRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.UserRepository; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class AdminUserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private UserSocialAccountRepository userSocialAccountRepository; + + @InjectMocks + private AdminUserService adminUserService; + + private User newUser(long id, String userTag, String nickname) { + User user = User.builder() + .userTag(userTag) + .nickname(nickname) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + @Test + @DisplayName("닉네임 또는 유저태그로 매칭된 유저를 조회한다") + void searchUsers_matchesByNicknameOrUserTag() { + User user = newUser(1L, "picke_abcd", "민초러버"); + Pageable pageable = PageRequest.of(0, 20); + when(userRepository.searchByNicknameOrUserTag(anyString(), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(user), pageable, 1)); + when(userSocialAccountRepository.findByProviderEmailContaining(anyString())).thenReturn(List.of()); + when(userSocialAccountRepository.findByUser_IdIn(any())).thenReturn(List.of()); + + AdminUserSearchResponse response = adminUserService.searchUsers("민초", 0, 20); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).userId()).isEqualTo(1L); + assertThat(response.items().get(0).nickname()).isEqualTo("민초러버"); + assertThat(response.items().get(0).email()).isNull(); + } + + @Test + @DisplayName("이메일로 매칭된 소셜 로그인 유저를 조회하고 이메일을 함께 내려준다") + void searchUsers_matchesByEmail() { + User user = newUser(2L, "picke_efgh", "질문왕"); + UserSocialAccount socialAccount = UserSocialAccount.builder() + .user(user) + .provider("GOOGLE") + .providerUserId("google-1") + .providerEmail("user@gmail.com") + .build(); + Pageable pageable = PageRequest.of(0, 20); + when(userRepository.searchByNicknameOrUserTag(anyString(), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(), pageable, 0)); + when(userSocialAccountRepository.findByProviderEmailContaining(anyString())) + .thenReturn(List.of(socialAccount)); + when(userSocialAccountRepository.findByUser_IdIn(any())).thenReturn(List.of(socialAccount)); + + AdminUserSearchResponse response = adminUserService.searchUsers("gmail", 0, 20); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).userId()).isEqualTo(2L); + assertThat(response.items().get(0).email()).isEqualTo("user@gmail.com"); + } + + @Test + @DisplayName("로컬 로그인 유저는 소셜 계정이 없어도 닉네임/유저태그로 조회된다") + void searchUsers_localLoginUser_hasNoEmail() { + User user = newUser(3L, "picke_ijkl", "로컬유저"); + Pageable pageable = PageRequest.of(0, 20); + when(userRepository.searchByNicknameOrUserTag(anyString(), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(user), pageable, 1)); + when(userSocialAccountRepository.findByProviderEmailContaining(anyString())).thenReturn(List.of()); + when(userSocialAccountRepository.findByUser_IdIn(any())).thenReturn(List.of()); + + AdminUserSearchResponse response = adminUserService.searchUsers("로컬", 0, 20); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).email()).isNull(); + } +} diff --git a/src/test/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcherTest.java b/src/test/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcherTest.java index a4460e8..42c1306 100644 --- a/src/test/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcherTest.java +++ b/src/test/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcherTest.java @@ -7,7 +7,9 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.swyp.picke.domain.notification.entity.Notification; import com.swyp.picke.domain.notification.entity.NotificationSchedule; +import com.swyp.picke.domain.notification.enums.NotificationCategory; import com.swyp.picke.domain.notification.enums.NotificationDetailCode; import com.swyp.picke.domain.notification.repository.NotificationScheduleRepository; import com.swyp.picke.domain.notification.service.NotificationDispatchService; @@ -22,6 +24,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) class NotificationScheduleDispatcherTest { @@ -66,14 +69,26 @@ void dispatchDueSchedules_sendsOnlyMatchingEnabledSchedules() { .build(); when(notificationScheduleRepository.findAllByEnabledTrue()).thenReturn(List.of(due, notDue)); + Notification notification = Notification.builder() + .user(null) + .category(NotificationCategory.CONTENT) + .detailCode(NotificationDetailCode.DAILY_MESSAGE) + .title("오늘의 질문") + .body("지금 확인해보세요") + .build(); + ReflectionTestUtils.setField(notification, "id", 1L); + when(notificationService.createBroadcastNotification( + eq(NotificationDetailCode.DAILY_MESSAGE), eq("오늘의 질문"), eq("지금 확인해보세요"), any())) + .thenReturn(notification); + notificationScheduleDispatcher.dispatchDueSchedules(); verify(notificationService, times(1)).createBroadcastNotification( eq(NotificationDetailCode.DAILY_MESSAGE), eq("오늘의 질문"), eq("지금 확인해보세요"), any()); verify(notificationDispatchService, times(1)).notifyAdminNotice( - eq(NotificationDetailCode.DAILY_MESSAGE), eq("오늘의 질문"), eq("지금 확인해보세요")); + eq(1L), eq(NotificationDetailCode.DAILY_MESSAGE), eq("오늘의 질문"), eq("지금 확인해보세요")); verify(notificationDispatchService, never()).notifyAdminNotice( - eq(NotificationDetailCode.DAILY_MESSAGE), eq("다른 알림"), any()); + any(), eq(NotificationDetailCode.DAILY_MESSAGE), eq("다른 알림"), any()); } @Test @@ -83,6 +98,6 @@ void dispatchDueSchedules_doesNothing_whenNoScheduleIsDue() { notificationScheduleDispatcher.dispatchDueSchedules(); - verify(notificationDispatchService, never()).notifyAdminNotice(any(), any(), any()); + verify(notificationDispatchService, never()).notifyAdminNotice(any(), any(), any(), any()); } } diff --git a/src/test/java/com/swyp/picke/domain/notification/service/NotificationDispatchServiceTest.java b/src/test/java/com/swyp/picke/domain/notification/service/NotificationDispatchServiceTest.java new file mode 100644 index 0000000..04d7532 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/notification/service/NotificationDispatchServiceTest.java @@ -0,0 +1,98 @@ +package com.swyp.picke.domain.notification.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.notification.entity.NotificationDeliveryResult; +import com.swyp.picke.domain.notification.entity.UserDevice; +import com.swyp.picke.domain.notification.enums.DevicePlatform; +import com.swyp.picke.domain.notification.enums.NotificationDetailCode; +import com.swyp.picke.domain.notification.repository.NotificationDeliveryResultRepository; +import com.swyp.picke.domain.notification.repository.UserDeviceRepository; +import com.swyp.picke.domain.user.repository.UserSettingsRepository; +import com.swyp.picke.global.infra.apns.service.ApnsPushService; +import com.swyp.picke.global.infra.fcm.service.FcmPushService; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class NotificationDispatchServiceTest { + + @Mock + private NotificationService notificationService; + + @Mock + private UserDeviceRepository userDeviceRepository; + + @Mock + private UserSettingsRepository userSettingsRepository; + + @Mock + private NotificationDeliveryResultRepository notificationDeliveryResultRepository; + + @Mock + private FcmPushService fcmPushService; + + @Mock + private ApnsPushService apnsPushService; + + private NotificationDispatchService newService() { + NotificationDispatchService service = new NotificationDispatchService( + notificationService, userDeviceRepository, userSettingsRepository, + notificationDeliveryResultRepository, fcmPushService, apnsPushService); + ReflectionTestUtils.setField(service, "baseUrl", "https://picke.store"); + return service; + } + + private UserDevice newDevice() { + return UserDevice.builder().fcmToken("token-" + Math.random()).platform(DevicePlatform.ANDROID).build(); + } + + @Test + @DisplayName("공지 발송 시 대상 디바이스 수로 발송 결과 row를 먼저 만들고, 발송 완료 후 성공/실패 건수를 갱신한다") + void notifyAdminNotice_recordsDeliveryResult() { + NotificationDispatchService notificationDispatchService = newService(); + + UserDevice success = newDevice(); + UserDevice failure = newDevice(); + when(userSettingsRepository.findUserIdsByMarketingEventEnabledTrue()).thenReturn(List.of(1L, 2L)); + when(userDeviceRepository.findAllByUserIdIn(List.of(1L, 2L))).thenReturn(List.of(success, failure)); + when(fcmPushService.send(eq(success), anyString(), anyString(), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + when(fcmPushService.send(eq(failure), anyString(), anyString(), any())) + .thenReturn(CompletableFuture.completedFuture(false)); + + notificationDispatchService.notifyAdminNotice(10L, NotificationDetailCode.POLICY_CHANGE, "제목", "본문"); + + ArgumentCaptor createdCaptor = ArgumentCaptor.forClass(NotificationDeliveryResult.class); + verify(notificationDeliveryResultRepository).save(createdCaptor.capture()); + assertThat(createdCaptor.getValue().getNotificationId()).isEqualTo(10L); + assertThat(createdCaptor.getValue().getTargetCount()).isEqualTo(2); + + verify(notificationDeliveryResultRepository).updateResult(10L, 1, 1); + } + + @Test + @DisplayName("공지 발송 대상자 수를 마케팅/이벤트 알림 설정 ON인 유저의 디바이스 수 기준으로 조회한다") + void countAdminNoticeTargets_returnsDeviceCount() { + NotificationDispatchService notificationDispatchService = newService(); + + when(userSettingsRepository.findUserIdsByMarketingEventEnabledTrue()).thenReturn(List.of(1L, 2L, 3L)); + when(userDeviceRepository.countByUserIdIn(List.of(1L, 2L, 3L))).thenReturn(5L); + + int targetCount = notificationDispatchService.countAdminNoticeTargets(); + + assertThat(targetCount).isEqualTo(5); + } +} diff --git a/src/test/java/com/swyp/picke/domain/notification/service/NotificationServiceTest.java b/src/test/java/com/swyp/picke/domain/notification/service/NotificationServiceTest.java index 0afe16f..f413779 100644 --- a/src/test/java/com/swyp/picke/domain/notification/service/NotificationServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/notification/service/NotificationServiceTest.java @@ -164,7 +164,7 @@ void getNotifications_includes_content_broadcast_notifications() { @Test @DisplayName("존재하지 않는 알림 읽음 처리 시 예외를 던진다") void markAsRead_throws_when_not_found() { - when(notificationRepository.findById(999L)).thenReturn(Optional.empty()); + when(notificationRepository.findByIdAndDeletedAtIsNull(999L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> notificationService.markAsRead(1L, 999L)) .isInstanceOf(CustomException.class); @@ -184,7 +184,7 @@ void markAsRead_saves_notification_read_for_broadcast() { .referenceId(50L) .build(); - when(notificationRepository.findById(notificationId)).thenReturn(Optional.of(notification)); + when(notificationRepository.findByIdAndDeletedAtIsNull(notificationId)).thenReturn(Optional.of(notification)); when(notificationReadRepository.existsByNotificationIdAndUserId(notificationId, userId)).thenReturn(false); notificationService.markAsRead(userId, notificationId); @@ -209,7 +209,7 @@ void getNotificationDetail_returns_owned_notification() { setUserId(user, userId); setNotificationId(notification, 10L); - when(notificationRepository.findById(10L)).thenReturn(Optional.of(notification)); + when(notificationRepository.findByIdAndDeletedAtIsNull(10L)).thenReturn(Optional.of(notification)); NotificationDetailResponse response = notificationService.getNotificationDetail(userId, 10L); @@ -235,7 +235,7 @@ void getNotificationDetail_returns_broadcast_notification() { setNotificationId(notification, notificationId); - when(notificationRepository.findById(notificationId)).thenReturn(Optional.of(notification)); + when(notificationRepository.findByIdAndDeletedAtIsNull(notificationId)).thenReturn(Optional.of(notification)); when(notificationReadRepository.existsByNotificationIdAndUserId(notificationId, userId)).thenReturn(false); NotificationDetailResponse response = notificationService.getNotificationDetail(userId, notificationId); @@ -263,7 +263,7 @@ void getNotificationDetail_throws_when_notification_not_accessible() { .build(); setUserId(owner, ownerId); - when(notificationRepository.findById(30L)).thenReturn(Optional.of(notification)); + when(notificationRepository.findByIdAndDeletedAtIsNull(30L)).thenReturn(Optional.of(notification)); assertThatThrownBy(() -> notificationService.getNotificationDetail(requesterId, 30L)) .isInstanceOf(CustomException.class);