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 index b54b890..79ca52e 100644 --- a/ai-service/src/main/java/com/kt/ai/controller/HealthController.java +++ b/ai-service/src/main/java/com/kt/ai/controller/HealthController.java @@ -32,7 +32,7 @@ public class HealthController { * 서비스 헬스체크 */ @Operation(summary = "서비스 헬스체크", description = "AI Service 상태 및 외부 연동 확인") - @GetMapping("/api/v1/ai-service/health") + @GetMapping("/health") public ResponseEntity healthCheck() { // Redis 상태 확인 ServiceStatus redisStatus = checkRedis(); diff --git a/ai-service/src/main/resources/application.yml b/ai-service/src/main/resources/application.yml index fa3f33d..df4adcd 100644 --- a/ai-service/src/main/resources/application.yml +++ b/ai-service/src/main/resources/application.yml @@ -39,7 +39,7 @@ spring: server: port: ${SERVER_PORT:8083} servlet: - context-path: /api/v1/ai-service + context-path: /api/v1/ai encoding: charset: UTF-8 enabled: true diff --git a/analytics-service/src/main/java/com/kt/event/analytics/config/SampleDataLoader.java b/analytics-service/src/main/java/com/kt/event/analytics/config/SampleDataLoader.java index 422ff1d..dcf3c82 100644 --- a/analytics-service/src/main/java/com/kt/event/analytics/config/SampleDataLoader.java +++ b/analytics-service/src/main/java/com/kt/event/analytics/config/SampleDataLoader.java @@ -225,7 +225,7 @@ public class SampleDataLoader implements ApplicationRunner { private void publishEventCreatedEvents() throws Exception { // 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과 - ROI 200%) EventCreatedEvent event1 = EventCreatedEvent.builder() - .eventId("1") + .eventId("evt_2025012301") .eventTitle("신년맞이 20% 할인 이벤트") .storeId("store_001") .totalInvestment(new BigDecimal("5000000")) @@ -238,7 +238,7 @@ public class SampleDataLoader implements ApplicationRunner { // 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과 - ROI 100%) EventCreatedEvent event2 = EventCreatedEvent.builder() - .eventId("2") + .eventId("evt_2025012302") .eventTitle("설날 특가 선물세트 이벤트") .storeId("store_001") .totalInvestment(new BigDecimal("3500000")) @@ -251,7 +251,7 @@ public class SampleDataLoader implements ApplicationRunner { // 이벤트 3: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과 - ROI 50%) EventCreatedEvent event3 = EventCreatedEvent.builder() - .eventId("3") + .eventId("evt_2025012303") .eventTitle("겨울 신메뉴 런칭 이벤트") .storeId("store_001") .totalInvestment(new BigDecimal("2000000")) @@ -269,7 +269,7 @@ public class SampleDataLoader implements ApplicationRunner { * DistributionCompleted 이벤트 발행 (설계서 기준 - 이벤트당 1번 발행, 여러 채널 배열) */ private void publishDistributionCompletedEvents() throws Exception { - String[] eventIds = {"1", "2", "3"}; + String[] eventIds = {"evt_2025012301", "evt_2025012302", "evt_2025012303"}; int[][] expectedViews = { {5000, 10000, 3000, 2000}, // 이벤트1: 우리동네TV, 지니TV, 링고비즈, SNS {3500, 7000, 2000, 1500}, // 이벤트2 @@ -359,7 +359,7 @@ public class SampleDataLoader implements ApplicationRunner { * - 이벤트3: 30명 (user071~user100) → 30명이 이전 이벤트들과 중복 */ private void publishParticipantRegisteredEvents() throws Exception { - String[] eventIds = {"1", "2", "3"}; + String[] eventIds = {"evt_2025012301", "evt_2025012302", "evt_2025012303"}; String[] channels = {"우리동네TV", "지니TV", "링고비즈", "SNS"}; // 이벤트별 참여자 범위 (중복 참여 반영) diff --git a/analytics-service/src/main/java/com/kt/event/analytics/config/SecurityConfig.java b/analytics-service/src/main/java/com/kt/event/analytics/config/SecurityConfig.java index 8820d17..943ecb8 100644 --- a/analytics-service/src/main/java/com/kt/event/analytics/config/SecurityConfig.java +++ b/analytics-service/src/main/java/com/kt/event/analytics/config/SecurityConfig.java @@ -3,7 +3,6 @@ package com.kt.event.analytics.config; import com.kt.event.common.security.JwtAuthenticationFilter; import com.kt.event.common.security.JwtTokenProvider; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -12,15 +11,12 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; - -import java.util.Arrays; /** * Spring Security 설정 * JWT 기반 인증 및 API 보안 설정 + * + * ⚠️ CORS 설정은 WebConfig에서 관리합니다. */ @Configuration @EnableWebSecurity @@ -29,14 +25,11 @@ public class SecurityConfig { private final JwtTokenProvider jwtTokenProvider; - @Value("${cors.allowed-origins:http://localhost:*}") - private String allowedOrigins; - @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .csrf(AbstractHttpConfigurer::disable) - .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .cors(AbstractHttpConfigurer::disable) // CORS는 WebConfig에서 관리 .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .anyRequest().permitAll() @@ -46,25 +39,5 @@ public class SecurityConfig { .build(); } - @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration configuration = new CorsConfiguration(); - - String[] origins = allowedOrigins.split(","); - configuration.setAllowedOriginPatterns(Arrays.asList(origins)); - - configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); - - configuration.setAllowedHeaders(Arrays.asList( - "Authorization", "Content-Type", "X-Requested-With", "Accept", - "Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers" - )); - - configuration.setAllowCredentials(true); - configuration.setMaxAge(3600L); - - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - return source; - } + // CORS 설정은 WebConfig에서 관리 (모든 origin 허용) } diff --git a/deployment/k8s/ai-service/deployment.yaml b/deployment/k8s/ai-service/deployment.yaml index 00349b0..4b122e0 100644 --- a/deployment/k8s/ai-service/deployment.yaml +++ b/deployment/k8s/ai-service/deployment.yaml @@ -19,7 +19,7 @@ spec: - name: kt-event-marketing containers: - name: ai-service - image: acrdigitalgarage01.azurecr.io/kt-event-marketing/ai-service:latest + image: acrdigitalgarage01.azurecr.io/kt-event-marketing/ai-service:dev imagePullPolicy: Always ports: - containerPort: 8083 @@ -42,21 +42,21 @@ spec: memory: "1024Mi" startupProbe: httpGet: - path: /api/v1/ai-service/actuator/health + path: /api/v1/ai/actuator/health port: 8083 initialDelaySeconds: 30 periodSeconds: 10 failureThreshold: 30 readinessProbe: httpGet: - path: /api/v1/ai-service/actuator/health/readiness + path: /api/v1/ai/actuator/health/readiness port: 8083 initialDelaySeconds: 10 periodSeconds: 5 failureThreshold: 3 livenessProbe: httpGet: - path: /api/v1/ai-service/actuator/health/liveness + path: /api/v1/ai/actuator/health/liveness port: 8083 initialDelaySeconds: 30 periodSeconds: 10 diff --git a/deployment/k8s/common/ingress.yaml b/deployment/k8s/common/ingress.yaml index 8c9127a..960c2fb 100644 --- a/deployment/k8s/common/ingress.yaml +++ b/deployment/k8s/common/ingress.yaml @@ -56,7 +56,7 @@ spec: number: 80 # AI Service - - path: /api/v1/ai-service + - path: /api/v1/ai pathType: Prefix backend: service: diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/service/EventService.java b/event-service/src/main/java/com/kt/event/eventservice/application/service/EventService.java index 1443ac2..5616742 100644 --- a/event-service/src/main/java/com/kt/event/eventservice/application/service/EventService.java +++ b/event-service/src/main/java/com/kt/event/eventservice/application/service/EventService.java @@ -10,7 +10,9 @@ import com.kt.event.eventservice.domain.entity.*; import com.kt.event.eventservice.domain.enums.EventStatus; import com.kt.event.eventservice.domain.repository.EventRepository; import com.kt.event.eventservice.domain.repository.JobRepository; +import com.kt.event.eventservice.infrastructure.client.AIServiceClient; import com.kt.event.eventservice.infrastructure.client.ContentServiceClient; +import com.kt.event.eventservice.infrastructure.client.dto.AIRecommendationResponse; import com.kt.event.eventservice.infrastructure.client.dto.ContentImageGenerationRequest; import com.kt.event.eventservice.infrastructure.client.dto.ContentJobResponse; import com.kt.event.eventservice.infrastructure.kafka.AIJobKafkaProducer; @@ -43,6 +45,7 @@ public class EventService { private final EventRepository eventRepository; private final JobRepository jobRepository; + private final AIServiceClient aiServiceClient; private final ContentServiceClient contentServiceClient; private final AIJobKafkaProducer aiJobKafkaProducer; private final ImageJobKafkaProducer imageJobKafkaProducer; @@ -611,4 +614,30 @@ public class EventService { .updatedAt(event.getUpdatedAt()) .build(); } + + /** + * AI 추천안 조회 (AI Service에서 직접 조회) + * + * @param userId 사용자 ID + * @param eventId 이벤트 ID + * @return AI 추천 결과 + */ + public AIRecommendationResponse getAiRecommendations(String userId, String eventId) { + log.info("AI 추천안 조회 - userId: {}, eventId: {}", userId, eventId); + + // 이벤트 권한 확인 + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); + + // AI Service에서 추천안 조회 + try { + AIRecommendationResponse response = aiServiceClient.getRecommendation(eventId); + log.info("AI 추천안 조회 성공 - eventId: {}, 추천안 수: {}", + eventId, response.getRecommendations() != null ? response.getRecommendations().size() : 0); + return response; + } catch (Exception e) { + log.error("AI 추천안 조회 실패 - eventId: {}", eventId, e); + throw new BusinessException(ErrorCode.AI_004); + } + } } diff --git a/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/AIServiceClient.java b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/AIServiceClient.java new file mode 100644 index 0000000..e1d2f02 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/AIServiceClient.java @@ -0,0 +1,31 @@ +package com.kt.event.eventservice.infrastructure.client; + +import com.kt.event.eventservice.infrastructure.client.dto.AIRecommendationResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +/** + * AI Service Feign Client + * + * AI Service의 추천안 조회 API를 호출합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-30 + */ +@FeignClient( + name = "ai-service", + url = "${feign.ai-service.url:http://localhost:8083}" +) +public interface AIServiceClient { + + /** + * AI 추천 결과 조회 + * + * @param eventId 이벤트 ID + * @return AI 추천 결과 + */ + @GetMapping("/recommendations/{eventId}") + AIRecommendationResponse getRecommendation(@PathVariable("eventId") String eventId); +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/dto/AIRecommendationResponse.java b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/dto/AIRecommendationResponse.java new file mode 100644 index 0000000..0dbbf94 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/dto/AIRecommendationResponse.java @@ -0,0 +1,123 @@ +package com.kt.event.eventservice.infrastructure.client.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * AI Service 추천안 응답 DTO + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-30 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AIRecommendationResponse { + + private String eventId; + private TrendAnalysis trendAnalysis; + private List recommendations; + private LocalDateTime generatedAt; + private LocalDateTime expiresAt; + private String aiProvider; + + /** + * 트렌드 분석 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TrendAnalysis { + private List industryTrends; + private List regionalTrends; + private List seasonalTrends; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TrendKeyword { + private String keyword; + private Double relevance; + private String description; + } + } + + /** + * 이벤트 추천안 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class EventRecommendation { + private Integer optionNumber; + private String concept; + private String title; + private String description; + private String targetAudience; + private Duration duration; + private Mechanics mechanics; + 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 String type; + private String details; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class EstimatedCost { + private Integer min; + private Integer max; + private Map breakdown; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ExpectedMetrics { + private Range newCustomers; + private Range revenueIncrease; + private Range roi; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Range { + private Double min; + private Double max; + } + } + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/EventController.java b/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/EventController.java index c0e016c..98549f9 100644 --- a/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/EventController.java +++ b/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/EventController.java @@ -6,6 +6,7 @@ import com.kt.event.common.security.UserPrincipal; import com.kt.event.eventservice.application.dto.request.*; import com.kt.event.eventservice.application.dto.response.*; import com.kt.event.eventservice.application.service.EventService; +import com.kt.event.eventservice.infrastructure.client.dto.AIRecommendationResponse; import com.kt.event.eventservice.domain.enums.EventStatus; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -287,6 +288,30 @@ public class EventController { .body(ApiResponse.success(response)); } + /** + * AI 추천안 조회 (Step 2-1) + * + * @param eventId 이벤트 ID + * @param userPrincipal 인증된 사용자 정보 + * @return AI 추천 결과 + */ + @GetMapping("/{eventId}/ai-recommendations") + @Operation(summary = "AI 추천안 조회", description = "AI Service에서 생성된 추천안을 조회합니다.") + public ResponseEntity> getAiRecommendations( + @PathVariable String eventId, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + log.info("AI 추천안 조회 API 호출 - userId: {}, eventId: {}", + userPrincipal.getUserId(), eventId); + + AIRecommendationResponse response = eventService.getAiRecommendations( + userPrincipal.getUserId(), + eventId + ); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + /** * AI 추천 선택 (Step 2-2) *