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
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.example.solidconnection.admin.service;

import static com.example.solidconnection.common.exception.ErrorCode.GPA_SCORE_NOT_FOUND;
import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND;

import com.example.solidconnection.admin.dto.GpaScoreResponse;
import com.example.solidconnection.admin.dto.GpaScoreSearchResponse;
Expand All @@ -10,10 +11,12 @@
import com.example.solidconnection.common.VerifyStatus;
import com.example.solidconnection.common.discord.DiscordNotificationType;
import com.example.solidconnection.common.discord.DiscordNotifier;
import com.example.solidconnection.common.discord.DiscordReactionEmoji;
import com.example.solidconnection.common.discord.DiscordReviewMarker;
import com.example.solidconnection.common.exception.CustomException;
import com.example.solidconnection.score.domain.GpaScore;
import com.example.solidconnection.score.repository.GpaScoreRepository;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
Expand All @@ -25,6 +28,7 @@
public class AdminGpaScoreService {

private final GpaScoreRepository gpaScoreRepository;
private final SiteUserRepository siteUserRepository;
private final DiscordNotifier discordNotifier;

@Transactional(readOnly = true)
Expand All @@ -45,18 +49,26 @@ public GpaScoreResponse updateGpaScore(Long gpaScoreId, GpaScoreUpdateRequest re
request.verifyStatus(),
request.verifyStatus() == VerifyStatus.REJECTED ? request.rejectedReason() : null
);
publishReaction(gpaScoreId, request.verifyStatus());
publishReviewResult(gpaScore, request.verifyStatus());
return GpaScoreResponse.from(gpaScore);
}

private void publishReaction(long gpaScoreId, VerifyStatus verifyStatus) {
String emoji = switch (verifyStatus) {
case APPROVED -> DiscordReactionEmoji.APPROVED.getValue();
case REJECTED -> DiscordReactionEmoji.REJECTED.getValue();
private void publishReviewResult(GpaScore gpaScore, VerifyStatus verifyStatus) {
String marker = switch (verifyStatus) {
case APPROVED -> DiscordReviewMarker.APPROVED.getValue();
case REJECTED -> DiscordReviewMarker.REJECTED.getValue();
case PENDING -> null;
};
if (emoji != null) {
discordNotifier.addReaction(DiscordNotificationType.GPA_SCORE, gpaScoreId, emoji);
if (marker == null) {
return;
}
SiteUser siteUser = siteUserRepository.findById(gpaScore.getSiteUserId())
.orElseThrow(() -> new CustomException(USER_NOT_FOUND));
discordNotifier.markReviewResult(
DiscordNotificationType.GPA_SCORE,
gpaScore.getId(),
siteUser.getNickname(),
marker
);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.example.solidconnection.admin.service;

import static com.example.solidconnection.common.exception.ErrorCode.LANGUAGE_TEST_SCORE_NOT_FOUND;
import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND;

import com.example.solidconnection.admin.dto.LanguageTestScoreResponse;
import com.example.solidconnection.admin.dto.LanguageTestScoreSearchResponse;
Expand All @@ -10,10 +11,12 @@
import com.example.solidconnection.common.VerifyStatus;
import com.example.solidconnection.common.discord.DiscordNotificationType;
import com.example.solidconnection.common.discord.DiscordNotifier;
import com.example.solidconnection.common.discord.DiscordReactionEmoji;
import com.example.solidconnection.common.discord.DiscordReviewMarker;
import com.example.solidconnection.common.exception.CustomException;
import com.example.solidconnection.score.domain.LanguageTestScore;
import com.example.solidconnection.score.repository.LanguageTestScoreRepository;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
Expand All @@ -25,6 +28,7 @@
public class AdminLanguageTestScoreService {

private final LanguageTestScoreRepository languageTestScoreRepository;
private final SiteUserRepository siteUserRepository;
private final DiscordNotifier discordNotifier;

@Transactional(readOnly = true)
Expand All @@ -45,18 +49,26 @@ public LanguageTestScoreResponse updateLanguageTestScore(Long languageTestScoreI
request.verifyStatus(),
request.verifyStatus() == VerifyStatus.REJECTED ? request.rejectedReason() : null
);
publishReaction(languageTestScoreId, request.verifyStatus());
publishReviewResult(languageTestScore, request.verifyStatus());
return LanguageTestScoreResponse.from(languageTestScore);
}

private void publishReaction(long languageTestScoreId, VerifyStatus verifyStatus) {
String emoji = switch (verifyStatus) {
case APPROVED -> DiscordReactionEmoji.APPROVED.getValue();
case REJECTED -> DiscordReactionEmoji.REJECTED.getValue();
private void publishReviewResult(LanguageTestScore languageTestScore, VerifyStatus verifyStatus) {
String marker = switch (verifyStatus) {
case APPROVED -> DiscordReviewMarker.APPROVED.getValue();
case REJECTED -> DiscordReviewMarker.REJECTED.getValue();
case PENDING -> null;
};
if (emoji != null) {
discordNotifier.addReaction(DiscordNotificationType.LANGUAGE_TEST_SCORE, languageTestScoreId, emoji);
if (marker == null) {
return;
}
SiteUser siteUser = siteUserRepository.findById(languageTestScore.getSiteUserId())
.orElseThrow(() -> new CustomException(USER_NOT_FOUND));
discordNotifier.markReviewResult(
DiscordNotificationType.LANGUAGE_TEST_SCORE,
languageTestScore.getId(),
siteUser.getNickname(),
marker
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import com.example.solidconnection.admin.dto.MentorApplicationSearchResponse;
import com.example.solidconnection.common.discord.DiscordNotificationType;
import com.example.solidconnection.common.discord.DiscordNotifier;
import com.example.solidconnection.common.discord.DiscordReactionEmoji;
import com.example.solidconnection.common.discord.DiscordReviewMarker;
import com.example.solidconnection.common.exception.CustomException;
import com.example.solidconnection.mentor.domain.Mentor;
import com.example.solidconnection.mentor.domain.MentorApplication;
Expand Down Expand Up @@ -67,7 +67,7 @@ public void approveMentorApplication(Long mentorApplicationId) {
);

mentorRepository.save(mentor);
publishReaction(mentorApplicationId, DiscordReactionEmoji.APPROVED.getValue());
publishReviewResult(mentorApplicationId, siteUser.getNickname(), DiscordReviewMarker.APPROVED.getValue());
}

private void validateUserCanCreateMentor(long siteUserId) {
Expand All @@ -85,11 +85,18 @@ public void rejectMentorApplication(
.orElseThrow(() -> new CustomException(MENTOR_APPLICATION_NOT_FOUND));

mentorApplication.reject(request.rejectedReason());
publishReaction(mentorApplicationId, DiscordReactionEmoji.REJECTED.getValue());
SiteUser siteUser = siteUserRepository.findById(mentorApplication.getSiteUserId())
.orElseThrow(() -> new CustomException(USER_NOT_FOUND));
publishReviewResult(mentorApplicationId, siteUser.getNickname(), DiscordReviewMarker.REJECTED.getValue());
}

private void publishReaction(long mentorApplicationId, String emoji) {
discordNotifier.addReaction(DiscordNotificationType.MENTOR_APPLICATION, mentorApplicationId, emoji);
private void publishReviewResult(long mentorApplicationId, String applicantInfo, String marker) {
discordNotifier.markReviewResult(
DiscordNotificationType.MENTOR_APPLICATION,
mentorApplicationId,
applicantInfo,
marker
);
}

@Transactional(readOnly = true)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package com.example.solidconnection.common.config.client;

import java.time.Duration;
import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder;
import org.springframework.boot.http.client.ClientHttpRequestFactorySettings;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;

@Configuration
Expand All @@ -24,20 +25,14 @@ public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
* - Discord webhook url 은 경로에 인증 토큰을 포함한다.
* - RestTemplateBuilder 로 만든 RestTemplate 은 observation 이 적용되어 요청 url 이 메트릭 태그로 남을 수 있으므로,
* webhook 전송에는 observation 이 붙지 않는 별도 인스턴스를 사용한다.
* - 메시지 편집에 PATCH 를 사용하는데, SimpleClientHttpRequestFactory 는 PATCH 를 지원하지 않으므로
* JDK HttpClient 기반 팩토리를 사용한다.
* */
@Bean
public RestTemplate discordWebhookRestTemplate() {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout((int) TIMEOUT.toMillis());
requestFactory.setReadTimeout((int) TIMEOUT.toMillis());
return new RestTemplate(requestFactory);
}

@Bean
public RestTemplate discordBotRestTemplate() {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout((int) TIMEOUT.toMillis());
requestFactory.setReadTimeout((int) TIMEOUT.toMillis());
return new RestTemplate(requestFactory);
return new RestTemplate(ClientHttpRequestFactoryBuilder.jdk()
.build(ClientHttpRequestFactorySettings.defaults()
.withConnectTimeout(TIMEOUT)
.withReadTimeout(TIMEOUT)));
}
}
Original file line number Diff line number Diff line change
@@ -1,39 +1,48 @@
package com.example.solidconnection.common.discord;

import com.example.solidconnection.common.discord.domain.DiscordNotification;
import com.example.solidconnection.common.discord.service.DiscordNotificationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpClientErrorException;

@Component
@RequiredArgsConstructor
@EnableAsync
@Slf4j
public class DiscordNotifier {

private static final String ADMIN_PAGE_URL = "https://www.admins.solid-connection.com";

private final DiscordWebhookSender discordWebhookSender;
private final DiscordNotificationService discordNotificationService;
private final DiscordReactionClient discordReactionClient;
private final String webhookUrl;
private final String environment;

@Value("${discord.webhook-url:}")
private String webhookUrl;

@Value("${spring.profiles.active:}")
private String environment;
public DiscordNotifier(
DiscordWebhookSender discordWebhookSender,
DiscordNotificationService discordNotificationService,
@Value("${discord.webhook-url:}") String webhookUrl,
@Value("${spring.profiles.active:}") String environment
) {
this.discordWebhookSender = discordWebhookSender;
this.discordNotificationService = discordNotificationService;
this.webhookUrl = webhookUrl;
this.environment = environment;
}

@Async
public void notify(DiscordNotificationType type, String applicantInfo) {
if (webhookUrl.isBlank() || "local".equalsIgnoreCase(environment)) {
if (isNotifyDisabled()) {
return;
}
discordWebhookSender.send(webhookUrl, buildMessage(type, applicantInfo));
}

public void notify(DiscordNotificationType type, long reviewId, String applicantInfo) {
if (webhookUrl.isBlank() || "local".equalsIgnoreCase(environment)) {
if (isNotifyDisabled()) {
return;
}
DiscordMessageResponse response = discordWebhookSender.sendAndGetMessage(
Expand All @@ -43,13 +52,50 @@ public void notify(DiscordNotificationType type, long reviewId, String applicant
discordNotificationService.save(type, reviewId, response.channelId(), response.id());
}

public void addReaction(DiscordNotificationType type, long reviewId, String emoji) {
/*
* - 검수 결과를 알림 메시지 앞에 마커로 표시한다.
* - 봇 권한이 필요한 반응 대신, webhook 이 보낸 메시지를 직접 편집한다.
* - 검수 시점 데이터로 본문을 다시 만들어 통째로 덮어쓰므로 마커가 누적되지 않는다.
* */
public void markReviewResult(
DiscordNotificationType type,
long reviewId,
String applicantInfo,
String marker
) {
if (isNotifyDisabled()) {
return;
}
discordNotificationService.findByReviewTypeAndReviewId(type, reviewId)
.ifPresent(message -> discordReactionClient.addReaction(
message.getDiscordChannelId(),
message.getDiscordMessageId(),
emoji
));
.ifPresent(notification -> editMessage(notification, type, applicantInfo, marker));
}

private void editMessage(
DiscordNotification notification,
DiscordNotificationType type,
String applicantInfo,
String marker
) {
try {
discordWebhookSender.editMessage(
webhookUrl,
notification.getDiscordMessageId(),
marker + " " + buildMessage(type, applicantInfo)
);
} catch (HttpClientErrorException.NotFound e) {
/*
* - 편집 대상이 사라진 경우로, 재시도해도 성공하지 않으므로 검수를 실패시키지 않는다.
* - 디스코드는 메시지 부재(10008)와 webhook 부재(10015)를 모두 404 로 응답한다.
* webhook 부재는 설정 사고이므로 구분할 수 있도록 응답 본문을 남긴다.
* - 응답 본문에는 webhook url 이 포함되지 않으므로 토큰이 노출되지 않는다.
* */
log.warn("검수 결과를 표시하지 못했습니다. reviewType={}, reviewId={}, discordError={}",
type, notification.getReviewId(), e.getResponseBodyAsString());
}
}

private boolean isNotifyDisabled() {
return webhookUrl.isBlank() || "local".equalsIgnoreCase(environment);
}

private String buildMessage(DiscordNotificationType type, String applicantInfo) {
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
import lombok.Getter;

@Getter
public enum DiscordReactionEmoji {
public enum DiscordReviewMarker {

APPROVED(""),
REJECTED(""),
APPROVED("(승인되었습니다.)"),
REJECTED("(반려되었습니다.)"),
;

private final String value;

DiscordReactionEmoji(String value) {
DiscordReviewMarker(String value) {
this.value = value;
}
}
Loading
Loading