From 4cf2db7f7738a729666cf98ca43b860d51a3e9f1 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Mon, 2 Mar 2026 22:23:22 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20Slack=20AI=20=EC=B1=97=EB=B4=87=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 ++ build.gradle | 3 + .../club/repository/ClubMemberRepository.java | 2 + .../repository/ClubRecruitmentRepository.java | 8 ++ .../club/repository/ClubRepository.java | 3 + .../user/repository/UserRepository.java | 7 ++ .../konect/global/config/SecurityPaths.java | 3 +- .../gemini/client/GeminiClient.java | 83 +++++++++++++++++++ .../gemini/config/GeminiProperties.java | 13 +++ .../slack/ai/SlackAIService.java | 77 +++++++++++++++++ .../slack/ai/SlackEventController.java | 74 +++++++++++++++++ .../slack/ai/StatisticsQueryExecutor.java | 51 ++++++++++++ .../resources/application-infrastructure.yml | 6 ++ 13 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 src/main/java/gg/agit/konect/infrastructure/gemini/client/GeminiClient.java create mode 100644 src/main/java/gg/agit/konect/infrastructure/gemini/config/GeminiProperties.java create mode 100644 src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java create mode 100644 src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackEventController.java create mode 100644 src/main/java/gg/agit/konect/infrastructure/slack/ai/StatisticsQueryExecutor.java diff --git a/.env.example b/.env.example index 4320a37f..47283994 100644 --- a/.env.example +++ b/.env.example @@ -45,3 +45,9 @@ OAUTH_APPLE_CLIENT_SECRET=your-apple-client-secret # Slack Webhooks (Optional) SLACK_WEBHOOK_ERROR=https://hooks.slack.com/services/YOUR/WEBHOOK/URL SLACK_WEBHOOK_EVENT=https://hooks.slack.com/services/YOUR/WEBHOOK/URL + +# Gemini AI Configuration +GEMINI_API_KEY=your-gemini-api-key +GEMINI_PROJECT_ID=your-gcp-project-id +GEMINI_LOCATION=us-central1 +GEMINI_MODEL=gemini-1.5-flash diff --git a/build.gradle b/build.gradle index 926ff476..e2926cfe 100644 --- a/build.gradle +++ b/build.gradle @@ -70,6 +70,9 @@ dependencies { // notification implementation 'com.google.firebase:firebase-admin:9.2.0' + // Gemini AI + implementation 'com.google.cloud:google-cloud-vertexai:1.15.0' + // test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java index 9aea18fa..42134e6a 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java @@ -190,4 +190,6 @@ long countByClubIdAndPosition( void deleteByUserId(Integer userId); + @Query("SELECT COUNT(cm) FROM ClubMember cm") + long countAll(); } diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubRecruitmentRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubRecruitmentRepository.java index 181ec3cc..c326b314 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubRecruitmentRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubRecruitmentRepository.java @@ -30,4 +30,12 @@ default ClubRecruitment getByClubId(Integer clubId) { return findByClubId(clubId) .orElseThrow(() -> CustomException.of(NOT_FOUND_CLUB_RECRUITMENT)); } + + @Query(""" + SELECT COUNT(cr) + FROM ClubRecruitment cr + WHERE cr.isAlwaysRecruiting = true + OR (cr.startAt <= CURRENT_TIMESTAMP AND cr.endAt >= CURRENT_TIMESTAMP) + """) + long countCurrentlyRecruiting(); } diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java index 55c605e5..3cee5893 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java @@ -29,4 +29,7 @@ default Club getById(Integer id) { List findAll(); Club save(Club club); + + @Query("SELECT COUNT(c) FROM Club c") + long countAll(); } diff --git a/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java b/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java index 7b06a292..e5c93a9a 100644 --- a/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java +++ b/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java @@ -143,4 +143,11 @@ List findUserIdsByUniversityAndStudentYear( AND u.deletedAt IS NULL """) List findAllByIdIn(@Param("ids") List ids); + + @Query(""" + SELECT COUNT(u) + FROM User u + WHERE u.deletedAt IS NULL + """) + long countActiveUsers(); } diff --git a/src/main/java/gg/agit/konect/global/config/SecurityPaths.java b/src/main/java/gg/agit/konect/global/config/SecurityPaths.java index 9ce92596..1e32788f 100644 --- a/src/main/java/gg/agit/konect/global/config/SecurityPaths.java +++ b/src/main/java/gg/agit/konect/global/config/SecurityPaths.java @@ -9,7 +9,8 @@ public final class SecurityPaths { "/swagger-ui.html", "/v3/api-docs/**", "/swagger-resources/**", - "/error" + "/error", + "/slack/**" }; public static final String[] DENY_PATHS = {}; diff --git a/src/main/java/gg/agit/konect/infrastructure/gemini/client/GeminiClient.java b/src/main/java/gg/agit/konect/infrastructure/gemini/client/GeminiClient.java new file mode 100644 index 00000000..72065c53 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/gemini/client/GeminiClient.java @@ -0,0 +1,83 @@ +package gg.agit.konect.infrastructure.gemini.client; + +import java.io.IOException; + +import org.springframework.stereotype.Component; + +import com.google.cloud.vertexai.VertexAI; +import com.google.cloud.vertexai.api.GenerateContentResponse; +import com.google.cloud.vertexai.generativeai.GenerativeModel; +import com.google.cloud.vertexai.generativeai.ResponseHandler; + +import gg.agit.konect.infrastructure.gemini.config.GeminiProperties; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class GeminiClient { + + private static final String INTENT_ANALYSIS_PROMPT = """ + 당신은 KONECT 서비스의 데이터 분석 AI입니다. + 사용자의 질문을 분석하여 다음 중 하나의 쿼리 타입만 반환하세요. + 반드시 아래 목록 중 하나의 값만 반환하고, 다른 텍스트는 포함하지 마세요. + + 가능한 쿼리 타입: + - USER_COUNT: 전체 가입된 사용자 수 관련 질문 + - CLUB_COUNT: 전체 동아리 수 관련 질문 + - CLUB_RECRUITING_COUNT: 현재 모집 중인 동아리 수 관련 질문 + - CLUB_MEMBER_TOTAL_COUNT: 전체 동아리원 수 관련 질문 + - UNKNOWN: 위 항목에 해당하지 않는 질문 + + 예시: + - "가입된 사용자 수 알려줘" -> USER_COUNT + - "현재 모집 중인 동아리 몇개야?" -> CLUB_RECRUITING_COUNT + - "전체 동아리 수는?" -> CLUB_COUNT + - "동아리원이 총 몇명이야?" -> CLUB_MEMBER_TOTAL_COUNT + - "오늘 날씨 어때?" -> UNKNOWN + + 사용자 질문: %s + + 쿼리 타입: + """; + + private static final String RESPONSE_GENERATION_PROMPT = """ + 당신은 KONECT 서비스의 친절한 AI 어시스턴트입니다. + 아래 정보를 바탕으로 사용자에게 자연스럽고 친절한 한국어로 응답해주세요. + 이모지를 적절히 사용해주세요. + + 사용자 질문: %s + 조회된 데이터: %s + + 응답: + """; + + private final GeminiProperties geminiProperties; + + public GeminiClient(GeminiProperties geminiProperties) { + this.geminiProperties = geminiProperties; + } + + public String analyzeIntent(String userQuery) { + String prompt = String.format(INTENT_ANALYSIS_PROMPT, userQuery); + return callGemini(prompt).trim().toUpperCase(); + } + + public String generateResponse(String userQuery, String data) { + String prompt = String.format(RESPONSE_GENERATION_PROMPT, userQuery, data); + return callGemini(prompt); + } + + private String callGemini(String prompt) { + try (VertexAI vertexAI = new VertexAI( + geminiProperties.projectId(), + geminiProperties.location() + )) { + GenerativeModel model = new GenerativeModel(geminiProperties.model(), vertexAI); + GenerateContentResponse response = model.generateContent(prompt); + return ResponseHandler.getText(response); + } catch (IOException e) { + log.error("Gemini API 호출 실패", e); + return "UNKNOWN"; + } + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/gemini/config/GeminiProperties.java b/src/main/java/gg/agit/konect/infrastructure/gemini/config/GeminiProperties.java new file mode 100644 index 00000000..07cfd989 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/gemini/config/GeminiProperties.java @@ -0,0 +1,13 @@ +package gg.agit.konect.infrastructure.gemini.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "gemini") +public record GeminiProperties( + String apiKey, + String projectId, + String location, + String model +) { + +} diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java new file mode 100644 index 00000000..e42c78e3 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java @@ -0,0 +1,77 @@ +package gg.agit.konect.infrastructure.slack.ai; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import gg.agit.konect.infrastructure.gemini.client.GeminiClient; +import gg.agit.konect.infrastructure.slack.client.SlackClient; +import gg.agit.konect.infrastructure.slack.config.SlackProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SlackAIService { + + private static final Pattern AI_PREFIX_PATTERN = Pattern.compile("^[Aa][Ii]\\)\\s*(.+)$"); + + private final GeminiClient geminiClient; + private final StatisticsQueryExecutor queryExecutor; + private final SlackClient slackClient; + private final SlackProperties slackProperties; + + public boolean isAIQuery(String text) { + if (text == null) { + return false; + } + return AI_PREFIX_PATTERN.matcher(text.trim()).matches(); + } + + public String extractQuery(String text) { + Matcher matcher = AI_PREFIX_PATTERN.matcher(text.trim()); + if (matcher.matches()) { + return matcher.group(1).trim(); + } + return text; + } + + @Async + public void processAIQuery(String text) { + try { + String query = extractQuery(text); + log.info("AI 질문 처리 시작: {}", query); + + // 1. Gemini에게 의도 분석 요청 + String queryType = geminiClient.analyzeIntent(query); + log.info("분석된 쿼리 타입: {}", queryType); + + // 2. 의도에 따른 데이터 조회 + String data = queryExecutor.execute(queryType); + log.info("조회된 데이터: {}", data); + + // 3. Gemini에게 자연어 응답 생성 요청 + String response = geminiClient.generateResponse(query, data); + log.info("생성된 응답: {}", response); + + // 4. Slack에 응답 전송 + String slackMessage = formatSlackResponse(response); + slackClient.sendMessage(slackMessage, slackProperties.webhooks().event()); + + } catch (Exception e) { + log.error("AI 질문 처리 중 오류 발생", e); + String errorMessage = ":warning: 죄송합니다. 요청을 처리하는 중 오류가 발생했습니다."; + slackClient.sendMessage(errorMessage, slackProperties.webhooks().event()); + } + } + + private String formatSlackResponse(String response) { + return String.format(""" + :robot_face: *AI 응답* + %s + """, response); + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackEventController.java b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackEventController.java new file mode 100644 index 00000000..d293d321 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackEventController.java @@ -0,0 +1,74 @@ +package gg.agit.konect.infrastructure.slack.ai; + +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequestMapping("/slack/events") +@RequiredArgsConstructor +public class SlackEventController { + + private final SlackAIService slackAIService; + + @PostMapping + @SuppressWarnings("unchecked") + public ResponseEntity handleSlackEvent(@RequestBody Map payload) { + String type = (String)payload.get("type"); + log.info("Slack 이벤트 수신: type={}", type); + + // Slack URL 검증 (최초 설정 시 Slack에서 호출) + if ("url_verification".equals(type)) { + String challenge = (String)payload.get("challenge"); + log.info("Slack URL 검증 요청: challenge={}", challenge); + return ResponseEntity.ok(Map.of("challenge", challenge)); + } + + // 이벤트 콜백 처리 + if ("event_callback".equals(type)) { + Map event = (Map)payload.get("event"); + if (event != null) { + handleEvent(event); + } + } + + // Slack은 3초 내 응답을 기대하므로 빠르게 200 반환 + return ResponseEntity.ok().build(); + } + + private void handleEvent(Map event) { + String eventType = (String)event.get("type"); + String text = (String)event.get("text"); + String subtype = (String)event.get("subtype"); + + log.info("이벤트 처리: eventType={}, text={}", eventType, text); + + // bot 메시지나 변경 이벤트는 무시 + if (subtype != null) { + log.debug("subtype이 있는 이벤트 무시: subtype={}", subtype); + return; + } + + // 메시지 이벤트 처리 + if ("message".equals(eventType) && text != null) { + if (slackAIService.isAIQuery(text)) { + log.info("AI 질문 감지: {}", text); + slackAIService.processAIQuery(text); + } + } + + // 앱 멘션 이벤트 처리 + if ("app_mention".equals(eventType) && text != null) { + log.info("앱 멘션 감지: {}", text); + slackAIService.processAIQuery(text); + } + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/ai/StatisticsQueryExecutor.java b/src/main/java/gg/agit/konect/infrastructure/slack/ai/StatisticsQueryExecutor.java new file mode 100644 index 00000000..4e2f98f7 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/slack/ai/StatisticsQueryExecutor.java @@ -0,0 +1,51 @@ +package gg.agit.konect.infrastructure.slack.ai; + +import org.springframework.stereotype.Component; + +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRecruitmentRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StatisticsQueryExecutor { + + private final UserRepository userRepository; + private final ClubRepository clubRepository; + private final ClubRecruitmentRepository clubRecruitmentRepository; + private final ClubMemberRepository clubMemberRepository; + + public String execute(String queryType) { + return switch (queryType) { + case "USER_COUNT" -> executeUserCount(); + case "CLUB_COUNT" -> executeClubCount(); + case "CLUB_RECRUITING_COUNT" -> executeClubRecruitingCount(); + case "CLUB_MEMBER_TOTAL_COUNT" -> executeClubMemberTotalCount(); + default -> "요청하신 정보를 찾을 수 없습니다."; + }; + } + + private String executeUserCount() { + long count = userRepository.countActiveUsers(); + return String.format("현재 서비스에 가입된 활성 사용자 수는 %d명입니다.", count); + } + + private String executeClubCount() { + long count = clubRepository.countAll(); + return String.format("현재 등록된 전체 동아리 수는 %d개입니다.", count); + } + + private String executeClubRecruitingCount() { + long count = clubRecruitmentRepository.countCurrentlyRecruiting(); + return String.format("현재 모집 중인 동아리 수는 %d개입니다.", count); + } + + private String executeClubMemberTotalCount() { + long count = clubMemberRepository.countAll(); + return String.format("전체 동아리원 수는 %d명입니다.", count); + } +} diff --git a/src/main/resources/application-infrastructure.yml b/src/main/resources/application-infrastructure.yml index 30320ca7..03dc27ff 100644 --- a/src/main/resources/application-infrastructure.yml +++ b/src/main/resources/application-infrastructure.yml @@ -11,3 +11,9 @@ slack: webhooks: error: ${SLACK_WEBHOOK_ERROR} event: ${SLACK_WEBHOOK_EVENT} + +gemini: + api-key: ${GEMINI_API_KEY} + project-id: ${GEMINI_PROJECT_ID} + location: ${GEMINI_LOCATION:us-central1} + model: ${GEMINI_MODEL:gemini-1.5-flash} From d52372d204347ac7f2b513471e055326e9a7d78f Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Mon, 2 Mar 2026 23:13:56 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EA=B3=A0=EC=A0=95=EB=90=9C=20?= =?UTF-8?q?=EC=A7=88=EB=AC=B8=20=ED=83=80=EC=9E=85=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gemini/client/GeminiClient.java | 134 +++++++++++++++--- .../slack/ai/DynamicQueryExecutor.java | 103 ++++++++++++++ .../slack/ai/SlackAIService.java | 41 ++++-- .../slack/ai/StatisticsQueryExecutor.java | 51 ------- 4 files changed, 243 insertions(+), 86 deletions(-) create mode 100644 src/main/java/gg/agit/konect/infrastructure/slack/ai/DynamicQueryExecutor.java delete mode 100644 src/main/java/gg/agit/konect/infrastructure/slack/ai/StatisticsQueryExecutor.java diff --git a/src/main/java/gg/agit/konect/infrastructure/gemini/client/GeminiClient.java b/src/main/java/gg/agit/konect/infrastructure/gemini/client/GeminiClient.java index 72065c53..591be08a 100644 --- a/src/main/java/gg/agit/konect/infrastructure/gemini/client/GeminiClient.java +++ b/src/main/java/gg/agit/konect/infrastructure/gemini/client/GeminiClient.java @@ -16,34 +16,115 @@ @Component public class GeminiClient { - private static final String INTENT_ANALYSIS_PROMPT = """ + private static final String DATABASE_SCHEMA = """ + ## KONECT 서비스 데이터베이스 스키마 (JPQL 엔티티) + + ### User (사용자) + - id: Integer (PK) + - email: String (이메일) + - name: String (이름) + - studentNumber: String (학번) + - provider: Provider (GOOGLE, APPLE, NAVER, KAKAO) + - university: University (소속 대학) + - createdAt: LocalDateTime (가입일) + - deletedAt: LocalDateTime (탈퇴일, null이면 활성 사용자) + + ### University (대학교) + - id: Integer (PK) + - koreanName: String (한글 이름) + - englishName: String (영문 이름) + + ### Club (동아리) + - id: Integer (PK) + - name: String (동아리 이름) + - description: String (소개) + - university: University (소속 대학) + - createdAt: LocalDateTime (생성일) + + ### ClubMember (동아리 멤버) + - id.clubId: Integer (동아리 ID) + - id.userId: Integer (사용자 ID) + - club: Club + - user: User + - clubPosition: ClubPosition (PRESIDENT, VICE_PRESIDENT, MANAGER, MEMBER) + - createdAt: LocalDateTime (가입일) + + ### ClubRecruitment (동아리 모집 공고) + - id: Integer (PK) + - club: Club + - isAlwaysRecruiting: Boolean (상시 모집 여부) + - startAt: LocalDateTime (모집 시작일) + - endAt: LocalDateTime (모집 종료일) + - content: String (공고 내용) + + ### ClubApply (동아리 지원) + - id: Integer (PK) + - club: Club + - user: User + - status: ClubApplyStatus (PENDING, APPROVED, REJECTED) + - createdAt: LocalDateTime (지원일) + + ### Schedule (일정) + - id: Integer (PK) + - title: String (제목) + - startedAt: LocalDateTime (시작일) + - endedAt: LocalDateTime (종료일) + - scheduleType: ScheduleType (UNIVERSITY, CLUB, COUNCIL) + + ### StudyTimeDaily (일별 공부 시간) + - id: Integer (PK) + - user: User + - studySeconds: Long (공부 시간, 초) + - date: LocalDate (날짜) + + ### StudyTimeMonthly (월별 공부 시간) + - id: Integer (PK) + - user: User + - studySeconds: Long (공부 시간, 초) + - yearMonth: String (년월, YYYY-MM) + + ## 중요 규칙 + - 활성 사용자 조회 시: WHERE u.deletedAt IS NULL + - 현재 모집 중: WHERE cr.isAlwaysRecruiting = true + OR (cr.startAt <= CURRENT_TIMESTAMP AND cr.endAt >= CURRENT_TIMESTAMP) + """; + + private static final String QUERY_GENERATION_PROMPT = """ 당신은 KONECT 서비스의 데이터 분석 AI입니다. - 사용자의 질문을 분석하여 다음 중 하나의 쿼리 타입만 반환하세요. - 반드시 아래 목록 중 하나의 값만 반환하고, 다른 텍스트는 포함하지 마세요. - - 가능한 쿼리 타입: - - USER_COUNT: 전체 가입된 사용자 수 관련 질문 - - CLUB_COUNT: 전체 동아리 수 관련 질문 - - CLUB_RECRUITING_COUNT: 현재 모집 중인 동아리 수 관련 질문 - - CLUB_MEMBER_TOTAL_COUNT: 전체 동아리원 수 관련 질문 - - UNKNOWN: 위 항목에 해당하지 않는 질문 - - 예시: - - "가입된 사용자 수 알려줘" -> USER_COUNT - - "현재 모집 중인 동아리 몇개야?" -> CLUB_RECRUITING_COUNT - - "전체 동아리 수는?" -> CLUB_COUNT - - "동아리원이 총 몇명이야?" -> CLUB_MEMBER_TOTAL_COUNT - - "오늘 날씨 어때?" -> UNKNOWN + 사용자의 자연어 질문을 분석하여 JPQL 쿼리를 생성해주세요. + + %s + + ## 규칙 + 1. 반드시 유효한 JPQL SELECT 쿼리만 생성하세요. + 2. DELETE, UPDATE, INSERT 등 데이터 변경 쿼리는 절대 생성하지 마세요. + 3. 쿼리만 반환하고, 다른 설명은 포함하지 마세요. + 4. 쿼리가 불가능한 질문이면 "UNSUPPORTED"만 반환하세요. + + ## 예시 + 질문: "가입된 사용자 수 알려줘" + 쿼리: SELECT COUNT(u) FROM User u WHERE u.deletedAt IS NULL + + 질문: "현재 모집 중인 동아리 몇개야?" + 쿼리: SELECT COUNT(cr) FROM ClubRecruitment cr WHERE cr.isAlwaysRecruiting = true + OR (cr.startAt <= CURRENT_TIMESTAMP AND cr.endAt >= CURRENT_TIMESTAMP) + 질문: "동아리별 멤버 수 알려줘" + 쿼리: SELECT c.name, COUNT(cm) FROM ClubMember cm JOIN cm.club c GROUP BY c.name + + 질문: "오늘 날씨 어때?" + 쿼리: UNSUPPORTED + + --- 사용자 질문: %s - 쿼리 타입: + 쿼리: """; private static final String RESPONSE_GENERATION_PROMPT = """ 당신은 KONECT 서비스의 친절한 AI 어시스턴트입니다. 아래 정보를 바탕으로 사용자에게 자연스럽고 친절한 한국어로 응답해주세요. - 이모지를 적절히 사용해주세요. + 이모지를 적절히 사용하여 친근하게 답변해주세요. 사용자 질문: %s 조회된 데이터: %s @@ -57,9 +138,16 @@ public GeminiClient(GeminiProperties geminiProperties) { this.geminiProperties = geminiProperties; } - public String analyzeIntent(String userQuery) { - String prompt = String.format(INTENT_ANALYSIS_PROMPT, userQuery); - return callGemini(prompt).trim().toUpperCase(); + public String generateQuery(String userQuery) { + String prompt = String.format(QUERY_GENERATION_PROMPT, DATABASE_SCHEMA, userQuery); + String result = callGemini(prompt).trim(); + + // 코드 블록 제거 + if (result.startsWith("```")) { + result = result.replaceAll("```\\w*\\n?", "").trim(); + } + + return result; } public String generateResponse(String userQuery, String data) { @@ -77,7 +165,7 @@ private String callGemini(String prompt) { return ResponseHandler.getText(response); } catch (IOException e) { log.error("Gemini API 호출 실패", e); - return "UNKNOWN"; + return "UNSUPPORTED"; } } } diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/ai/DynamicQueryExecutor.java b/src/main/java/gg/agit/konect/infrastructure/slack/ai/DynamicQueryExecutor.java new file mode 100644 index 00000000..713466d1 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/slack/ai/DynamicQueryExecutor.java @@ -0,0 +1,103 @@ +package gg.agit.konect.infrastructure.slack.ai; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DynamicQueryExecutor { + + private static final int MAX_RESULTS = 100; + private static final int DISPLAY_LIMIT = 10; + + private final EntityManager entityManager; + + public String executeQuery(String jpqlQuery) { + try { + validateQuery(jpqlQuery); + + Query query = entityManager.createQuery(jpqlQuery); + query.setMaxResults(MAX_RESULTS); + + List results = query.getResultList(); + + return formatResults(results); + } catch (Exception e) { + log.error("쿼리 실행 실패: query={}", jpqlQuery, e); + return "쿼리 실행 중 오류가 발생했습니다: " + e.getMessage(); + } + } + + private void validateQuery(String query) { + String upperQuery = query.toUpperCase().trim(); + + // SELECT 쿼리만 허용 + if (!upperQuery.startsWith("SELECT")) { + throw new IllegalArgumentException("SELECT 쿼리만 허용됩니다."); + } + + // 위험한 키워드 차단 + String[] dangerousKeywords = { + "DELETE", "UPDATE", "INSERT", "DROP", "TRUNCATE", + "ALTER", "CREATE", "GRANT", "REVOKE", "EXEC", "EXECUTE" + }; + + for (String keyword : dangerousKeywords) { + if (upperQuery.contains(keyword)) { + throw new IllegalArgumentException("허용되지 않는 키워드: " + keyword); + } + } + } + + private String formatResults(List results) { + if (results.isEmpty()) { + return "조회 결과가 없습니다."; + } + + Object first = results.get(0); + + // 단일 값 (COUNT 등) + if (first instanceof Number || first instanceof String) { + if (results.size() == 1) { + return "결과: " + first.toString(); + } + return "결과 목록: " + results.toString(); + } + + // Object[] (여러 컬럼 조회) + if (first instanceof Object[]) { + StringBuilder sb = new StringBuilder(); + sb.append("총 ").append(results.size()).append("건 조회됨\n"); + int count = 0; + for (Object row : results) { + if (count >= DISPLAY_LIMIT) { + sb.append("... 외 ").append(results.size() - DISPLAY_LIMIT).append("건"); + break; + } + Object[] cols = (Object[])row; + sb.append("- "); + for (int i = 0; i < cols.length; i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(cols[i]); + } + sb.append("\n"); + count++; + } + return sb.toString(); + } + + // 엔티티 객체 + StringBuilder sb = new StringBuilder(); + sb.append("총 ").append(results.size()).append("건 조회됨"); + return sb.toString(); + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java index e42c78e3..0bce093e 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java @@ -18,9 +18,10 @@ public class SlackAIService { private static final Pattern AI_PREFIX_PATTERN = Pattern.compile("^[Aa][Ii]\\)\\s*(.+)$"); + private static final String UNSUPPORTED = "UNSUPPORTED"; private final GeminiClient geminiClient; - private final StatisticsQueryExecutor queryExecutor; + private final DynamicQueryExecutor queryExecutor; private final SlackClient slackClient; private final SlackProperties slackProperties; @@ -42,22 +43,30 @@ public String extractQuery(String text) { @Async public void processAIQuery(String text) { try { - String query = extractQuery(text); - log.info("AI 질문 처리 시작: {}", query); + String userQuery = extractQuery(text); + log.info("AI 질문 처리 시작: {}", userQuery); - // 1. Gemini에게 의도 분석 요청 - String queryType = geminiClient.analyzeIntent(query); - log.info("분석된 쿼리 타입: {}", queryType); + // 1. Gemini에게 JPQL 쿼리 생성 요청 + String jpqlQuery = geminiClient.generateQuery(userQuery); + log.info("생성된 JPQL: {}", jpqlQuery); - // 2. 의도에 따른 데이터 조회 - String data = queryExecutor.execute(queryType); - log.info("조회된 데이터: {}", data); + String response; + + // 2. 지원하지 않는 질문인 경우 + if (UNSUPPORTED.equalsIgnoreCase(jpqlQuery.trim())) { + response = generateUnsupportedResponse(userQuery); + } else { + // 3. 쿼리 실행 + String data = queryExecutor.executeQuery(jpqlQuery); + log.info("조회된 데이터: {}", data); + + // 4. Gemini에게 자연어 응답 생성 요청 + response = geminiClient.generateResponse(userQuery, data); + } - // 3. Gemini에게 자연어 응답 생성 요청 - String response = geminiClient.generateResponse(query, data); log.info("생성된 응답: {}", response); - // 4. Slack에 응답 전송 + // 5. Slack에 응답 전송 String slackMessage = formatSlackResponse(response); slackClient.sendMessage(slackMessage, slackProperties.webhooks().event()); @@ -68,6 +77,14 @@ public void processAIQuery(String text) { } } + private String generateUnsupportedResponse(String userQuery) { + return geminiClient.generateResponse( + userQuery, + "이 질문은 KONECT 서비스 데이터와 관련이 없어서 답변하기 어렵습니다. " + + "사용자 수, 동아리 정보, 일정, 공부 시간 등 서비스 관련 질문을 해주세요." + ); + } + private String formatSlackResponse(String response) { return String.format(""" :robot_face: *AI 응답* diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/ai/StatisticsQueryExecutor.java b/src/main/java/gg/agit/konect/infrastructure/slack/ai/StatisticsQueryExecutor.java deleted file mode 100644 index 4e2f98f7..00000000 --- a/src/main/java/gg/agit/konect/infrastructure/slack/ai/StatisticsQueryExecutor.java +++ /dev/null @@ -1,51 +0,0 @@ -package gg.agit.konect.infrastructure.slack.ai; - -import org.springframework.stereotype.Component; - -import gg.agit.konect.domain.club.repository.ClubMemberRepository; -import gg.agit.konect.domain.club.repository.ClubRecruitmentRepository; -import gg.agit.konect.domain.club.repository.ClubRepository; -import gg.agit.konect.domain.user.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Component -@RequiredArgsConstructor -public class StatisticsQueryExecutor { - - private final UserRepository userRepository; - private final ClubRepository clubRepository; - private final ClubRecruitmentRepository clubRecruitmentRepository; - private final ClubMemberRepository clubMemberRepository; - - public String execute(String queryType) { - return switch (queryType) { - case "USER_COUNT" -> executeUserCount(); - case "CLUB_COUNT" -> executeClubCount(); - case "CLUB_RECRUITING_COUNT" -> executeClubRecruitingCount(); - case "CLUB_MEMBER_TOTAL_COUNT" -> executeClubMemberTotalCount(); - default -> "요청하신 정보를 찾을 수 없습니다."; - }; - } - - private String executeUserCount() { - long count = userRepository.countActiveUsers(); - return String.format("현재 서비스에 가입된 활성 사용자 수는 %d명입니다.", count); - } - - private String executeClubCount() { - long count = clubRepository.countAll(); - return String.format("현재 등록된 전체 동아리 수는 %d개입니다.", count); - } - - private String executeClubRecruitingCount() { - long count = clubRecruitmentRepository.countCurrentlyRecruiting(); - return String.format("현재 모집 중인 동아리 수는 %d개입니다.", count); - } - - private String executeClubMemberTotalCount() { - long count = clubMemberRepository.countAll(); - return String.format("전체 동아리원 수는 %d명입니다.", count); - } -} From 6d312b1ed965057c8f4f956506ea0ff95437e71a Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 3 Mar 2026 01:55:15 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EB=B3=B4=EC=95=88=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 +- .../konect/global/config/SecurityPaths.java | 2 +- .../gemini/client/GeminiClient.java | 178 +++++++----------- .../gemini/config/GeminiProperties.java | 1 - .../slack/ai/DynamicQueryExecutor.java | 103 ---------- .../slack/ai/SlackAIService.java | 48 +++-- .../slack/ai/SlackEventController.java | 50 ++++- .../slack/ai/StatisticsQueryExecutor.java | 53 ++++++ .../slack/config/SlackProperties.java | 3 +- .../slack/config/SlackSignatureVerifier.java | 96 ++++++++++ .../resources/application-infrastructure.yml | 2 +- 11 files changed, 288 insertions(+), 252 deletions(-) delete mode 100644 src/main/java/gg/agit/konect/infrastructure/slack/ai/DynamicQueryExecutor.java create mode 100644 src/main/java/gg/agit/konect/infrastructure/slack/ai/StatisticsQueryExecutor.java create mode 100644 src/main/java/gg/agit/konect/infrastructure/slack/config/SlackSignatureVerifier.java diff --git a/.env.example b/.env.example index 47283994..7fac6a6d 100644 --- a/.env.example +++ b/.env.example @@ -42,12 +42,12 @@ OAUTH_APPLE_KEY_ID=your-apple-key-id OAUTH_APPLE_PRIVATE_KEY_PATH=/path/to/apple/private/key.p8 OAUTH_APPLE_CLIENT_SECRET=your-apple-client-secret -# Slack Webhooks (Optional) +# Slack Configuration SLACK_WEBHOOK_ERROR=https://hooks.slack.com/services/YOUR/WEBHOOK/URL SLACK_WEBHOOK_EVENT=https://hooks.slack.com/services/YOUR/WEBHOOK/URL +SLACK_SIGNING_SECRET=your-slack-signing-secret # Gemini AI Configuration -GEMINI_API_KEY=your-gemini-api-key GEMINI_PROJECT_ID=your-gcp-project-id GEMINI_LOCATION=us-central1 GEMINI_MODEL=gemini-1.5-flash diff --git a/src/main/java/gg/agit/konect/global/config/SecurityPaths.java b/src/main/java/gg/agit/konect/global/config/SecurityPaths.java index 1e32788f..74dee66f 100644 --- a/src/main/java/gg/agit/konect/global/config/SecurityPaths.java +++ b/src/main/java/gg/agit/konect/global/config/SecurityPaths.java @@ -10,7 +10,7 @@ public final class SecurityPaths { "/v3/api-docs/**", "/swagger-resources/**", "/error", - "/slack/**" + "/slack/events" }; public static final String[] DENY_PATHS = {}; diff --git a/src/main/java/gg/agit/konect/infrastructure/gemini/client/GeminiClient.java b/src/main/java/gg/agit/konect/infrastructure/gemini/client/GeminiClient.java index 591be08a..f7059df1 100644 --- a/src/main/java/gg/agit/konect/infrastructure/gemini/client/GeminiClient.java +++ b/src/main/java/gg/agit/konect/infrastructure/gemini/client/GeminiClient.java @@ -10,121 +10,44 @@ import com.google.cloud.vertexai.generativeai.ResponseHandler; import gg.agit.konect.infrastructure.gemini.config.GeminiProperties; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; @Slf4j @Component public class GeminiClient { - private static final String DATABASE_SCHEMA = """ - ## KONECT 서비스 데이터베이스 스키마 (JPQL 엔티티) - - ### User (사용자) - - id: Integer (PK) - - email: String (이메일) - - name: String (이름) - - studentNumber: String (학번) - - provider: Provider (GOOGLE, APPLE, NAVER, KAKAO) - - university: University (소속 대학) - - createdAt: LocalDateTime (가입일) - - deletedAt: LocalDateTime (탈퇴일, null이면 활성 사용자) - - ### University (대학교) - - id: Integer (PK) - - koreanName: String (한글 이름) - - englishName: String (영문 이름) - - ### Club (동아리) - - id: Integer (PK) - - name: String (동아리 이름) - - description: String (소개) - - university: University (소속 대학) - - createdAt: LocalDateTime (생성일) - - ### ClubMember (동아리 멤버) - - id.clubId: Integer (동아리 ID) - - id.userId: Integer (사용자 ID) - - club: Club - - user: User - - clubPosition: ClubPosition (PRESIDENT, VICE_PRESIDENT, MANAGER, MEMBER) - - createdAt: LocalDateTime (가입일) - - ### ClubRecruitment (동아리 모집 공고) - - id: Integer (PK) - - club: Club - - isAlwaysRecruiting: Boolean (상시 모집 여부) - - startAt: LocalDateTime (모집 시작일) - - endAt: LocalDateTime (모집 종료일) - - content: String (공고 내용) - - ### ClubApply (동아리 지원) - - id: Integer (PK) - - club: Club - - user: User - - status: ClubApplyStatus (PENDING, APPROVED, REJECTED) - - createdAt: LocalDateTime (지원일) - - ### Schedule (일정) - - id: Integer (PK) - - title: String (제목) - - startedAt: LocalDateTime (시작일) - - endedAt: LocalDateTime (종료일) - - scheduleType: ScheduleType (UNIVERSITY, CLUB, COUNCIL) - - ### StudyTimeDaily (일별 공부 시간) - - id: Integer (PK) - - user: User - - studySeconds: Long (공부 시간, 초) - - date: LocalDate (날짜) - - ### StudyTimeMonthly (월별 공부 시간) - - id: Integer (PK) - - user: User - - studySeconds: Long (공부 시간, 초) - - yearMonth: String (년월, YYYY-MM) - - ## 중요 규칙 - - 활성 사용자 조회 시: WHERE u.deletedAt IS NULL - - 현재 모집 중: WHERE cr.isAlwaysRecruiting = true - OR (cr.startAt <= CURRENT_TIMESTAMP AND cr.endAt >= CURRENT_TIMESTAMP) - """; - - private static final String QUERY_GENERATION_PROMPT = """ + private static final String INTENT_ANALYSIS_PROMPT = """ 당신은 KONECT 서비스의 데이터 분석 AI입니다. - 사용자의 자연어 질문을 분석하여 JPQL 쿼리를 생성해주세요. - - %s - - ## 규칙 - 1. 반드시 유효한 JPQL SELECT 쿼리만 생성하세요. - 2. DELETE, UPDATE, INSERT 등 데이터 변경 쿼리는 절대 생성하지 마세요. - 3. 쿼리만 반환하고, 다른 설명은 포함하지 마세요. - 4. 쿼리가 불가능한 질문이면 "UNSUPPORTED"만 반환하세요. - - ## 예시 - 질문: "가입된 사용자 수 알려줘" - 쿼리: SELECT COUNT(u) FROM User u WHERE u.deletedAt IS NULL + 사용자의 질문을 분석하여 다음 중 하나의 쿼리 타입만 반환하세요. + 반드시 아래 목록 중 하나의 값만 반환하고, 다른 텍스트는 포함하지 마세요. + + 가능한 쿼리 타입: + - USER_COUNT: 가입된 사용자 수, 회원 수, 유저 수 관련 질문 + - CLUB_COUNT: 전체 동아리 수, 동아리 개수 관련 질문 + - CLUB_RECRUITING_COUNT: 현재 모집 중인 동아리 수, 모집 현황 관련 질문 + - CLUB_MEMBER_TOTAL_COUNT: 전체 동아리원 수, 동아리 멤버 총 인원 관련 질문 + - UNKNOWN: 위 항목에 해당하지 않는 질문 + + 예시: + - "가입된 사용자 수 알려줘" -> USER_COUNT + - "현재 모집 중인 동아리 몇개야?" -> CLUB_RECRUITING_COUNT + - "전체 동아리 수는?" -> CLUB_COUNT + - "동아리원이 총 몇명이야?" -> CLUB_MEMBER_TOTAL_COUNT + - "오늘 날씨 어때?" -> UNKNOWN + - "특정 사용자 이메일 알려줘" -> UNKNOWN - 질문: "현재 모집 중인 동아리 몇개야?" - 쿼리: SELECT COUNT(cr) FROM ClubRecruitment cr WHERE cr.isAlwaysRecruiting = true - OR (cr.startAt <= CURRENT_TIMESTAMP AND cr.endAt >= CURRENT_TIMESTAMP) - - 질문: "동아리별 멤버 수 알려줘" - 쿼리: SELECT c.name, COUNT(cm) FROM ClubMember cm JOIN cm.club c GROUP BY c.name - - 질문: "오늘 날씨 어때?" - 쿼리: UNSUPPORTED - - --- 사용자 질문: %s - 쿼리: + 쿼리 타입: """; private static final String RESPONSE_GENERATION_PROMPT = """ 당신은 KONECT 서비스의 친절한 AI 어시스턴트입니다. 아래 정보를 바탕으로 사용자에게 자연스럽고 친절한 한국어로 응답해주세요. 이모지를 적절히 사용하여 친근하게 답변해주세요. + 응답은 간결하게 2-3문장으로 작성해주세요. 사용자 질문: %s 조회된 데이터: %s @@ -133,39 +56,66 @@ public class GeminiClient { """; private final GeminiProperties geminiProperties; + private VertexAI vertexAI; + private GenerativeModel generativeModel; public GeminiClient(GeminiProperties geminiProperties) { this.geminiProperties = geminiProperties; } - public String generateQuery(String userQuery) { - String prompt = String.format(QUERY_GENERATION_PROMPT, DATABASE_SCHEMA, userQuery); - String result = callGemini(prompt).trim(); + @PostConstruct + public void init() { + this.vertexAI = new VertexAI( + geminiProperties.projectId(), + geminiProperties.location() + ); + this.generativeModel = new GenerativeModel(geminiProperties.model(), vertexAI); + log.info("GeminiClient 초기화 완료: project={}, location={}, model={}", + geminiProperties.projectId(), + geminiProperties.location(), + geminiProperties.model() + ); + } - // 코드 블록 제거 - if (result.startsWith("```")) { - result = result.replaceAll("```\\w*\\n?", "").trim(); + @PreDestroy + public void destroy() { + if (vertexAI != null) { + try { + vertexAI.close(); + log.info("GeminiClient 리소스 해제 완료"); + } catch (Exception e) { + log.warn("GeminiClient 리소스 해제 중 오류", e); + } } + } - return result; + public String analyzeIntent(String userQuery) { + String prompt = String.format(INTENT_ANALYSIS_PROMPT, userQuery); + String result = callGemini(prompt); + if (result == null) { + return "UNKNOWN"; + } + return result.trim().toUpperCase(); } public String generateResponse(String userQuery, String data) { String prompt = String.format(RESPONSE_GENERATION_PROMPT, userQuery, data); - return callGemini(prompt); + String result = callGemini(prompt); + return result != null ? result : "응답을 생성할 수 없습니다."; } private String callGemini(String prompt) { - try (VertexAI vertexAI = new VertexAI( - geminiProperties.projectId(), - geminiProperties.location() - )) { - GenerativeModel model = new GenerativeModel(geminiProperties.model(), vertexAI); - GenerateContentResponse response = model.generateContent(prompt); + if (generativeModel == null) { + log.error("GenerativeModel이 초기화되지 않았습니다."); + return null; + } + + try { + GenerateContentResponse response = generativeModel.generateContent(prompt); return ResponseHandler.getText(response); } catch (IOException e) { log.error("Gemini API 호출 실패", e); - return "UNSUPPORTED"; + return null; } } } diff --git a/src/main/java/gg/agit/konect/infrastructure/gemini/config/GeminiProperties.java b/src/main/java/gg/agit/konect/infrastructure/gemini/config/GeminiProperties.java index 07cfd989..3cc553f8 100644 --- a/src/main/java/gg/agit/konect/infrastructure/gemini/config/GeminiProperties.java +++ b/src/main/java/gg/agit/konect/infrastructure/gemini/config/GeminiProperties.java @@ -4,7 +4,6 @@ @ConfigurationProperties(prefix = "gemini") public record GeminiProperties( - String apiKey, String projectId, String location, String model diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/ai/DynamicQueryExecutor.java b/src/main/java/gg/agit/konect/infrastructure/slack/ai/DynamicQueryExecutor.java deleted file mode 100644 index 713466d1..00000000 --- a/src/main/java/gg/agit/konect/infrastructure/slack/ai/DynamicQueryExecutor.java +++ /dev/null @@ -1,103 +0,0 @@ -package gg.agit.konect.infrastructure.slack.ai; - -import java.util.List; - -import org.springframework.stereotype.Component; - -import jakarta.persistence.EntityManager; -import jakarta.persistence.Query; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Component -@RequiredArgsConstructor -public class DynamicQueryExecutor { - - private static final int MAX_RESULTS = 100; - private static final int DISPLAY_LIMIT = 10; - - private final EntityManager entityManager; - - public String executeQuery(String jpqlQuery) { - try { - validateQuery(jpqlQuery); - - Query query = entityManager.createQuery(jpqlQuery); - query.setMaxResults(MAX_RESULTS); - - List results = query.getResultList(); - - return formatResults(results); - } catch (Exception e) { - log.error("쿼리 실행 실패: query={}", jpqlQuery, e); - return "쿼리 실행 중 오류가 발생했습니다: " + e.getMessage(); - } - } - - private void validateQuery(String query) { - String upperQuery = query.toUpperCase().trim(); - - // SELECT 쿼리만 허용 - if (!upperQuery.startsWith("SELECT")) { - throw new IllegalArgumentException("SELECT 쿼리만 허용됩니다."); - } - - // 위험한 키워드 차단 - String[] dangerousKeywords = { - "DELETE", "UPDATE", "INSERT", "DROP", "TRUNCATE", - "ALTER", "CREATE", "GRANT", "REVOKE", "EXEC", "EXECUTE" - }; - - for (String keyword : dangerousKeywords) { - if (upperQuery.contains(keyword)) { - throw new IllegalArgumentException("허용되지 않는 키워드: " + keyword); - } - } - } - - private String formatResults(List results) { - if (results.isEmpty()) { - return "조회 결과가 없습니다."; - } - - Object first = results.get(0); - - // 단일 값 (COUNT 등) - if (first instanceof Number || first instanceof String) { - if (results.size() == 1) { - return "결과: " + first.toString(); - } - return "결과 목록: " + results.toString(); - } - - // Object[] (여러 컬럼 조회) - if (first instanceof Object[]) { - StringBuilder sb = new StringBuilder(); - sb.append("총 ").append(results.size()).append("건 조회됨\n"); - int count = 0; - for (Object row : results) { - if (count >= DISPLAY_LIMIT) { - sb.append("... 외 ").append(results.size() - DISPLAY_LIMIT).append("건"); - break; - } - Object[] cols = (Object[])row; - sb.append("- "); - for (int i = 0; i < cols.length; i++) { - if (i > 0) { - sb.append(", "); - } - sb.append(cols[i]); - } - sb.append("\n"); - count++; - } - return sb.toString(); - } - - // 엔티티 객체 - StringBuilder sb = new StringBuilder(); - sb.append("총 ").append(results.size()).append("건 조회됨"); - return sb.toString(); - } -} diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java index 0bce093e..7bc41f8d 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java @@ -18,10 +18,11 @@ public class SlackAIService { private static final Pattern AI_PREFIX_PATTERN = Pattern.compile("^[Aa][Ii]\\)\\s*(.+)$"); - private static final String UNSUPPORTED = "UNSUPPORTED"; + private static final Pattern MENTION_PATTERN = Pattern.compile("^<@[^>]+>\\s*"); + private static final String UNKNOWN = "UNKNOWN"; private final GeminiClient geminiClient; - private final DynamicQueryExecutor queryExecutor; + private final StatisticsQueryExecutor queryExecutor; private final SlackClient slackClient; private final SlackProperties slackProperties; @@ -40,31 +41,41 @@ public String extractQuery(String text) { return text; } + public String normalizeAppMentionText(String text) { + if (text == null) { + return null; + } + return MENTION_PATTERN.matcher(text).replaceFirst("").trim(); + } + @Async public void processAIQuery(String text) { try { String userQuery = extractQuery(text); - log.info("AI 질문 처리 시작: {}", userQuery); + log.debug("AI 질문 처리 시작"); - // 1. Gemini에게 JPQL 쿼리 생성 요청 - String jpqlQuery = geminiClient.generateQuery(userQuery); - log.info("생성된 JPQL: {}", jpqlQuery); + // 1. Gemini에게 의도 분석 요청 + String queryType = geminiClient.analyzeIntent(userQuery); + log.debug("분석된 쿼리 타입: {}", queryType); String response; // 2. 지원하지 않는 질문인 경우 - if (UNSUPPORTED.equalsIgnoreCase(jpqlQuery.trim())) { + if (UNKNOWN.equals(queryType)) { response = generateUnsupportedResponse(userQuery); } else { - // 3. 쿼리 실행 - String data = queryExecutor.executeQuery(jpqlQuery); - log.info("조회된 데이터: {}", data); - - // 4. Gemini에게 자연어 응답 생성 요청 - response = geminiClient.generateResponse(userQuery, data); + // 3. 안전한 통계 쿼리 실행 + String data = queryExecutor.execute(queryType); + + if (data == null) { + response = generateUnsupportedResponse(userQuery); + } else { + // 4. Gemini에게 자연어 응답 생성 요청 + response = geminiClient.generateResponse(userQuery, data); + } } - log.info("생성된 응답: {}", response); + log.debug("AI 응답 생성 완료"); // 5. Slack에 응답 전송 String slackMessage = formatSlackResponse(response); @@ -80,15 +91,12 @@ public void processAIQuery(String text) { private String generateUnsupportedResponse(String userQuery) { return geminiClient.generateResponse( userQuery, - "이 질문은 KONECT 서비스 데이터와 관련이 없어서 답변하기 어렵습니다. " - + "사용자 수, 동아리 정보, 일정, 공부 시간 등 서비스 관련 질문을 해주세요." + "이 질문은 현재 지원하지 않는 유형입니다. " + + "사용자 수, 동아리 수, 모집 현황, 동아리원 수 등의 통계 질문을 해주세요." ); } private String formatSlackResponse(String response) { - return String.format(""" - :robot_face: *AI 응답* - %s - """, response); + return String.format(":robot_face: *AI 응답*\n%s", response); } } diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackEventController.java b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackEventController.java index d293d321..806f005d 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackEventController.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackEventController.java @@ -2,12 +2,18 @@ import java.util.Map; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import gg.agit.konect.infrastructure.slack.config.SlackSignatureVerifier; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -17,21 +23,38 @@ @RequiredArgsConstructor public class SlackEventController { + private static final String SLACK_TIMESTAMP_HEADER = "X-Slack-Request-Timestamp"; + private static final String SLACK_SIGNATURE_HEADER = "X-Slack-Signature"; + private final SlackAIService slackAIService; + private final SlackSignatureVerifier signatureVerifier; + private final ObjectMapper objectMapper; @PostMapping @SuppressWarnings("unchecked") - public ResponseEntity handleSlackEvent(@RequestBody Map payload) { + public ResponseEntity handleSlackEvent( + @RequestHeader(value = SLACK_TIMESTAMP_HEADER, required = false) String timestamp, + @RequestHeader(value = SLACK_SIGNATURE_HEADER, required = false) String signature, + @RequestBody Map payload + ) { String type = (String)payload.get("type"); - log.info("Slack 이벤트 수신: type={}", type); - // Slack URL 검증 (최초 설정 시 Slack에서 호출) + // URL 검증은 서명 검증 없이 처리 (최초 설정 시) if ("url_verification".equals(type)) { String challenge = (String)payload.get("challenge"); - log.info("Slack URL 검증 요청: challenge={}", challenge); + log.info("Slack URL 검증 요청 처리"); return ResponseEntity.ok(Map.of("challenge", challenge)); } + // 서명 검증 + String requestBody = toJson(payload); + if (!signatureVerifier.isValidRequest(timestamp, signature, requestBody)) { + log.warn("Slack 서명 검증 실패"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + log.debug("Slack 이벤트 수신: type={}", type); + // 이벤트 콜백 처리 if ("event_callback".equals(type)) { Map event = (Map)payload.get("event"); @@ -49,26 +72,35 @@ private void handleEvent(Map event) { String text = (String)event.get("text"); String subtype = (String)event.get("subtype"); - log.info("이벤트 처리: eventType={}, text={}", eventType, text); + log.debug("이벤트 처리: eventType={}", eventType); // bot 메시지나 변경 이벤트는 무시 if (subtype != null) { - log.debug("subtype이 있는 이벤트 무시: subtype={}", subtype); return; } // 메시지 이벤트 처리 if ("message".equals(eventType) && text != null) { if (slackAIService.isAIQuery(text)) { - log.info("AI 질문 감지: {}", text); + log.debug("AI 질문 감지"); slackAIService.processAIQuery(text); } } // 앱 멘션 이벤트 처리 if ("app_mention".equals(eventType) && text != null) { - log.info("앱 멘션 감지: {}", text); - slackAIService.processAIQuery(text); + String normalizedText = slackAIService.normalizeAppMentionText(text); + log.debug("앱 멘션 감지"); + slackAIService.processAIQuery(normalizedText); + } + } + + private String toJson(Map payload) { + try { + return objectMapper.writeValueAsString(payload); + } catch (JsonProcessingException e) { + log.error("JSON 변환 실패", e); + return ""; } } } diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/ai/StatisticsQueryExecutor.java b/src/main/java/gg/agit/konect/infrastructure/slack/ai/StatisticsQueryExecutor.java new file mode 100644 index 00000000..941d5420 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/slack/ai/StatisticsQueryExecutor.java @@ -0,0 +1,53 @@ +package gg.agit.konect.infrastructure.slack.ai; + +import org.springframework.stereotype.Component; + +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRecruitmentRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StatisticsQueryExecutor { + + private final UserRepository userRepository; + private final ClubRepository clubRepository; + private final ClubRecruitmentRepository clubRecruitmentRepository; + private final ClubMemberRepository clubMemberRepository; + + public String execute(String queryType) { + log.debug("통계 쿼리 실행: queryType={}", queryType); + + return switch (queryType) { + case "USER_COUNT" -> executeUserCount(); + case "CLUB_COUNT" -> executeClubCount(); + case "CLUB_RECRUITING_COUNT" -> executeClubRecruitingCount(); + case "CLUB_MEMBER_TOTAL_COUNT" -> executeClubMemberTotalCount(); + default -> null; + }; + } + + private String executeUserCount() { + long count = userRepository.countActiveUsers(); + return String.format("현재 가입된 활성 사용자 수: %d명", count); + } + + private String executeClubCount() { + long count = clubRepository.countAll(); + return String.format("전체 동아리 수: %d개", count); + } + + private String executeClubRecruitingCount() { + long count = clubRecruitmentRepository.countCurrentlyRecruiting(); + return String.format("현재 모집 중인 동아리 수: %d개", count); + } + + private String executeClubMemberTotalCount() { + long count = clubMemberRepository.countAll(); + return String.format("전체 동아리원 수: %d명", count); + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/config/SlackProperties.java b/src/main/java/gg/agit/konect/infrastructure/slack/config/SlackProperties.java index e66b97cc..05b8780d 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/config/SlackProperties.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/config/SlackProperties.java @@ -4,7 +4,8 @@ @ConfigurationProperties(prefix = "slack") public record SlackProperties( - Webhooks webhooks + Webhooks webhooks, + String signingSecret ) { public record Webhooks( String error, diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/config/SlackSignatureVerifier.java b/src/main/java/gg/agit/konect/infrastructure/slack/config/SlackSignatureVerifier.java new file mode 100644 index 00000000..d3e8cd0a --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/slack/config/SlackSignatureVerifier.java @@ -0,0 +1,96 @@ +package gg.agit.konect.infrastructure.slack.config; + +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SlackSignatureVerifier { + + private static final String HMAC_SHA256 = "HmacSHA256"; + private static final String VERSION = "v0"; + private static final long TIMESTAMP_TOLERANCE_SECONDS = 300; + private static final long MILLIS_TO_SECONDS = 1000L; + private static final int BYTE_MASK = 0xff; + + private final SlackProperties slackProperties; + + public boolean isValidRequest(String timestamp, String signature, String requestBody) { + if (timestamp == null || signature == null || requestBody == null) { + log.warn("Slack 서명 검증 실패: 필수 헤더 누락"); + return false; + } + + // 타임스탬프 검증 (5분 이내) + if (!isTimestampValid(timestamp)) { + log.warn("Slack 서명 검증 실패: 타임스탬프 만료"); + return false; + } + + // 서명 검증 + String expectedSignature = calculateSignature(timestamp, requestBody); + boolean isValid = signature.equals(expectedSignature); + + if (!isValid) { + log.warn("Slack 서명 검증 실패: 서명 불일치"); + } + + return isValid; + } + + private boolean isTimestampValid(String timestamp) { + try { + long requestTime = Long.parseLong(timestamp); + long currentTime = System.currentTimeMillis() / MILLIS_TO_SECONDS; + return Math.abs(currentTime - requestTime) <= TIMESTAMP_TOLERANCE_SECONDS; + } catch (NumberFormatException e) { + return false; + } + } + + private String calculateSignature(String timestamp, String requestBody) { + String signingSecret = slackProperties.signingSecret(); + if (signingSecret == null || signingSecret.isBlank()) { + log.error("Slack signing secret이 설정되지 않았습니다."); + return ""; + } + + String baseString = VERSION + ":" + timestamp + ":" + requestBody; + + try { + Mac mac = Mac.getInstance(HMAC_SHA256); + SecretKeySpec secretKey = new SecretKeySpec( + signingSecret.getBytes(StandardCharsets.UTF_8), + HMAC_SHA256 + ); + mac.init(secretKey); + byte[] hash = mac.doFinal(baseString.getBytes(StandardCharsets.UTF_8)); + return VERSION + "=" + bytesToHex(hash); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + log.error("Slack 서명 계산 실패", e); + return ""; + } + } + + private String bytesToHex(byte[] bytes) { + StringBuilder hexString = new StringBuilder(); + for (byte b : bytes) { + String hex = Integer.toHexString(BYTE_MASK & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } +} diff --git a/src/main/resources/application-infrastructure.yml b/src/main/resources/application-infrastructure.yml index 03dc27ff..310869bd 100644 --- a/src/main/resources/application-infrastructure.yml +++ b/src/main/resources/application-infrastructure.yml @@ -11,9 +11,9 @@ slack: webhooks: error: ${SLACK_WEBHOOK_ERROR} event: ${SLACK_WEBHOOK_EVENT} + signing-secret: ${SLACK_SIGNING_SECRET} gemini: - api-key: ${GEMINI_API_KEY} project-id: ${GEMINI_PROJECT_ID} location: ${GEMINI_LOCATION:us-central1} model: ${GEMINI_MODEL:gemini-1.5-flash} From 080fd23a48f8ee921610cc3a1346e5c6447fdce0 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 3 Mar 2026 02:10:50 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20=EB=B9=88=20userQuery=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC,=20raw=20Body=EB=A5=BC=20=EC=9D=B4=EC=9A=A9?= =?UTF-8?q?=ED=95=9C=20=EA=B2=80=EC=A6=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- .../slack/ai/SlackAIService.java | 11 ++++ .../slack/ai/SlackEventController.java | 33 +++++++---- .../slack/config/SlackSignatureVerifier.java | 59 +++++++++++++------ 4 files changed, 74 insertions(+), 31 deletions(-) diff --git a/.env.example b/.env.example index 7fac6a6d..a24f6592 100644 --- a/.env.example +++ b/.env.example @@ -43,9 +43,9 @@ OAUTH_APPLE_PRIVATE_KEY_PATH=/path/to/apple/private/key.p8 OAUTH_APPLE_CLIENT_SECRET=your-apple-client-secret # Slack Configuration +SLACK_SIGNING_SECRET=your-slack-signing-secret SLACK_WEBHOOK_ERROR=https://hooks.slack.com/services/YOUR/WEBHOOK/URL SLACK_WEBHOOK_EVENT=https://hooks.slack.com/services/YOUR/WEBHOOK/URL -SLACK_SIGNING_SECRET=your-slack-signing-secret # Gemini AI Configuration GEMINI_PROJECT_ID=your-gcp-project-id diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java index 7bc41f8d..9f997d3b 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java @@ -52,6 +52,17 @@ public String normalizeAppMentionText(String text) { public void processAIQuery(String text) { try { String userQuery = extractQuery(text); + + // 빈 질문은 처리하지 않음 + if (userQuery == null || userQuery.isBlank()) { + log.debug("빈 질문으로 처리 중단"); + String guidanceMessage = formatSlackResponse( + "질문 내용이 비어있습니다. 예: `AI) 가입자 수 알려줘` 또는 `@봇이름 동아리 수는?`" + ); + slackClient.sendMessage(guidanceMessage, slackProperties.webhooks().event()); + return; + } + log.debug("AI 질문 처리 시작"); // 1. Gemini에게 의도 분석 요청 diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackEventController.java b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackEventController.java index 806f005d..67ad3d47 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackEventController.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackEventController.java @@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RestController; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import gg.agit.konect.infrastructure.slack.config.SlackSignatureVerifier; @@ -35,8 +36,14 @@ public class SlackEventController { public ResponseEntity handleSlackEvent( @RequestHeader(value = SLACK_TIMESTAMP_HEADER, required = false) String timestamp, @RequestHeader(value = SLACK_SIGNATURE_HEADER, required = false) String signature, - @RequestBody Map payload + @RequestBody String rawBody ) { + Map payload = parsePayload(rawBody); + if (payload == null) { + log.warn("Slack 요청 본문 파싱 실패"); + return ResponseEntity.badRequest().build(); + } + String type = (String)payload.get("type"); // URL 검증은 서명 검증 없이 처리 (최초 설정 시) @@ -46,9 +53,8 @@ public ResponseEntity handleSlackEvent( return ResponseEntity.ok(Map.of("challenge", challenge)); } - // 서명 검증 - String requestBody = toJson(payload); - if (!signatureVerifier.isValidRequest(timestamp, signature, requestBody)) { + // 서명 검증 - 원본 요청 본문 사용 + if (!signatureVerifier.isValidRequest(timestamp, signature, rawBody)) { log.warn("Slack 서명 검증 실패"); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } @@ -67,6 +73,16 @@ public ResponseEntity handleSlackEvent( return ResponseEntity.ok().build(); } + private Map parsePayload(String rawBody) { + try { + return objectMapper.readValue(rawBody, new TypeReference>() { + }); + } catch (JsonProcessingException e) { + log.error("JSON 파싱 실패", e); + return null; + } + } + private void handleEvent(Map event) { String eventType = (String)event.get("type"); String text = (String)event.get("text"); @@ -94,13 +110,4 @@ private void handleEvent(Map event) { slackAIService.processAIQuery(normalizedText); } } - - private String toJson(Map payload) { - try { - return objectMapper.writeValueAsString(payload); - } catch (JsonProcessingException e) { - log.error("JSON 변환 실패", e); - return ""; - } - } } diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/config/SlackSignatureVerifier.java b/src/main/java/gg/agit/konect/infrastructure/slack/config/SlackSignatureVerifier.java index d3e8cd0a..ae41e6c6 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/config/SlackSignatureVerifier.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/config/SlackSignatureVerifier.java @@ -2,6 +2,7 @@ import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; +import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import javax.crypto.Mac; @@ -19,9 +20,10 @@ public class SlackSignatureVerifier { private static final String HMAC_SHA256 = "HmacSHA256"; private static final String VERSION = "v0"; + private static final String VERSION_PREFIX = VERSION + "="; private static final long TIMESTAMP_TOLERANCE_SECONDS = 300; private static final long MILLIS_TO_SECONDS = 1000L; - private static final int BYTE_MASK = 0xff; + private static final int HEX_RADIX = 16; private final SlackProperties slackProperties; @@ -37,9 +39,19 @@ public boolean isValidRequest(String timestamp, String signature, String request return false; } - // 서명 검증 - String expectedSignature = calculateSignature(timestamp, requestBody); - boolean isValid = signature.equals(expectedSignature); + // 서명 검증 (constant-time comparison) + byte[] expectedHash = calculateSignatureBytes(timestamp, requestBody); + if (expectedHash == null) { + return false; + } + + byte[] providedHash = parseSignature(signature); + if (providedHash == null) { + log.warn("Slack 서명 검증 실패: 서명 형식 오류"); + return false; + } + + boolean isValid = MessageDigest.isEqual(expectedHash, providedHash); if (!isValid) { log.warn("Slack 서명 검증 실패: 서명 불일치"); @@ -58,11 +70,11 @@ private boolean isTimestampValid(String timestamp) { } } - private String calculateSignature(String timestamp, String requestBody) { + private byte[] calculateSignatureBytes(String timestamp, String requestBody) { String signingSecret = slackProperties.signingSecret(); if (signingSecret == null || signingSecret.isBlank()) { log.error("Slack signing secret이 설정되지 않았습니다."); - return ""; + return null; } String baseString = VERSION + ":" + timestamp + ":" + requestBody; @@ -74,23 +86,36 @@ private String calculateSignature(String timestamp, String requestBody) { HMAC_SHA256 ); mac.init(secretKey); - byte[] hash = mac.doFinal(baseString.getBytes(StandardCharsets.UTF_8)); - return VERSION + "=" + bytesToHex(hash); + return mac.doFinal(baseString.getBytes(StandardCharsets.UTF_8)); } catch (NoSuchAlgorithmException | InvalidKeyException e) { log.error("Slack 서명 계산 실패", e); - return ""; + return null; + } + } + + private byte[] parseSignature(String signature) { + if (!signature.startsWith(VERSION_PREFIX)) { + return null; } + + String hexPart = signature.substring(VERSION_PREFIX.length()); + return hexToBytes(hexPart); } - private String bytesToHex(byte[] bytes) { - StringBuilder hexString = new StringBuilder(); - for (byte b : bytes) { - String hex = Integer.toHexString(BYTE_MASK & b); - if (hex.length() == 1) { - hexString.append('0'); + private byte[] hexToBytes(String hex) { + if (hex.length() % 2 != 0) { + return null; + } + + try { + byte[] bytes = new byte[hex.length() / 2]; + for (int i = 0; i < bytes.length; i++) { + int index = i * 2; + bytes[i] = (byte)Integer.parseInt(hex.substring(index, index + 2), HEX_RADIX); } - hexString.append(hex); + return bytes; + } catch (NumberFormatException e) { + return null; } - return hexString.toString(); } }