diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/model/event/SurveyCompletedEvent.java b/src/main/java/OneQ/OnSurvey/domain/participation/model/event/SurveyCompletedEvent.java new file mode 100644 index 00000000..1d69ea14 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/participation/model/event/SurveyCompletedEvent.java @@ -0,0 +1,28 @@ +package OneQ.OnSurvey.domain.participation.model.event; + +import OneQ.OnSurvey.global.common.event.pushAlim.PushAlimEvent; + +import java.util.Map; + +public record SurveyCompletedEvent ( + Long userKey, + Map eventContext +) implements PushAlimEvent { + + @Override + public Long getTargetUserKey() { + return userKey; + } + + @Override + public String getPushTemplateName() { + return this.getClass().getSimpleName(); + } + + @Override + public Map getPushContext() { + return eventContext == null + ? Map.of() + : eventContext; + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java index 797d3336..aa2a4ba2 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java @@ -1,6 +1,7 @@ package OneQ.OnSurvey.domain.participation.service.response; import OneQ.OnSurvey.domain.participation.entity.Response; +import OneQ.OnSurvey.domain.participation.model.event.SurveyCompletedEvent; import OneQ.OnSurvey.domain.participation.repository.response.ResponseRepository; import OneQ.OnSurvey.domain.survey.SurveyErrorCode; import OneQ.OnSurvey.domain.survey.entity.Survey; @@ -12,11 +13,14 @@ import OneQ.OnSurvey.global.common.exception.CustomException; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; +import java.util.Objects; @Service @RequiredArgsConstructor @@ -25,6 +29,7 @@ public class ResponseCommandService implements ResponseCommand { private final StringRedisTemplate redisTemplate; + private final ApplicationEventPublisher eventPublisher; private final ResponseRepository responseRepository; private final SurveyRepository surveyRepository; private final SurveyInfoRepository surveyInfoRepository; @@ -67,7 +72,10 @@ public Boolean createResponse(Long surveyId, Long memberId, Long userKey) { .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_NOT_FOUND)); survey.updateSurveyStatus(SurveyStatus.CLOSED); + Long creator = Long.parseLong(Objects.requireNonNull(redisTemplate.opsForValue().get(creatorKey + surveyId))); deleteAllRedisKeys(surveyId); + + eventPublisher.publishEvent(new SurveyCompletedEvent(creator, Map.of())); } return true; diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepositoryImpl.java index 795da163..62917478 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepositoryImpl.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepositoryImpl.java @@ -176,7 +176,6 @@ public SurveyDetailData getSurveyDetailDataById(Long surveyId) { set(interestAlias).as("interests") )) ); - System.out.println("result = " + result); return result.get(surveyId); } diff --git a/src/main/java/OneQ/OnSurvey/global/common/config/AsyncConfig.java b/src/main/java/OneQ/OnSurvey/global/common/config/AsyncConfig.java index 260a37d6..a0e94040 100644 --- a/src/main/java/OneQ/OnSurvey/global/common/config/AsyncConfig.java +++ b/src/main/java/OneQ/OnSurvey/global/common/config/AsyncConfig.java @@ -6,6 +6,7 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; @Configuration @EnableAsync @@ -21,4 +22,18 @@ public Executor discordAlarmExecutor() { exec.initialize(); return exec; } + + @Bean(name = "pushAlimExecutor") + public Executor pushAlimExecutor() { + ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor(); + exec.setCorePoolSize(4); + exec.setMaxPoolSize(8); + exec.setQueueCapacity(100); + exec.setThreadNamePrefix("pushalim-"); + exec.setWaitForTasksToCompleteOnShutdown(true); + exec.setAwaitTerminationSeconds(10); // Graceful Shutdown을 위해 10초 대기 + exec.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 큐가 가득 찼을 때 호출한 스레드에서 실행하도록 설정 + exec.initialize(); + return exec; + } } diff --git a/src/main/java/OneQ/OnSurvey/global/common/event/pushAlim/PushAlimEvent.java b/src/main/java/OneQ/OnSurvey/global/common/event/pushAlim/PushAlimEvent.java new file mode 100644 index 00000000..308f20e7 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/common/event/pushAlim/PushAlimEvent.java @@ -0,0 +1,9 @@ +package OneQ.OnSurvey.global.common.event.pushAlim; + +import java.util.Map; + +public interface PushAlimEvent { + Long getTargetUserKey(); + String getPushTemplateName(); + Map getPushContext(); +} diff --git a/src/main/java/OneQ/OnSurvey/global/infra/discord/DiscordAlarmAsyncFacade.java b/src/main/java/OneQ/OnSurvey/global/infra/discord/DiscordAlarmAsyncFacade.java index e3988a4a..4f856b9c 100644 --- a/src/main/java/OneQ/OnSurvey/global/infra/discord/DiscordAlarmAsyncFacade.java +++ b/src/main/java/OneQ/OnSurvey/global/infra/discord/DiscordAlarmAsyncFacade.java @@ -3,6 +3,7 @@ import OneQ.OnSurvey.global.infra.discord.notifier.dto.PaymentCompletedAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveySubmittedAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.TossAccessTokenAlert; +import OneQ.OnSurvey.global.infra.discord.notifier.dto.PushAlimAlert; import lombok.RequiredArgsConstructor; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @@ -31,4 +32,9 @@ public void sendSurveySubmittedAsync(SurveySubmittedAlert alert) { public void sendTossAccessTokenAsync(TossAccessTokenAlert alert) { service.sendTossAccessTokenAlert(alert); } + + @Async("discordAlarmExecutor") + public void sendPushAlimAsync(PushAlimAlert alert) { + service.sendPushAlimAsync(alert); + } } diff --git a/src/main/java/OneQ/OnSurvey/global/infra/discord/DiscordAlarmService.java b/src/main/java/OneQ/OnSurvey/global/infra/discord/DiscordAlarmService.java index 7bc957a7..2cebc2e6 100644 --- a/src/main/java/OneQ/OnSurvey/global/infra/discord/DiscordAlarmService.java +++ b/src/main/java/OneQ/OnSurvey/global/infra/discord/DiscordAlarmService.java @@ -5,6 +5,7 @@ import OneQ.OnSurvey.global.infra.discord.notifier.dto.PaymentCompletedAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveySubmittedAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.TossAccessTokenAlert; +import OneQ.OnSurvey.global.infra.discord.notifier.dto.PushAlimAlert; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -40,6 +41,9 @@ public class DiscordAlarmService { @Value("${discord.test-toss-auth-url:}") private String tossAuthTestWebhookUrl; + @Value("${discord.push-alim-alert-url:}") + private String pushAlimWebhookUrl; + public void sendErrorAlert(Throwable e, String method, String path, String query) { if (!enabled || errorWebhookUrl == null || errorWebhookUrl.isBlank()) return; @@ -114,6 +118,31 @@ public void sendTossAccessTokenAlert(TossAccessTokenAlert a) { post(url, title, desc); } + public void sendPushAlimAsync(PushAlimAlert alert) { + if (!enabled) return; + + String url = (pushAlimWebhookUrl != null && !pushAlimWebhookUrl.isBlank()) + ? pushAlimWebhookUrl + : errorWebhookUrl; + if (url == null || url.isBlank()) return; + + String title, description; + if (alert.failedCount() <= 0) { + title = "🔔 푸시 알림 발생"; + description = "• userKey: `" + alert.userKey() + "`\n" + + "• 템플릿: `" + safe(alert.templateSetCode()) + "`\n" + + "• 성공: `" + alert.completedCount() + "`\n"; + } else { + title = "🔔 푸시 알림 실패건 발생"; + description = "• userKey: `" + alert.userKey() + "`\n" + + "• 템플릿: `" + safe(alert.templateSetCode()) + "`\n" + + "• 성공: `" + alert.completedCount() + "`\n" + + "• 실패: `" + alert.failedCount() + "`\n" + + "• 실패 상세: `" + safe(alert.errorReason()) + "`\n"; + } + post(url, title, description); + } + private String maskKey(String key) { return JwtDecodeUtils.maskToken(key); } diff --git a/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/AlertNotifier.java b/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/AlertNotifier.java index 5970c822..a0b054c8 100644 --- a/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/AlertNotifier.java +++ b/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/AlertNotifier.java @@ -3,10 +3,12 @@ import OneQ.OnSurvey.global.infra.discord.notifier.dto.PaymentCompletedAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveySubmittedAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.TossAccessTokenAlert; +import OneQ.OnSurvey.global.infra.discord.notifier.dto.PushAlimAlert; public interface AlertNotifier { void sendErrorAlertAsync(Exception e, String method, String uri, String queryString); void sendPaymentCompletedAsync(PaymentCompletedAlert alert); void sendSurveySubmittedAsync(SurveySubmittedAlert alert); void sendTossAccessTokenAsync(TossAccessTokenAlert alert); + void sendPushAlimAsync(PushAlimAlert alert); } diff --git a/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/DiscordAlertNotifier.java b/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/DiscordAlertNotifier.java index b56fb79b..a4154adc 100644 --- a/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/DiscordAlertNotifier.java +++ b/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/DiscordAlertNotifier.java @@ -4,6 +4,7 @@ import OneQ.OnSurvey.global.infra.discord.notifier.dto.PaymentCompletedAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveySubmittedAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.TossAccessTokenAlert; +import OneQ.OnSurvey.global.infra.discord.notifier.dto.PushAlimAlert; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; @@ -34,4 +35,9 @@ public void sendSurveySubmittedAsync(SurveySubmittedAlert alert) { public void sendTossAccessTokenAsync(TossAccessTokenAlert alert) { discord.sendTossAccessTokenAsync(alert); } + + @Override + public void sendPushAlimAsync(PushAlimAlert alert) { + discord.sendPushAlimAsync(alert); + } } diff --git a/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/NoOpAlertNotifier.java b/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/NoOpAlertNotifier.java index a6b4607c..6e442982 100644 --- a/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/NoOpAlertNotifier.java +++ b/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/NoOpAlertNotifier.java @@ -1,6 +1,7 @@ package OneQ.OnSurvey.global.infra.discord.notifier; import OneQ.OnSurvey.global.infra.discord.notifier.dto.PaymentCompletedAlert; +import OneQ.OnSurvey.global.infra.discord.notifier.dto.PushAlimAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveySubmittedAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.TossAccessTokenAlert; import org.springframework.context.annotation.Profile; @@ -21,4 +22,7 @@ public void sendSurveySubmittedAsync(SurveySubmittedAlert alert) {} @Override public void sendTossAccessTokenAsync(TossAccessTokenAlert alert) {} + + @Override + public void sendPushAlimAsync(PushAlimAlert alert) {} } diff --git a/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/dto/PushAlimAlert.java b/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/dto/PushAlimAlert.java new file mode 100644 index 00000000..540a3409 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/dto/PushAlimAlert.java @@ -0,0 +1,10 @@ +package OneQ.OnSurvey.global.infra.discord.notifier.dto; + +public record PushAlimAlert( + long userKey, + String templateSetCode, + long completedCount, + long failedCount, + String errorReason +) { +} diff --git a/src/main/java/OneQ/OnSurvey/global/infra/toss/client/TossApiClient.java b/src/main/java/OneQ/OnSurvey/global/infra/toss/client/TossApiClient.java index 79ded0fd..c6dbb7a2 100644 --- a/src/main/java/OneQ/OnSurvey/global/infra/toss/client/TossApiClient.java +++ b/src/main/java/OneQ/OnSurvey/global/infra/toss/client/TossApiClient.java @@ -3,6 +3,7 @@ import OneQ.OnSurvey.global.auth.port.out.TossAuthPort; import OneQ.OnSurvey.global.common.exception.CustomException; import OneQ.OnSurvey.global.infra.discord.notifier.AlertNotifier; +import OneQ.OnSurvey.global.infra.discord.notifier.dto.PushAlimAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.TossAccessTokenAlert; import OneQ.OnSurvey.global.infra.toss.common.dto.auth.LoginMeResponse; import OneQ.OnSurvey.global.infra.toss.common.dto.auth.TossLoginRequest; @@ -12,10 +13,13 @@ import OneQ.OnSurvey.global.infra.toss.common.dto.promotion.ExecutePromotionResponse; import OneQ.OnSurvey.global.infra.toss.common.dto.promotion.ExecutionResultResponse; import OneQ.OnSurvey.global.infra.toss.common.dto.promotion.PromotionKeyResponse; +import OneQ.OnSurvey.global.infra.toss.common.dto.push.PushResultResponse; +import OneQ.OnSurvey.global.infra.toss.common.dto.push.PushTemplateSendRequest; import OneQ.OnSurvey.global.infra.toss.common.exception.TossApiException; import OneQ.OnSurvey.global.infra.toss.common.exception.TossErrorCode; import OneQ.OnSurvey.global.payment.port.out.TossIapPort; import OneQ.OnSurvey.global.promotion.port.out.TossPromotionPort; +import OneQ.OnSurvey.global.push.application.port.out.TossPushPort; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -41,11 +45,12 @@ import java.util.ArrayList; import java.util.Base64; import java.util.List; +import java.util.Map; @Component @Slf4j @RequiredArgsConstructor -public class TossApiClient implements TossAuthPort, TossIapPort, TossPromotionPort { +public class TossApiClient implements TossAuthPort, TossIapPort, TossPromotionPort, TossPushPort { private static final int CONNECT_TIMEOUT_MS = 5_000; private static final int READ_TIMEOUT_MS = 5_000; @@ -81,6 +86,9 @@ public class TossApiClient implements TossAuthPort, TossIapPort, TossPromotionPo @Value("${toss.api.iap.get-order-status}") private String getIapOrderStatusUrl; + @Value("${toss.api.send-message.url}") + private String sendMessageUrl; + private final ObjectMapper objectMapper; private final AlertNotifier alertNotifier; @@ -364,6 +372,58 @@ public OrderStatusResponse getIapOrderStatus(SSLContext ctx, long userKey, Strin } } + /* ===================== 푸시알림 ===================== */ + @Override + public PushResultResponse sendPush(SSLContext ctx, PushTemplateSendRequest request) throws IOException { + long userKey = request.userKey(); + String templateSetCode = request.templateSetCode(); + Map templateCtx = request.templateCtx(); + + HttpsURLConnection conn = open(sendMessageUrl, ctx, "POST", true); + conn.setRequestProperty("x-toss-user-key", String.valueOf(userKey)); + + try { + ObjectNode body = objectMapper.createObjectNode() + .put("templateSetCode", templateSetCode) + .set("context", objectMapper.valueToTree(templateCtx)); + writeJson(conn, body); + JsonNode root = readJson(conn); + + JsonNode successRoot = root.path("success"); + if (!isSuccess(root)) { + int code = root.path("error").path("errorType").asInt(-1); + String msg = root.path("error").path("errorCode").asText("unknown"); + log.error("[PushAPI:sendPush] code={}, message={}, raw={}", code, msg, root); + alertNotifier.sendPushAlimAsync( + new PushAlimAlert( + userKey, + templateSetCode, + successRoot.path("sentPushCount").asLong(0), + successRoot.path("fail").path("sentPush").size(), + root.path("error").path("reason").asText("unknown") + ) + ); + throw new CustomException(TossErrorCode.TOSS_PUSH_SEND_ERROR); + } + alertNotifier.sendPushAlimAsync( + new PushAlimAlert( + userKey, + templateSetCode, + successRoot.path("sentPushCount").asLong(0), + successRoot.path("fail").path("sentPush").size(), + "none" + ) + ); + + return PushResultResponse.of( + successRoot.path("sentPushCount").asLong(), + successRoot.path("detail").path("sentPush").toPrettyString() + ); + } finally { + conn.disconnect(); + } + } + private HttpsURLConnection open(String url, SSLContext ctx, String method, boolean doOutput) throws IOException { HttpsURLConnection conn = (HttpsURLConnection) new URL(url).openConnection(); diff --git a/src/main/java/OneQ/OnSurvey/global/infra/toss/common/dto/push/PushResultResponse.java b/src/main/java/OneQ/OnSurvey/global/infra/toss/common/dto/push/PushResultResponse.java new file mode 100644 index 00000000..f6895f96 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/infra/toss/common/dto/push/PushResultResponse.java @@ -0,0 +1,11 @@ +package OneQ.OnSurvey.global.infra.toss.common.dto.push; + +public record PushResultResponse ( + Long sentPushCount, + String sentPushContentIds +) { + + public static PushResultResponse of(Long sentPushCount, String sentPushContentIds) { + return new PushResultResponse(sentPushCount, sentPushContentIds); + } +} diff --git a/src/main/java/OneQ/OnSurvey/global/infra/toss/common/dto/push/PushTemplateAddRequest.java b/src/main/java/OneQ/OnSurvey/global/infra/toss/common/dto/push/PushTemplateAddRequest.java new file mode 100644 index 00000000..ebeca6d3 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/infra/toss/common/dto/push/PushTemplateAddRequest.java @@ -0,0 +1,29 @@ +package OneQ.OnSurvey.global.infra.toss.common.dto.push; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.util.List; +import java.util.Map; + +public record PushTemplateAddRequest( + @Schema(description = "메시지 템플릿 이름 (이벤트 클래스 명)", example = "SurveyCompletedEvent") + @NotBlank + String name, + + @Schema(description = "발송할 메시지 템플릿 코드", example = "test_01") + @NotBlank + String code, + + @Schema( + description = "템플릿에서 사용할 기본 context 값. key는 템플릿에서 사용할 변수명, value는 해당 변수에 들어갈 기본값(index 0)과 설명(index 1)을 담은 리스트", + example = "{" + + "\"surveyTitle\": [\"기본 설문 제목\", \"설문 제목에 들어갈 기본 변수값\"], " + + "\"surveyDeadline\": [\"2024-12-31\", null]" + + "}" + ) + + Map<@NotBlank String, @Size(min = 1) List> defaultContext +) { +} diff --git a/src/main/java/OneQ/OnSurvey/global/infra/toss/common/dto/push/PushTemplateModifyRequest.java b/src/main/java/OneQ/OnSurvey/global/infra/toss/common/dto/push/PushTemplateModifyRequest.java new file mode 100644 index 00000000..a97c20ed --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/infra/toss/common/dto/push/PushTemplateModifyRequest.java @@ -0,0 +1,24 @@ +package OneQ.OnSurvey.global.infra.toss.common.dto.push; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.util.List; +import java.util.Map; + +public record PushTemplateModifyRequest( + @Schema(description = "발송할 메시지 템플릿 코드", example = "test_01") + @NotBlank + String code, + + @Schema( + description = "템플릿에서 사용할 기본 context 값. key는 템플릿에서 사용할 변수명, value는 해당 변수에 들어갈 기본값(index 0)과 설명(index 1)을 담은 리스트", + example = "{" + + "\"surveyTitle\": [\"기본 설문 제목\", \"설문 제목에 들어갈 기본 변수값\"], " + + "\"surveyDeadline\": [\"2024-12-31\", null]" + + "}" + ) + Map<@NotBlank String, @Size(min = 1) List> defaultContext +) { +} diff --git a/src/main/java/OneQ/OnSurvey/global/infra/toss/common/dto/push/PushTemplateSendRequest.java b/src/main/java/OneQ/OnSurvey/global/infra/toss/common/dto/push/PushTemplateSendRequest.java new file mode 100644 index 00000000..3e6259e1 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/infra/toss/common/dto/push/PushTemplateSendRequest.java @@ -0,0 +1,13 @@ +package OneQ.OnSurvey.global.infra.toss.common.dto.push; + +import lombok.Builder; + +import java.util.Map; + +@Builder +public record PushTemplateSendRequest ( + long userKey, + String templateSetCode, + Map templateCtx +) { +} diff --git a/src/main/java/OneQ/OnSurvey/global/infra/toss/common/exception/TossErrorCode.java b/src/main/java/OneQ/OnSurvey/global/infra/toss/common/exception/TossErrorCode.java index f029d6a0..4cecdced 100644 --- a/src/main/java/OneQ/OnSurvey/global/infra/toss/common/exception/TossErrorCode.java +++ b/src/main/java/OneQ/OnSurvey/global/infra/toss/common/exception/TossErrorCode.java @@ -26,6 +26,9 @@ public enum TossErrorCode implements ApiErrorCode { TOSS_IAP_STATUS_NOT_GRANTED("TOSS_IAP_003", "지급 가능한 주문 상태가 아닙니다.", HttpStatus.PRECONDITION_FAILED), TOSS_PARTNER_PRODUCT_GRANT_FAILED("TOSS_IAP_004", "상품 지급 처리에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + TOSS_PUSH_SEND_ERROR("TOSS_PUSH_001", "토스 푸시 발송 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + TOSS_PUSH_NOT_FOUND("TOSS_PUSH_404", "토스 푸시 코드를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + TOSS_API_CONNECTION_ERROR("TOSS_500", "그 밖에 토스 api를 연동하는 과정에서 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR); private final String errorCode; diff --git a/src/main/java/OneQ/OnSurvey/global/push/adapter/in/PushEventListener.java b/src/main/java/OneQ/OnSurvey/global/push/adapter/in/PushEventListener.java new file mode 100644 index 00000000..945bb0f6 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/push/adapter/in/PushEventListener.java @@ -0,0 +1,42 @@ +package OneQ.OnSurvey.global.push.adapter.in; + +import OneQ.OnSurvey.global.common.event.pushAlim.PushAlimEvent; +import OneQ.OnSurvey.global.push.application.port.in.PushUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PushEventListener { + + private final PushUseCase pushUseCase; + + @Async("pushAlimExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePushAlimEvent(PushAlimEvent event) { + + try { + long userKey = event.getTargetUserKey(); + Map templateCtx = event.getPushContext(); + + PushUseCase.PushCommand command = new PushUseCase.PushCommand( + userKey, + event.getPushTemplateName(), + templateCtx + ); + + pushUseCase.fillTemplateAndSendPush(command); + } catch (Exception e) { + log.error("[PushEventListener] 푸시알림 비동기 처리 중 예외 발생 - event: {}, error: {}", + event.getPushTemplateName(), e.getMessage(), e + ); + } + } +} diff --git a/src/main/java/OneQ/OnSurvey/global/push/adapter/out/persistence/PushPropertyJpaRepository.java b/src/main/java/OneQ/OnSurvey/global/push/adapter/out/persistence/PushPropertyJpaRepository.java new file mode 100644 index 00000000..bbceb350 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/push/adapter/out/persistence/PushPropertyJpaRepository.java @@ -0,0 +1,10 @@ +package OneQ.OnSurvey.global.push.adapter.out.persistence; + +import OneQ.OnSurvey.global.push.domain.entity.PushProperty; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PushPropertyJpaRepository extends JpaRepository { + List findAllByTemplateSetCode(String templateSetCode); +} diff --git a/src/main/java/OneQ/OnSurvey/global/push/adapter/out/persistence/PushPropertyRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/global/push/adapter/out/persistence/PushPropertyRepositoryImpl.java new file mode 100644 index 00000000..b88edc88 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/push/adapter/out/persistence/PushPropertyRepositoryImpl.java @@ -0,0 +1,49 @@ +package OneQ.OnSurvey.global.push.adapter.out.persistence; + +import OneQ.OnSurvey.global.push.application.port.out.PushPropertyRepository; +import OneQ.OnSurvey.global.push.domain.entity.PushProperty; +import OneQ.OnSurvey.global.push.domain.vo.PushTemplateVO; +import com.querydsl.core.group.GroupBy; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; + +import static OneQ.OnSurvey.global.push.domain.entity.QPushProperty.pushProperty; + +@Repository +@RequiredArgsConstructor +public class PushPropertyRepositoryImpl implements PushPropertyRepository { + + private final PushPropertyJpaRepository pushPropertyJpaRepository; + private final JPAQueryFactory jpaQueryFactory; + + @Override + public List findPushPropertiesByCode(String code) { + return pushPropertyJpaRepository.findAllByTemplateSetCode(code); + } + + @Override + public PushTemplateVO findPushTemplateContextByName(String name) { + return jpaQueryFactory + .from(pushProperty) + .where(pushProperty.templateName.eq(name)) + .transform( + GroupBy.groupBy(pushProperty.templateName).as( + Projections.constructor(PushTemplateVO.class, + pushProperty.templateSetCode, + GroupBy.map(pushProperty.contextKey, pushProperty.defaultValue) + ) + ) + ) + .getOrDefault(name, null); + } + + @Override + public void saveAll(Collection pushProperties) { + pushPropertyJpaRepository.saveAllAndFlush(pushProperties); + } +} diff --git a/src/main/java/OneQ/OnSurvey/global/push/application/PushFacade.java b/src/main/java/OneQ/OnSurvey/global/push/application/PushFacade.java new file mode 100644 index 00000000..d784fce6 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/push/application/PushFacade.java @@ -0,0 +1,129 @@ +package OneQ.OnSurvey.global.push.application; + +import OneQ.OnSurvey.global.common.exception.CustomException; +import OneQ.OnSurvey.global.infra.toss.common.dto.push.PushTemplateAddRequest; +import OneQ.OnSurvey.global.infra.toss.common.dto.push.PushTemplateModifyRequest; +import OneQ.OnSurvey.global.infra.toss.common.dto.push.PushTemplateSendRequest; +import OneQ.OnSurvey.global.infra.toss.common.exception.TossErrorCode; +import OneQ.OnSurvey.global.push.application.port.in.PushUseCase; +import OneQ.OnSurvey.global.push.application.port.out.PushPropertyRepository; +import OneQ.OnSurvey.global.push.application.port.out.TossPushPort; +import OneQ.OnSurvey.global.push.domain.entity.PushProperty; +import OneQ.OnSurvey.global.push.domain.vo.PushTemplateAddVO; +import OneQ.OnSurvey.global.push.domain.vo.PushTemplateModifyVO; +import OneQ.OnSurvey.global.push.domain.vo.PushTemplateVO; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import javax.net.ssl.SSLContext; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class PushFacade implements PushUseCase { + + private final TossPushPort tossPushPort; + private final PushPropertyRepository pushPropertyRepository; + + @Value("${toss.secret.private-key}") + private String privateKey; + + @Value("${toss.secret.public-crt}") + private String publicCrt; + + private SSLContext sslContext; + + @PostConstruct + public void init() throws Exception { + this.sslContext = tossPushPort.createSSLContext(publicCrt, privateKey); + } + + /** + * 템플릿 이름으로 템플릿 코드, 기본 컨텍스트를 조회하고 인자로 받은 컨텍스트를 오버라이드하여 토스 푸시 API를 비동기로 호출하는 메서드 + * @param command 사용자 키, 템플릿 이름, 템플릿 컨텍스트를 포함하는 커맨드 객체 + */ + @Override + public void fillTemplateAndSendPush(PushCommand command) { + long userKey = command.userKey(); + Map templateCtx = command.templateCtx(); + + PushTemplateVO pushProperties = pushPropertyRepository.findPushTemplateContextByName(command.templateName()); + if (pushProperties == null) { + throw new CustomException(TossErrorCode.TOSS_PUSH_NOT_FOUND); + } + + // DB에 저장된 컨텍스트 기본값과 전달받은 컨텍스트 값을 병합하여 최종 컨텍스트 생성 + if (pushProperties.context() != null && !pushProperties.context().isEmpty()) { + pushProperties.context().remove(null); + + if (command.templateCtx() != null && !command.templateCtx().isEmpty()) { + pushProperties.context().keySet() + .forEach( + key -> pushProperties.context().put( + key, + templateCtx.getOrDefault(key, pushProperties.context().get(key)) + ) + ); + } + } + PushTemplateSendRequest request = PushTemplateSendRequest.builder() + .userKey(userKey) + .templateSetCode(pushProperties.code()) + .templateCtx(pushProperties.context()) + .build(); + + try { + tossPushPort.sendPush(sslContext, request); + } catch (IOException e) { + throw new CustomException(TossErrorCode.TOSS_PUSH_SEND_ERROR); + } + } + + @Override + @Transactional + public void addPushTemplate(PushTemplateAddRequest request) { + PushTemplateAddVO vo = PushTemplateAddVO.of(request); + + List propertyList = vo.addTemplateList().stream().map( + addTemplate -> PushProperty.of( + addTemplate.name(), + addTemplate.code(), + addTemplate.contextKey(), + addTemplate.contextValue(), + addTemplate.description() + ) + ) + .toList(); + + pushPropertyRepository.saveAll(propertyList); + } + + @Override + @Transactional + public void modifyTemplateContext(PushTemplateModifyRequest request) { + PushTemplateModifyVO vo = PushTemplateModifyVO.of(request); + + Map pushPropertyMap = pushPropertyRepository.findPushPropertiesByCode(vo.code()) + .stream() + .collect(Collectors.toMap(PushProperty::getContextKey, Function.identity())); + + vo.modifyTemplateList().forEach( + modify -> { + PushProperty property = pushPropertyMap.get(modify.contextKey()); + if (property == null) { + return; + } + property.updateContext(modify.contextValue(), modify.description()); + } + ); + + pushPropertyRepository.saveAll(pushPropertyMap.values()); + } +} diff --git a/src/main/java/OneQ/OnSurvey/global/push/application/port/in/PushUseCase.java b/src/main/java/OneQ/OnSurvey/global/push/application/port/in/PushUseCase.java new file mode 100644 index 00000000..759b2681 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/push/application/port/in/PushUseCase.java @@ -0,0 +1,21 @@ +package OneQ.OnSurvey.global.push.application.port.in; + +import OneQ.OnSurvey.global.infra.toss.common.dto.push.PushTemplateAddRequest; +import OneQ.OnSurvey.global.infra.toss.common.dto.push.PushTemplateModifyRequest; + +import java.util.Map; + +public interface PushUseCase { + + void fillTemplateAndSendPush(PushCommand command); + + void addPushTemplate(PushTemplateAddRequest request); + + void modifyTemplateContext(PushTemplateModifyRequest request); + + record PushCommand( + long userKey, + String templateName, + Map templateCtx + ) {} +} diff --git a/src/main/java/OneQ/OnSurvey/global/push/application/port/out/PushPropertyRepository.java b/src/main/java/OneQ/OnSurvey/global/push/application/port/out/PushPropertyRepository.java new file mode 100644 index 00000000..43d9b622 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/push/application/port/out/PushPropertyRepository.java @@ -0,0 +1,16 @@ +package OneQ.OnSurvey.global.push.application.port.out; + +import OneQ.OnSurvey.global.push.domain.entity.PushProperty; +import OneQ.OnSurvey.global.push.domain.vo.PushTemplateVO; + +import java.util.Collection; +import java.util.List; + +public interface PushPropertyRepository { + + List findPushPropertiesByCode(String code); + + PushTemplateVO findPushTemplateContextByName(String name); + + void saveAll(Collection pushProperties); +} diff --git a/src/main/java/OneQ/OnSurvey/global/push/application/port/out/TossPushPort.java b/src/main/java/OneQ/OnSurvey/global/push/application/port/out/TossPushPort.java new file mode 100644 index 00000000..3f9674de --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/push/application/port/out/TossPushPort.java @@ -0,0 +1,15 @@ +package OneQ.OnSurvey.global.push.application.port.out; + +import OneQ.OnSurvey.global.infra.toss.common.dto.push.PushResultResponse; +import OneQ.OnSurvey.global.infra.toss.common.dto.push.PushTemplateSendRequest; + +import javax.net.ssl.SSLContext; +import java.io.IOException; + +public interface TossPushPort { + + SSLContext createSSLContext(String certPath, String keyPath) throws Exception; + + PushResultResponse sendPush(SSLContext sslContext, PushTemplateSendRequest request) throws IOException; + +} diff --git a/src/main/java/OneQ/OnSurvey/global/push/domain/entity/PushProperty.java b/src/main/java/OneQ/OnSurvey/global/push/domain/entity/PushProperty.java new file mode 100644 index 00000000..ee9829bb --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/push/domain/entity/PushProperty.java @@ -0,0 +1,66 @@ +package OneQ.OnSurvey.global.push.domain.entity; + +import OneQ.OnSurvey.global.common.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Entity @Table( + name = "push_property", + uniqueConstraints = { + @UniqueConstraint( + name = "unique_push_property_name_code_key", + columnNames = {"template_name", "template_set_code", "context_key"} + ) + } +) +public class PushProperty extends BaseEntity { + + @Id + @Column + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "template_name", nullable = false) + private String templateName; + + @Column(name = "template_set_code", nullable = false) + private String templateSetCode; + + @Column(name = "context_key") + private String contextKey; + + @Column(name = "default_value", columnDefinition = "TEXT") + private String defaultValue; + + @Column + private String description; + + public static PushProperty of( + String templateName, String templateSetCode + ) { + return new PushProperty(null, templateName, templateSetCode, null, null, null); + } + + public static PushProperty of( + String templateName, String templateSetCode, String contextKey, String defaultValue, String description + ) { + return new PushProperty(null, templateName, templateSetCode, contextKey, defaultValue, description); + } + + public void updateContext(String value, String description) { + this.defaultValue = value; + this.description = description; + } +} diff --git a/src/main/java/OneQ/OnSurvey/global/push/domain/vo/PushTemplateAddVO.java b/src/main/java/OneQ/OnSurvey/global/push/domain/vo/PushTemplateAddVO.java new file mode 100644 index 00000000..d8a2dc74 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/push/domain/vo/PushTemplateAddVO.java @@ -0,0 +1,45 @@ +package OneQ.OnSurvey.global.push.domain.vo; + +import OneQ.OnSurvey.global.infra.toss.common.dto.push.PushTemplateAddRequest; +import lombok.Builder; + +import java.util.List; + +@Builder +public record PushTemplateAddVO( + List addTemplateList +) { + + public record NewPushTemplate( + String name, + String code, + String contextKey, + String contextValue, + String description + ) { } + + public static PushTemplateAddVO of(PushTemplateAddRequest pushTemplateAddRequest) { + return pushTemplateAddRequest.defaultContext().isEmpty() + ? new PushTemplateAddVO(List.of( + new NewPushTemplate( + pushTemplateAddRequest.name(), + pushTemplateAddRequest.code(), + null, + null, + null + ) + )) + : new PushTemplateAddVO( + pushTemplateAddRequest.defaultContext().entrySet().stream().map( + entry -> new NewPushTemplate( + pushTemplateAddRequest.name(), + pushTemplateAddRequest.code(), + entry.getKey(), + entry.getValue().getFirst(), + entry.getValue().size() >= 2 ? entry.getValue().get(1) : null + ) + ) + .toList() + ); + } +} diff --git a/src/main/java/OneQ/OnSurvey/global/push/domain/vo/PushTemplateModifyVO.java b/src/main/java/OneQ/OnSurvey/global/push/domain/vo/PushTemplateModifyVO.java new file mode 100644 index 00000000..682367d6 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/push/domain/vo/PushTemplateModifyVO.java @@ -0,0 +1,31 @@ +package OneQ.OnSurvey.global.push.domain.vo; + +import OneQ.OnSurvey.global.infra.toss.common.dto.push.PushTemplateModifyRequest; + +import java.util.List; + +public record PushTemplateModifyVO( + String code, + List modifyTemplateList +) { + + public record ModifyPushTemplate( + String contextKey, + String contextValue, + String description + ) {} + + public static PushTemplateModifyVO of(PushTemplateModifyRequest pushTemplateModifyRequest) { + return new PushTemplateModifyVO( + pushTemplateModifyRequest.code(), + pushTemplateModifyRequest.defaultContext().entrySet().stream().map( + entry -> new ModifyPushTemplate( + entry.getKey(), + entry.getValue().getFirst(), + entry.getValue().size() >= 2 ? entry.getValue().get(1) : null + ) + ) + .toList() + ); + } +} diff --git a/src/main/java/OneQ/OnSurvey/global/push/domain/vo/PushTemplateVO.java b/src/main/java/OneQ/OnSurvey/global/push/domain/vo/PushTemplateVO.java new file mode 100644 index 00000000..adf24737 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/push/domain/vo/PushTemplateVO.java @@ -0,0 +1,9 @@ +package OneQ.OnSurvey.global.push.domain.vo; + +import java.util.Map; + +public record PushTemplateVO( + String code, + Map context +) { +}