Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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<String, String> eventContext
) implements PushAlimEvent {

@Override
public Long getTargetUserKey() {
return userKey;
}

@Override
public String getPushTemplateName() {
return this.getClass().getSimpleName();
}

@Override
public Map<String, String> getPushContext() {
return eventContext == null
? Map.of()
: eventContext;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,6 @@ public SurveyDetailData getSurveyDetailDataById(Long surveyId) {
set(interestAlias).as("interests")
))
);
System.out.println("result = " + result);
return result.get(surveyId);
}

Expand Down
15 changes: 15 additions & 0 deletions src/main/java/OneQ/OnSurvey/global/common/config/AsyncConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;

@Configuration
@EnableAsync
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package OneQ.OnSurvey.global.common.event.pushAlim;

import java.util.Map;

public interface PushAlimEvent {
Long getTargetUserKey();
String getPushTemplateName();
Map<String, String> getPushContext();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -21,4 +22,7 @@ public void sendSurveySubmittedAsync(SurveySubmittedAlert alert) {}

@Override
public void sendTossAccessTokenAsync(TossAccessTokenAlert alert) {}

@Override
public void sendPushAlimAsync(PushAlimAlert alert) {}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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<String, String> 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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<String>> defaultContext
) {
}
Loading
Loading