add ai-service
This commit is contained in:
+10
-2
@@ -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,12 @@ 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.kt.ai;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
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
|
||||
public class AiServiceApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(AiServiceApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -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> T executeWithCircuitBreaker(
|
||||
String circuitBreakerName,
|
||||
Supplier<T> supplier,
|
||||
Supplier<T> 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> T executeWithCircuitBreaker(String circuitBreakerName, Supplier<T> supplier) {
|
||||
return executeWithCircuitBreaker(circuitBreakerName, supplier, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit Breaker 상태 조회
|
||||
*/
|
||||
public CircuitBreaker.State getCircuitBreakerState(String circuitBreakerName) {
|
||||
CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(circuitBreakerName);
|
||||
return circuitBreaker.getState();
|
||||
}
|
||||
}
|
||||
@@ -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<TrendAnalysis.TrendKeyword> industryTrends = List.of(
|
||||
TrendAnalysis.TrendKeyword.builder()
|
||||
.keyword("고객 만족도 향상")
|
||||
.relevance(0.8)
|
||||
.description(industry + " 업종에서 고객 만족도가 중요한 트렌드입니다")
|
||||
.build(),
|
||||
TrendAnalysis.TrendKeyword.builder()
|
||||
.keyword("디지털 마케팅")
|
||||
.relevance(0.75)
|
||||
.description("SNS 및 온라인 마케팅이 효과적입니다")
|
||||
.build()
|
||||
);
|
||||
|
||||
List<TrendAnalysis.TrendKeyword> regionalTrends = List.of(
|
||||
TrendAnalysis.TrendKeyword.builder()
|
||||
.keyword("지역 커뮤니티")
|
||||
.relevance(0.7)
|
||||
.description(region + " 지역 커뮤니티 참여가 효과적입니다")
|
||||
.build()
|
||||
);
|
||||
|
||||
List<TrendAnalysis.TrendKeyword> seasonalTrends = List.of(
|
||||
TrendAnalysis.TrendKeyword.builder()
|
||||
.keyword("시즌 이벤트")
|
||||
.relevance(0.85)
|
||||
.description("계절 특성을 반영한 이벤트가 효과적입니다")
|
||||
.build()
|
||||
);
|
||||
|
||||
return TrendAnalysis.builder()
|
||||
.industryTrends(industryTrends)
|
||||
.regionalTrends(regionalTrends)
|
||||
.seasonalTrends(seasonalTrends)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 이벤트 추천안 반환
|
||||
*/
|
||||
public List<EventRecommendation> getDefaultRecommendations(String objective, String industry) {
|
||||
log.info("Fallback: 기본 이벤트 추천안 반환 - objective={}, industry={}", objective, industry);
|
||||
|
||||
List<EventRecommendation> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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
|
||||
ClaudeResponse sendMessage(
|
||||
@RequestHeader("x-api-key") String apiKey,
|
||||
@RequestHeader("anthropic-version") String anthropicVersion,
|
||||
@RequestHeader("content-type") String contentType,
|
||||
@RequestBody ClaudeRequest request
|
||||
);
|
||||
}
|
||||
@@ -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회)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Message> 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;
|
||||
}
|
||||
}
|
||||
@@ -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> 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, AIJobMessage> consumerFactory() {
|
||||
Map<String, Object> 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<String, AIJobMessage> kafkaListenerContainerFactory() {
|
||||
ConcurrentKafkaListenerContainerFactory<String, AIJobMessage> factory =
|
||||
new ConcurrentKafkaListenerContainerFactory<>();
|
||||
factory.setConsumerFactory(consumerFactory());
|
||||
factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL);
|
||||
return factory;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.kt.ai.config;
|
||||
|
||||
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.LettuceConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
return new LettuceConnectionFactory(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* RedisTemplate 설정
|
||||
* - Key: String
|
||||
* - Value: JSON (Jackson)
|
||||
*/
|
||||
@Bean
|
||||
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
|
||||
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(connectionFactory);
|
||||
|
||||
// Key Serializer: String
|
||||
template.setKeySerializer(new StringRedisSerializer());
|
||||
template.setHashKeySerializer(new StringRedisSerializer());
|
||||
|
||||
// Value Serializer: JSON
|
||||
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
|
||||
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
|
||||
|
||||
template.afterPropertiesSet();
|
||||
return template;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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.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.RestController;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 헬스체크 Controller
|
||||
*
|
||||
* @author AI Service Team
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Slf4j
|
||||
@Tag(name = "Health Check", description = "서비스 상태 확인")
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class HealthController {
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
/**
|
||||
* 서비스 헬스체크
|
||||
*/
|
||||
@Operation(summary = "서비스 헬스체크", description = "AI Service 상태 및 외부 연동 확인")
|
||||
@GetMapping("/health")
|
||||
public ResponseEntity<HealthCheckResponse> healthCheck() {
|
||||
// Redis 상태 확인
|
||||
ServiceStatus redisStatus = checkRedis();
|
||||
|
||||
// 전체 서비스 상태
|
||||
ServiceStatus overallStatus = (redisStatus == ServiceStatus.UP) ? ServiceStatus.UP : ServiceStatus.DEGRADED;
|
||||
|
||||
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() {
|
||||
try {
|
||||
redisTemplate.getConnectionFactory().getConnection().ping();
|
||||
return ServiceStatus.UP;
|
||||
} catch (Exception e) {
|
||||
log.error("Redis 연결 실패", e);
|
||||
return ServiceStatus.DOWN;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.kt.ai.controller;
|
||||
|
||||
import com.kt.ai.model.dto.response.JobStatusResponse;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Internal Job Controller
|
||||
* Event Service에서 호출하는 내부 API
|
||||
*
|
||||
* @author AI Service Team
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Slf4j
|
||||
@Tag(name = "Internal API", description = "내부 서비스 간 통신용 API")
|
||||
@RestController
|
||||
@RequestMapping("/internal/jobs")
|
||||
@RequiredArgsConstructor
|
||||
public class InternalJobController {
|
||||
|
||||
private final JobStatusService jobStatusService;
|
||||
|
||||
/**
|
||||
* 작업 상태 조회
|
||||
*/
|
||||
@Operation(summary = "작업 상태 조회", description = "Redis에 저장된 AI 추천 작업 상태 조회")
|
||||
@GetMapping("/{jobId}/status")
|
||||
public ResponseEntity<JobStatusResponse> getJobStatus(@PathVariable String jobId) {
|
||||
log.info("Job 상태 조회 요청: jobId={}", jobId);
|
||||
JobStatusResponse response = jobStatusService.getJobStatus(jobId);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.kt.ai.controller;
|
||||
|
||||
import com.kt.ai.model.dto.response.AIRecommendationResult;
|
||||
import com.kt.ai.service.AIRecommendationService;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Internal Recommendation Controller
|
||||
* Event Service에서 호출하는 내부 API
|
||||
*
|
||||
* @author AI Service Team
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Slf4j
|
||||
@Tag(name = "Internal API", description = "내부 서비스 간 통신용 API")
|
||||
@RestController
|
||||
@RequestMapping("/internal/recommendations")
|
||||
@RequiredArgsConstructor
|
||||
public class InternalRecommendationController {
|
||||
|
||||
private final AIRecommendationService aiRecommendationService;
|
||||
|
||||
/**
|
||||
* AI 추천 결과 조회
|
||||
*/
|
||||
@Operation(summary = "AI 추천 결과 조회", description = "Redis에 캐시된 AI 추천 결과 조회")
|
||||
@GetMapping("/{eventId}")
|
||||
public ResponseEntity<AIRecommendationResult> getRecommendation(@PathVariable String eventId) {
|
||||
log.info("AI 추천 결과 조회 요청: eventId={}", eventId);
|
||||
AIRecommendationResult response = aiRecommendationService.getRecommendation(eventId);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
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 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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> handleCircuitBreakerOpenException(CircuitBreakerOpenException ex) {
|
||||
log.error("Circuit breaker open: {}", ex.getMessage());
|
||||
|
||||
Map<String, Object> 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<ErrorResponse> 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일반 예외 처리
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ErrorResponse> 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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로 이동)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<EventRecommendation> recommendations;
|
||||
|
||||
/**
|
||||
* 생성 시각
|
||||
*/
|
||||
private LocalDateTime generatedAt;
|
||||
|
||||
/**
|
||||
* 캐시 만료 시각 (생성 시각 + 24시간)
|
||||
*/
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
/**
|
||||
* 사용된 AI 제공자
|
||||
*/
|
||||
private AIProvider aiProvider;
|
||||
}
|
||||
@@ -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<String, Object> details;
|
||||
}
|
||||
@@ -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<String> 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<String, Integer> breakdown;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<TrendKeyword> industryTrends;
|
||||
|
||||
/**
|
||||
* 지역 트렌드 키워드 (최대 5개)
|
||||
*/
|
||||
private List<TrendKeyword> regionalTrends;
|
||||
|
||||
/**
|
||||
* 시즌 트렌드 키워드 (최대 5개)
|
||||
*/
|
||||
private List<TrendKeyword> seasonalTrends;
|
||||
|
||||
/**
|
||||
* 트렌드 키워드 정보
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class TrendKeyword {
|
||||
/**
|
||||
* 트렌드 키워드
|
||||
*/
|
||||
private String keyword;
|
||||
|
||||
/**
|
||||
* 연관도 (0-1)
|
||||
*/
|
||||
private Double relevance;
|
||||
|
||||
/**
|
||||
* 트렌드 설명
|
||||
*/
|
||||
private String description;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.kt.ai.model.enums;
|
||||
|
||||
/**
|
||||
* 서비스 상태
|
||||
*
|
||||
* @author AI Service Team
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public enum ServiceStatus {
|
||||
/**
|
||||
* 정상 동작
|
||||
*/
|
||||
UP,
|
||||
|
||||
/**
|
||||
* 서비스 중단
|
||||
*/
|
||||
DOWN,
|
||||
|
||||
/**
|
||||
* 성능 저하
|
||||
*/
|
||||
DEGRADED
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
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<EventRecommendation> 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<EventRecommendation> 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<EventRecommendation> 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,
|
||||
"application/json",
|
||||
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<EventRecommendation> parseRecommendationResponse(String responseText) {
|
||||
try {
|
||||
// JSON 부분만 추출
|
||||
String jsonText = extractJsonFromMarkdown(responseText);
|
||||
|
||||
// JSON 파싱
|
||||
JsonNode rootNode = objectMapper.readTree(jsonText);
|
||||
JsonNode recommendationsNode = rootNode.get("recommendations");
|
||||
|
||||
List<EventRecommendation> 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<String> promotionChannels = new ArrayList<>();
|
||||
JsonNode channelsNode = node.get("promotionChannels");
|
||||
if (channelsNode != null && channelsNode.isArray()) {
|
||||
channelsNode.forEach(channel -> promotionChannels.add(channel.asText()));
|
||||
}
|
||||
|
||||
// Breakdown 파싱
|
||||
Map<String, Integer> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
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,
|
||||
"application/json",
|
||||
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<TrendAnalysis.TrendKeyword> parseTrendKeywords(JsonNode arrayNode) {
|
||||
List<TrendAnalysis.TrendKeyword> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
spring:
|
||||
application:
|
||||
name: ai-service
|
||||
|
||||
# Redis Configuration
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:20.214.210.71}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
database: ${REDIS_DATABASE:3} # 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
|
||||
|
||||
# JPA Configuration (Not used but included for consistency)
|
||||
jpa:
|
||||
open-in-view: false
|
||||
show-sql: false
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
use_sql_comments: false
|
||||
|
||||
# Database Configuration (Not used but included for consistency)
|
||||
datasource:
|
||||
url: jdbc:postgresql://${DB_HOST:4.230.112.141}:${DB_PORT:5432}/${DB_NAME:aidb}
|
||||
username: ${DB_USERNAME:eventuser}
|
||||
password: ${DB_PASSWORD:}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
hikari:
|
||||
maximum-pool-size: 10
|
||||
minimum-idle: 2
|
||||
connection-timeout: 30000
|
||||
|
||||
# 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"
|
||||
|
||||
# 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:}
|
||||
model: ${CLAUDE_MODEL:claude-3-5-sonnet-20241022}
|
||||
max-tokens: ${CLAUDE_MAX_TOKENS:4096}
|
||||
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
|
||||
Reference in New Issue
Block a user