Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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,26 @@
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;
}
}
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
11 changes: 11 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 @@ -21,4 +21,15 @@ 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-async");
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 sentPushContent
) {

public static PushResultResponse of(Long sentPushCount, String sentPushContentId) {
return new PushResultResponse(sentPushCount, sentPushContentId);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

팩토리 메서드 파라미터 이름 불일치

of() 메서드의 파라미터 이름이 sentPushContentId이지만, 실제로는 sentPushContent 필드에 매핑됩니다. TossApiClient에서는 JSON 문자열(toPrettyString())을 전달하므로, 파라미터 이름이 실제 용도와 일치하지 않습니다.

♻️ 파라미터 이름 일치 수정
-    public static PushResultResponse of(Long sentPushCount, String sentPushContentId) {
-        return new PushResultResponse(sentPushCount, sentPushContentId);
+    public static PushResultResponse of(Long sentPushCount, String sentPushContent) {
+        return new PushResultResponse(sentPushCount, sentPushContent);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public static PushResultResponse of(Long sentPushCount, String sentPushContentId) {
return new PushResultResponse(sentPushCount, sentPushContentId);
}
public static PushResultResponse of(Long sentPushCount, String sentPushContent) {
return new PushResultResponse(sentPushCount, sentPushContent);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/OneQ/OnSurvey/global/infra/toss/common/dto/push/PushResultResponse.java`
around lines 8 - 10, The factory method PushResultResponse.of(...) uses
parameter name sentPushContentId which doesn't match the actual field
sentPushContent and the JSON string passed from TossApiClient; rename the
parameter in of(Long sentPushCount, String sentPushContentId) to sentPushContent
(or vice versa rename the field) so names reflect the same data, and update the
constructor invocation inside of(...) to pass the renamed parameter to the
PushResultResponse(...) constructor (ensure PushResultResponse.of, its parameter
list, and any callers use the new name consistently).

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package OneQ.OnSurvey.global.infra.toss.common.dto.push;

import io.swagger.v3.oas.annotations.media.Schema;

import java.util.List;
import java.util.Map;

public record PushTemplateAddRequest(
@Schema(description = "메시지 템플릿 이름 (이벤트 클래스 명)", example = "SurveyCompletedEvent")
String name,

@Schema(description = "발송할 메시지 템플릿 코드", example = "test_01")
String code,

@Schema(
description = "템플릿에서 사용할 기본 context 값. key는 템플릿에서 사용할 변수명, value는 해당 변수에 들어갈 기본값(index 0)과 설명(index 1)을 담은 리스트",
example = "{" +
"\"surveyTitle\": [\"기본 설문 제목\", \"설문 제목에 들어갈 기본 변수값\"], " +
"\"surveyDeadline\": [\"2024-12-31\", null]" +
"}"
)
Map<String, List<String>> defaultContext
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package OneQ.OnSurvey.global.infra.toss.common.dto.push;

import io.swagger.v3.oas.annotations.media.Schema;

import java.util.List;
import java.util.Map;

public record PushTemplateModifyRequest(
@Schema(description = "발송할 메시지 템플릿 코드", example = "test_01")
String code,

@Schema(
description = "템플릿에서 사용할 기본 context 값. key는 템플릿에서 사용할 변수명, value는 해당 변수에 들어갈 기본값(index 0)과 설명(index 1)을 담은 리스트",
example = "{" +
"\"surveyTitle\": [\"기본 설문 제목\", \"설문 제목에 들어갈 기본 변수값\"], " +
"\"surveyDeadline\": [\"2024-12-31\", null]" +
"}"
)
Map<String, List<String>> defaultContext
) {
}
Loading
Loading