diff --git a/.gitignore b/.gitignore index 635b6bd..74a08c5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ yarn-error.log* # IDE .idea/ .vscode/ +.run/ *.swp *.swo *~ @@ -31,6 +32,13 @@ logs/ logs/ *.log +# Gradle +.gradle/ +gradle-app.setting +!gradle-wrapper.jar +!gradle-wrapper.properties +.gradletasknamecache + # Environment .env .env.local diff --git a/ai-service/build.gradle b/ai-service/build.gradle index a39127e..ffa12b5 100644 --- a/ai-service/build.gradle +++ b/ai-service/build.gradle @@ -2,8 +2,8 @@ dependencies { // Kafka Consumer implementation 'org.springframework.kafka:spring-kafka' - // Redis for result caching - implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // Redis for result caching (already in root build.gradle) + // implementation 'org.springframework.boot:spring-boot-starter-data-redis' // OpenFeign for Claude/GPT API implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' @@ -14,4 +14,20 @@ dependencies { // Jackson for JSON implementation 'com.fasterxml.jackson.core:jackson-databind' + + // JWT (for security) + implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}" + runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}" + runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jjwtVersion}" + + // Note: PostgreSQL dependency is in root build.gradle but AI Service doesn't use DB + // We still include it for consistency, but no JPA entities will be created +} + +// Kafka Manual Test 실행 태스크 +task runKafkaManualTest(type: JavaExec) { + group = 'verification' + description = 'Run Kafka manual test' + classpath = sourceSets.test.runtimeClasspath + mainClass = 'com.kt.ai.test.manual.KafkaManualTest' } diff --git a/ai-service/src/main/java/com/kt/ai/AiServiceApplication.java b/ai-service/src/main/java/com/kt/ai/AiServiceApplication.java new file mode 100644 index 0000000..be8b721 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/AiServiceApplication.java @@ -0,0 +1,24 @@ +package com.kt.ai; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.cloud.openfeign.EnableFeignClients; + +/** + * AI Service Application + * - Kafka를 통한 비동기 AI 추천 처리 + * - Claude API / GPT-4 API 연동 + * - Redis 기반 결과 캐싱 + * + * @author AI Service Team + * @since 1.0.0 + */ +@EnableFeignClients +@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class}) +public class AiServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(AiServiceApplication.class, args); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/circuitbreaker/CircuitBreakerManager.java b/ai-service/src/main/java/com/kt/ai/circuitbreaker/CircuitBreakerManager.java new file mode 100644 index 0000000..870b4b1 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/circuitbreaker/CircuitBreakerManager.java @@ -0,0 +1,87 @@ +package com.kt.ai.circuitbreaker; + +import com.kt.ai.exception.CircuitBreakerOpenException; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.function.Supplier; + +/** + * Circuit Breaker Manager + * - Claude API / GPT-4 API 호출 시 Circuit Breaker 적용 + * - Fallback 처리 + * + * @author AI Service Team + * @since 1.0.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CircuitBreakerManager { + + private final CircuitBreakerRegistry circuitBreakerRegistry; + + /** + * Circuit Breaker를 통한 API 호출 + * + * @param circuitBreakerName Circuit Breaker 이름 (claudeApi, gpt4Api) + * @param supplier API 호출 로직 + * @param fallback Fallback 로직 + * @return API 호출 결과 또는 Fallback 결과 + */ + public T executeWithCircuitBreaker( + String circuitBreakerName, + Supplier supplier, + Supplier fallback + ) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(circuitBreakerName); + + try { + // Circuit Breaker 상태 확인 + if (circuitBreaker.getState() == CircuitBreaker.State.OPEN) { + log.warn("Circuit Breaker is OPEN: {}", circuitBreakerName); + throw new CircuitBreakerOpenException(circuitBreakerName); + } + + // Circuit Breaker를 통한 API 호출 + return circuitBreaker.executeSupplier(() -> { + log.debug("Executing with Circuit Breaker: {}", circuitBreakerName); + return supplier.get(); + }); + + } catch (CircuitBreakerOpenException e) { + // Circuit Breaker가 열린 경우 Fallback 실행 + log.warn("Circuit Breaker OPEN, executing fallback: {}", circuitBreakerName); + if (fallback != null) { + return fallback.get(); + } + throw e; + + } catch (Exception e) { + // 기타 예외 발생 시 Fallback 실행 + log.error("API call failed, executing fallback: {}", circuitBreakerName, e); + if (fallback != null) { + return fallback.get(); + } + throw e; + } + } + + /** + * Circuit Breaker를 통한 API 호출 (Fallback 없음) + */ + public T executeWithCircuitBreaker(String circuitBreakerName, Supplier supplier) { + return executeWithCircuitBreaker(circuitBreakerName, supplier, null); + } + + /** + * Circuit Breaker 상태 조회 + */ + public CircuitBreaker.State getCircuitBreakerState(String circuitBreakerName) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(circuitBreakerName); + return circuitBreaker.getState(); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/circuitbreaker/fallback/AIServiceFallback.java b/ai-service/src/main/java/com/kt/ai/circuitbreaker/fallback/AIServiceFallback.java new file mode 100644 index 0000000..d7860cf --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/circuitbreaker/fallback/AIServiceFallback.java @@ -0,0 +1,130 @@ +package com.kt.ai.circuitbreaker.fallback; + +import com.kt.ai.model.dto.response.EventRecommendation; +import com.kt.ai.model.dto.response.ExpectedMetrics; +import com.kt.ai.model.dto.response.TrendAnalysis; +import com.kt.ai.model.enums.EventMechanicsType; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * AI Service Fallback 처리 + * - Circuit Breaker가 열린 경우 기본 데이터 반환 + * + * @author AI Service Team + * @since 1.0.0 + */ +@Slf4j +@Component +public class AIServiceFallback { + + /** + * 기본 트렌드 분석 결과 반환 + */ + public TrendAnalysis getDefaultTrendAnalysis(String industry, String region) { + log.info("Fallback: 기본 트렌드 분석 결과 반환 - industry={}, region={}", industry, region); + + List industryTrends = List.of( + TrendAnalysis.TrendKeyword.builder() + .keyword("고객 만족도 향상") + .relevance(0.8) + .description(industry + " 업종에서 고객 만족도가 중요한 트렌드입니다") + .build(), + TrendAnalysis.TrendKeyword.builder() + .keyword("디지털 마케팅") + .relevance(0.75) + .description("SNS 및 온라인 마케팅이 효과적입니다") + .build() + ); + + List regionalTrends = List.of( + TrendAnalysis.TrendKeyword.builder() + .keyword("지역 커뮤니티") + .relevance(0.7) + .description(region + " 지역 커뮤니티 참여가 효과적입니다") + .build() + ); + + List seasonalTrends = List.of( + TrendAnalysis.TrendKeyword.builder() + .keyword("시즌 이벤트") + .relevance(0.85) + .description("계절 특성을 반영한 이벤트가 효과적입니다") + .build() + ); + + return TrendAnalysis.builder() + .industryTrends(industryTrends) + .regionalTrends(regionalTrends) + .seasonalTrends(seasonalTrends) + .build(); + } + + /** + * 기본 이벤트 추천안 반환 + */ + public List getDefaultRecommendations(String objective, String industry) { + log.info("Fallback: 기본 이벤트 추천안 반환 - objective={}, industry={}", objective, industry); + + List recommendations = new ArrayList<>(); + + // 옵션 1: 저비용 이벤트 + recommendations.add(createDefaultRecommendation(1, "저비용 SNS 이벤트", objective, industry, 100000, 200000)); + + // 옵션 2: 중비용 이벤트 + recommendations.add(createDefaultRecommendation(2, "중비용 방문 유도 이벤트", objective, industry, 300000, 500000)); + + // 옵션 3: 고비용 이벤트 + recommendations.add(createDefaultRecommendation(3, "고비용 프리미엄 이벤트", objective, industry, 500000, 1000000)); + + return recommendations; + } + + /** + * 기본 추천안 생성 + */ + private EventRecommendation createDefaultRecommendation( + int optionNumber, + String concept, + String objective, + String industry, + int minCost, + int maxCost + ) { + return EventRecommendation.builder() + .optionNumber(optionNumber) + .concept(concept) + .title(objective + " - " + concept) + .description("AI 서비스가 일시적으로 사용 불가능하여 기본 추천안을 제공합니다. " + + industry + " 업종에 적합한 " + concept + "입니다.") + .targetAudience("일반 고객") + .duration(EventRecommendation.Duration.builder() + .recommendedDays(14) + .recommendedPeriod("2주") + .build()) + .mechanics(EventRecommendation.Mechanics.builder() + .type(EventMechanicsType.DISCOUNT) + .details("할인 쿠폰 제공 또는 경품 추첨") + .build()) + .promotionChannels(List.of("Instagram", "네이버 블로그", "카카오톡 채널")) + .estimatedCost(EventRecommendation.EstimatedCost.builder() + .min(minCost) + .max(maxCost) + .breakdown(Map.of( + "경품비", minCost / 2, + "홍보비", minCost / 2 + )) + .build()) + .expectedMetrics(ExpectedMetrics.builder() + .newCustomers(ExpectedMetrics.Range.builder().min(30.0).max(50.0).build()) + .revenueIncrease(ExpectedMetrics.Range.builder().min(10.0).max(20.0).build()) + .roi(ExpectedMetrics.Range.builder().min(100.0).max(150.0).build()) + .build()) + .differentiator("AI 분석이 제한적으로 제공되는 기본 추천안입니다") + .build(); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/client/ClaudeApiClient.java b/ai-service/src/main/java/com/kt/ai/client/ClaudeApiClient.java new file mode 100644 index 0000000..abc2137 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/client/ClaudeApiClient.java @@ -0,0 +1,39 @@ +package com.kt.ai.client; + +import com.kt.ai.client.config.FeignClientConfig; +import com.kt.ai.client.dto.ClaudeRequest; +import com.kt.ai.client.dto.ClaudeResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +/** + * Claude API Feign Client + * API Docs: https://docs.anthropic.com/claude/reference/messages_post + * + * @author AI Service Team + * @since 1.0.0 + */ +@FeignClient( + name = "claudeApiClient", + url = "${ai.claude.api-url}", + configuration = FeignClientConfig.class +) +public interface ClaudeApiClient { + + /** + * Claude Messages API 호출 + * + * @param apiKey Claude API Key + * @param anthropicVersion API Version (2023-06-01) + * @param request Claude 요청 + * @return Claude 응답 + */ + @PostMapping(consumes = "application/json", produces = "application/json") + ClaudeResponse sendMessage( + @RequestHeader("x-api-key") String apiKey, + @RequestHeader("anthropic-version") String anthropicVersion, + @RequestBody ClaudeRequest request + ); +} diff --git a/ai-service/src/main/java/com/kt/ai/client/config/FeignClientConfig.java b/ai-service/src/main/java/com/kt/ai/client/config/FeignClientConfig.java new file mode 100644 index 0000000..f68466c --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/client/config/FeignClientConfig.java @@ -0,0 +1,57 @@ +package com.kt.ai.client.config; + +import feign.Logger; +import feign.Request; +import feign.Retryer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.TimeUnit; + +/** + * Feign Client 설정 + * - Claude API / GPT-4 API 연동 설정 + * - Timeout, Retry 설정 + * + * @author AI Service Team + * @since 1.0.0 + */ +@Configuration +public class FeignClientConfig { + + /** + * Feign Logger Level 설정 + */ + @Bean + public Logger.Level feignLoggerLevel() { + return Logger.Level.FULL; + } + + /** + * Feign Request Options (Timeout 설정) + * - Connect Timeout: 10초 + * - Read Timeout: 5분 (300초) + */ + @Bean + public Request.Options requestOptions() { + return new Request.Options( + 10, TimeUnit.SECONDS, // connectTimeout + 300, TimeUnit.SECONDS, // readTimeout (5분) + true // followRedirects + ); + } + + /** + * Feign Retryer 설정 + * - 최대 3회 재시도 + * - Exponential Backoff: 1초, 5초, 10초 + */ + @Bean + public Retryer retryer() { + return new Retryer.Default( + 1000L, // period (1초) + 5000L, // maxPeriod (5초) + 3 // maxAttempts (3회) + ); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/client/dto/ClaudeRequest.java b/ai-service/src/main/java/com/kt/ai/client/dto/ClaudeRequest.java new file mode 100644 index 0000000..6dd394b --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/client/dto/ClaudeRequest.java @@ -0,0 +1,67 @@ +package com.kt.ai.client.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Claude API 요청 DTO + * API Docs: https://docs.anthropic.com/claude/reference/messages_post + * + * @author AI Service Team + * @since 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ClaudeRequest { + /** + * 모델명 (예: claude-3-5-sonnet-20241022) + */ + private String model; + + /** + * 메시지 목록 + */ + private List messages; + + /** + * 최대 토큰 수 + */ + @JsonProperty("max_tokens") + private Integer maxTokens; + + /** + * Temperature (0.0 ~ 1.0) + */ + private Double temperature; + + /** + * System 프롬프트 (선택) + */ + private String system; + + /** + * 메시지 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Message { + /** + * 역할 (user, assistant) + */ + private String role; + + /** + * 메시지 내용 + */ + private String content; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/client/dto/ClaudeResponse.java b/ai-service/src/main/java/com/kt/ai/client/dto/ClaudeResponse.java new file mode 100644 index 0000000..d587474 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/client/dto/ClaudeResponse.java @@ -0,0 +1,108 @@ +package com.kt.ai.client.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Claude API 응답 DTO + * API Docs: https://docs.anthropic.com/claude/reference/messages_post + * + * @author AI Service Team + * @since 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ClaudeResponse { + /** + * 응답 ID + */ + private String id; + + /** + * 타입 (message) + */ + private String type; + + /** + * 역할 (assistant) + */ + private String role; + + /** + * 콘텐츠 목록 + */ + private List content; + + /** + * 모델명 + */ + private String model; + + /** + * 중단 이유 (end_turn, max_tokens, stop_sequence) + */ + @JsonProperty("stop_reason") + private String stopReason; + + /** + * 사용량 + */ + private Usage usage; + + /** + * 콘텐츠 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Content { + /** + * 타입 (text) + */ + private String type; + + /** + * 텍스트 내용 + */ + private String text; + } + + /** + * 토큰 사용량 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Usage { + /** + * 입력 토큰 수 + */ + @JsonProperty("input_tokens") + private Integer inputTokens; + + /** + * 출력 토큰 수 + */ + @JsonProperty("output_tokens") + private Integer outputTokens; + } + + /** + * 텍스트 내용 추출 + */ + public String extractText() { + if (content != null && !content.isEmpty()) { + return content.get(0).getText(); + } + return null; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/config/CircuitBreakerConfig.java b/ai-service/src/main/java/com/kt/ai/config/CircuitBreakerConfig.java new file mode 100644 index 0000000..c4e7b8d --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/config/CircuitBreakerConfig.java @@ -0,0 +1,71 @@ +package com.kt.ai.config; + +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig.SlidingWindowType; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.github.resilience4j.timelimiter.TimeLimiterConfig; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +/** + * Circuit Breaker 설정 + * - Claude API / GPT-4 API 장애 대응 + * - Timeout: 5분 (300초) + * - Failure Threshold: 50% + * + * @author AI Service Team + * @since 1.0.0 + */ +@Configuration +public class CircuitBreakerConfig { + + /** + * Circuit Breaker Registry 설정 + */ + @Bean + public CircuitBreakerRegistry circuitBreakerRegistry() { + io.github.resilience4j.circuitbreaker.CircuitBreakerConfig config = + io.github.resilience4j.circuitbreaker.CircuitBreakerConfig.custom() + .failureRateThreshold(50) + .slowCallRateThreshold(50) + .slowCallDurationThreshold(Duration.ofSeconds(60)) + .permittedNumberOfCallsInHalfOpenState(3) + .maxWaitDurationInHalfOpenState(Duration.ZERO) + .slidingWindowType(SlidingWindowType.COUNT_BASED) + .slidingWindowSize(10) + .minimumNumberOfCalls(5) + .waitDurationInOpenState(Duration.ofSeconds(60)) + .automaticTransitionFromOpenToHalfOpenEnabled(true) + .build(); + + return CircuitBreakerRegistry.of(config); + } + + /** + * Claude API Circuit Breaker + */ + @Bean + public CircuitBreaker claudeApiCircuitBreaker(CircuitBreakerRegistry registry) { + return registry.circuitBreaker("claudeApi"); + } + + /** + * GPT-4 API Circuit Breaker + */ + @Bean + public CircuitBreaker gpt4ApiCircuitBreaker(CircuitBreakerRegistry registry) { + return registry.circuitBreaker("gpt4Api"); + } + + /** + * Time Limiter 설정 (5분) + */ + @Bean + public TimeLimiterConfig timeLimiterConfig() { + return TimeLimiterConfig.custom() + .timeoutDuration(Duration.ofSeconds(300)) + .build(); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/config/JacksonConfig.java b/ai-service/src/main/java/com/kt/ai/config/JacksonConfig.java new file mode 100644 index 0000000..16de92f --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/config/JacksonConfig.java @@ -0,0 +1,25 @@ +package com.kt.ai.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Jackson ObjectMapper 설정 + * + * @author AI Service Team + * @since 1.0.0 + */ +@Configuration +public class JacksonConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return mapper; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/config/KafkaConsumerConfig.java b/ai-service/src/main/java/com/kt/ai/config/KafkaConsumerConfig.java new file mode 100644 index 0000000..23df4d9 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/config/KafkaConsumerConfig.java @@ -0,0 +1,76 @@ +package com.kt.ai.config; + +import com.kt.ai.kafka.message.AIJobMessage; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.listener.ContainerProperties; +import org.springframework.kafka.support.serializer.ErrorHandlingDeserializer; +import org.springframework.kafka.support.serializer.JsonDeserializer; + +import java.util.HashMap; +import java.util.Map; + +/** + * Kafka Consumer 설정 + * - Topic: ai-event-generation-job + * - Consumer Group: ai-service-consumers + * - Manual ACK 모드 + * + * @author AI Service Team + * @since 1.0.0 + */ +@EnableKafka +@Configuration +public class KafkaConsumerConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${spring.kafka.consumer.group-id}") + private String groupId; + + /** + * Kafka Consumer 팩토리 설정 + */ + @Bean + public ConsumerFactory consumerFactory() { + Map props = new HashMap<>(); + props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); + props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); + props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 10); + props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, 30000); + + // Key Deserializer + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + + // Value Deserializer with Error Handling + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class); + props.put(ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS, JsonDeserializer.class.getName()); + props.put(JsonDeserializer.VALUE_DEFAULT_TYPE, AIJobMessage.class.getName()); + props.put(JsonDeserializer.TRUSTED_PACKAGES, "*"); + + return new DefaultKafkaConsumerFactory<>(props); + } + + /** + * Kafka Listener Container Factory 설정 + * - Manual ACK 모드 + */ + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + return factory; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/config/RedisConfig.java b/ai-service/src/main/java/com/kt/ai/config/RedisConfig.java new file mode 100644 index 0000000..1790966 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/config/RedisConfig.java @@ -0,0 +1,120 @@ +package com.kt.ai.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.lettuce.core.ClientOptions; +import io.lettuce.core.SocketOptions; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; + +/** + * Redis 설정 + * - 작업 상태 및 추천 결과 캐싱 + * - TTL: 추천 24시간, Job 상태 24시간, 트렌드 1시간 + * + * @author AI Service Team + * @since 1.0.0 + */ +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + @Value("${spring.data.redis.password}") + private String redisPassword; + + @Value("${spring.data.redis.database}") + private int redisDatabase; + + @Value("${spring.data.redis.timeout:3000}") + private long redisTimeout; + + /** + * Redis 연결 팩토리 설정 + */ + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); + config.setHostName(redisHost); + config.setPort(redisPort); + if (redisPassword != null && !redisPassword.isEmpty()) { + config.setPassword(redisPassword); + } + config.setDatabase(redisDatabase); + + // Lettuce Client 설정: Timeout 및 Connection 옵션 + SocketOptions socketOptions = SocketOptions.builder() + .connectTimeout(Duration.ofMillis(redisTimeout)) + .keepAlive(true) + .build(); + + ClientOptions clientOptions = ClientOptions.builder() + .socketOptions(socketOptions) + .autoReconnect(true) + .build(); + + LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder() + .commandTimeout(Duration.ofMillis(redisTimeout)) + .clientOptions(clientOptions) + .build(); + + // afterPropertiesSet() 제거: Spring이 자동으로 호출함 + return new LettuceConnectionFactory(config, clientConfig); + } + + /** + * ObjectMapper for Redis (Java 8 Date/Time 지원) + */ + @Bean + public ObjectMapper redisObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + + // Java 8 Date/Time 모듈 등록 + mapper.registerModule(new JavaTimeModule()); + + // Timestamp 대신 ISO-8601 형식으로 직렬화 + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + return mapper; + } + + /** + * RedisTemplate 설정 + * - Key: String + * - Value: JSON (Jackson with Java 8 Date/Time support) + */ + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // Key Serializer: String + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + + // Value Serializer: JSON with Java 8 Date/Time support + GenericJackson2JsonRedisSerializer serializer = + new GenericJackson2JsonRedisSerializer(redisObjectMapper()); + + template.setValueSerializer(serializer); + template.setHashValueSerializer(serializer); + + template.afterPropertiesSet(); + return template; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/config/SecurityConfig.java b/ai-service/src/main/java/com/kt/ai/config/SecurityConfig.java new file mode 100644 index 0000000..08e9b2e --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/config/SecurityConfig.java @@ -0,0 +1,67 @@ +package com.kt.ai.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; +import java.util.List; + +/** + * Spring Security 설정 + * - Internal API만 제공 (Event Service에서만 호출) + * - JWT 인증 없음 (내부 통신) + * - CORS 설정 + * + * @author AI Service Team + * @since 1.0.0 + */ +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + /** + * Security Filter Chain 설정 + * - 모든 요청 허용 (내부 API) + * - CSRF 비활성화 + * - Stateless 세션 + */ + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/health", "/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll() + .requestMatchers("/internal/**").permitAll() // Internal API + .anyRequest().permitAll() + ); + + return http.build(); + } + + /** + * CORS 설정 + */ + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "http://localhost:8080")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/config/SwaggerConfig.java b/ai-service/src/main/java/com/kt/ai/config/SwaggerConfig.java new file mode 100644 index 0000000..4523c0d --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/config/SwaggerConfig.java @@ -0,0 +1,64 @@ +package com.kt.ai.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +/** + * Swagger/OpenAPI 설정 + * + * @author AI Service Team + * @since 1.0.0 + */ +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + Server localServer = new Server(); + localServer.setUrl("http://localhost:8083"); + localServer.setDescription("Local Development Server"); + + Server devServer = new Server(); + devServer.setUrl("https://dev-api.kt-event-marketing.com/ai/v1"); + devServer.setDescription("Development Server"); + + Server prodServer = new Server(); + prodServer.setUrl("https://api.kt-event-marketing.com/ai/v1"); + prodServer.setDescription("Production Server"); + + Contact contact = new Contact(); + contact.setName("Digital Garage Team"); + contact.setEmail("support@kt-event-marketing.com"); + + Info info = new Info() + .title("AI Service API") + .version("1.0.0") + .description(""" + KT AI 기반 소상공인 이벤트 자동 생성 서비스 - AI Service + + ## 서비스 개요 + - Kafka를 통한 비동기 AI 추천 처리 + - Claude API / GPT-4 API 연동 + - Redis 기반 결과 캐싱 (TTL 24시간) + + ## 처리 흐름 + 1. Event Service가 Kafka Topic에 Job 메시지 발행 + 2. AI Service가 메시지 구독 및 처리 + 3. 트렌드 분석 수행 (Claude/GPT-4 API) + 4. 3가지 이벤트 추천안 생성 + 5. 결과를 Redis에 저장 (TTL 24시간) + 6. Job 상태를 Redis에 업데이트 + """) + .contact(contact); + + return new OpenAPI() + .info(info) + .servers(List.of(localServer, devServer, prodServer)); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/controller/HealthController.java b/ai-service/src/main/java/com/kt/ai/controller/HealthController.java new file mode 100644 index 0000000..b54b890 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/controller/HealthController.java @@ -0,0 +1,91 @@ +package com.kt.ai.controller; + +import com.kt.ai.model.dto.response.HealthCheckResponse; +import com.kt.ai.model.enums.CircuitBreakerState; +import com.kt.ai.model.enums.ServiceStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; + +/** + * 헬스체크 Controller + * + * @author AI Service Team + * @since 1.0.0 + */ +@Slf4j +@Tag(name = "Health Check", description = "서비스 상태 확인") +@RestController +public class HealthController { + + @Autowired(required = false) + private RedisTemplate redisTemplate; + + /** + * 서비스 헬스체크 + */ + @Operation(summary = "서비스 헬스체크", description = "AI Service 상태 및 외부 연동 확인") + @GetMapping("/api/v1/ai-service/health") + public ResponseEntity healthCheck() { + // Redis 상태 확인 + ServiceStatus redisStatus = checkRedis(); + + // 전체 서비스 상태 (Redis가 DOWN이면 DEGRADED, UNKNOWN이면 UP으로 처리) + ServiceStatus overallStatus; + if (redisStatus == ServiceStatus.DOWN) { + overallStatus = ServiceStatus.DEGRADED; + } else { + overallStatus = ServiceStatus.UP; + } + + HealthCheckResponse.Services services = HealthCheckResponse.Services.builder() + .kafka(ServiceStatus.UP) // TODO: 실제 Kafka 상태 확인 + .redis(redisStatus) + .claudeApi(ServiceStatus.UP) // TODO: 실제 Claude API 상태 확인 + .gpt4Api(ServiceStatus.UP) // TODO: 실제 GPT-4 API 상태 확인 (선택) + .circuitBreaker(CircuitBreakerState.CLOSED) // TODO: 실제 Circuit Breaker 상태 확인 + .build(); + + HealthCheckResponse response = HealthCheckResponse.builder() + .status(overallStatus) + .timestamp(LocalDateTime.now()) + .services(services) + .build(); + + return ResponseEntity.ok(response); + } + + /** + * Redis 연결 상태 확인 + */ + private ServiceStatus checkRedis() { + // RedisTemplate이 주입되지 않은 경우 (로컬 환경 등) + if (redisTemplate == null) { + log.warn("RedisTemplate이 주입되지 않았습니다. Redis 상태를 UNKNOWN으로 표시합니다."); + return ServiceStatus.UNKNOWN; + } + + try { + log.debug("Redis 연결 테스트 시작..."); + String pong = redisTemplate.getConnectionFactory().getConnection().ping(); + log.info("✅ Redis 연결 성공! PING 응답: {}", pong); + return ServiceStatus.UP; + } catch (Exception e) { + log.error("❌ Redis 연결 실패", e); + log.error("상세 오류 정보:"); + log.error(" - 오류 타입: {}", e.getClass().getName()); + log.error(" - 오류 메시지: {}", e.getMessage()); + if (e.getCause() != null) { + log.error(" - 원인: {}", e.getCause().getMessage()); + } + return ServiceStatus.DOWN; + } + } +} diff --git a/ai-service/src/main/java/com/kt/ai/controller/InternalJobController.java b/ai-service/src/main/java/com/kt/ai/controller/InternalJobController.java new file mode 100644 index 0000000..aba5cc0 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/controller/InternalJobController.java @@ -0,0 +1,92 @@ +package com.kt.ai.controller; + +import com.kt.ai.model.dto.response.JobStatusResponse; +import com.kt.ai.model.enums.JobStatus; +import com.kt.ai.service.CacheService; +import com.kt.ai.service.JobStatusService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.Map; + +/** + * Internal Job Controller + * Event Service에서 호출하는 내부 API + * + * @author AI Service Team + * @since 1.0.0 + */ +@Slf4j +@Tag(name = "Internal API", description = "내부 서비스 간 통신용 API") +@RestController +@RequestMapping("/api/v1/ai-service/internal/jobs") +@RequiredArgsConstructor +public class InternalJobController { + + private final JobStatusService jobStatusService; + private final CacheService cacheService; + + /** + * 작업 상태 조회 + */ + @Operation(summary = "작업 상태 조회", description = "Redis에 저장된 AI 추천 작업 상태 조회") + @GetMapping("/{jobId}/status") + public ResponseEntity getJobStatus(@PathVariable String jobId) { + log.info("Job 상태 조회 요청: jobId={}", jobId); + JobStatusResponse response = jobStatusService.getJobStatus(jobId); + return ResponseEntity.ok(response); + } + + /** + * Redis 디버그: Job 상태 테스트 데이터 생성 + */ + @Operation(summary = "Job 테스트 데이터 생성 (디버그)", description = "Redis에 샘플 Job 상태 데이터 저장") + @GetMapping("/debug/create-test-job/{jobId}") + public ResponseEntity> createTestJob(@PathVariable String jobId) { + log.info("Job 테스트 데이터 생성 요청: jobId={}", jobId); + + Map result = new HashMap<>(); + + try { + // 다양한 상태의 테스트 데이터 생성 + JobStatus[] statuses = JobStatus.values(); + + // 요청된 jobId로 PROCESSING 상태 데이터 생성 + jobStatusService.updateJobStatus(jobId, JobStatus.PROCESSING, "AI 추천 생성 중 (50%)"); + + // 추가 샘플 데이터 생성 (다양한 상태) + jobStatusService.updateJobStatus(jobId + "-pending", JobStatus.PENDING, "대기 중"); + jobStatusService.updateJobStatus(jobId + "-completed", JobStatus.COMPLETED, "AI 추천 완료"); + jobStatusService.updateJobStatus(jobId + "-failed", JobStatus.FAILED, "AI API 호출 실패"); + + // 저장 확인 + Object saved = cacheService.getJobStatus(jobId); + + result.put("success", true); + result.put("jobId", jobId); + result.put("saved", saved != null); + result.put("data", saved); + result.put("additionalSamples", Map.of( + "pending", jobId + "-pending", + "completed", jobId + "-completed", + "failed", jobId + "-failed" + )); + + log.info("Job 테스트 데이터 생성 완료: jobId={}, saved={}", jobId, saved != null); + } catch (Exception e) { + log.error("Job 테스트 데이터 생성 실패: jobId={}", jobId, e); + result.put("success", false); + result.put("error", e.getMessage()); + } + + return ResponseEntity.ok(result); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/controller/InternalRecommendationController.java b/ai-service/src/main/java/com/kt/ai/controller/InternalRecommendationController.java new file mode 100644 index 0000000..883d1d8 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/controller/InternalRecommendationController.java @@ -0,0 +1,264 @@ +package com.kt.ai.controller; + +import com.kt.ai.model.dto.response.AIRecommendationResult; +import com.kt.ai.model.dto.response.EventRecommendation; +import com.kt.ai.model.dto.response.TrendAnalysis; +import com.kt.ai.service.AIRecommendationService; +import com.kt.ai.service.CacheService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Internal Recommendation Controller + * Event Service에서 호출하는 내부 API + * + * @author AI Service Team + * @since 1.0.0 + */ +@Slf4j +@Tag(name = "Internal API", description = "내부 서비스 간 통신용 API") +@RestController +@RequestMapping("/api/v1/ai-service/internal/recommendations") +@RequiredArgsConstructor +public class InternalRecommendationController { + + private final AIRecommendationService aiRecommendationService; + private final CacheService cacheService; + private final RedisTemplate redisTemplate; + + /** + * AI 추천 결과 조회 + */ + @Operation(summary = "AI 추천 결과 조회", description = "Redis에 캐시된 AI 추천 결과 조회") + @GetMapping("/{eventId}") + public ResponseEntity getRecommendation(@PathVariable String eventId) { + log.info("AI 추천 결과 조회 요청: eventId={}", eventId); + AIRecommendationResult response = aiRecommendationService.getRecommendation(eventId); + return ResponseEntity.ok(response); + } + + /** + * Redis 디버그: 모든 키 조회 + */ + @Operation(summary = "Redis 키 조회 (디버그)", description = "Redis에 저장된 모든 키 조회") + @GetMapping("/debug/redis-keys") + public ResponseEntity> debugRedisKeys() { + log.info("Redis 키 디버그 요청"); + + Map result = new HashMap<>(); + + try { + // 모든 ai:* 키 조회 + Set keys = redisTemplate.keys("ai:*"); + result.put("totalKeys", keys != null ? keys.size() : 0); + result.put("keys", keys); + + // 특정 키의 값 조회 + if (keys != null && !keys.isEmpty()) { + Map values = new HashMap<>(); + for (String key : keys) { + Object value = redisTemplate.opsForValue().get(key); + values.put(key, value); + } + result.put("values", values); + } + + log.info("Redis 키 조회 성공: {} 개의 키 발견", keys != null ? keys.size() : 0); + } catch (Exception e) { + log.error("Redis 키 조회 실패", e); + result.put("error", e.getMessage()); + } + + return ResponseEntity.ok(result); + } + + /** + * Redis 디버그: 특정 키 조회 + */ + @Operation(summary = "Redis 특정 키 조회 (디버그)", description = "Redis에서 특정 키의 값 조회") + @GetMapping("/debug/redis-key/{key}") + public ResponseEntity> debugRedisKey(@PathVariable String key) { + log.info("Redis 특정 키 조회 요청: key={}", key); + + Map result = new HashMap<>(); + result.put("key", key); + + try { + Object value = redisTemplate.opsForValue().get(key); + result.put("exists", value != null); + result.put("value", value); + + log.info("Redis 키 조회: key={}, exists={}", key, value != null); + } catch (Exception e) { + log.error("Redis 키 조회 실패: key={}", key, e); + result.put("error", e.getMessage()); + } + + return ResponseEntity.ok(result); + } + + /** + * Redis 디버그: 모든 database 검색 + */ + @Operation(summary = "모든 Redis DB 검색 (디버그)", description = "Redis database 0~15에서 ai:* 키 검색") + @GetMapping("/debug/search-all-databases") + public ResponseEntity> searchAllDatabases() { + log.info("모든 Redis database 검색 시작"); + + Map result = new HashMap<>(); + Map> databaseKeys = new HashMap<>(); + + try { + // Redis connection factory를 통해 database 변경하며 검색 + var connectionFactory = redisTemplate.getConnectionFactory(); + + for (int db = 0; db < 16; db++) { + try { + var connection = connectionFactory.getConnection(); + connection.select(db); + + Set keyBytes = connection.keys("ai:*".getBytes()); + if (keyBytes != null && !keyBytes.isEmpty()) { + Set keys = new java.util.HashSet<>(); + for (byte[] keyByte : keyBytes) { + keys.add(new String(keyByte)); + } + databaseKeys.put(db, keys); + log.info("Database {} 에서 {} 개의 ai:* 키 발견", db, keys.size()); + } + + connection.close(); + } catch (Exception e) { + log.warn("Database {} 검색 실패: {}", db, e.getMessage()); + } + } + + result.put("databasesWithKeys", databaseKeys); + result.put("totalDatabases", databaseKeys.size()); + + log.info("모든 database 검색 완료: {} 개의 database에 키 존재", databaseKeys.size()); + } catch (Exception e) { + log.error("모든 database 검색 실패", e); + result.put("error", e.getMessage()); + } + + return ResponseEntity.ok(result); + } + + /** + * Redis 디버그: 테스트 데이터 생성 + */ + @Operation(summary = "테스트 데이터 생성 (디버그)", description = "Redis에 샘플 AI 추천 데이터 저장") + @GetMapping("/debug/create-test-data/{eventId}") + public ResponseEntity> createTestData(@PathVariable String eventId) { + log.info("테스트 데이터 생성 요청: eventId={}", eventId); + + Map result = new HashMap<>(); + + try { + // 샘플 AI 추천 결과 생성 + AIRecommendationResult testData = AIRecommendationResult.builder() + .eventId(eventId) + .trendAnalysis(TrendAnalysis.builder() + .industryTrends(List.of( + TrendAnalysis.TrendKeyword.builder() + .keyword("BBQ 고기집") + .relevance(0.95) + .description("음식점 업종, 고기 구이 인기 트렌드") + .build() + )) + .regionalTrends(List.of( + TrendAnalysis.TrendKeyword.builder() + .keyword("강남 맛집") + .relevance(0.90) + .description("강남구 지역 외식 인기 증가") + .build() + )) + .seasonalTrends(List.of( + TrendAnalysis.TrendKeyword.builder() + .keyword("봄나들이 외식") + .relevance(0.85) + .description("봄철 야외 활동 및 외식 증가") + .build() + )) + .build()) + .recommendations(List.of( + EventRecommendation.builder() + .optionNumber(1) + .concept("SNS 이벤트") + .title("인스타그램 후기 이벤트") + .description("음식 사진을 인스타그램에 올리고 해시태그를 달면 할인 쿠폰 제공") + .targetAudience("20-30대 SNS 활동층") + .duration(EventRecommendation.Duration.builder() + .recommendedDays(14) + .recommendedPeriod("2주") + .build()) + .mechanics(EventRecommendation.Mechanics.builder() + .type(com.kt.ai.model.enums.EventMechanicsType.DISCOUNT) + .details("인스타그램 게시물 작성 시 10% 할인") + .build()) + .promotionChannels(List.of("Instagram", "Facebook", "매장 포스터")) + .estimatedCost(EventRecommendation.EstimatedCost.builder() + .min(100000) + .max(200000) + .breakdown(Map.of( + "할인비용", 150000, + "홍보비", 50000 + )) + .build()) + .expectedMetrics(com.kt.ai.model.dto.response.ExpectedMetrics.builder() + .newCustomers(com.kt.ai.model.dto.response.ExpectedMetrics.Range.builder() + .min(30.0) + .max(50.0) + .build()) + .revenueIncrease(com.kt.ai.model.dto.response.ExpectedMetrics.Range.builder() + .min(10.0) + .max(20.0) + .build()) + .roi(com.kt.ai.model.dto.response.ExpectedMetrics.Range.builder() + .min(100.0) + .max(150.0) + .build()) + .build()) + .differentiator("SNS를 활용한 바이럴 마케팅") + .build() + )) + .generatedAt(java.time.LocalDateTime.now()) + .expiresAt(java.time.LocalDateTime.now().plusDays(1)) + .aiProvider(com.kt.ai.model.enums.AIProvider.CLAUDE) + .build(); + + // Redis에 저장 + cacheService.saveRecommendation(eventId, testData); + + // 저장 확인 + Object saved = cacheService.getRecommendation(eventId); + + result.put("success", true); + result.put("eventId", eventId); + result.put("saved", saved != null); + result.put("data", saved); + + log.info("테스트 데이터 생성 완료: eventId={}, saved={}", eventId, saved != null); + } catch (Exception e) { + log.error("테스트 데이터 생성 실패: eventId={}", eventId, e); + result.put("success", false); + result.put("error", e.getMessage()); + } + + return ResponseEntity.ok(result); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/exception/AIServiceException.java b/ai-service/src/main/java/com/kt/ai/exception/AIServiceException.java new file mode 100644 index 0000000..3167bf2 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/exception/AIServiceException.java @@ -0,0 +1,25 @@ +package com.kt.ai.exception; + +/** + * AI Service 공통 예외 + * + * @author AI Service Team + * @since 1.0.0 + */ +public class AIServiceException extends RuntimeException { + private final String errorCode; + + public AIServiceException(String errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + public AIServiceException(String errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } + + public String getErrorCode() { + return errorCode; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/exception/CircuitBreakerOpenException.java b/ai-service/src/main/java/com/kt/ai/exception/CircuitBreakerOpenException.java new file mode 100644 index 0000000..82b2118 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/exception/CircuitBreakerOpenException.java @@ -0,0 +1,13 @@ +package com.kt.ai.exception; + +/** + * Circuit Breaker가 열린 상태 예외 + * + * @author AI Service Team + * @since 1.0.0 + */ +public class CircuitBreakerOpenException extends AIServiceException { + public CircuitBreakerOpenException(String apiName) { + super("CIRCUIT_BREAKER_OPEN", "Circuit Breaker가 열린 상태입니다: " + apiName); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/exception/GlobalExceptionHandler.java b/ai-service/src/main/java/com/kt/ai/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..e00c26c --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/exception/GlobalExceptionHandler.java @@ -0,0 +1,131 @@ +package com.kt.ai.exception; + +import com.kt.ai.model.dto.response.ErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +/** + * 전역 예외 처리 핸들러 + * + * @author AI Service Team + * @since 1.0.0 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * Job을 찾을 수 없는 예외 처리 + */ + @ExceptionHandler(JobNotFoundException.class) + public ResponseEntity handleJobNotFoundException(JobNotFoundException ex) { + log.error("Job not found: {}", ex.getMessage()); + + ErrorResponse error = ErrorResponse.builder() + .code(ex.getErrorCode()) + .message(ex.getMessage()) + .timestamp(LocalDateTime.now()) + .build(); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); + } + + /** + * 추천 결과를 찾을 수 없는 예외 처리 + */ + @ExceptionHandler(RecommendationNotFoundException.class) + public ResponseEntity handleRecommendationNotFoundException(RecommendationNotFoundException ex) { + log.error("Recommendation not found: {}", ex.getMessage()); + + ErrorResponse error = ErrorResponse.builder() + .code(ex.getErrorCode()) + .message(ex.getMessage()) + .timestamp(LocalDateTime.now()) + .build(); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); + } + + /** + * Circuit Breaker가 열린 상태 예외 처리 + */ + @ExceptionHandler(CircuitBreakerOpenException.class) + public ResponseEntity handleCircuitBreakerOpenException(CircuitBreakerOpenException ex) { + log.error("Circuit breaker open: {}", ex.getMessage()); + + Map details = new HashMap<>(); + details.put("message", "외부 AI API가 일시적으로 사용 불가능합니다. 잠시 후 다시 시도해주세요."); + + ErrorResponse error = ErrorResponse.builder() + .code(ex.getErrorCode()) + .message(ex.getMessage()) + .timestamp(LocalDateTime.now()) + .details(details) + .build(); + + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(error); + } + + /** + * AI Service 공통 예외 처리 + */ + @ExceptionHandler(AIServiceException.class) + public ResponseEntity handleAIServiceException(AIServiceException ex) { + log.error("AI Service error: {}", ex.getMessage(), ex); + + ErrorResponse error = ErrorResponse.builder() + .code(ex.getErrorCode()) + .message(ex.getMessage()) + .timestamp(LocalDateTime.now()) + .build(); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + } + + /** + * 정적 리소스를 찾을 수 없는 예외 처리 (favicon.ico 등) + * WARN 레벨로 로깅하여 에러 로그 오염 방지 + */ + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResourceFoundException(NoResourceFoundException ex) { + // favicon.ico 등 브라우저가 자동으로 요청하는 리소스는 DEBUG 레벨로 로깅 + String resourcePath = ex.getResourcePath(); + if (resourcePath != null && (resourcePath.contains("favicon") || resourcePath.endsWith(".ico"))) { + log.debug("Static resource not found (expected): {}", resourcePath); + } else { + log.warn("Static resource not found: {}", resourcePath); + } + + ErrorResponse error = ErrorResponse.builder() + .code("RESOURCE_NOT_FOUND") + .message("요청하신 리소스를 찾을 수 없습니다") + .timestamp(LocalDateTime.now()) + .build(); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); + } + + /** + * 일반 예외 처리 + */ + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception ex) { + log.error("Unexpected error: {}", ex.getMessage(), ex); + + ErrorResponse error = ErrorResponse.builder() + .code("INTERNAL_ERROR") + .message("서버 내부 오류가 발생했습니다") + .timestamp(LocalDateTime.now()) + .build(); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/exception/JobNotFoundException.java b/ai-service/src/main/java/com/kt/ai/exception/JobNotFoundException.java new file mode 100644 index 0000000..b574dca --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/exception/JobNotFoundException.java @@ -0,0 +1,13 @@ +package com.kt.ai.exception; + +/** + * Job을 찾을 수 없는 예외 + * + * @author AI Service Team + * @since 1.0.0 + */ +public class JobNotFoundException extends AIServiceException { + public JobNotFoundException(String jobId) { + super("JOB_NOT_FOUND", "작업을 찾을 수 없습니다: " + jobId); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/exception/RecommendationNotFoundException.java b/ai-service/src/main/java/com/kt/ai/exception/RecommendationNotFoundException.java new file mode 100644 index 0000000..feba7e5 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/exception/RecommendationNotFoundException.java @@ -0,0 +1,13 @@ +package com.kt.ai.exception; + +/** + * 추천 결과를 찾을 수 없는 예외 + * + * @author AI Service Team + * @since 1.0.0 + */ +public class RecommendationNotFoundException extends AIServiceException { + public RecommendationNotFoundException(String eventId) { + super("RECOMMENDATION_NOT_FOUND", "추천 결과를 찾을 수 없습니다: " + eventId); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/kafka/consumer/AIJobConsumer.java b/ai-service/src/main/java/com/kt/ai/kafka/consumer/AIJobConsumer.java new file mode 100644 index 0000000..2b82f8a --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/kafka/consumer/AIJobConsumer.java @@ -0,0 +1,60 @@ +package com.kt.ai.kafka.consumer; + +import com.kt.ai.kafka.message.AIJobMessage; +import com.kt.ai.service.AIRecommendationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.kafka.support.KafkaHeaders; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Component; + +/** + * AI Job Kafka Consumer + * - Topic: ai-event-generation-job + * - Consumer Group: ai-service-consumers + * + * @author AI Service Team + * @since 1.0.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AIJobConsumer { + + private final AIRecommendationService aiRecommendationService; + + /** + * Kafka 메시지 수신 및 처리 + */ + @KafkaListener( + topics = "${kafka.topics.ai-job}", + groupId = "${spring.kafka.consumer.group-id}", + containerFactory = "kafkaListenerContainerFactory" + ) + public void consume( + @Payload AIJobMessage message, + @Header(KafkaHeaders.RECEIVED_TOPIC) String topic, + @Header(KafkaHeaders.OFFSET) Long offset, + Acknowledgment acknowledgment + ) { + try { + log.info("Kafka 메시지 수신: topic={}, offset={}, jobId={}, eventId={}", + topic, offset, message.getJobId(), message.getEventId()); + + // AI 추천 생성 + aiRecommendationService.generateRecommendations(message); + + // Manual ACK + acknowledgment.acknowledge(); + log.info("Kafka 메시지 처리 완료: jobId={}", message.getJobId()); + + } catch (Exception e) { + log.error("Kafka 메시지 처리 실패: jobId={}", message.getJobId(), e); + // DLQ로 이동하거나 재시도 로직 추가 가능 + acknowledgment.acknowledge(); // 실패한 메시지도 ACK (DLQ로 이동) + } + } +} diff --git a/ai-service/src/main/java/com/kt/ai/kafka/message/AIJobMessage.java b/ai-service/src/main/java/com/kt/ai/kafka/message/AIJobMessage.java new file mode 100644 index 0000000..e0165d6 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/kafka/message/AIJobMessage.java @@ -0,0 +1,71 @@ +package com.kt.ai.kafka.message; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * AI 이벤트 생성 요청 메시지 (Kafka) + * Topic: ai-event-generation-job + * Consumer Group: ai-service-consumers + * + * @author AI Service Team + * @since 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AIJobMessage { + /** + * Job 고유 ID + */ + private String jobId; + + /** + * 이벤트 ID (Event Service에서 생성) + */ + private String eventId; + + /** + * 이벤트 목적 + * - "신규 고객 유치" + * - "재방문 유도" + * - "매출 증대" + * - "브랜드 인지도 향상" + */ + private String objective; + + /** + * 업종 + */ + private String industry; + + /** + * 지역 (시/구/동) + */ + private String region; + + /** + * 매장명 (선택) + */ + private String storeName; + + /** + * 목표 고객층 (선택) + */ + private String targetAudience; + + /** + * 예산 (원) (선택) + */ + private Integer budget; + + /** + * 요청 시각 + */ + private LocalDateTime requestedAt; +} diff --git a/ai-service/src/main/java/com/kt/ai/model/dto/response/AIRecommendationResult.java b/ai-service/src/main/java/com/kt/ai/model/dto/response/AIRecommendationResult.java new file mode 100644 index 0000000..294dafa --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/dto/response/AIRecommendationResult.java @@ -0,0 +1,54 @@ +package com.kt.ai.model.dto.response; + +import com.kt.ai.model.enums.AIProvider; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * AI 이벤트 추천 결과 DTO + * Redis Key: ai:recommendation:{eventId} + * TTL: 86400초 (24시간) + * + * @author AI Service Team + * @since 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AIRecommendationResult { + /** + * 이벤트 ID + */ + private String eventId; + + /** + * 트렌드 분석 결과 + */ + private TrendAnalysis trendAnalysis; + + /** + * 추천 이벤트 기획안 (3개) + */ + private List recommendations; + + /** + * 생성 시각 + */ + private LocalDateTime generatedAt; + + /** + * 캐시 만료 시각 (생성 시각 + 24시간) + */ + private LocalDateTime expiresAt; + + /** + * 사용된 AI 제공자 + */ + private AIProvider aiProvider; +} diff --git a/ai-service/src/main/java/com/kt/ai/model/dto/response/ErrorResponse.java b/ai-service/src/main/java/com/kt/ai/model/dto/response/ErrorResponse.java new file mode 100644 index 0000000..612093b --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/dto/response/ErrorResponse.java @@ -0,0 +1,41 @@ +package com.kt.ai.model.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 에러 응답 DTO + * + * @author AI Service Team + * @since 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ErrorResponse { + /** + * 에러 코드 + */ + private String code; + + /** + * 에러 메시지 + */ + private String message; + + /** + * 에러 발생 시각 + */ + private LocalDateTime timestamp; + + /** + * 추가 에러 상세 + */ + private Map details; +} diff --git a/ai-service/src/main/java/com/kt/ai/model/dto/response/EventRecommendation.java b/ai-service/src/main/java/com/kt/ai/model/dto/response/EventRecommendation.java new file mode 100644 index 0000000..284793f --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/dto/response/EventRecommendation.java @@ -0,0 +1,139 @@ +package com.kt.ai.model.dto.response; + +import com.kt.ai.model.enums.EventMechanicsType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * 이벤트 추천안 DTO + * + * @author AI Service Team + * @since 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EventRecommendation { + /** + * 옵션 번호 (1-3) + */ + private Integer optionNumber; + + /** + * 이벤트 컨셉 + */ + private String concept; + + /** + * 이벤트 제목 + */ + private String title; + + /** + * 이벤트 설명 + */ + private String description; + + /** + * 목표 고객층 + */ + private String targetAudience; + + /** + * 이벤트 기간 + */ + private Duration duration; + + /** + * 이벤트 메커니즘 + */ + private Mechanics mechanics; + + /** + * 추천 홍보 채널 (최대 5개) + */ + private List promotionChannels; + + /** + * 예상 비용 + */ + private EstimatedCost estimatedCost; + + /** + * 예상 성과 지표 + */ + private ExpectedMetrics expectedMetrics; + + /** + * 다른 옵션과의 차별점 + */ + private String differentiator; + + /** + * 이벤트 기간 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Duration { + /** + * 권장 진행 일수 + */ + private Integer recommendedDays; + + /** + * 권장 진행 시기 + */ + private String recommendedPeriod; + } + + /** + * 이벤트 메커니즘 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Mechanics { + /** + * 이벤트 유형 + */ + private EventMechanicsType type; + + /** + * 상세 메커니즘 + */ + private String details; + } + + /** + * 예상 비용 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class EstimatedCost { + /** + * 최소 비용 (원) + */ + private Integer min; + + /** + * 최대 비용 (원) + */ + private Integer max; + + /** + * 비용 구성 + */ + private Map breakdown; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/model/dto/response/ExpectedMetrics.java b/ai-service/src/main/java/com/kt/ai/model/dto/response/ExpectedMetrics.java new file mode 100644 index 0000000..e7a2a6b --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/dto/response/ExpectedMetrics.java @@ -0,0 +1,74 @@ +package com.kt.ai.model.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 예상 성과 지표 DTO + * + * @author AI Service Team + * @since 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ExpectedMetrics { + /** + * 신규 고객 수 + */ + private Range newCustomers; + + /** + * 재방문 고객 수 (선택) + */ + private Range repeatVisits; + + /** + * 매출 증가율 (%) + */ + private Range revenueIncrease; + + /** + * ROI - 투자 대비 수익률 (%) + */ + private Range roi; + + /** + * SNS 참여도 (선택) + */ + private SocialEngagement socialEngagement; + + /** + * 범위 값 (최소-최대) + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Range { + private Double min; + private Double max; + } + + /** + * SNS 참여도 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class SocialEngagement { + /** + * 예상 게시물 수 + */ + private Integer estimatedPosts; + + /** + * 예상 도달 수 + */ + private Integer estimatedReach; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/model/dto/response/HealthCheckResponse.java b/ai-service/src/main/java/com/kt/ai/model/dto/response/HealthCheckResponse.java new file mode 100644 index 0000000..a8cc11f --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/dto/response/HealthCheckResponse.java @@ -0,0 +1,72 @@ +package com.kt.ai.model.dto.response; + +import com.kt.ai.model.enums.CircuitBreakerState; +import com.kt.ai.model.enums.ServiceStatus; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 서비스 헬스체크 응답 DTO + * + * @author AI Service Team + * @since 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class HealthCheckResponse { + /** + * 전체 서비스 상태 + */ + private ServiceStatus status; + + /** + * 체크 시각 + */ + private LocalDateTime timestamp; + + /** + * 개별 서비스 상태 + */ + private Services services; + + /** + * 개별 서비스 상태 정보 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Services { + /** + * Kafka 연결 상태 + */ + private ServiceStatus kafka; + + /** + * Redis 연결 상태 + */ + private ServiceStatus redis; + + /** + * Claude API 상태 + */ + private ServiceStatus claudeApi; + + /** + * GPT-4 API 상태 (선택) + */ + private ServiceStatus gpt4Api; + + /** + * Circuit Breaker 상태 + */ + private CircuitBreakerState circuitBreaker; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/model/dto/response/JobStatusResponse.java b/ai-service/src/main/java/com/kt/ai/model/dto/response/JobStatusResponse.java new file mode 100644 index 0000000..0bbe149 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/dto/response/JobStatusResponse.java @@ -0,0 +1,83 @@ +package com.kt.ai.model.dto.response; + +import com.kt.ai.model.enums.JobStatus; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 작업 상태 응답 DTO + * Redis Key: ai:job:status:{jobId} + * TTL: 86400초 (24시간) + * + * @author AI Service Team + * @since 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class JobStatusResponse { + /** + * Job ID + */ + private String jobId; + + /** + * 작업 상태 + */ + private JobStatus status; + + /** + * 진행률 (0-100) + */ + private Integer progress; + + /** + * 상태 메시지 + */ + private String message; + + /** + * 이벤트 ID + */ + private String eventId; + + /** + * 작업 생성 시각 + */ + private LocalDateTime createdAt; + + /** + * 작업 시작 시각 + */ + private LocalDateTime startedAt; + + /** + * 작업 완료 시각 (완료 시) + */ + private LocalDateTime completedAt; + + /** + * 작업 실패 시각 (실패 시) + */ + private LocalDateTime failedAt; + + /** + * 에러 메시지 (실패 시) + */ + private String errorMessage; + + /** + * 재시도 횟수 + */ + private Integer retryCount; + + /** + * 처리 시간 (밀리초) + */ + private Long processingTimeMs; +} diff --git a/ai-service/src/main/java/com/kt/ai/model/dto/response/TrendAnalysis.java b/ai-service/src/main/java/com/kt/ai/model/dto/response/TrendAnalysis.java new file mode 100644 index 0000000..aa5c99d --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/dto/response/TrendAnalysis.java @@ -0,0 +1,59 @@ +package com.kt.ai.model.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 트렌드 분석 결과 DTO + * + * @author AI Service Team + * @since 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TrendAnalysis { + /** + * 업종 트렌드 키워드 (최대 5개) + */ + private List industryTrends; + + /** + * 지역 트렌드 키워드 (최대 5개) + */ + private List regionalTrends; + + /** + * 시즌 트렌드 키워드 (최대 5개) + */ + private List seasonalTrends; + + /** + * 트렌드 키워드 정보 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TrendKeyword { + /** + * 트렌드 키워드 + */ + private String keyword; + + /** + * 연관도 (0-1) + */ + private Double relevance; + + /** + * 트렌드 설명 + */ + private String description; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/model/enums/AIProvider.java b/ai-service/src/main/java/com/kt/ai/model/enums/AIProvider.java new file mode 100644 index 0000000..1bc7084 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/enums/AIProvider.java @@ -0,0 +1,19 @@ +package com.kt.ai.model.enums; + +/** + * AI 제공자 타입 + * + * @author AI Service Team + * @since 1.0.0 + */ +public enum AIProvider { + /** + * Claude API (Anthropic) + */ + CLAUDE, + + /** + * GPT-4 API (OpenAI) + */ + GPT4 +} diff --git a/ai-service/src/main/java/com/kt/ai/model/enums/CircuitBreakerState.java b/ai-service/src/main/java/com/kt/ai/model/enums/CircuitBreakerState.java new file mode 100644 index 0000000..a2120fc --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/enums/CircuitBreakerState.java @@ -0,0 +1,24 @@ +package com.kt.ai.model.enums; + +/** + * Circuit Breaker 상태 + * + * @author AI Service Team + * @since 1.0.0 + */ +public enum CircuitBreakerState { + /** + * 닫힘 - 정상 동작 + */ + CLOSED, + + /** + * 열림 - 장애 발생, 요청 차단 + */ + OPEN, + + /** + * 반열림 - 복구 시도 중 + */ + HALF_OPEN +} diff --git a/ai-service/src/main/java/com/kt/ai/model/enums/EventMechanicsType.java b/ai-service/src/main/java/com/kt/ai/model/enums/EventMechanicsType.java new file mode 100644 index 0000000..e027024 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/enums/EventMechanicsType.java @@ -0,0 +1,39 @@ +package com.kt.ai.model.enums; + +/** + * 이벤트 메커니즘 타입 + * + * @author AI Service Team + * @since 1.0.0 + */ +public enum EventMechanicsType { + /** + * 할인형 이벤트 + */ + DISCOUNT, + + /** + * 경품 증정형 이벤트 + */ + GIFT, + + /** + * 스탬프 적립형 이벤트 + */ + STAMP, + + /** + * 체험형 이벤트 + */ + EXPERIENCE, + + /** + * 추첨형 이벤트 + */ + LOTTERY, + + /** + * 묶음 구매형 이벤트 + */ + COMBO +} diff --git a/ai-service/src/main/java/com/kt/ai/model/enums/JobStatus.java b/ai-service/src/main/java/com/kt/ai/model/enums/JobStatus.java new file mode 100644 index 0000000..0381d80 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/enums/JobStatus.java @@ -0,0 +1,29 @@ +package com.kt.ai.model.enums; + +/** + * AI 추천 작업 상태 + * + * @author AI Service Team + * @since 1.0.0 + */ +public enum JobStatus { + /** + * 대기 중 - Kafka 메시지 수신 후 처리 대기 + */ + PENDING, + + /** + * 처리 중 - AI API 호출 및 분석 진행 중 + */ + PROCESSING, + + /** + * 완료 - AI 추천 결과 생성 완료 + */ + COMPLETED, + + /** + * 실패 - AI API 호출 실패 또는 타임아웃 + */ + FAILED +} diff --git a/ai-service/src/main/java/com/kt/ai/model/enums/ServiceStatus.java b/ai-service/src/main/java/com/kt/ai/model/enums/ServiceStatus.java new file mode 100644 index 0000000..f5bbba6 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/enums/ServiceStatus.java @@ -0,0 +1,29 @@ +package com.kt.ai.model.enums; + +/** + * 서비스 상태 + * + * @author AI Service Team + * @since 1.0.0 + */ +public enum ServiceStatus { + /** + * 정상 동작 + */ + UP, + + /** + * 서비스 중단 + */ + DOWN, + + /** + * 성능 저하 + */ + DEGRADED, + + /** + * 상태 알 수 없음 (설정되지 않음) + */ + UNKNOWN +} diff --git a/ai-service/src/main/java/com/kt/ai/service/AIRecommendationService.java b/ai-service/src/main/java/com/kt/ai/service/AIRecommendationService.java new file mode 100644 index 0000000..1f56bf7 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/service/AIRecommendationService.java @@ -0,0 +1,418 @@ +package com.kt.ai.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kt.ai.circuitbreaker.CircuitBreakerManager; +import com.kt.ai.circuitbreaker.fallback.AIServiceFallback; +import com.kt.ai.client.ClaudeApiClient; +import com.kt.ai.client.dto.ClaudeRequest; +import com.kt.ai.client.dto.ClaudeResponse; +import com.kt.ai.exception.RecommendationNotFoundException; +import com.kt.ai.kafka.message.AIJobMessage; +import com.kt.ai.model.dto.response.AIRecommendationResult; +import com.kt.ai.model.dto.response.EventRecommendation; +import com.kt.ai.model.dto.response.ExpectedMetrics; +import com.kt.ai.model.dto.response.TrendAnalysis; +import com.kt.ai.model.enums.AIProvider; +import com.kt.ai.model.enums.EventMechanicsType; +import com.kt.ai.model.enums.JobStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * AI 추천 서비스 + * - 트렌드 분석 및 이벤트 추천 총괄 + * - Claude API 연동 + * + * @author AI Service Team + * @since 1.0.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AIRecommendationService { + + private final CacheService cacheService; + private final JobStatusService jobStatusService; + private final TrendAnalysisService trendAnalysisService; + private final ClaudeApiClient claudeApiClient; + private final CircuitBreakerManager circuitBreakerManager; + private final AIServiceFallback fallback; + private final ObjectMapper objectMapper; + + @Value("${ai.provider:CLAUDE}") + private String aiProvider; + + @Value("${ai.claude.api-key}") + private String apiKey; + + @Value("${ai.claude.anthropic-version}") + private String anthropicVersion; + + @Value("${ai.claude.model}") + private String model; + + @Value("${ai.claude.max-tokens}") + private Integer maxTokens; + + @Value("${ai.claude.temperature}") + private Double temperature; + + /** + * AI 추천 결과 조회 + */ + public AIRecommendationResult getRecommendation(String eventId) { + Object cached = cacheService.getRecommendation(eventId); + if (cached == null) { + throw new RecommendationNotFoundException(eventId); + } + + return objectMapper.convertValue(cached, AIRecommendationResult.class); + } + + /** + * AI 추천 생성 (Kafka Consumer에서 호출) + */ + public void generateRecommendations(AIJobMessage message) { + try { + log.info("AI 추천 생성 시작: jobId={}, eventId={}", message.getJobId(), message.getEventId()); + + // Job 상태 업데이트: PROCESSING + jobStatusService.updateJobStatus(message.getJobId(), JobStatus.PROCESSING, "트렌드 분석 중 (10%)"); + + // 1. 트렌드 분석 + TrendAnalysis trendAnalysis = analyzeTrend(message); + jobStatusService.updateJobStatus(message.getJobId(), JobStatus.PROCESSING, "이벤트 추천안 생성 중 (50%)"); + + // 2. 이벤트 추천안 생성 + List recommendations = createRecommendations(message, trendAnalysis); + jobStatusService.updateJobStatus(message.getJobId(), JobStatus.PROCESSING, "결과 저장 중 (90%)"); + + // 3. 결과 생성 및 저장 + AIRecommendationResult result = AIRecommendationResult.builder() + .eventId(message.getEventId()) + .trendAnalysis(trendAnalysis) + .recommendations(recommendations) + .generatedAt(LocalDateTime.now()) + .expiresAt(LocalDateTime.now().plusDays(1)) + .aiProvider(AIProvider.valueOf(aiProvider)) + .build(); + + // 결과 캐싱 + cacheService.saveRecommendation(message.getEventId(), result); + + // Job 상태 업데이트: COMPLETED + jobStatusService.updateJobStatus(message.getJobId(), JobStatus.COMPLETED, "AI 추천 완료"); + + log.info("AI 추천 생성 완료: jobId={}, eventId={}", message.getJobId(), message.getEventId()); + + } catch (Exception e) { + log.error("AI 추천 생성 실패: jobId={}", message.getJobId(), e); + jobStatusService.updateJobStatus(message.getJobId(), JobStatus.FAILED, "AI 추천 실패: " + e.getMessage()); + } + } + + /** + * 트렌드 분석 + */ + private TrendAnalysis analyzeTrend(AIJobMessage message) { + String industry = message.getIndustry(); + String region = message.getRegion(); + + // 캐시 확인 + Object cached = cacheService.getTrend(industry, region); + if (cached != null) { + log.info("트렌드 분석 캐시 히트 - industry={}, region={}", industry, region); + return objectMapper.convertValue(cached, TrendAnalysis.class); + } + + // TrendAnalysisService를 통한 실제 분석 + log.info("트렌드 분석 시작 - industry={}, region={}", industry, region); + TrendAnalysis analysis = trendAnalysisService.analyzeTrend(industry, region); + + // 캐시 저장 + cacheService.saveTrend(industry, region, analysis); + + return analysis; + } + + /** + * 이벤트 추천안 생성 + */ + private List createRecommendations(AIJobMessage message, TrendAnalysis trendAnalysis) { + log.info("이벤트 추천안 생성 시작 - eventId={}", message.getEventId()); + + return circuitBreakerManager.executeWithCircuitBreaker( + "claudeApi", + () -> callClaudeApiForRecommendations(message, trendAnalysis), + () -> fallback.getDefaultRecommendations(message.getObjective(), message.getIndustry()) + ); + } + + /** + * Claude API를 통한 추천안 생성 + */ + private List callClaudeApiForRecommendations(AIJobMessage message, TrendAnalysis trendAnalysis) { + // 프롬프트 생성 + String prompt = buildRecommendationPrompt(message, trendAnalysis); + + // Claude API 요청 생성 + ClaudeRequest request = ClaudeRequest.builder() + .model(model) + .messages(List.of( + ClaudeRequest.Message.builder() + .role("user") + .content(prompt) + .build() + )) + .maxTokens(maxTokens) + .temperature(temperature) + .system("당신은 소상공인을 위한 마케팅 이벤트 기획 전문가입니다. 트렌드 분석을 바탕으로 실행 가능한 이벤트 추천안을 제공합니다.") + .build(); + + // API 호출 + log.debug("Claude API 호출 (추천안 생성) - model={}", model); + ClaudeResponse response = claudeApiClient.sendMessage( + apiKey, + anthropicVersion, + request + ); + + // 응답 파싱 + String responseText = response.extractText(); + log.debug("Claude API 응답 수신 (추천안) - length={}", responseText.length()); + + return parseRecommendationResponse(responseText); + } + + /** + * 추천안 프롬프트 생성 + */ + private String buildRecommendationPrompt(AIJobMessage message, TrendAnalysis trendAnalysis) { + StringBuilder trendSummary = new StringBuilder(); + + trendSummary.append("**업종 트렌드:**\n"); + trendAnalysis.getIndustryTrends().forEach(trend -> + trendSummary.append(String.format("- %s (연관도: %.2f): %s\n", + trend.getKeyword(), trend.getRelevance(), trend.getDescription())) + ); + + trendSummary.append("\n**지역 트렌드:**\n"); + trendAnalysis.getRegionalTrends().forEach(trend -> + trendSummary.append(String.format("- %s (연관도: %.2f): %s\n", + trend.getKeyword(), trend.getRelevance(), trend.getDescription())) + ); + + trendSummary.append("\n**계절 트렌드:**\n"); + trendAnalysis.getSeasonalTrends().forEach(trend -> + trendSummary.append(String.format("- %s (연관도: %.2f): %s\n", + trend.getKeyword(), trend.getRelevance(), trend.getDescription())) + ); + + return String.format(""" + # 이벤트 추천안 생성 요청 + + ## 고객 정보 + - 매장명: %s + - 업종: %s + - 지역: %s + - 목표: %s + - 타겟 고객: %s + - 예산: %,d원 + + ## 트렌드 분석 결과 + %s + + ## 요구사항 + 위 트렌드 분석을 바탕으로 **3가지 이벤트 추천안**을 생성해주세요: + 1. **저비용 옵션** (100,000 ~ 200,000원): SNS/온라인 중심 + 2. **중비용 옵션** (300,000 ~ 500,000원): 온/오프라인 결합 + 3. **고비용 옵션** (500,000 ~ 1,000,000원): 프리미엄 경험 제공 + + ## 응답 형식 + 응답은 반드시 다음 JSON 형식으로 작성해주세요: + + ```json + { + "recommendations": [ + { + "optionNumber": 1, + "concept": "이벤트 컨셉 (10자 이내)", + "title": "이벤트 제목 (20자 이내)", + "description": "이벤트 상세 설명 (3-5문장)", + "targetAudience": "타겟 고객층", + "duration": { + "recommendedDays": 14, + "recommendedPeriod": "2주" + }, + "mechanics": { + "type": "DISCOUNT", + "details": "이벤트 참여 방법 및 혜택 상세" + }, + "promotionChannels": ["채널1", "채널2", "채널3"], + "estimatedCost": { + "min": 100000, + "max": 200000, + "breakdown": { + "경품비": 50000, + "홍보비": 50000 + } + }, + "expectedMetrics": { + "newCustomers": { "min": 30.0, "max": 50.0 }, + "revenueIncrease": { "min": 10.0, "max": 20.0 }, + "roi": { "min": 100.0, "max": 150.0 } + }, + "differentiator": "차별화 포인트 (2-3문장)" + } + ] + } + ``` + + ## mechanics.type 값 + - DISCOUNT: 할인 + - GIFT: 경품/사은품 + - STAMP: 스탬프 적립 + - EXPERIENCE: 체험형 이벤트 + - LOTTERY: 추첨 이벤트 + - COMBO: 결합 혜택 + + ## 주의사항 + - 각 옵션은 예산 범위 내에서 실행 가능해야 함 + - 트렌드 분석 결과를 반영한 구체적인 기획 + - 타겟 고객과 지역 특성을 고려 + - expectedMetrics는 백분율(%%로 표기) + - promotionChannels는 실제 활용 가능한 채널로 제시 + """, + message.getStoreName(), + message.getIndustry(), + message.getRegion(), + message.getObjective(), + message.getTargetAudience(), + message.getBudget(), + trendSummary.toString() + ); + } + + /** + * 추천안 응답 파싱 + */ + private List parseRecommendationResponse(String responseText) { + try { + // JSON 부분만 추출 + String jsonText = extractJsonFromMarkdown(responseText); + + // JSON 파싱 + JsonNode rootNode = objectMapper.readTree(jsonText); + JsonNode recommendationsNode = rootNode.get("recommendations"); + + List recommendations = new ArrayList<>(); + + if (recommendationsNode != null && recommendationsNode.isArray()) { + recommendationsNode.forEach(node -> { + recommendations.add(parseEventRecommendation(node)); + }); + } + + return recommendations; + + } catch (JsonProcessingException e) { + log.error("추천안 응답 파싱 실패", e); + throw new RuntimeException("이벤트 추천안 응답 파싱 중 오류 발생", e); + } + } + + /** + * EventRecommendation 파싱 + */ + private EventRecommendation parseEventRecommendation(JsonNode node) { + // Mechanics Type 파싱 + String mechanicsTypeStr = node.get("mechanics").get("type").asText(); + EventMechanicsType mechanicsType = EventMechanicsType.valueOf(mechanicsTypeStr); + + // Promotion Channels 파싱 + List promotionChannels = new ArrayList<>(); + JsonNode channelsNode = node.get("promotionChannels"); + if (channelsNode != null && channelsNode.isArray()) { + channelsNode.forEach(channel -> promotionChannels.add(channel.asText())); + } + + // Breakdown 파싱 + Map breakdown = new HashMap<>(); + JsonNode breakdownNode = node.get("estimatedCost").get("breakdown"); + if (breakdownNode != null && breakdownNode.isObject()) { + breakdownNode.fields().forEachRemaining(entry -> + breakdown.put(entry.getKey(), entry.getValue().asInt()) + ); + } + + return EventRecommendation.builder() + .optionNumber(node.get("optionNumber").asInt()) + .concept(node.get("concept").asText()) + .title(node.get("title").asText()) + .description(node.get("description").asText()) + .targetAudience(node.get("targetAudience").asText()) + .duration(EventRecommendation.Duration.builder() + .recommendedDays(node.get("duration").get("recommendedDays").asInt()) + .recommendedPeriod(node.get("duration").get("recommendedPeriod").asText()) + .build()) + .mechanics(EventRecommendation.Mechanics.builder() + .type(mechanicsType) + .details(node.get("mechanics").get("details").asText()) + .build()) + .promotionChannels(promotionChannels) + .estimatedCost(EventRecommendation.EstimatedCost.builder() + .min(node.get("estimatedCost").get("min").asInt()) + .max(node.get("estimatedCost").get("max").asInt()) + .breakdown(breakdown) + .build()) + .expectedMetrics(ExpectedMetrics.builder() + .newCustomers(parseRange(node.get("expectedMetrics").get("newCustomers"))) + .revenueIncrease(parseRange(node.get("expectedMetrics").get("revenueIncrease"))) + .roi(parseRange(node.get("expectedMetrics").get("roi"))) + .build()) + .differentiator(node.get("differentiator").asText()) + .build(); + } + + /** + * Range 파싱 + */ + private ExpectedMetrics.Range parseRange(JsonNode node) { + return ExpectedMetrics.Range.builder() + .min(node.get("min").asDouble()) + .max(node.get("max").asDouble()) + .build(); + } + + /** + * Markdown에서 JSON 추출 + */ + private String extractJsonFromMarkdown(String text) { + // ```json ... ``` 형태에서 JSON만 추출 + if (text.contains("```json")) { + int start = text.indexOf("```json") + 7; + int end = text.indexOf("```", start); + return text.substring(start, end).trim(); + } + + // ```{ ... }``` 형태에서 JSON만 추출 + if (text.contains("```")) { + int start = text.indexOf("```") + 3; + int end = text.indexOf("```", start); + return text.substring(start, end).trim(); + } + + // 순수 JSON인 경우 + return text.trim(); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/service/CacheService.java b/ai-service/src/main/java/com/kt/ai/service/CacheService.java new file mode 100644 index 0000000..9b36d39 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/service/CacheService.java @@ -0,0 +1,134 @@ +package com.kt.ai.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +/** + * Redis 캐시 서비스 + * - Job 상태 관리 + * - AI 추천 결과 캐싱 + * - 트렌드 분석 결과 캐싱 + * + * @author AI Service Team + * @since 1.0.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CacheService { + + private final RedisTemplate redisTemplate; + + @Value("${cache.ttl.recommendation:86400}") + private long recommendationTtl; + + @Value("${cache.ttl.job-status:86400}") + private long jobStatusTtl; + + @Value("${cache.ttl.trend:3600}") + private long trendTtl; + + /** + * 캐시 저장 + * + * @param key Redis Key + * @param value 저장할 값 + * @param ttlSeconds TTL (초) + */ + public void set(String key, Object value, long ttlSeconds) { + try { + redisTemplate.opsForValue().set(key, value, ttlSeconds, TimeUnit.SECONDS); + log.debug("캐시 저장 성공: key={}, ttl={}초", key, ttlSeconds); + } catch (Exception e) { + log.error("캐시 저장 실패: key={}", key, e); + } + } + + /** + * 캐시 조회 + * + * @param key Redis Key + * @return 캐시된 값 (없으면 null) + */ + public Object get(String key) { + try { + Object value = redisTemplate.opsForValue().get(key); + if (value != null) { + log.debug("캐시 조회 성공: key={}", key); + } else { + log.debug("캐시 미스: key={}", key); + } + return value; + } catch (Exception e) { + log.error("캐시 조회 실패: key={}", key, e); + return null; + } + } + + /** + * 캐시 삭제 + * + * @param key Redis Key + */ + public void delete(String key) { + try { + redisTemplate.delete(key); + log.debug("캐시 삭제 성공: key={}", key); + } catch (Exception e) { + log.error("캐시 삭제 실패: key={}", key, e); + } + } + + /** + * Job 상태 저장 + */ + public void saveJobStatus(String jobId, Object status) { + String key = "ai:job:status:" + jobId; + set(key, status, jobStatusTtl); + } + + /** + * Job 상태 조회 + */ + public Object getJobStatus(String jobId) { + String key = "ai:job:status:" + jobId; + return get(key); + } + + /** + * AI 추천 결과 저장 + */ + public void saveRecommendation(String eventId, Object recommendation) { + String key = "ai:recommendation:" + eventId; + set(key, recommendation, recommendationTtl); + } + + /** + * AI 추천 결과 조회 + */ + public Object getRecommendation(String eventId) { + String key = "ai:recommendation:" + eventId; + return get(key); + } + + /** + * 트렌드 분석 결과 저장 + */ + public void saveTrend(String industry, String region, Object trend) { + String key = "ai:trend:" + industry + ":" + region; + set(key, trend, trendTtl); + } + + /** + * 트렌드 분석 결과 조회 + */ + public Object getTrend(String industry, String region) { + String key = "ai:trend:" + industry + ":" + region; + return get(key); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/service/JobStatusService.java b/ai-service/src/main/java/com/kt/ai/service/JobStatusService.java new file mode 100644 index 0000000..cf1e332 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/service/JobStatusService.java @@ -0,0 +1,63 @@ +package com.kt.ai.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kt.ai.exception.JobNotFoundException; +import com.kt.ai.model.dto.response.JobStatusResponse; +import com.kt.ai.model.enums.JobStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +/** + * Job 상태 관리 서비스 + * + * @author AI Service Team + * @since 1.0.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class JobStatusService { + + private final CacheService cacheService; + private final ObjectMapper objectMapper; + + /** + * Job 상태 조회 + */ + public JobStatusResponse getJobStatus(String jobId) { + Object cached = cacheService.getJobStatus(jobId); + if (cached == null) { + throw new JobNotFoundException(jobId); + } + + return objectMapper.convertValue(cached, JobStatusResponse.class); + } + + /** + * Job 상태 업데이트 + */ + public void updateJobStatus(String jobId, JobStatus status, String message) { + JobStatusResponse response = JobStatusResponse.builder() + .jobId(jobId) + .status(status) + .progress(calculateProgress(status)) + .message(message) + .createdAt(LocalDateTime.now()) + .build(); + + cacheService.saveJobStatus(jobId, response); + log.info("Job 상태 업데이트: jobId={}, status={}", jobId, status); + } + + private int calculateProgress(JobStatus status) { + return switch (status) { + case PENDING -> 0; + case PROCESSING -> 50; + case COMPLETED -> 100; + case FAILED -> 0; + }; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/service/TrendAnalysisService.java b/ai-service/src/main/java/com/kt/ai/service/TrendAnalysisService.java new file mode 100644 index 0000000..dc82b99 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/service/TrendAnalysisService.java @@ -0,0 +1,222 @@ +package com.kt.ai.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kt.ai.circuitbreaker.CircuitBreakerManager; +import com.kt.ai.circuitbreaker.fallback.AIServiceFallback; +import com.kt.ai.client.ClaudeApiClient; +import com.kt.ai.client.dto.ClaudeRequest; +import com.kt.ai.client.dto.ClaudeResponse; +import com.kt.ai.model.dto.response.TrendAnalysis; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +/** + * 트렌드 분석 서비스 + * - Claude AI를 통한 업종/지역/계절 트렌드 분석 + * - Circuit Breaker 적용 + * + * @author AI Service Team + * @since 1.0.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TrendAnalysisService { + + private final ClaudeApiClient claudeApiClient; + private final CircuitBreakerManager circuitBreakerManager; + private final AIServiceFallback fallback; + private final ObjectMapper objectMapper; + + @Value("${ai.claude.api-key}") + private String apiKey; + + @Value("${ai.claude.anthropic-version}") + private String anthropicVersion; + + @Value("${ai.claude.model}") + private String model; + + @Value("${ai.claude.max-tokens}") + private Integer maxTokens; + + @Value("${ai.claude.temperature}") + private Double temperature; + + /** + * 트렌드 분석 수행 + * + * @param industry 업종 + * @param region 지역 + * @return 트렌드 분석 결과 + */ + public TrendAnalysis analyzeTrend(String industry, String region) { + log.info("트렌드 분석 시작 - industry={}, region={}", industry, region); + + return circuitBreakerManager.executeWithCircuitBreaker( + "claudeApi", + () -> callClaudeApi(industry, region), + () -> fallback.getDefaultTrendAnalysis(industry, region) + ); + } + + /** + * Claude API 호출 + */ + private TrendAnalysis callClaudeApi(String industry, String region) { + // 프롬프트 생성 + String prompt = buildPrompt(industry, region); + + // Claude API 요청 생성 + ClaudeRequest request = ClaudeRequest.builder() + .model(model) + .messages(List.of( + ClaudeRequest.Message.builder() + .role("user") + .content(prompt) + .build() + )) + .maxTokens(maxTokens) + .temperature(temperature) + .system("당신은 마케팅 트렌드 분석 전문가입니다. 업종별, 지역별 트렌드를 분석하고 인사이트를 제공합니다.") + .build(); + + // API 호출 + log.debug("Claude API 호출 - model={}", model); + ClaudeResponse response = claudeApiClient.sendMessage( + apiKey, + anthropicVersion, + request + ); + + // 응답 파싱 + String responseText = response.extractText(); + log.debug("Claude API 응답 수신 - length={}", responseText.length()); + + return parseResponse(responseText); + } + + /** + * 프롬프트 생성 + */ + private String buildPrompt(String industry, String region) { + return String.format(""" + # 트렌드 분석 요청 + + 다음 조건에 맞는 마케팅 트렌드를 분석해주세요: + - 업종: %s + - 지역: %s + + ## 분석 요구사항 + 1. **업종 트렌드**: 해당 업종에서 현재 주목받는 마케팅 트렌드 3개 + 2. **지역 트렌드**: 해당 지역의 특성과 소비자 성향을 반영한 트렌드 2개 + 3. **계절 트렌드**: 현재 계절(또는 다가오는 시즌)에 적합한 트렌드 2개 + + ## 응답 형식 + 응답은 반드시 다음 JSON 형식으로 작성해주세요: + + ```json + { + "industryTrends": [ + { + "keyword": "트렌드 키워드", + "relevance": 0.9, + "description": "트렌드에 대한 상세 설명 (2-3문장)" + } + ], + "regionalTrends": [ + { + "keyword": "트렌드 키워드", + "relevance": 0.85, + "description": "트렌드에 대한 상세 설명 (2-3문장)" + } + ], + "seasonalTrends": [ + { + "keyword": "트렌드 키워드", + "relevance": 0.8, + "description": "트렌드에 대한 상세 설명 (2-3문장)" + } + ] + } + ``` + + ## 주의사항 + - relevance 값은 0.0 ~ 1.0 사이의 소수점 값 + - description은 구체적이고 실행 가능한 인사이트 포함 + - 한국 시장과 문화를 고려한 분석 + """, industry, region); + } + + /** + * Claude 응답 파싱 + */ + private TrendAnalysis parseResponse(String responseText) { + try { + // JSON 부분만 추출 (```json ... ``` 형태로 올 수 있음) + String jsonText = extractJsonFromMarkdown(responseText); + + // JSON 파싱 + JsonNode rootNode = objectMapper.readTree(jsonText); + + // TrendAnalysis 객체 생성 + return TrendAnalysis.builder() + .industryTrends(parseTrendKeywords(rootNode.get("industryTrends"))) + .regionalTrends(parseTrendKeywords(rootNode.get("regionalTrends"))) + .seasonalTrends(parseTrendKeywords(rootNode.get("seasonalTrends"))) + .build(); + + } catch (JsonProcessingException e) { + log.error("응답 파싱 실패", e); + throw new RuntimeException("트렌드 분석 응답 파싱 중 오류 발생", e); + } + } + + /** + * Markdown에서 JSON 추출 + */ + private String extractJsonFromMarkdown(String text) { + // ```json ... ``` 형태에서 JSON만 추출 + if (text.contains("```json")) { + int start = text.indexOf("```json") + 7; + int end = text.indexOf("```", start); + return text.substring(start, end).trim(); + } + + // ```{ ... }``` 형태에서 JSON만 추출 + if (text.contains("```")) { + int start = text.indexOf("```") + 3; + int end = text.indexOf("```", start); + return text.substring(start, end).trim(); + } + + // 순수 JSON인 경우 + return text.trim(); + } + + /** + * TrendKeyword 리스트 파싱 + */ + private List parseTrendKeywords(JsonNode arrayNode) { + List keywords = new ArrayList<>(); + + if (arrayNode != null && arrayNode.isArray()) { + arrayNode.forEach(node -> { + keywords.add(TrendAnalysis.TrendKeyword.builder() + .keyword(node.get("keyword").asText()) + .relevance(node.get("relevance").asDouble()) + .description(node.get("description").asText()) + .build()); + }); + } + + return keywords; + } +} diff --git a/ai-service/src/main/resources/application.yml b/ai-service/src/main/resources/application.yml new file mode 100644 index 0000000..d9b615a --- /dev/null +++ b/ai-service/src/main/resources/application.yml @@ -0,0 +1,174 @@ +spring: + application: + name: ai-service + + # Redis Configuration + data: + redis: + host: ${REDIS_HOST:redis-external} # Production: redis-external, Local: 20.214.210.71 + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + database: ${REDIS_DATABASE:0} # AI Service uses database 3 + timeout: ${REDIS_TIMEOUT:3000} + lettuce: + pool: + max-active: 8 + max-idle: 8 + min-idle: 2 + max-wait: -1ms + + # Kafka Consumer Configuration + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} + consumer: + group-id: ai-service-consumers + auto-offset-reset: earliest + enable-auto-commit: false + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + properties: + spring.json.trusted.packages: "*" + max.poll.records: ${KAFKA_MAX_POLL_RECORDS:10} + session.timeout.ms: ${KAFKA_SESSION_TIMEOUT:30000} + listener: + ack-mode: manual + +# Server Configuration +server: + port: ${SERVER_PORT:8083} + servlet: + context-path: / + encoding: + charset: UTF-8 + enabled: true + force: true + +# JWT Configuration +jwt: + secret: ${JWT_SECRET:} + access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800} + refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400} + +# CORS Configuration +cors: + allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:8080} + allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS,PATCH} + allowed-headers: ${CORS_ALLOWED_HEADERS:*} + allow-credentials: ${CORS_ALLOW_CREDENTIALS:true} + max-age: ${CORS_MAX_AGE:3600} + +# Actuator Configuration +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + endpoint: + health: + show-details: always + health: + redis: + enabled: true + kafka: + enabled: true + +# OpenAPI Documentation Configuration +springdoc: + api-docs: + path: /v3/api-docs + enabled: true + swagger-ui: + path: /swagger-ui.html + enabled: true + operations-sorter: method + tags-sorter: alpha + display-request-duration: true + doc-expansion: none + show-actuator: false + default-consumes-media-type: application/json + default-produces-media-type: application/json + +# Logging Configuration +logging: + level: + root: INFO + com.kt.ai: DEBUG + org.springframework.kafka: INFO + org.springframework.data.redis: INFO + io.github.resilience4j: DEBUG + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + file: + name: ${LOG_FILE:logs/ai-service.log} + logback: + rollingpolicy: + max-file-size: 10MB + max-history: 7 + total-size-cap: 100MB + +# Kafka Topics Configuration +kafka: + topics: + ai-job: ${KAFKA_TOPIC_AI_JOB:ai-event-generation-job} + ai-job-dlq: ${KAFKA_TOPIC_AI_JOB_DLQ:ai-event-generation-job-dlq} + +# AI External API Configuration +ai: + claude: + api-url: ${CLAUDE_API_URL:https://api.anthropic.com/v1/messages} + api-key: ${CLAUDE_API_KEY:} + anthropic-version: ${CLAUDE_ANTHROPIC_VERSION:2023-06-01} + model: ${CLAUDE_MODEL:claude-3-5-sonnet-20241022} + max-tokens: ${CLAUDE_MAX_TOKENS:4096} + temperature: ${CLAUDE_TEMPERATURE:0.7} + timeout: ${CLAUDE_TIMEOUT:300000} # 5 minutes + gpt4: + api-url: ${GPT4_API_URL:https://api.openai.com/v1/chat/completions} + api-key: ${GPT4_API_KEY:} + model: ${GPT4_MODEL:gpt-4-turbo-preview} + max-tokens: ${GPT4_MAX_TOKENS:4096} + timeout: ${GPT4_TIMEOUT:300000} # 5 minutes + provider: ${AI_PROVIDER:CLAUDE} # CLAUDE or GPT4 + +# Circuit Breaker Configuration +resilience4j: + circuitbreaker: + configs: + default: + failure-rate-threshold: 50 + slow-call-rate-threshold: 50 + slow-call-duration-threshold: 60s + permitted-number-of-calls-in-half-open-state: 3 + max-wait-duration-in-half-open-state: 0 + sliding-window-type: COUNT_BASED + sliding-window-size: 10 + minimum-number-of-calls: 5 + wait-duration-in-open-state: 60s + automatic-transition-from-open-to-half-open-enabled: true + instances: + claudeApi: + base-config: default + failure-rate-threshold: 50 + wait-duration-in-open-state: 60s + gpt4Api: + base-config: default + failure-rate-threshold: 50 + wait-duration-in-open-state: 60s + timelimiter: + configs: + default: + timeout-duration: 300s # 5 minutes + instances: + claudeApi: + timeout-duration: 300s + gpt4Api: + timeout-duration: 300s + +# Redis Cache TTL Configuration (seconds) +cache: + ttl: + recommendation: ${CACHE_TTL_RECOMMENDATION:86400} # 24 hours + job-status: ${CACHE_TTL_JOB_STATUS:86400} # 24 hours + trend: ${CACHE_TTL_TREND:3600} # 1 hour + fallback: ${CACHE_TTL_FALLBACK:604800} # 7 days diff --git a/ai-service/src/test/java/com/kt/ai/test/integration/kafka/AIJobConsumerIntegrationTest.java b/ai-service/src/test/java/com/kt/ai/test/integration/kafka/AIJobConsumerIntegrationTest.java new file mode 100644 index 0000000..a7180f7 --- /dev/null +++ b/ai-service/src/test/java/com/kt/ai/test/integration/kafka/AIJobConsumerIntegrationTest.java @@ -0,0 +1,127 @@ +package com.kt.ai.test.integration.kafka; + +import com.kt.ai.kafka.message.AIJobMessage; +import com.kt.ai.service.CacheService; +import com.kt.ai.service.JobStatusService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * AIJobConsumer Kafka 통합 테스트 + * + * 실제 Kafka 브로커가 실행 중이어야 합니다. + * + * @author AI Service Team + * @since 1.0.0 + */ +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("AIJobConsumer Kafka 통합 테스트") +class AIJobConsumerIntegrationTest { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${kafka.topics.ai-job}") + private String aiJobTopic; + + @Autowired + private JobStatusService jobStatusService; + + @Autowired + private CacheService cacheService; + + private KafkaTestProducer testProducer; + + @BeforeEach + void setUp() { + testProducer = new KafkaTestProducer(bootstrapServers, aiJobTopic); + } + + @AfterEach + void tearDown() { + if (testProducer != null) { + testProducer.close(); + } + } + + @Test + @DisplayName("Given valid AI job message, When send to Kafka, Then consumer processes and saves to Redis") + void givenValidAIJobMessage_whenSendToKafka_thenConsumerProcessesAndSavesToRedis() { + // Given + String jobId = "test-job-" + System.currentTimeMillis(); + String eventId = "test-event-" + System.currentTimeMillis(); + AIJobMessage message = KafkaTestProducer.createSampleMessage(jobId, eventId); + + // When + testProducer.sendAIJobMessage(message); + + // Then - Kafka Consumer가 메시지를 처리하고 Redis에 저장할 때까지 대기 + await() + .atMost(30, TimeUnit.SECONDS) + .pollInterval(1, TimeUnit.SECONDS) + .untilAsserted(() -> { + // Job 상태가 Redis에 저장되었는지 확인 + Object jobStatus = cacheService.getJobStatus(jobId); + assertThat(jobStatus).isNotNull(); + System.out.println("Job 상태 확인: " + jobStatus); + }); + + // 최종 상태 확인 (COMPLETED 또는 FAILED) + await() + .atMost(60, TimeUnit.SECONDS) + .pollInterval(2, TimeUnit.SECONDS) + .untilAsserted(() -> { + Object jobStatus = cacheService.getJobStatus(jobId); + assertThat(jobStatus).isNotNull(); + + // AI 추천 결과도 저장되었는지 확인 (COMPLETED 상태인 경우) + Object recommendation = cacheService.getRecommendation(eventId); + System.out.println("AI 추천 결과: " + (recommendation != null ? "있음" : "없음")); + }); + } + + @Test + @DisplayName("Given multiple messages, When send to Kafka, Then all messages are processed") + void givenMultipleMessages_whenSendToKafka_thenAllMessagesAreProcessed() { + // Given + int messageCount = 3; + String[] jobIds = new String[messageCount]; + String[] eventIds = new String[messageCount]; + + // When - 여러 메시지 전송 + for (int i = 0; i < messageCount; i++) { + jobIds[i] = "batch-job-" + i + "-" + System.currentTimeMillis(); + eventIds[i] = "batch-event-" + i + "-" + System.currentTimeMillis(); + AIJobMessage message = KafkaTestProducer.createSampleMessage(jobIds[i], eventIds[i]); + testProducer.sendAIJobMessage(message); + } + + // Then - 모든 메시지가 처리되었는지 확인 + await() + .atMost(90, TimeUnit.SECONDS) + .pollInterval(2, TimeUnit.SECONDS) + .untilAsserted(() -> { + int processedCount = 0; + for (int i = 0; i < messageCount; i++) { + Object jobStatus = cacheService.getJobStatus(jobIds[i]); + if (jobStatus != null) { + processedCount++; + } + } + assertThat(processedCount).isEqualTo(messageCount); + System.out.println("처리된 메시지 수: " + processedCount + "/" + messageCount); + }); + } +} diff --git a/ai-service/src/test/java/com/kt/ai/test/integration/kafka/KafkaTestProducer.java b/ai-service/src/test/java/com/kt/ai/test/integration/kafka/KafkaTestProducer.java new file mode 100644 index 0000000..1889a97 --- /dev/null +++ b/ai-service/src/test/java/com/kt/ai/test/integration/kafka/KafkaTestProducer.java @@ -0,0 +1,92 @@ +package com.kt.ai.test.integration.kafka; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.kt.ai.kafka.message.AIJobMessage; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.apache.kafka.common.serialization.StringSerializer; + +import java.time.LocalDateTime; +import java.util.Properties; +import java.util.concurrent.Future; + +/** + * Kafka 테스트용 Producer 유틸리티 + * + * @author AI Service Team + * @since 1.0.0 + */ +@Slf4j +public class KafkaTestProducer { + + private final KafkaProducer producer; + private final ObjectMapper objectMapper; + private final String topic; + + public KafkaTestProducer(String bootstrapServers, String topic) { + this.topic = topic; + this.objectMapper = new ObjectMapper(); + this.objectMapper.registerModule(new JavaTimeModule()); + + Properties props = new Properties(); + props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); + props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); + props.put(ProducerConfig.ACKS_CONFIG, "all"); + props.put(ProducerConfig.RETRIES_CONFIG, 3); + + this.producer = new KafkaProducer<>(props); + } + + /** + * AI Job 메시지 전송 + */ + public RecordMetadata sendAIJobMessage(AIJobMessage message) { + try { + String json = objectMapper.writeValueAsString(message); + ProducerRecord record = new ProducerRecord<>(topic, message.getJobId(), json); + + Future future = producer.send(record); + RecordMetadata metadata = future.get(); + + log.info("Kafka 메시지 전송 성공: topic={}, partition={}, offset={}, jobId={}", + metadata.topic(), metadata.partition(), metadata.offset(), message.getJobId()); + + return metadata; + } catch (Exception e) { + log.error("Kafka 메시지 전송 실패: jobId={}", message.getJobId(), e); + throw new RuntimeException("Kafka 메시지 전송 실패", e); + } + } + + /** + * 테스트용 샘플 메시지 생성 + */ + public static AIJobMessage createSampleMessage(String jobId, String eventId) { + return AIJobMessage.builder() + .jobId(jobId) + .eventId(eventId) + .objective("신규 고객 유치") + .industry("음식점") + .region("강남구") + .storeName("테스트 BBQ 레스토랑") + .targetAudience("20-30대 직장인") + .budget(500000) + .requestedAt(LocalDateTime.now()) + .build(); + } + + /** + * Producer 종료 + */ + public void close() { + if (producer != null) { + producer.close(); + log.info("Kafka Producer 종료"); + } + } +} diff --git a/ai-service/src/test/java/com/kt/ai/test/manual/KafkaManualTest.java b/ai-service/src/test/java/com/kt/ai/test/manual/KafkaManualTest.java new file mode 100644 index 0000000..38cf813 --- /dev/null +++ b/ai-service/src/test/java/com/kt/ai/test/manual/KafkaManualTest.java @@ -0,0 +1,114 @@ +package com.kt.ai.test.manual; + +import com.kt.ai.kafka.message.AIJobMessage; +import com.kt.ai.test.integration.kafka.KafkaTestProducer; + +import java.time.LocalDateTime; + +/** + * Kafka 수동 테스트 + * + * 이 클래스는 main 메서드를 실행하여 Kafka에 메시지를 직접 전송할 수 있습니다. + * IDE에서 직접 실행하거나 Gradle로 실행할 수 있습니다. + * + * @author AI Service Team + * @since 1.0.0 + */ +public class KafkaManualTest { + + // Kafka 설정 (환경에 맞게 수정) + private static final String BOOTSTRAP_SERVERS = "20.249.182.13:9095,4.217.131.59:9095"; + private static final String TOPIC = "ai-event-generation-job"; + + public static void main(String[] args) { + System.out.println("=== Kafka 수동 테스트 시작 ==="); + System.out.println("Bootstrap Servers: " + BOOTSTRAP_SERVERS); + System.out.println("Topic: " + TOPIC); + + KafkaTestProducer producer = new KafkaTestProducer(BOOTSTRAP_SERVERS, TOPIC); + + try { + // 테스트 메시지 1: 기본 메시지 + AIJobMessage message1 = createTestMessage( + "manual-job-001", + "manual-event-001", + "신규 고객 유치", + "음식점", + "강남구", + "테스트 BBQ 레스토랑", + 500000 + ); + + System.out.println("\n[메시지 1] 전송 중..."); + producer.sendAIJobMessage(message1); + System.out.println("[메시지 1] 전송 완료"); + + // 테스트 메시지 2: 다른 업종 + AIJobMessage message2 = createTestMessage( + "manual-job-002", + "manual-event-002", + "재방문 유도", + "카페", + "서초구", + "테스트 카페", + 300000 + ); + + System.out.println("\n[메시지 2] 전송 중..."); + producer.sendAIJobMessage(message2); + System.out.println("[메시지 2] 전송 완료"); + + // 테스트 메시지 3: 저예산 + AIJobMessage message3 = createTestMessage( + "manual-job-003", + "manual-event-003", + "매출 증대", + "소매점", + "마포구", + "테스트 편의점", + 100000 + ); + + System.out.println("\n[메시지 3] 전송 중..."); + producer.sendAIJobMessage(message3); + System.out.println("[메시지 3] 전송 완료"); + + System.out.println("\n=== 모든 메시지 전송 완료 ==="); + System.out.println("\n다음 API로 결과를 확인하세요:"); + System.out.println("- Job 상태: GET http://localhost:8083/api/v1/ai-service/internal/jobs/{jobId}/status"); + System.out.println("- AI 추천: GET http://localhost:8083/api/v1/ai-service/internal/recommendations/{eventId}"); + System.out.println("\n예시:"); + System.out.println(" curl http://localhost:8083/api/v1/ai-service/internal/jobs/manual-job-001/status"); + System.out.println(" curl http://localhost:8083/api/v1/ai-service/internal/recommendations/manual-event-001"); + + } catch (Exception e) { + System.err.println("에러 발생: " + e.getMessage()); + e.printStackTrace(); + } finally { + producer.close(); + System.out.println("\n=== Kafka Producer 종료 ==="); + } + } + + private static AIJobMessage createTestMessage( + String jobId, + String eventId, + String objective, + String industry, + String region, + String storeName, + int budget + ) { + return AIJobMessage.builder() + .jobId(jobId) + .eventId(eventId) + .objective(objective) + .industry(industry) + .region(region) + .storeName(storeName) + .targetAudience("20-40대 고객") + .budget(budget) + .requestedAt(LocalDateTime.now()) + .build(); + } +} diff --git a/ai-service/src/test/java/com/kt/ai/test/unit/controller/InternalJobControllerUnitTest.java b/ai-service/src/test/java/com/kt/ai/test/unit/controller/InternalJobControllerUnitTest.java new file mode 100644 index 0000000..4a26729 --- /dev/null +++ b/ai-service/src/test/java/com/kt/ai/test/unit/controller/InternalJobControllerUnitTest.java @@ -0,0 +1,177 @@ +package com.kt.ai.test.unit.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kt.ai.controller.InternalJobController; +import com.kt.ai.exception.JobNotFoundException; +import com.kt.ai.model.dto.response.JobStatusResponse; +import com.kt.ai.model.enums.JobStatus; +import com.kt.ai.service.CacheService; +import com.kt.ai.service.JobStatusService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; + +import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * InternalJobController 단위 테스트 + * + * @author AI Service Team + * @since 1.0.0 + */ +@WebMvcTest(controllers = InternalJobController.class, + excludeAutoConfiguration = {org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration.class}) +@DisplayName("InternalJobController 단위 테스트") +class InternalJobControllerUnitTest { + + // Constants + private static final String VALID_JOB_ID = "job-123"; + private static final String INVALID_JOB_ID = "job-999"; + private static final String BASE_URL = "/api/v1/ai-service/internal/jobs"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private JobStatusService jobStatusService; + + @MockBean + private CacheService cacheService; + + private JobStatusResponse sampleJobStatusResponse; + + @BeforeEach + void setUp() { + sampleJobStatusResponse = JobStatusResponse.builder() + .jobId(VALID_JOB_ID) + .status(JobStatus.PROCESSING) + .progress(50) + .message("AI 추천 생성 중 (50%)") + .createdAt(LocalDateTime.now()) + .build(); + } + + // ========== GET /{jobId}/status 테스트 ========== + + @Test + @DisplayName("Given existing job, When get status, Then return 200 with job status") + void givenExistingJob_whenGetStatus_thenReturn200WithJobStatus() throws Exception { + // Given + when(jobStatusService.getJobStatus(VALID_JOB_ID)).thenReturn(sampleJobStatusResponse); + + // When & Then + mockMvc.perform(get(BASE_URL + "/{jobId}/status", VALID_JOB_ID) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.jobId", is(VALID_JOB_ID))) + .andExpect(jsonPath("$.status", is("PROCESSING"))) + .andExpect(jsonPath("$.progress", is(50))) + .andExpect(jsonPath("$.message", is("AI 추천 생성 중 (50%)"))) + .andExpect(jsonPath("$.createdAt", notNullValue())); + + verify(jobStatusService, times(1)).getJobStatus(VALID_JOB_ID); + } + + @Test + @DisplayName("Given non-existing job, When get status, Then return 404") + void givenNonExistingJob_whenGetStatus_thenReturn404() throws Exception { + // Given + when(jobStatusService.getJobStatus(INVALID_JOB_ID)) + .thenThrow(new JobNotFoundException(INVALID_JOB_ID)); + + // When & Then + mockMvc.perform(get(BASE_URL + "/{jobId}/status", INVALID_JOB_ID) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code", is("JOB_NOT_FOUND"))) + .andExpect(jsonPath("$.message", containsString(INVALID_JOB_ID))); + + verify(jobStatusService, times(1)).getJobStatus(INVALID_JOB_ID); + } + + @Test + @DisplayName("Given completed job, When get status, Then return COMPLETED status with 100% progress") + void givenCompletedJob_whenGetStatus_thenReturnCompletedStatus() throws Exception { + // Given + JobStatusResponse completedResponse = JobStatusResponse.builder() + .jobId(VALID_JOB_ID) + .status(JobStatus.COMPLETED) + .progress(100) + .message("AI 추천 완료") + .createdAt(LocalDateTime.now()) + .build(); + + when(jobStatusService.getJobStatus(VALID_JOB_ID)).thenReturn(completedResponse); + + // When & Then + mockMvc.perform(get(BASE_URL + "/{jobId}/status", VALID_JOB_ID) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status", is("COMPLETED"))) + .andExpect(jsonPath("$.progress", is(100))); + + verify(jobStatusService, times(1)).getJobStatus(VALID_JOB_ID); + } + + @Test + @DisplayName("Given failed job, When get status, Then return FAILED status") + void givenFailedJob_whenGetStatus_thenReturnFailedStatus() throws Exception { + // Given + JobStatusResponse failedResponse = JobStatusResponse.builder() + .jobId(VALID_JOB_ID) + .status(JobStatus.FAILED) + .progress(0) + .message("AI API 호출 실패") + .createdAt(LocalDateTime.now()) + .build(); + + when(jobStatusService.getJobStatus(VALID_JOB_ID)).thenReturn(failedResponse); + + // When & Then + mockMvc.perform(get(BASE_URL + "/{jobId}/status", VALID_JOB_ID) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status", is("FAILED"))) + .andExpect(jsonPath("$.progress", is(0))) + .andExpect(jsonPath("$.message", containsString("실패"))); + + verify(jobStatusService, times(1)).getJobStatus(VALID_JOB_ID); + } + + // ========== 디버그 엔드포인트 테스트 (선택사항) ========== + + @Test + @DisplayName("Given valid jobId, When create test job, Then return 200 with test data") + void givenValidJobId_whenCreateTestJob_thenReturn200WithTestData() throws Exception { + // Given + doNothing().when(jobStatusService).updateJobStatus(anyString(), org.mockito.ArgumentMatchers.any(JobStatus.class), anyString()); + when(cacheService.getJobStatus(VALID_JOB_ID)).thenReturn(sampleJobStatusResponse); + + // When & Then + mockMvc.perform(get(BASE_URL + "/debug/create-test-job/{jobId}", VALID_JOB_ID) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success", is(true))) + .andExpect(jsonPath("$.jobId", is(VALID_JOB_ID))) + .andExpect(jsonPath("$.saved", is(true))) + .andExpect(jsonPath("$.additionalSamples", notNullValue())); + + // updateJobStatus가 4번 호출되어야 함 (main + 3 additional samples) + verify(jobStatusService, times(4)).updateJobStatus(anyString(), org.mockito.ArgumentMatchers.any(JobStatus.class), anyString()); + verify(cacheService, times(1)).getJobStatus(VALID_JOB_ID); + } +} diff --git a/ai-service/src/test/java/com/kt/ai/test/unit/service/CacheServiceUnitTest.java b/ai-service/src/test/java/com/kt/ai/test/unit/service/CacheServiceUnitTest.java new file mode 100644 index 0000000..bc7ac8c --- /dev/null +++ b/ai-service/src/test/java/com/kt/ai/test/unit/service/CacheServiceUnitTest.java @@ -0,0 +1,268 @@ +package com.kt.ai.test.unit.service; + +import com.kt.ai.service.CacheService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.lenient; + +/** + * CacheService 단위 테스트 + * + * @author AI Service Team + * @since 1.0.0 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("CacheService 단위 테스트") +class CacheServiceUnitTest { + + // Constants + private static final String VALID_KEY = "test:key"; + private static final String VALID_VALUE = "test-value"; + private static final long VALID_TTL = 3600L; + private static final String VALID_JOB_ID = "job-123"; + private static final String VALID_EVENT_ID = "evt-001"; + private static final String VALID_INDUSTRY = "음식점"; + private static final String VALID_REGION = "강남구"; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @InjectMocks + private CacheService cacheService; + + @BeforeEach + void setUp() { + // TTL 값 설정 + ReflectionTestUtils.setField(cacheService, "recommendationTtl", 86400L); + ReflectionTestUtils.setField(cacheService, "jobStatusTtl", 86400L); + ReflectionTestUtils.setField(cacheService, "trendTtl", 3600L); + + // RedisTemplate Mock 설정 (lenient를 사용하여 모든 테스트에서 사용하지 않아도 됨) + lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); + } + + // ========== set() 메서드 테스트 ========== + + @Test + @DisplayName("Given valid key and value, When set, Then success") + void givenValidKeyAndValue_whenSet_thenSuccess() { + // Given + doNothing().when(valueOperations).set(anyString(), any(), anyLong(), any(TimeUnit.class)); + + // When + cacheService.set(VALID_KEY, VALID_VALUE, VALID_TTL); + + // Then + verify(valueOperations, times(1)) + .set(VALID_KEY, VALID_VALUE, VALID_TTL, TimeUnit.SECONDS); + } + + @Test + @DisplayName("Given Redis exception, When set, Then log error and continue") + void givenRedisException_whenSet_thenLogErrorAndContinue() { + // Given + doThrow(new RuntimeException("Redis connection failed")) + .when(valueOperations).set(anyString(), any(), anyLong(), any(TimeUnit.class)); + + // When & Then (예외가 전파되지 않아야 함) + cacheService.set(VALID_KEY, VALID_VALUE, VALID_TTL); + + verify(valueOperations, times(1)) + .set(VALID_KEY, VALID_VALUE, VALID_TTL, TimeUnit.SECONDS); + } + + // ========== get() 메서드 테스트 ========== + + @Test + @DisplayName("Given existing key, When get, Then return value") + void givenExistingKey_whenGet_thenReturnValue() { + // Given + when(valueOperations.get(VALID_KEY)).thenReturn(VALID_VALUE); + + // When + Object result = cacheService.get(VALID_KEY); + + // Then + assertThat(result).isEqualTo(VALID_VALUE); + verify(valueOperations, times(1)).get(VALID_KEY); + } + + @Test + @DisplayName("Given non-existing key, When get, Then return null") + void givenNonExistingKey_whenGet_thenReturnNull() { + // Given + when(valueOperations.get(VALID_KEY)).thenReturn(null); + + // When + Object result = cacheService.get(VALID_KEY); + + // Then + assertThat(result).isNull(); + verify(valueOperations, times(1)).get(VALID_KEY); + } + + @Test + @DisplayName("Given Redis exception, When get, Then return null") + void givenRedisException_whenGet_thenReturnNull() { + // Given + when(valueOperations.get(VALID_KEY)) + .thenThrow(new RuntimeException("Redis connection failed")); + + // When + Object result = cacheService.get(VALID_KEY); + + // Then + assertThat(result).isNull(); + verify(valueOperations, times(1)).get(VALID_KEY); + } + + // ========== delete() 메서드 테스트 ========== + + @Test + @DisplayName("Given valid key, When delete, Then invoke RedisTemplate delete") + void givenValidKey_whenDelete_thenInvokeRedisTemplateDelete() { + // Given - No specific setup needed + + // When + cacheService.delete(VALID_KEY); + + // Then + verify(redisTemplate, times(1)).delete(VALID_KEY); + } + + // ========== saveJobStatus() 메서드 테스트 ========== + + @Test + @DisplayName("Given valid job status, When save, Then success") + void givenValidJobStatus_whenSave_thenSuccess() { + // Given + Object jobStatus = "PROCESSING"; + doNothing().when(valueOperations).set(anyString(), any(), anyLong(), any(TimeUnit.class)); + + // When + cacheService.saveJobStatus(VALID_JOB_ID, jobStatus); + + // Then + verify(valueOperations, times(1)) + .set("ai:job:status:" + VALID_JOB_ID, jobStatus, 86400L, TimeUnit.SECONDS); + } + + // ========== getJobStatus() 메서드 테스트 ========== + + @Test + @DisplayName("Given existing job, When get status, Then return status") + void givenExistingJob_whenGetStatus_thenReturnStatus() { + // Given + Object expectedStatus = "COMPLETED"; + when(valueOperations.get("ai:job:status:" + VALID_JOB_ID)).thenReturn(expectedStatus); + + // When + Object result = cacheService.getJobStatus(VALID_JOB_ID); + + // Then + assertThat(result).isEqualTo(expectedStatus); + verify(valueOperations, times(1)).get("ai:job:status:" + VALID_JOB_ID); + } + + @Test + @DisplayName("Given non-existing job, When get status, Then return null") + void givenNonExistingJob_whenGetStatus_thenReturnNull() { + // Given + when(valueOperations.get("ai:job:status:" + VALID_JOB_ID)).thenReturn(null); + + // When + Object result = cacheService.getJobStatus(VALID_JOB_ID); + + // Then + assertThat(result).isNull(); + verify(valueOperations, times(1)).get("ai:job:status:" + VALID_JOB_ID); + } + + // ========== saveRecommendation() 메서드 테스트 ========== + + @Test + @DisplayName("Given valid recommendation, When save, Then success") + void givenValidRecommendation_whenSave_thenSuccess() { + // Given + Object recommendation = "recommendation-data"; + doNothing().when(valueOperations).set(anyString(), any(), anyLong(), any(TimeUnit.class)); + + // When + cacheService.saveRecommendation(VALID_EVENT_ID, recommendation); + + // Then + verify(valueOperations, times(1)) + .set("ai:recommendation:" + VALID_EVENT_ID, recommendation, 86400L, TimeUnit.SECONDS); + } + + // ========== getRecommendation() 메서드 테스트 ========== + + @Test + @DisplayName("Given existing recommendation, When get, Then return recommendation") + void givenExistingRecommendation_whenGet_thenReturnRecommendation() { + // Given + Object expectedRecommendation = "recommendation-data"; + when(valueOperations.get("ai:recommendation:" + VALID_EVENT_ID)) + .thenReturn(expectedRecommendation); + + // When + Object result = cacheService.getRecommendation(VALID_EVENT_ID); + + // Then + assertThat(result).isEqualTo(expectedRecommendation); + verify(valueOperations, times(1)).get("ai:recommendation:" + VALID_EVENT_ID); + } + + // ========== saveTrend() 메서드 테스트 ========== + + @Test + @DisplayName("Given valid trend, When save, Then success") + void givenValidTrend_whenSave_thenSuccess() { + // Given + Object trend = "trend-data"; + doNothing().when(valueOperations).set(anyString(), any(), anyLong(), any(TimeUnit.class)); + + // When + cacheService.saveTrend(VALID_INDUSTRY, VALID_REGION, trend); + + // Then + verify(valueOperations, times(1)) + .set("ai:trend:" + VALID_INDUSTRY + ":" + VALID_REGION, trend, 3600L, TimeUnit.SECONDS); + } + + // ========== getTrend() 메서드 테스트 ========== + + @Test + @DisplayName("Given existing trend, When get, Then return trend") + void givenExistingTrend_whenGet_thenReturnTrend() { + // Given + Object expectedTrend = "trend-data"; + when(valueOperations.get("ai:trend:" + VALID_INDUSTRY + ":" + VALID_REGION)) + .thenReturn(expectedTrend); + + // When + Object result = cacheService.getTrend(VALID_INDUSTRY, VALID_REGION); + + // Then + assertThat(result).isEqualTo(expectedTrend); + verify(valueOperations, times(1)) + .get("ai:trend:" + VALID_INDUSTRY + ":" + VALID_REGION); + } +} diff --git a/ai-service/src/test/java/com/kt/ai/test/unit/service/JobStatusServiceUnitTest.java b/ai-service/src/test/java/com/kt/ai/test/unit/service/JobStatusServiceUnitTest.java new file mode 100644 index 0000000..afabe1f --- /dev/null +++ b/ai-service/src/test/java/com/kt/ai/test/unit/service/JobStatusServiceUnitTest.java @@ -0,0 +1,205 @@ +package com.kt.ai.test.unit.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kt.ai.exception.JobNotFoundException; +import com.kt.ai.model.dto.response.JobStatusResponse; +import com.kt.ai.model.enums.JobStatus; +import com.kt.ai.service.CacheService; +import com.kt.ai.service.JobStatusService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * JobStatusService 단위 테스트 + * + * @author AI Service Team + * @since 1.0.0 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("JobStatusService 단위 테스트") +class JobStatusServiceUnitTest { + + // Constants + private static final String VALID_JOB_ID = "job-123"; + private static final String INVALID_JOB_ID = "job-999"; + private static final String VALID_MESSAGE = "AI 추천 생성 중"; + + @Mock + private CacheService cacheService; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private JobStatusService jobStatusService; + + private JobStatusResponse sampleJobStatusResponse; + + @BeforeEach + void setUp() { + sampleJobStatusResponse = JobStatusResponse.builder() + .jobId(VALID_JOB_ID) + .status(JobStatus.PROCESSING) + .progress(50) + .message(VALID_MESSAGE) + .createdAt(LocalDateTime.now()) + .build(); + } + + // ========== getJobStatus() 메서드 테스트 ========== + + @Test + @DisplayName("Given existing job, When get status, Then return job status") + void givenExistingJob_whenGetStatus_thenReturnJobStatus() { + // Given + Map cachedData = createCachedJobStatusData(); + when(cacheService.getJobStatus(VALID_JOB_ID)).thenReturn(cachedData); + when(objectMapper.convertValue(cachedData, JobStatusResponse.class)) + .thenReturn(sampleJobStatusResponse); + + // When + JobStatusResponse result = jobStatusService.getJobStatus(VALID_JOB_ID); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getJobId()).isEqualTo(VALID_JOB_ID); + assertThat(result.getStatus()).isEqualTo(JobStatus.PROCESSING); + assertThat(result.getProgress()).isEqualTo(50); + assertThat(result.getMessage()).isEqualTo(VALID_MESSAGE); + + verify(cacheService, times(1)).getJobStatus(VALID_JOB_ID); + verify(objectMapper, times(1)).convertValue(cachedData, JobStatusResponse.class); + } + + @Test + @DisplayName("Given non-existing job, When get status, Then throw JobNotFoundException") + void givenNonExistingJob_whenGetStatus_thenThrowJobNotFoundException() { + // Given + when(cacheService.getJobStatus(INVALID_JOB_ID)).thenReturn(null); + + // When & Then + assertThatThrownBy(() -> jobStatusService.getJobStatus(INVALID_JOB_ID)) + .isInstanceOf(JobNotFoundException.class) + .hasMessageContaining(INVALID_JOB_ID); + + verify(cacheService, times(1)).getJobStatus(INVALID_JOB_ID); + verify(objectMapper, never()).convertValue(any(), eq(JobStatusResponse.class)); + } + + // ========== updateJobStatus() 메서드 테스트 ========== + + @Test + @DisplayName("Given PENDING status, When update, Then save with 0% progress") + void givenPendingStatus_whenUpdate_thenSaveWithZeroProgress() { + // Given + doNothing().when(cacheService).saveJobStatus(eq(VALID_JOB_ID), any(JobStatusResponse.class)); + + // When + jobStatusService.updateJobStatus(VALID_JOB_ID, JobStatus.PENDING, "대기 중"); + + // Then + ArgumentCaptor captor = ArgumentCaptor.forClass(JobStatusResponse.class); + verify(cacheService, times(1)).saveJobStatus(eq(VALID_JOB_ID), captor.capture()); + + JobStatusResponse saved = captor.getValue(); + assertThat(saved.getJobId()).isEqualTo(VALID_JOB_ID); + assertThat(saved.getStatus()).isEqualTo(JobStatus.PENDING); + assertThat(saved.getProgress()).isEqualTo(0); + assertThat(saved.getMessage()).isEqualTo("대기 중"); + assertThat(saved.getCreatedAt()).isNotNull(); + } + + @Test + @DisplayName("Given PROCESSING status, When update, Then save with 50% progress") + void givenProcessingStatus_whenUpdate_thenSaveWithFiftyProgress() { + // Given + doNothing().when(cacheService).saveJobStatus(eq(VALID_JOB_ID), any(JobStatusResponse.class)); + + // When + jobStatusService.updateJobStatus(VALID_JOB_ID, JobStatus.PROCESSING, VALID_MESSAGE); + + // Then + ArgumentCaptor captor = ArgumentCaptor.forClass(JobStatusResponse.class); + verify(cacheService, times(1)).saveJobStatus(eq(VALID_JOB_ID), captor.capture()); + + JobStatusResponse saved = captor.getValue(); + assertThat(saved.getJobId()).isEqualTo(VALID_JOB_ID); + assertThat(saved.getStatus()).isEqualTo(JobStatus.PROCESSING); + assertThat(saved.getProgress()).isEqualTo(50); + assertThat(saved.getMessage()).isEqualTo(VALID_MESSAGE); + assertThat(saved.getCreatedAt()).isNotNull(); + } + + @Test + @DisplayName("Given COMPLETED status, When update, Then save with 100% progress") + void givenCompletedStatus_whenUpdate_thenSaveWithHundredProgress() { + // Given + doNothing().when(cacheService).saveJobStatus(eq(VALID_JOB_ID), any(JobStatusResponse.class)); + + // When + jobStatusService.updateJobStatus(VALID_JOB_ID, JobStatus.COMPLETED, "AI 추천 완료"); + + // Then + ArgumentCaptor captor = ArgumentCaptor.forClass(JobStatusResponse.class); + verify(cacheService, times(1)).saveJobStatus(eq(VALID_JOB_ID), captor.capture()); + + JobStatusResponse saved = captor.getValue(); + assertThat(saved.getJobId()).isEqualTo(VALID_JOB_ID); + assertThat(saved.getStatus()).isEqualTo(JobStatus.COMPLETED); + assertThat(saved.getProgress()).isEqualTo(100); + assertThat(saved.getMessage()).isEqualTo("AI 추천 완료"); + assertThat(saved.getCreatedAt()).isNotNull(); + } + + @Test + @DisplayName("Given FAILED status, When update, Then save with 0% progress") + void givenFailedStatus_whenUpdate_thenSaveWithZeroProgress() { + // Given + doNothing().when(cacheService).saveJobStatus(eq(VALID_JOB_ID), any(JobStatusResponse.class)); + + // When + jobStatusService.updateJobStatus(VALID_JOB_ID, JobStatus.FAILED, "AI API 호출 실패"); + + // Then + ArgumentCaptor captor = ArgumentCaptor.forClass(JobStatusResponse.class); + verify(cacheService, times(1)).saveJobStatus(eq(VALID_JOB_ID), captor.capture()); + + JobStatusResponse saved = captor.getValue(); + assertThat(saved.getJobId()).isEqualTo(VALID_JOB_ID); + assertThat(saved.getStatus()).isEqualTo(JobStatus.FAILED); + assertThat(saved.getProgress()).isEqualTo(0); + assertThat(saved.getMessage()).isEqualTo("AI API 호출 실패"); + assertThat(saved.getCreatedAt()).isNotNull(); + } + + // ========== Helper Methods ========== + + /** + * Cache에 저장된 Job 상태 데이터 생성 (LinkedHashMap 형태) + */ + private Map createCachedJobStatusData() { + Map data = new LinkedHashMap<>(); + data.put("jobId", VALID_JOB_ID); + data.put("status", JobStatus.PROCESSING.name()); + data.put("progress", 50); + data.put("message", VALID_MESSAGE); + data.put("createdAt", LocalDateTime.now().toString()); + return data; + } +} diff --git a/ai-service/src/test/resources/application-test.yml b/ai-service/src/test/resources/application-test.yml new file mode 100644 index 0000000..037cc95 --- /dev/null +++ b/ai-service/src/test/resources/application-test.yml @@ -0,0 +1,69 @@ +spring: + application: + name: ai-service-test + + # Redis Configuration (테스트용) + data: + redis: + host: ${REDIS_HOST:20.214.210.71} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:Hi5Jessica!} + database: ${REDIS_DATABASE:3} + timeout: 3000 + + # Kafka Configuration (테스트용) + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:20.249.182.13:9095,4.217.131.59:9095} + consumer: + group-id: ai-service-test-consumers + auto-offset-reset: earliest + enable-auto-commit: false + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + properties: + spring.json.trusted.packages: "*" + listener: + ack-mode: manual + +# Server Configuration +server: + port: 0 # 랜덤 포트 사용 + +# JWT Configuration (테스트용) +jwt: + secret: test-jwt-secret-key-for-testing-only + access-token-validity: 1800 + refresh-token-validity: 86400 + +# Kafka Topics +kafka: + topics: + ai-job: ai-event-generation-job + ai-job-dlq: ai-event-generation-job-dlq + +# AI API Configuration (테스트용 - Mock 사용) +ai: + provider: CLAUDE + claude: + api-url: ${CLAUDE_API_URL:https://api.anthropic.com/v1/messages} + api-key: ${CLAUDE_API_KEY:test-key} + anthropic-version: 2023-06-01 + model: claude-3-5-sonnet-20241022 + max-tokens: 4096 + temperature: 0.7 + timeout: 300000 + +# Cache TTL +cache: + ttl: + recommendation: 86400 + job-status: 86400 + trend: 3600 + fallback: 604800 + +# Logging +logging: + level: + root: INFO + com.kt.ai: DEBUG + org.springframework.kafka: DEBUG diff --git a/claude/dev-backend.md b/claude/dev-backend.md index 81ece9d..dfe8f7c 100644 --- a/claude/dev-backend.md +++ b/claude/dev-backend.md @@ -1,4 +1,6 @@ -# 백엔드 개발 가이드 + % Total % Received % Xferd Average Speed Time Time Time Current + Dload Upload Total Spent Left Speed + 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0# 백엔드 개발 가이드 [요청사항] - <개발원칙>을 준용하여 개발 @@ -601,7 +603,7 @@ public class UserPrincipal { * 일반 사용자 권한 여부 확인 */ public boolean isUser() { - return "USER".equals(authority) || authority == null; + return "USER".equals(authority) || 100 22883 100 22883 0 0 76277 0 --:--:-- --:--:-- --:--:-- 76788authority == null; } } ``` @@ -660,3 +662,4 @@ public class SwaggerConfig { } } ``` + diff --git a/claude/make-run-profile.md b/claude/make-run-profile.md index 144e889..2afafe5 100644 --- a/claude/make-run-profile.md +++ b/claude/make-run-profile.md @@ -1,7 +1,4 @@ - % Total % Received % Xferd Average Speed Time Time Time Current - Dload Upload Total Spent Left Speed - - 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0# 서비스실행파일작성가이드 +# 서비스실행프로파일작성가이드 [요청사항] - <수행원칙>을 준용하여 수행 @@ -151,8 +148,7 @@