Merge branch 'develop' of https://github.com/ktds-dg0501/kt-event-marketing into feature/distribution

This commit is contained in:
sunmingLee 2025-10-30 20:30:54 +09:00
commit 06ea838547
10 changed files with 224 additions and 43 deletions

View File

@ -32,7 +32,7 @@ public class HealthController {
* 서비스 헬스체크 * 서비스 헬스체크
*/ */
@Operation(summary = "서비스 헬스체크", description = "AI Service 상태 및 외부 연동 확인") @Operation(summary = "서비스 헬스체크", description = "AI Service 상태 및 외부 연동 확인")
@GetMapping("/api/v1/ai-service/health") @GetMapping("/health")
public ResponseEntity<HealthCheckResponse> healthCheck() { public ResponseEntity<HealthCheckResponse> healthCheck() {
// Redis 상태 확인 // Redis 상태 확인
ServiceStatus redisStatus = checkRedis(); ServiceStatus redisStatus = checkRedis();

View File

@ -39,7 +39,7 @@ spring:
server: server:
port: ${SERVER_PORT:8083} port: ${SERVER_PORT:8083}
servlet: servlet:
context-path: /api/v1/ai-service context-path: /api/v1/ai
encoding: encoding:
charset: UTF-8 charset: UTF-8
enabled: true enabled: true

View File

@ -225,7 +225,7 @@ public class SampleDataLoader implements ApplicationRunner {
private void publishEventCreatedEvents() throws Exception { private void publishEventCreatedEvents() throws Exception {
// 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과 - ROI 200%) // 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과 - ROI 200%)
EventCreatedEvent event1 = EventCreatedEvent.builder() EventCreatedEvent event1 = EventCreatedEvent.builder()
.eventId("1") .eventId("evt_2025012301")
.eventTitle("신년맞이 20% 할인 이벤트") .eventTitle("신년맞이 20% 할인 이벤트")
.storeId("store_001") .storeId("store_001")
.totalInvestment(new BigDecimal("5000000")) .totalInvestment(new BigDecimal("5000000"))
@ -238,7 +238,7 @@ public class SampleDataLoader implements ApplicationRunner {
// 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과 - ROI 100%) // 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과 - ROI 100%)
EventCreatedEvent event2 = EventCreatedEvent.builder() EventCreatedEvent event2 = EventCreatedEvent.builder()
.eventId("2") .eventId("evt_2025012302")
.eventTitle("설날 특가 선물세트 이벤트") .eventTitle("설날 특가 선물세트 이벤트")
.storeId("store_001") .storeId("store_001")
.totalInvestment(new BigDecimal("3500000")) .totalInvestment(new BigDecimal("3500000"))
@ -251,7 +251,7 @@ public class SampleDataLoader implements ApplicationRunner {
// 이벤트 3: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과 - ROI 50%) // 이벤트 3: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과 - ROI 50%)
EventCreatedEvent event3 = EventCreatedEvent.builder() EventCreatedEvent event3 = EventCreatedEvent.builder()
.eventId("3") .eventId("evt_2025012303")
.eventTitle("겨울 신메뉴 런칭 이벤트") .eventTitle("겨울 신메뉴 런칭 이벤트")
.storeId("store_001") .storeId("store_001")
.totalInvestment(new BigDecimal("2000000")) .totalInvestment(new BigDecimal("2000000"))
@ -269,7 +269,7 @@ public class SampleDataLoader implements ApplicationRunner {
* DistributionCompleted 이벤트 발행 (설계서 기준 - 이벤트당 1번 발행, 여러 채널 배열) * DistributionCompleted 이벤트 발행 (설계서 기준 - 이벤트당 1번 발행, 여러 채널 배열)
*/ */
private void publishDistributionCompletedEvents() throws Exception { private void publishDistributionCompletedEvents() throws Exception {
String[] eventIds = {"1", "2", "3"}; String[] eventIds = {"evt_2025012301", "evt_2025012302", "evt_2025012303"};
int[][] expectedViews = { int[][] expectedViews = {
{5000, 10000, 3000, 2000}, // 이벤트1: 우리동네TV, 지니TV, 링고비즈, SNS {5000, 10000, 3000, 2000}, // 이벤트1: 우리동네TV, 지니TV, 링고비즈, SNS
{3500, 7000, 2000, 1500}, // 이벤트2 {3500, 7000, 2000, 1500}, // 이벤트2
@ -359,7 +359,7 @@ public class SampleDataLoader implements ApplicationRunner {
* - 이벤트3: 30명 (user071~user100) 30명이 이전 이벤트들과 중복 * - 이벤트3: 30명 (user071~user100) 30명이 이전 이벤트들과 중복
*/ */
private void publishParticipantRegisteredEvents() throws Exception { private void publishParticipantRegisteredEvents() throws Exception {
String[] eventIds = {"1", "2", "3"}; String[] eventIds = {"evt_2025012301", "evt_2025012302", "evt_2025012303"};
String[] channels = {"우리동네TV", "지니TV", "링고비즈", "SNS"}; String[] channels = {"우리동네TV", "지니TV", "링고비즈", "SNS"};
// 이벤트별 참여자 범위 (중복 참여 반영) // 이벤트별 참여자 범위 (중복 참여 반영)

View File

@ -3,7 +3,6 @@ package com.kt.event.analytics.config;
import com.kt.event.common.security.JwtAuthenticationFilter; import com.kt.event.common.security.JwtAuthenticationFilter;
import com.kt.event.common.security.JwtTokenProvider; import com.kt.event.common.security.JwtTokenProvider;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; 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.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; 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 설정 * Spring Security 설정
* JWT 기반 인증 API 보안 설정 * JWT 기반 인증 API 보안 설정
*
* CORS 설정은 WebConfig에서 관리합니다.
*/ */
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@ -29,14 +25,11 @@ public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider; private final JwtTokenProvider jwtTokenProvider;
@Value("${cors.allowed-origins:http://localhost:*}")
private String allowedOrigins;
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http return http
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource())) .cors(AbstractHttpConfigurer::disable) // CORS는 WebConfig에서 관리
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.anyRequest().permitAll() .anyRequest().permitAll()
@ -46,25 +39,5 @@ public class SecurityConfig {
.build(); .build();
} }
@Bean // CORS 설정은 WebConfig에서 관리 (모든 origin 허용)
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;
}
} }

View File

@ -19,7 +19,7 @@ spec:
- name: kt-event-marketing - name: kt-event-marketing
containers: containers:
- name: ai-service - 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 imagePullPolicy: Always
ports: ports:
- containerPort: 8083 - containerPort: 8083
@ -42,21 +42,21 @@ spec:
memory: "1024Mi" memory: "1024Mi"
startupProbe: startupProbe:
httpGet: httpGet:
path: /api/v1/ai-service/actuator/health path: /api/v1/ai/actuator/health
port: 8083 port: 8083
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 10 periodSeconds: 10
failureThreshold: 30 failureThreshold: 30
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /api/v1/ai-service/actuator/health/readiness path: /api/v1/ai/actuator/health/readiness
port: 8083 port: 8083
initialDelaySeconds: 10 initialDelaySeconds: 10
periodSeconds: 5 periodSeconds: 5
failureThreshold: 3 failureThreshold: 3
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /api/v1/ai-service/actuator/health/liveness path: /api/v1/ai/actuator/health/liveness
port: 8083 port: 8083
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 10 periodSeconds: 10

View File

@ -56,7 +56,7 @@ spec:
number: 80 number: 80
# AI Service # AI Service
- path: /api/v1/ai-service - path: /api/v1/ai
pathType: Prefix pathType: Prefix
backend: backend:
service: service:

View File

@ -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.enums.EventStatus;
import com.kt.event.eventservice.domain.repository.EventRepository; import com.kt.event.eventservice.domain.repository.EventRepository;
import com.kt.event.eventservice.domain.repository.JobRepository; 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.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.ContentImageGenerationRequest;
import com.kt.event.eventservice.infrastructure.client.dto.ContentJobResponse; import com.kt.event.eventservice.infrastructure.client.dto.ContentJobResponse;
import com.kt.event.eventservice.infrastructure.kafka.AIJobKafkaProducer; import com.kt.event.eventservice.infrastructure.kafka.AIJobKafkaProducer;
@ -43,6 +45,7 @@ public class EventService {
private final EventRepository eventRepository; private final EventRepository eventRepository;
private final JobRepository jobRepository; private final JobRepository jobRepository;
private final AIServiceClient aiServiceClient;
private final ContentServiceClient contentServiceClient; private final ContentServiceClient contentServiceClient;
private final AIJobKafkaProducer aiJobKafkaProducer; private final AIJobKafkaProducer aiJobKafkaProducer;
private final ImageJobKafkaProducer imageJobKafkaProducer; private final ImageJobKafkaProducer imageJobKafkaProducer;
@ -611,4 +614,30 @@ public class EventService {
.updatedAt(event.getUpdatedAt()) .updatedAt(event.getUpdatedAt())
.build(); .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);
}
}
} }

View File

@ -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);
}

View File

@ -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<EventRecommendation> recommendations;
private LocalDateTime generatedAt;
private LocalDateTime expiresAt;
private String aiProvider;
/**
* 트렌드 분석
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class TrendAnalysis {
private List<TrendKeyword> industryTrends;
private List<TrendKeyword> regionalTrends;
private List<TrendKeyword> 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<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 String type;
private String details;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class EstimatedCost {
private Integer min;
private Integer max;
private Map<String, Integer> 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;
}
}
}
}

View File

@ -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.request.*;
import com.kt.event.eventservice.application.dto.response.*; import com.kt.event.eventservice.application.dto.response.*;
import com.kt.event.eventservice.application.service.EventService; 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 com.kt.event.eventservice.domain.enums.EventStatus;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@ -287,6 +288,30 @@ public class EventController {
.body(ApiResponse.success(response)); .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<ApiResponse<AIRecommendationResponse>> 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) * AI 추천 선택 (Step 2-2)
* *