-
Notifications
You must be signed in to change notification settings - Fork 8
feat: 관리자 검수 결과에 따라 이모지를 남기도록 #838
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
56f51cb
b0ade22
3680f45
bcb3e26
809ce20
7cf3649
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,9 @@ | |
| import com.example.solidconnection.admin.dto.ScoreSearchCondition; | ||
| import com.example.solidconnection.application.domain.Gpa; | ||
| 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.exception.CustomException; | ||
| import com.example.solidconnection.score.domain.GpaScore; | ||
| import com.example.solidconnection.score.repository.GpaScoreRepository; | ||
|
|
@@ -22,6 +25,7 @@ | |
| public class AdminGpaScoreService { | ||
|
|
||
| private final GpaScoreRepository gpaScoreRepository; | ||
| private final DiscordNotifier discordNotifier; | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public Page<GpaScoreSearchResponse> searchGpaScores(ScoreSearchCondition scoreSearchCondition, Pageable pageable) { | ||
|
|
@@ -41,6 +45,18 @@ public GpaScoreResponse updateGpaScore(Long gpaScoreId, GpaScoreUpdateRequest re | |
| request.verifyStatus(), | ||
| request.verifyStatus() == VerifyStatus.REJECTED ? request.rejectedReason() : null | ||
| ); | ||
| publishReaction(gpaScoreId, 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(); | ||
| case PENDING -> null; | ||
|
Comment on lines
+54
to
+56
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The score update APIs accept any Useful? React with 👍 / 👎. |
||
| }; | ||
| if (emoji != null) { | ||
| discordNotifier.addReaction(DiscordNotificationType.GPA_SCORE, gpaScoreId, emoji); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.example.solidconnection.common.discord; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonProperty; | ||
|
|
||
| public record DiscordMessageResponse( | ||
| String id, | ||
| @JsonProperty("channel_id") String channelId | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| package com.example.solidconnection.common.discord; | ||
|
|
||
| import com.example.solidconnection.common.discord.service.DiscordNotificationService; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.scheduling.annotation.Async; | ||
|
|
@@ -14,6 +15,8 @@ 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; | ||
|
|
||
| @Value("${discord.webhook-url:}") | ||
| private String webhookUrl; | ||
|
|
@@ -29,6 +32,26 @@ public void notify(DiscordNotificationType type, String applicantInfo) { | |
| discordWebhookSender.send(webhookUrl, buildMessage(type, applicantInfo)); | ||
| } | ||
|
|
||
| public void notify(DiscordNotificationType type, long reviewId, String applicantInfo) { | ||
| if (webhookUrl.isBlank() || "local".equalsIgnoreCase(environment)) { | ||
| return; | ||
| } | ||
| DiscordMessageResponse response = discordWebhookSender.sendAndGetMessage( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the configured Discord webhook times out or returns a non-2xx response, Useful? React with 👍 / 👎. |
||
| webhookUrl, | ||
| buildMessage(type, applicantInfo) | ||
| ); | ||
| discordNotificationService.save(type, reviewId, response.channelId(), response.id()); | ||
|
Comment on lines
+35
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🌐 Web query:
💡 Result: Spring Boot 3.5.11은 Spring Framework 6.2.16 버전을 사용합니다 [1][2][3]. 요청하신 내용에 대한 상세 정보는 다음과 같습니다. 1. RestTemplate.postForObject의 예외 타입 RestTemplate.postForObject는 HTTP 오류 응답(4xx 또는 5xx 등)이 발생할 경우, 기본적으로 RestClientException을 상속하는 예외를 던집니다 [4][5][6]. 구체적인 예외 타입은 상태 코드에 따라 다음과 같이 구분됩니다 [5][6]. - HttpClientErrorException: 4xx 클라이언트 오류 발생 시 발생합니다 [5][6]. - HttpServerErrorException: 5xx 서버 오류 발생 시 발생합니다 [5][6]. - UnknownHttpStatusCodeException: 정의되지 않은 상태 코드를 받았을 때 발생합니다 [5]. 이러한 예외들은 모두 RestClientResponseException의 하위 클래스이며, 이를 통해 응답 본문(getResponseBodyAsString)이나 헤더 등을 확인할 수 있습니다 [5][6]. 2. Citations:
🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- target outline ---'
ast-grep outline src/main/java/com/example/solidconnection/common/discord/DiscordNotifier.java --view expanded || true
printf '%s\n' '--- target source ---'
cat -n src/main/java/com/example/solidconnection/common/discord/DiscordNotifier.java
printf '%s\n' '--- related symbols ---'
rg -n -C 5 'sendAndGetMessage|DiscordNotifier|notify\(.*reviewId|class ScoreService|class MentorApplicationService|`@Transactional`' src/main/java
printf '%s\n' '--- imports and build versions ---'
rg -n -C 3 'spring-boot|spring-framework|spring-web|RestTemplate|DiscordWebhookSender' build.gradle* settings.gradle* gradle.properties src/main/java 2>/dev/null || true
printf '%s\n' '--- file ending ---'
tail -c 20 src/main/java/com/example/solidconnection/common/discord/DiscordNotifier.java | od -An -t x1Repository: solid-connection/solid-connect-server Length of output: 50396 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- Discord files ---'
git ls-files '*Discord*' '*discord*' | sort
printf '%s\n' '--- direct notifier call sites ---'
rg -n -C 4 'discordNotifier\.notify|\.notify\(DiscordNotificationType' src/main/java
printf '%s\n' '--- transactional caller declarations ---'
for f in $(git ls-files 'src/main/java/**/*.java' | grep -E 'ScoreService|MentorApplicationService'); do
echo "### $f"
rg -n -C 5 '`@Transactional`|discordNotifier\.notify' "$f"
done
printf '%s\n' '--- webhook sender implementation ---'
sender=$(git ls-files | grep -E 'DiscordWebhookSender\.java$' | head -n 1)
if [ -n "${sender:-}" ]; then cat -n "$sender"; else echo 'DiscordWebhookSender.java not found'; fi
printf '%s\n' '--- notifier tests and exception handling ---'
rg -n -C 4 'DiscordNotifier|DiscordWebhookSender|RestClientException|postForObject|sendAndGetMessage' src/test src/main/java 2>/dev/null | head -n 300 || trueRepository: solid-connection/solid-connect-server Length of output: 50393 1. Discord 웹훅 실패를 검수 등록 트랜잭션에서 분리하십시오.
커밋 이후 비동기 작업 또는 outbox로 알림을 분리하십시오. 최소한 이 경계에서 전송 실패를 처리하고 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| public void addReaction(DiscordNotificationType type, long reviewId, String emoji) { | ||
| discordNotificationService.findByReviewTypeAndReviewId(type, reviewId) | ||
| .ifPresent(message -> discordReactionClient.addReaction( | ||
| message.getDiscordChannelId(), | ||
| message.getDiscordMessageId(), | ||
| emoji | ||
| )); | ||
| } | ||
|
|
||
| private String buildMessage(DiscordNotificationType type, String applicantInfo) { | ||
| String body = "%s 검수 요청이 등록되었습니다.\n신청자: %s\n관리자 페이지: %s" | ||
| .formatted(type.getDisplayName(), applicantInfo, ADMIN_PAGE_URL); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| package com.example.solidconnection.common.discord; | ||
|
|
||
| import java.nio.charset.StandardCharsets; | ||
| import org.springframework.beans.factory.annotation.Qualifier; | ||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.http.HttpEntity; | ||
| import org.springframework.http.HttpHeaders; | ||
| import org.springframework.http.HttpMethod; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.web.client.RestTemplate; | ||
| import org.springframework.web.util.UriUtils; | ||
|
|
||
| @Component | ||
| public class DiscordReactionClient { | ||
|
|
||
| private static final String REACTION_URL = | ||
| "https://discord.com/api/v10/channels/%s/messages/%s/reactions/%s/@me"; | ||
|
|
||
| private final RestTemplate discordBotRestTemplate; | ||
|
|
||
| @Value("${discord.bot-token:}") | ||
| private String botToken; | ||
|
|
||
| public DiscordReactionClient(@Qualifier("discordBotRestTemplate") RestTemplate discordBotRestTemplate) { | ||
| this.discordBotRestTemplate = discordBotRestTemplate; | ||
| } | ||
|
|
||
| public void addReaction(String channelId, String messageId, String emoji) { | ||
| String encodedEmoji = UriUtils.encodePathSegment(emoji, StandardCharsets.UTF_8); | ||
| String url = REACTION_URL.formatted(channelId, messageId, encodedEmoji); | ||
| HttpHeaders headers = new HttpHeaders(); | ||
| headers.set("Authorization", "Bot " + botToken); | ||
| discordBotRestTemplate.exchange(url, HttpMethod.PUT, new HttpEntity<>(headers), Void.class); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a notification mapping exists and Discord returns 401/429/5xx or times out, this synchronous Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| package com.example.solidconnection.common.discord; | ||
|
|
||
| import lombok.Getter; | ||
|
|
||
| @Getter | ||
| public enum DiscordReactionEmoji { | ||
|
|
||
| APPROVED("✅"), | ||
| REJECTED("❌"), | ||
| ; | ||
|
|
||
| private final String value; | ||
|
|
||
| DiscordReactionEmoji(String value) { | ||
| this.value = value; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| package com.example.solidconnection.common.discord.domain; | ||
|
|
||
| import com.example.solidconnection.common.BaseEntity; | ||
| import com.example.solidconnection.common.discord.DiscordNotificationType; | ||
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.EnumType; | ||
| import jakarta.persistence.Enumerated; | ||
| 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.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Getter | ||
| @Entity | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| @Table( | ||
| name = "discord_notification", | ||
| uniqueConstraints = @UniqueConstraint( | ||
| name = "uk_discord_notification_target", | ||
| columnNames = {"review_type", "review_id"} | ||
| ) | ||
| ) | ||
| public class DiscordNotification extends BaseEntity { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| @Column(name = "id") | ||
| private Long id; | ||
|
|
||
| @Enumerated(EnumType.STRING) | ||
| @Column(name = "review_type", nullable = false, length = 32) | ||
| private DiscordNotificationType reviewType; | ||
|
|
||
| @Column(name = "review_id", nullable = false) | ||
| private long reviewId; | ||
|
|
||
| @Column(name = "discord_channel_id", nullable = false, length = 32) | ||
| private String discordChannelId; | ||
|
|
||
| @Column(name = "discord_message_id", nullable = false, length = 32) | ||
| private String discordMessageId; | ||
|
|
||
| private DiscordNotification( | ||
| DiscordNotificationType reviewType, | ||
| long reviewId, | ||
| String discordChannelId, | ||
| String discordMessageId | ||
| ) { | ||
| this.reviewType = reviewType; | ||
| this.reviewId = reviewId; | ||
| this.discordChannelId = discordChannelId; | ||
| this.discordMessageId = discordMessageId; | ||
| } | ||
|
|
||
| public static DiscordNotification of( | ||
| DiscordNotificationType reviewType, | ||
| long reviewId, | ||
| String discordChannelId, | ||
| String discordMessageId | ||
| ) { | ||
| return new DiscordNotification(reviewType, reviewId, discordChannelId, discordMessageId); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package com.example.solidconnection.common.discord.repository; | ||
|
|
||
| import com.example.solidconnection.common.discord.DiscordNotificationType; | ||
| import com.example.solidconnection.common.discord.domain.DiscordNotification; | ||
| import java.util.Optional; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| public interface DiscordNotificationRepository extends JpaRepository<DiscordNotification, Long> { | ||
|
|
||
| Optional<DiscordNotification> findByReviewTypeAndReviewId( | ||
| DiscordNotificationType reviewType, | ||
| long reviewId | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
Repository: solid-connection/solid-connect-server
Length of output: 33573
🏁 Script executed:
Repository: solid-connection/solid-connect-server
Length of output: 11571
🏁 Script executed:
Repository: solid-connection/solid-connect-server
Length of output: 24238
1. 트랜잭션 커밋 후 Discord 리액션을 발행하십시오.
addReaction은 동기RestTemplate.exchange를 실행하며, Discord 오류를 호출자에게 전파합니다. 따라서 커밋 전에 Discord 오류가 발생하면 검수 상태 변경이 롤백됩니다. 반대로 리액션 발행 후 데이터베이스 커밋이 실패하면 두 시스템의 상태가 달라집니다.다음 네 메서드에서 트랜잭셔널 아웃박스에 리액션 작업을 기록하고, 커밋 후 재시도 가능한 방식으로 처리하십시오.
AdminGpaScoreService.updateGpaScoreAdminLanguageTestScoreService.updateLanguageTestScoreAdminMentorApplicationService.approveMentorApplicationAdminMentorApplicationService.rejectMentorApplication📍 Affects 3 files
src/main/java/com/example/solidconnection/admin/service/AdminGpaScoreService.java#L48-L48(this comment)src/main/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreService.java#L48-L48src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java#L70-L70src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java#L88-L88🤖 Prompt for AI Agents