Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/api-specs/notification-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@
| `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` 요청 바디**
Expand Down Expand Up @@ -389,6 +390,41 @@

성공 시 `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
Expand Down Expand Up @@ -480,3 +516,4 @@
| `USER_404` | `404` | 존재하지 않는 사용자입니다. |
| `NOTIFICATION_404` | `404` | 존재하지 않는 알림입니다. (본인 소유가 아닌 알림 포함) |
| `NOTIFICATION_404_SCHEDULE` | `404` | 존재하지 않는 예약 알림입니다. |
| `NOTIFICATION_404_DELIVERY_RESULT` | `404` | 발송 결과가 집계되지 않은 알림입니다. |
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
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.service.AdminNotificationService;
Expand Down Expand Up @@ -74,6 +75,12 @@ public ApiResponse<Void> deleteNotice(@PathVariable Long noticeId) {
return ApiResponse.onSuccess(null);
}

@Operation(summary = "공지사항 발송 결과 조회", description = "대상 디바이스 수, 성공/실패 건수를 조회한다. 푸시 발송은 비동기로 이뤄지므로 완료 전에는 pending=true로 내려간다.")
@GetMapping("/{noticeId}/delivery-result")
public ApiResponse<AdminNoticeDeliveryResultResponse> getDeliveryResult(@PathVariable Long noticeId) {
return ApiResponse.onSuccess(adminNotificationService.getDeliveryResult(noticeId));
}

@Operation(summary = "푸시 알림 발송 테스트", description = "특정 유저의 등록된 디바이스로 알림 설정(ON/OFF) 무관하게 즉시 테스트 푸시를 발송한다.")
@PostMapping("/test")
public ApiResponse<Void> sendTestPush(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

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.AdminNoticeSummaryResponse;
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;
Expand All @@ -29,6 +32,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) {
Expand All @@ -42,7 +46,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);
Expand Down Expand Up @@ -87,6 +91,22 @@ public void deleteNotice(Long notificationId) {
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()
);
}

private NotificationCategory normalizeCategory(NotificationCategory category) {
if (category == null || category == NotificationCategory.ALL) {
return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<NotificationDeliveryResult, Long> {

Optional<NotificationDeliveryResult> 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
);
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
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;
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.Map;
import java.util.concurrent.CompletableFuture;
import java.util.function.Predicate;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
Expand All @@ -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;

Expand Down Expand Up @@ -83,15 +87,33 @@ 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<String, String> data = Map.of("type", detailCode.getCategory().name());

List<Long> userIds = userSettingsRepository.findUserIdsByMarketingEventEnabledTrue();
for (UserDevice device : userDeviceRepository.findAllByUserIdIn(userIds)) {
sendPush(device, title, body, data);
}
List<UserDevice> devices = userDeviceRepository.findAllByUserIdIn(userIds);

notificationDeliveryResultRepository.save(
NotificationDeliveryResult.builder()
.notificationId(notificationId)
.targetCount(devices.size())
.build()
);

List<CompletableFuture<Boolean>> 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);
});
}

/**
Expand Down Expand Up @@ -128,11 +150,10 @@ private void sendCommentPush(Long userId, NotificationDetailCode detailCode, Str
}
}

private void sendPush(UserDevice device, String title, String body, Map<String, String> data) {
private CompletableFuture<Boolean> sendPush(UserDevice device, String title, String body, Map<String, String> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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가 유효하지 않습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;

/**
* iOS 디바이스에 FCM을 거치지 않고 APNs로 직접 푸시를 발송한다.
Expand All @@ -35,10 +36,10 @@ public class ApnsPushService {
@Value("${apns.bundle-id}")
private String bundleId;

public void send(UserDevice device, String title, String body, Map<String, String> data) {
public CompletableFuture<Boolean> send(UserDevice device, String title, String body, Map<String, String> data) {
if (apnsClient.isEmpty()) {
log.warn("APNs가 설정되지 않아 푸시를 건너뜁니다. deviceId={}", device.getId());
return;
return CompletableFuture.completedFuture(false);
}

ApnsPayloadBuilder payloadBuilder = new SimpleApnsPayloadBuilder()
Expand All @@ -51,18 +52,18 @@ public void send(UserDevice device, String title, String body, Map<String, Strin
SimpleApnsPushNotification notification =
new SimpleApnsPushNotification(token, bundleId, payloadBuilder.build());

apnsClient.get().sendNotification(notification).whenComplete((response, cause) -> {
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<SimpleApnsPushNotification> response) {
private boolean handleResponse(UserDevice device, PushNotificationResponse<SimpleApnsPushNotification> response) {
if (response.isAccepted()) {
return;
return true;
}

String reason = response.getRejectionReason().orElse("UNKNOWN");
Expand All @@ -71,5 +72,6 @@ private void handleResponse(UserDevice device, PushNotificationResponse<SimpleAp
if (INVALID_TOKEN_REASONS.contains(reason)) {
userDeviceRepository.deleteById(device.getId());
}
return false;
}
}
Loading
Loading