Compare commits

41 Commits

Author SHA1 Message Date
이선민 9ce62738a1 Merge pull request #38 from ktds-dg0501/feature/distribution
Feature/distribution
2025-10-30 20:42:22 +09:00
sunmingLee 06ea838547 Merge branch 'develop' of https://github.com/ktds-dg0501/kt-event-marketing into feature/distribution 2025-10-30 20:30:54 +09:00
sunmingLee efcec065ec 네이버 블로그 포스팅 선택사항으로 고쳐서 도커 ì파일 ã… 수정 2025-10-30 20:30:44 +09:00
SWPARK 262a5fea33 Merge pull request #37 from ktds-dg0501/feature/ai
edit
2025-10-30 20:12:49 +09:00
박세원 d14a7349bc edit 2025-10-30 20:12:14 +09:00
Hyowon Yang 6e7a9386f6 CORS설정변경 2025-10-30 19:34:27 +09:00
이선민 047703fb89 Merge pull request #36 from ktds-dg0501/feature/distribution
merge feature/distribution into develop
2025-10-30 19:01:35 +09:00
Hyowon Yang 17278ad045 샘플데이터 수정 2025-10-30 18:47:28 +09:00
SWPARK cf379407e8 Merge pull request #35 from ktds-dg0501/feature/ai
remove api path
2025-10-30 18:43:35 +09:00
박세원 f13bfe6a6e remove api path 2025-10-30 18:42:49 +09:00
sunmingLee 4bc7f87663 Merge branch 'develop' of https://github.com/ktds-dg0501/kt-event-marketing into feature/distribution 2025-10-30 18:38:14 +09:00
sunmingLee ae8f540d46 네이버 블로그 ìž 배포 개발(ì이미지 없음) 2025-10-30 18:37:31 +09:00
kkkd-max c6dfc74bda Merge pull request #34 from ktds-dg0501/feature/ai
Feature/ai
2025-10-30 18:10:47 +09:00
jhbkjh 027ab86e8d 파티시페이션 2025-10-30 18:07:28 +09:00
박세원 c95c47d630 edit api 2025-10-30 18:06:18 +09:00
Hyowon Yang b92307d564 Analytics 서비스 인증 제거 - 전체 접근 허용
- SecurityConfig를 content-service처럼 단순화
- 모든 요청에 대해 인증 없이 접근 가능하도록 변경
- Swagger UI 및 API 엔드포인트 접근 문제 해결

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 18:03:28 +09:00
Hyowon Yang 2663baf615 Merge branch 'develop' of https://github.com/ktds-dg0501/kt-event-marketing into develop 2025-10-30 17:50:19 +09:00
Hyowon Yang 349b644617 Analytics 서비스 Swagger 및 보안 설정 개선
- Redis read-only replica 에러 처리 추가 (SampleDataLoader)
  - MVP 환경에서 샘플 데이터 로딩 시 Redis 삭제 실패해도 계속 진행
- Swagger UI context-path 설정 수정 (SwaggerConfig)
  - 서버 URL에 /api/v1/analytics context-path 포함하여 올바른 curl 명령 생성
- Spring Security 경로 매칭 수정 (SecurityConfig)
  - context-path 제거된 실제 경로 (/events/**, /users/**) 매칭
  - 403 Forbidden 에러 해결
- Dockerfile 빌드 경로 수정
  - 멀티 모듈 프로젝트 구조에 맞게 JAR 복사 경로 수정

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 17:49:47 +09:00
SWPARK ea4d551d3e Merge pull request #33 from ktds-dg0501/feature/ai
edit CORS error
2025-10-30 17:39:10 +09:00
박세원 d81c5be90d edit CORS error 2025-10-30 17:38:02 +09:00
박세원 e080acbcb9 Merge branch 'develop' of https://github.com/ktds-dg0501/kt-event-marketing into develop 2025-10-30 17:04:31 +09:00
박세원 29285d8576 AI Service CORS 설정 추가로 Swagger UI 테스트 지원
- SecurityConfig에 CORS 설정 추가
- 모든 Origin 허용 (AllowedOriginPatterns: *)
- 모든 HTTP Method 허용 (GET, POST, PUT, DELETE, OPTIONS, PATCH)
- 모든 Header 허용
- Credentials 지원
- Swagger UI에서 API 테스트 시 CORS 에러 해결

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 17:04:11 +09:00
kkkd-max f2e8f7499f Merge pull request #32 from ktds-dg0501/feature/partici2
Feature/partici2
2025-10-30 17:01:19 +09:00
박세원 a23b4eb505 Merge branch 'feature/ai' into develop 2025-10-30 16:45:26 +09:00
박세원 c6b33885e0 AI Service Security 설정 단순화 및 워크플로우 문서 추가
- SecurityConfig CORS 설정 제거 및 단순화
- 모든 요청 허용으로 변경 (내부 API 특성 반영)
- DevTools 요청 정적 리소스 제외 처리
- AI Service 워크플로우 문서 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 16:44:23 +09:00
Hyowon Yang 97f50fd751 Analytics Service context-path 설정 및 Controller 경로 최적화
- context-path 추가: /api/v1/analytics
- Swagger UI 경로를 기본값으로 수정 (/swagger-ui.html)
- 모든 Controller의 @RequestMapping에서 /api/v1 제거
  - Events 관련 Controller 4개: /api/v1/events → /events
  - Users 관련 Controller 4개: /api/v1/users → /users
  - DebugController: /api/debug → /debug

이제 Ingress를 통한 접근 및 Swagger UI가 정상 작동합니다.
- Swagger UI: /api/v1/analytics/swagger-ui/index.html
- API: /api/v1/analytics/events/{eventId}/analytics

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 16:16:24 +09:00
merrycoral c53cbdf4f8 Merge feature/event into develop
Event-AI Kafka 통신 개선 및 타입 헤더 불일치 문제 해결

주요 변경사항:
- event-service KafkaConfig: JsonSerializer로 변경, 타입 헤더 비활성화
- ai-service application.yml: 타입 헤더 사용 안 함, 기본 타입 지정
- AIEventGenerationJobMessage: region, targetAudience, budget 필드 추가
- AiRecommendationRequest: region, targetAudience, budget 필드 추가
- AIJobKafkaProducer: 객체 직접 전송으로 변경 (이중 직렬화 문제 해결)
- AIJobKafkaConsumer: 양방향 통신 이슈로 비활성화 (.bak)
- EventService: Kafka producer 호출 시 새 필드 전달

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 15:59:46 +09:00
merrycoral 7dc039361f Event-AI Kafka 통신 개선 및 타입 헤더 불일치 문제 해결
주요 변경사항:
- event-service KafkaConfig: JsonSerializer로 변경, 타입 헤더 비활성화
- ai-service application.yml: 타입 헤더 사용 안 함, 기본 타입 지정
- AIEventGenerationJobMessage: region, targetAudience, budget 필드 추가
- AiRecommendationRequest: region, targetAudience, budget 필드 추가
- AIJobKafkaProducer: 객체 직접 전송으로 변경 (이중 직렬화 문제 해결)
- AIJobKafkaConsumer: 양방향 통신 이슈로 비활성화 (.bak)
- EventService: Kafka producer 호출 시 새 필드 전달

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 15:58:23 +09:00
kkkd-max 48c76db83a Merge pull request #31 from ktds-dg0501/feature/partici2
security 수정
2025-10-30 15:47:30 +09:00
Hyowon Yang 9e2d0a3889 Analytics Service Swagger 설정 개선
- Swagger UI 경로를 Ingress 경로와 일치하도록 수정 (/api/v1/analytics/swagger-ui.html)
- AKS 환경 서버 URL을 Swagger 서버 목록에 추가
- API 테스트 편의성 향상

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 15:31:32 +09:00
Hyowon Yang 14823a17c4 Analytics 서비스 CORS 설정 추가
- WebConfig.java 추가하여 CORS 정책 설정
- 프론트엔드에서 Analytics API 호출 시 CORS 에러 해결
- 모든 origin 패턴 허용 및 credentials 지원

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 14:48:09 +09:00
Hyowon Yang a3781a279a Merge pull request #30 from ktds-dg0501/feature/analytics
이벤트별 성과분석 날짜 로직 수정 및 설정 개선
2025-10-30 12:54:56 +09:00
Hyowon Yang f80418f5ee 이벤트별 성과분석 날짜 로직 수정 및 설정 개선
- EventCreatedEvent, EventStats에 startDate, endDate 필드 추가
- EventCreatedConsumer에서 이벤트 시작/종료 날짜 저장
- SampleDataLoader에서 실제 날짜로 이벤트 발행
  - evt_2025012301: 2025-01-23 시작 (ACTIVE)
  - evt_2025020101: 2025-02-01 시작 (ACTIVE)
  - evt_2025011501: 2025-01-15~2025-01-31 (COMPLETED)
- AnalyticsService: 이벤트 시작일~종료일(또는 현재) 기간 계산
- UserAnalyticsService: 가장 빠른 이벤트 시작일~현재 기간 계산
- application.yml에서 중복된 context-path 제거
- Consumer Group ID를 analytics-service-consumers-v3로 통일

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 12:47:19 +09:00
kkkd-max 5c365fe899 Merge pull request #29 from ktds-dg0501/feature/partici2
Feature/partici2
2025-10-30 12:25:44 +09:00
Hyowon Yang acd827b226 Merge branch 'origin/develop' into develop
- 이벤트 ID 단순화 변경사항 병합 (1, 2, 3)
- 원격 변경사항 통합

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 10:10:47 +09:00
Hyowon Yang ea53bd13a8 analytics 서비스 샘플 데이터 이벤트 ID 단순화
- 이벤트 ID를 evt_2025012301 형식에서 1, 2, 3으로 변경
- 다른 마이크로서비스와의 연동을 위한 단순 ID 체계 적용

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 10:08:48 +09:00
Hyowon Yang aa8db3bf2f Merge pull request #27 from ktds-dg0501/feature/analytics
Feature/analytics
2025-10-30 09:40:29 +09:00
sunmingLee be59934f78 swagger 문제 수정 2025-10-30 09:37:54 +09:00
Hyowon Yang 108ee10293 Merge branch 'develop' into feature/analytics 2025-10-29 19:31:10 +09:00
Hyowon Yang 20e0d24930 이벤트별 성과분석 대시보드 상세 정보 추가 및 Timeline 날짜 수정
## 주요 변경사항

### 1. Timeline 데이터 날짜 로직 수정
- **파일**: SampleDataLoader.java
- **변경**: 이벤트 ID에서 날짜를 파싱하여 실제 이벤트 시작일 기준으로 Timeline 생성
  - 기존: 모든 이벤트가 2024-09-24부터 시작
  - 수정: evt_2025012301 → 2025-01-23부터 30일치 생성
- **채널 분포**: 가중치 기반 랜덤 배정으로 변경
  - SNS: 45% (최고 비율)
  - 우리동네TV: 25%
  - 지니TV: 20%
  - 링고비즈: 10%

### 2. 이벤트별 API 상세 정보 추가
- **파일**: AnalyticsDashboardResponse.java
- **추가 필드**:
  - investment: InvestmentDetails (투자 비용 상세)
  - revenue: RevenueDetails (수익 상세)
  - costEfficiency: CostEfficiency (비용 효율성)

### 3. 이벤트별 상세 계산 로직 구현
- **파일**: AnalyticsService.java
- **추가 메서드**:
  - buildInvestmentDetails(): 투자 비용 상세 계산
    - 경품비용 50%, 콘텐츠제작비 30%, 운영비 20%, 채널배포비용(실제)
  - buildRevenueDetails(): 수익 상세 계산
    - 직접매출 70%, 예상추가매출 30%, 신규고객 40%, 기존고객 60%
  - buildCostEfficiency(): 비용 효율성 계산
    - 참여자당 비용, 참여자당 수익

### 4. ROI 전용 API 필드 수정
- **파일**: ROICalculator.java
- **수정**: UserRoiAnalyticsService와 동일한 비율 적용
  - investmentDetails에 prizeCost, channelCost 추가
  - revenueDetails에 newCustomerRevenue, existingCustomerRevenue 추가
- **기존 문제**: null 값 반환
- **해결**: 통합분석과 동일한 계산 로직 적용

## API 응답 구조

### GET /api/v1/events/{eventId}/analytics
```json
{
  "investment": {
    "total": 5000000,
    "prizeCost": 1250000,
    "contentCreation": 750000,
    "operation": 500000,
    "distribution": 2500000,
    "channelCost": 2500000
  },
  "revenue": {
    "total": 15000000,
    "directSales": 10500000,
    "expectedSales": 4500000,
    "newCustomerRevenue": 6000000,
    "existingCustomerRevenue": 9000000
  },
  "costEfficiency": {
    "costPerParticipant": 50000,
    "revenuePerParticipant": 150000
  }
}
```

## 테스트 결과
-  Timeline 날짜가 이벤트별로 정확하게 생성됨
-  채널별 참여자 분포가 가중치대로 배정됨
-  이벤트별 API에서 상세 투자/수익 정보 제공
-  ROI API에서 null 값 문제 해결

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 19:28:58 +09:00
Hyowon Yang 98ed508a6f User-level Analytics API 구현 및 Kafka Consumer 설정 개선
주요 변경사항:
- User-level Analytics API 기간 파라미터 제거 (전체 기간 자동 계산)
  * /api/v1/users/{userId}/analytics/dashboard
  * /api/v1/users/{userId}/analytics/channels
  * /api/v1/users/{userId}/analytics/roi
  * /api/v1/users/{userId}/analytics/timeline

- Kafka Consumer 안정성 개선
  * Consumer Group ID를 analytics-service-consumers-v3로 변경
  * Redis 멱등성 키 v2 버전 사용 (processed_events_v2, distribution_completed_v2, processed_participants_v2)
  * ParticipantRegisteredConsumer 멱등성 키를 eventId:participantId 조합으로 변경하여 중복 방지 강화

- 설정 개선
  * UTF-8 인코딩 명시적 설정 추가
  * Kafka auto.offset.reset 설정 명확화

- 테스트 도구 추가
  * tools/reset-analytics-data.ps1: 테스트 데이터 초기화 스크립트
  * DebugController: 개발 환경 디버깅용 엔드포인트

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 18:07:20 +09:00
80 changed files with 2678 additions and 571 deletions
@@ -23,6 +23,11 @@
<env name="KAFKA_CONSUMER_GROUP" value="distribution-service" />
<env name="JPA_DDL_AUTO" value="update" />
<env name="JPA_SHOW_SQL" value="false" />
<env name="NAVER_BLOG_USERNAME" value="" />
<env name="NAVER_BLOG_PASSWORD" value="" />
<env name="NAVER_BLOG_BLOG_ID" value="" />
<env name="NAVER_BLOG_HEADLESS" value="false" />
<env name="NAVER_BLOG_SESSION_PATH" value="playwright-sessions" />
</envs>
<method v="2">
<option name="Make" enabled="true" />
+1 -1
View File
@@ -24,7 +24,7 @@
<!-- Kafka Configuration (원격 서버) -->
<entry key="KAFKA_ENABLED" value="true" />
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers" />
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers-v3" />
<!-- Sample Data Configuration (MVP Only) -->
<!-- ⚠️ Kafka Producer로 이벤트 발행 (Consumer가 처리) -->
@@ -4,6 +4,7 @@ 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.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@@ -27,21 +28,22 @@ import java.util.List;
@EnableWebSecurity
public class SecurityConfig {
/**
* Security Filter Chain 설정
* - 모든 요청 허용 (내부 API)
* - CSRF 비활성화
* - Stateless 세션
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// CSRF 비활성화 (REST API는 CSRF 불필요)
.csrf(AbstractHttpConfigurer::disable)
// CORS 설정
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 세션 사용 안 함 (JWT 기반 인증)
.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()
);
@@ -50,11 +52,14 @@ public class SecurityConfig {
/**
* CORS 설정
* - 모든 Origin 허용 (Swagger UI 테스트를 위해)
* - 모든 HTTP Method 허용
* - 모든 Header 허용
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "http://localhost:8080"));
configuration.setAllowedOriginPatterns(List.of("*")); // 모든 Origin 허용
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
@@ -64,4 +69,13 @@ public class SecurityConfig {
source.registerCorsConfiguration("/**", configuration);
return source;
}
/**
* Chrome DevTools 요청 등 정적 리소스 요청을 Spring Security에서 제외
*/
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring()
.requestMatchers("/.well-known/**");
}
}
@@ -20,6 +20,10 @@ public class SwaggerConfig {
@Bean
public OpenAPI openAPI() {
Server vmServer = new Server();
vmServer.setUrl("http://kt-event-marketing-api.20.214.196.128.nip.io/api/v1/ai");
vmServer.setDescription("VM Development Server");
Server localServer = new Server();
localServer.setUrl("http://localhost:8083");
localServer.setDescription("Local Development Server");
@@ -59,6 +63,6 @@ public class SwaggerConfig {
return new OpenAPI()
.info(info)
.servers(List.of(localServer, devServer, prodServer));
.servers(List.of(vmServer, localServer, devServer, prodServer));
}
}
@@ -32,7 +32,7 @@ public class HealthController {
* 서비스 헬스체크
*/
@Operation(summary = "서비스 헬스체크", description = "AI Service 상태 및 외부 연동 확인")
@GetMapping("/api/v1/ai-service/health")
@GetMapping("/health")
public ResponseEntity<HealthCheckResponse> healthCheck() {
// Redis 상태 확인
ServiceStatus redisStatus = checkRedis();
@@ -27,7 +27,7 @@ import java.util.Map;
@Slf4j
@Tag(name = "Internal API", description = "내부 서비스 간 통신용 API")
@RestController
@RequestMapping("/api/v1/ai-service/internal/jobs")
@RequestMapping("/jobs")
@RequiredArgsConstructor
public class InternalJobController {
@@ -31,7 +31,7 @@ import java.util.Set;
@Slf4j
@Tag(name = "Internal API", description = "내부 서비스 간 통신용 API")
@RestController
@RequestMapping("/api/v1/ai-service/internal/recommendations")
@RequestMapping("/recommendations")
@RequiredArgsConstructor
public class InternalRecommendationController {
@@ -28,6 +28,8 @@ spring:
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
properties:
spring.json.trusted.packages: "*"
spring.json.use.type.headers: false
spring.json.value.default.type: com.kt.ai.kafka.message.AIJobMessage
max.poll.records: 10
session.timeout.ms: 30000
listener:
@@ -37,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
@@ -51,7 +53,7 @@ jwt:
# CORS Configuration
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*}
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*,http://kt-event-marketing.20.214.196.128.nip.io}
allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS,PATCH}
allowed-headers: ${CORS_ALLOWED_HEADERS:*}
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
@@ -24,7 +24,7 @@
<!-- Kafka Configuration (원격 서버) -->
<entry key="KAFKA_ENABLED" value="true" />
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers" />
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers-v3" />
<!-- Sample Data Configuration (MVP Only) -->
<!-- ⚠️ Kafka Producer로 이벤트 발행 (Consumer가 처리) -->
+1 -1
View File
@@ -1,7 +1,7 @@
# Multi-stage build for Spring Boot application
FROM eclipse-temurin:21-jre-alpine AS builder
WORKDIR /app
COPY build/libs/*.jar app.jar
COPY analytics-service/build/libs/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
FROM eclipse-temurin:21-jre-alpine
@@ -63,7 +63,7 @@ public class AnalyticsBatchScheduler {
event.getEventId(), event.getEventTitle());
// refresh=true로 호출하여 캐시 갱신 및 외부 API 호출
analyticsService.getDashboardData(event.getEventId(), null, null, true);
analyticsService.getDashboardData(event.getEventId(), true);
successCount++;
log.info("✅ 배치 갱신 완료: eventId={}", event.getEventId());
@@ -99,7 +99,7 @@ public class AnalyticsBatchScheduler {
for (EventStats event : allEvents) {
try {
analyticsService.getDashboardData(event.getEventId(), null, null, true);
analyticsService.getDashboardData(event.getEventId(), true);
log.debug("초기 데이터 로딩 완료: eventId={}", event.getEventId());
} catch (Exception e) {
log.warn("초기 데이터 로딩 실패: eventId={}, error={}",
@@ -17,13 +17,13 @@ import java.util.Map;
* Kafka Consumer 설정
*/
@Configuration
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = true)
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false)
public class KafkaConsumerConfig {
@Value("${spring.kafka.bootstrap-servers}")
private String bootstrapServers;
@Value("${spring.kafka.consumer.group-id:analytics-service}")
@Value("${spring.kafka.consumer.group-id:analytics-service-consumers-v3}")
private String groupId;
@Bean
@@ -0,0 +1,46 @@
package com.kt.event.analytics.config;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;
import java.util.HashMap;
import java.util.Map;
/**
* Kafka Producer 설정
*
* ⚠️ MVP 전용: SampleDataLoader가 Kafka 이벤트를 발행하기 위해 필요
* ⚠️ 실제 운영: Analytics Service는 순수 Consumer 역할만 수행하므로 Producer 불필요
*
* String 직렬화 방식 사용 (SampleDataLoader가 JSON 문자열을 직접 발행)
*/
@Configuration
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false)
public class KafkaProducerConfig {
@Value("${spring.kafka.bootstrap-servers}")
private String bootstrapServers;
@Bean
public ProducerFactory<String, String> producerFactory() {
Map<String, Object> configProps = new HashMap<>();
configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
configProps.put(ProducerConfig.ACKS_CONFIG, "all");
configProps.put(ProducerConfig.RETRIES_CONFIG, 3);
return new DefaultKafkaProducerFactory<>(configProps);
}
@Bean
public KafkaTemplate<String, String> kafkaTemplate() {
return new KafkaTemplate<>(producerFactory());
}
}
@@ -11,19 +11,23 @@ import jakarta.annotation.PreDestroy;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.admin.AdminClient;
import org.apache.kafka.clients.admin.DeleteConsumerGroupOffsetsResult;
import org.apache.kafka.clients.admin.ListConsumerGroupOffsetsResult;
import org.apache.kafka.common.TopicPartition;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.kafka.core.KafkaAdmin;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.UUID;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* 샘플 데이터 로더 (Kafka Producer 방식)
@@ -47,6 +51,7 @@ import java.util.UUID;
public class SampleDataLoader implements ApplicationRunner {
private final KafkaTemplate<String, String> kafkaTemplate;
private final KafkaAdmin kafkaAdmin;
private final ObjectMapper objectMapper;
private final EventStatsRepository eventStatsRepository;
private final ChannelStatsRepository channelStatsRepository;
@@ -56,6 +61,9 @@ public class SampleDataLoader implements ApplicationRunner {
private final Random random = new Random();
@Value("${spring.kafka.consumer.group-id}")
private String consumerGroupId;
// Kafka Topic Names (MVP용 샘플 토픽)
private static final String EVENT_CREATED_TOPIC = "sample.event.created";
private static final String PARTICIPANT_REGISTERED_TOPIC = "sample.participant.registered";
@@ -85,10 +93,15 @@ public class SampleDataLoader implements ApplicationRunner {
// Redis 멱등성 키 삭제 (새로운 이벤트 처리를 위해)
log.info("Redis 멱등성 키 삭제 중...");
redisTemplate.delete("processed_events");
redisTemplate.delete("distribution_completed");
redisTemplate.delete("processed_participants");
log.info("✅ Redis 멱등성 키 삭제 완료");
try {
redisTemplate.delete("processed_events_v2");
redisTemplate.delete("distribution_completed_v2");
redisTemplate.delete("processed_participants_v2");
log.info("✅ Redis 멱등성 키 삭제 완료");
} catch (Exception e) {
log.warn("⚠️ Redis 삭제 실패 (read-only replica일 수 있음): {}", e.getMessage());
log.info("→ Redis 삭제 건너뛰고 계속 진행...");
}
try {
// 1. EventCreated 이벤트 발행 (3개 이벤트)
@@ -103,6 +116,8 @@ public class SampleDataLoader implements ApplicationRunner {
// 3. ParticipantRegistered 이벤트 발행 (각 이벤트당 다수 참여자)
publishParticipantRegisteredEvents();
log.info("⏳ 참여자 등록 이벤트 처리 대기 중... (20초)");
Thread.sleep(20000); // ParticipantRegisteredConsumer가 180개 이벤트 처리할 시간 (비관적 락 고려)
log.info("========================================");
log.info("🎉 Kafka 이벤트 발행 완료! (Consumer가 처리 중...)");
@@ -127,16 +142,17 @@ public class SampleDataLoader implements ApplicationRunner {
}
/**
* 서비스 종료 시 전체 데이터 삭제
* 서비스 종료 시 전체 데이터 삭제 및 Consumer Offset 리셋
*/
@PreDestroy
@Transactional
public void onShutdown() {
log.info("========================================");
log.info("🛑 서비스 종료: PostgreSQL 전체 데이터 삭제");
log.info("🛑 서비스 종료: PostgreSQL 전체 데이터 삭제 + Kafka Consumer Offset 리셋");
log.info("========================================");
try {
// 1. PostgreSQL 데이터 삭제
long timelineCount = timelineDataRepository.count();
long channelCount = channelStatsRepository.count();
long eventCount = eventStatsRepository.count();
@@ -153,6 +169,10 @@ public class SampleDataLoader implements ApplicationRunner {
entityManager.clear();
log.info("✅ 모든 샘플 데이터 삭제 완료!");
// 2. Kafka Consumer Offset 리셋 (다음 시작 시 처음부터 읽도록)
resetConsumerOffsets();
log.info("========================================");
} catch (Exception e) {
@@ -160,37 +180,85 @@ public class SampleDataLoader implements ApplicationRunner {
}
}
/**
* Kafka Consumer Group Offset 리셋
*
* 서비스 종료 시 Consumer offset을 삭제하여 다음 시작 시
* auto.offset.reset=earliest 설정에 따라 처음부터 읽도록 함
*/
private void resetConsumerOffsets() {
try (AdminClient adminClient = AdminClient.create(kafkaAdmin.getConfigurationProperties())) {
log.info("🔄 Kafka Consumer Offset 리셋 시작: group={}", consumerGroupId);
// 모든 토픽의 offset 삭제
Set<TopicPartition> partitions = new HashSet<>();
// 토픽별 파티션 추가 (설계서상 각 토픽은 3개 파티션)
for (int i = 0; i < 3; i++) {
partitions.add(new TopicPartition(EVENT_CREATED_TOPIC, i));
partitions.add(new TopicPartition(PARTICIPANT_REGISTERED_TOPIC, i));
partitions.add(new TopicPartition(DISTRIBUTION_COMPLETED_TOPIC, i));
}
// Consumer Group Offset 삭제
DeleteConsumerGroupOffsetsResult result = adminClient.deleteConsumerGroupOffsets(
consumerGroupId,
partitions
);
// 완료 대기 (최대 10초)
result.all().get(10, TimeUnit.SECONDS);
log.info("✅ Kafka Consumer Offset 리셋 완료!");
log.info(" → 다음 시작 시 처음부터(earliest) 메시지를 읽습니다.");
} catch (Exception e) {
// Offset 리셋 실패는 치명적이지 않으므로 경고만 출력
log.warn("⚠️ Kafka Consumer Offset 리셋 실패 (무시 가능): {}", e.getMessage());
log.warn(" → 수동으로 Consumer Group ID를 변경하거나, Kafka 도구로 offset을 삭제하세요.");
}
}
/**
* EventCreated 이벤트 발행
*/
private void publishEventCreatedEvents() throws Exception {
// 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과)
// 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과 - ROI 200%)
EventCreatedEvent event1 = EventCreatedEvent.builder()
.eventId("evt_2025012301")
.eventTitle("신년맞이 20% 할인 이벤트")
.storeId("store_001")
.totalInvestment(new BigDecimal("5000000"))
.expectedRevenue(new BigDecimal("15000000")) // 투자 대비 3배 수익
.status("ACTIVE")
.startDate(java.time.LocalDateTime.of(2025, 1, 23, 0, 0)) // 2025-01-23 시작
.endDate(null) // 진행중
.build();
publishEvent(EVENT_CREATED_TOPIC, event1);
// 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과)
// 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과 - ROI 100%)
EventCreatedEvent event2 = EventCreatedEvent.builder()
.eventId("evt_2025020101")
.eventId("evt_2025012302")
.eventTitle("설날 특가 선물세트 이벤트")
.storeId("store_001")
.totalInvestment(new BigDecimal("3500000"))
.expectedRevenue(new BigDecimal("7000000")) // 투자 대비 2배 수익
.status("ACTIVE")
.startDate(java.time.LocalDateTime.of(2025, 2, 1, 0, 0)) // 2025-02-01 시작
.endDate(null) // 진행중
.build();
publishEvent(EVENT_CREATED_TOPIC, event2);
// 이벤트 3: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과)
// 이벤트 3: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과 - ROI 50%)
EventCreatedEvent event3 = EventCreatedEvent.builder()
.eventId("evt_2025011501")
.eventId("evt_2025012303")
.eventTitle("겨울 신메뉴 런칭 이벤트")
.storeId("store_001")
.totalInvestment(new BigDecimal("2000000"))
.expectedRevenue(new BigDecimal("3000000")) // 투자 대비 1.5배 수익
.status("COMPLETED")
.startDate(java.time.LocalDateTime.of(2025, 1, 15, 0, 0)) // 2025-01-15 시작
.endDate(java.time.LocalDateTime.of(2025, 1, 31, 23, 59)) // 2025-01-31 종료
.build();
publishEvent(EVENT_CREATED_TOPIC, event3);
@@ -201,49 +269,70 @@ public class SampleDataLoader implements ApplicationRunner {
* DistributionCompleted 이벤트 발행 (설계서 기준 - 이벤트당 1번 발행, 여러 채널 배열)
*/
private void publishDistributionCompletedEvents() throws Exception {
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"};
String[] eventIds = {"evt_2025012301", "evt_2025012302", "evt_2025012303"};
int[][] expectedViews = {
{5000, 10000, 3000, 2000}, // 이벤트1: 우리동네TV, 지니TV, 링고비즈, SNS
{3500, 7000, 2000, 1500}, // 이벤트2
{1500, 3000, 1000, 500} // 이벤트3
};
// 각 이벤트의 총 투자 금액
BigDecimal[] totalInvestments = {
new BigDecimal("5000000"), // 이벤트1: 500만원
new BigDecimal("3500000"), // 이벤트2: 350만원
new BigDecimal("2000000") // 이벤트3: 200만원
};
// 채널 배포는 총 투자의 50%만 사용 (나머지는 경품/콘텐츠/운영비용)
double channelBudgetRatio = 0.50;
// 채널별 비용 비율 (채널 예산 내에서: 우리동네TV 30%, 지니TV 30%, 링고비즈 25%, SNS 15%)
double[] costRatios = {0.30, 0.30, 0.25, 0.15};
for (int i = 0; i < eventIds.length; i++) {
String eventId = eventIds[i];
BigDecimal totalInvestment = totalInvestments[i];
// 채널 배포 예산: 총 투자의 50%
BigDecimal channelBudget = totalInvestment.multiply(BigDecimal.valueOf(channelBudgetRatio));
// 4개 채널을 배열로 구성
List<DistributionCompletedEvent.ChannelDistribution> channels = new ArrayList<>();
// 1. 우리동네TV (TV)
// 1. 우리동네TV (TV) - 채널 예산의 30%
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("우리동네TV")
.channelType("TV")
.status("SUCCESS")
.expectedViews(expectedViews[i][0])
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[0])))
.build());
// 2. 지니TV (TV)
// 2. 지니TV (TV) - 채널 예산의 30%
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("지니TV")
.channelType("TV")
.status("SUCCESS")
.expectedViews(expectedViews[i][1])
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[1])))
.build());
// 3. 링고비즈 (CALL)
// 3. 링고비즈 (CALL) - 채널 예산의 25%
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("링고비즈")
.channelType("CALL")
.status("SUCCESS")
.expectedViews(expectedViews[i][2])
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[2])))
.build());
// 4. SNS (SNS)
// 4. SNS (SNS) - 채널 예산의 15%
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("SNS")
.channelType("SNS")
.status("SUCCESS")
.expectedViews(expectedViews[i][3])
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[3])))
.build());
// 이벤트 발행 (채널 배열 포함)
@@ -261,22 +350,53 @@ public class SampleDataLoader implements ApplicationRunner {
/**
* ParticipantRegistered 이벤트 발행
*
* 현실적인 참여 패턴 반영:
* - 총 120명의 고유 참여자 풀 생성
* - 일부 참여자는 여러 이벤트에 중복 참여
* - 이벤트1: 100명 (user001~user100)
* - 이벤트2: 50명 (user051~user100) → 50명이 이벤트1과 중복
* - 이벤트3: 30명 (user071~user100) → 30명이 이전 이벤트들과 중복
*/
private void publishParticipantRegisteredEvents() throws Exception {
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"};
int[] totalParticipants = {100, 50, 30}; // MVP 테스트용 샘플 데이터 (총 180명)
String[] eventIds = {"evt_2025012301", "evt_2025012302", "evt_2025012303"};
String[] channels = {"우리동네TV", "지니TV", "링고비즈", "SNS"};
// 이벤트별 참여자 범위 (중복 참여 반영)
int[][] participantRanges = {
{1, 100}, // 이벤트1: user001~user100 (100명)
{51, 100}, // 이벤트2: user051~user100 (50명, 이벤트1과 50명 중복)
{71, 100} // 이벤트3: user071~user100 (30명, 모두 중복)
};
int totalPublished = 0;
for (int i = 0; i < eventIds.length; i++) {
String eventId = eventIds[i];
int participants = totalParticipants[i];
int startUser = participantRanges[i][0];
int endUser = participantRanges[i][1];
int eventParticipants = endUser - startUser + 1;
// 각 이벤트에 대해 참여자 수만큼 ParticipantRegistered 이벤트 발행
for (int j = 0; j < participants; j++) {
String participantId = UUID.randomUUID().toString();
String channel = channels[j % channels.length]; // 채널 순환 배정
log.info("이벤트 {} 참여자 발행 시작: user{:03d}~user{:03d} ({}명)",
eventId, startUser, endUser, eventParticipants);
// 각 참여자에 대해 ParticipantRegistered 이벤트 발행
for (int userId = startUser; userId <= endUser; userId++) {
String participantId = String.format("user%03d", userId); // user001, user002, ...
// 채널별 가중치 기반 랜덤 배정
// SNS: 45%, 우리동네TV: 25%, 지니TV: 20%, 링고비즈: 10%
int randomValue = random.nextInt(100);
String channel;
if (randomValue < 45) {
channel = "SNS"; // 0~44: 45%
} else if (randomValue < 70) {
channel = "우리동네TV"; // 45~69: 25%
} else if (randomValue < 90) {
channel = "지니TV"; // 70~89: 20%
} else {
channel = "링고비즈"; // 90~99: 10%
}
ParticipantRegisteredEvent event = ParticipantRegisteredEvent.builder()
.eventId(eventId)
@@ -288,72 +408,102 @@ public class SampleDataLoader implements ApplicationRunner {
totalPublished++;
// 동시성 충돌 방지: 10개마다 100ms 대기
if ((j + 1) % 10 == 0) {
if (totalPublished % 10 == 0) {
Thread.sleep(100);
}
}
log.info("✅ 이벤트 {} 참여자 발행 완료: {}명", eventId, eventParticipants);
}
log.info("========================================");
log.info("✅ ParticipantRegistered 이벤트 {}건 발행 완료", totalPublished);
log.info("📊 참여 패턴:");
log.info(" - 총 고유 참여자: 100명 (user001~user100)");
log.info(" - 이벤트1 참여: 100명");
log.info(" - 이벤트2 참여: 50명 (이벤트1과 50명 중복)");
log.info(" - 이벤트3 참여: 30명 (이벤트1,2와 모두 중복)");
log.info(" - 3개 이벤트 모두 참여: 30명");
log.info(" - 2개 이벤트 참여: 20명");
log.info(" - 1개 이벤트만 참여: 50명");
log.info("📺 채널별 참여 비율 (가중치):");
log.info(" - SNS: 45% (가장 높음)");
log.info(" - 우리동네TV: 25%");
log.info(" - 지니TV: 20%");
log.info(" - 링고비즈: 10%");
log.info("========================================");
}
/**
* TimelineData 생성 (시간대별 샘플 데이터)
*
* - 각 이벤트마다 30일 치 daily 데이터 생성
* - 각 이벤트마다 30일 × 24시간 = 720시간 치 hourly 데이터 생성
* - interval=hourly: 시간별 표시 (최근 7일 적합)
* - interval=daily: 일별 자동 집계 (30일 전체)
* - 참여자 수, 조회수, 참여행동, 전환수, 누적 참여자 수
*/
private void createTimelineData() {
log.info("📊 TimelineData 생성 시작...");
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"};
String[] eventIds = {"evt_2025012301", "evt_2025012302", "evt_2025012303"};
// 각 이벤트별 기준 참여자 수 (이벤트 성과에 따라 다름)
int[] baseParticipants = {20, 12, 5}; // 이벤트1(높음), 이벤트2(중간), 이벤트3(낮음)
// 각 이벤트별 시간당 기준 참여자 수 (이벤트 성과에 따라 다름)
int[] baseParticipantsPerHour = {4, 2, 1}; // 이벤트1(높음), 이벤트2(중간), 이벤트3(낮음)
for (int eventIndex = 0; eventIndex < eventIds.length; eventIndex++) {
String eventId = eventIds[eventIndex];
int baseParticipant = baseParticipants[eventIndex];
int baseParticipant = baseParticipantsPerHour[eventIndex];
int cumulativeParticipants = 0;
// 30일 치 데이터 생성 (2024-09-24부터)
java.time.LocalDateTime startDate = java.time.LocalDateTime.of(2024, 9, 24, 0, 0);
// 이벤트 ID에서 날짜 파싱 (evt_2025012301 → 2025-01-23)
String dateStr = eventId.substring(4); // "2025012301"
int year = Integer.parseInt(dateStr.substring(0, 4)); // 2025
int month = Integer.parseInt(dateStr.substring(4, 6)); // 01
int day = Integer.parseInt(dateStr.substring(6, 8)); // 23
for (int day = 0; day < 30; day++) {
java.time.LocalDateTime timestamp = startDate.plusDays(day);
// 이벤트 시작일부터 30일 치 hourly 데이터 생성
java.time.LocalDateTime startDate = java.time.LocalDateTime.of(year, month, day, 0, 0);
// 랜덤한 참여자 수 생성 (기준값 ± 50%)
int dailyParticipants = baseParticipant + random.nextInt(baseParticipant + 1);
cumulativeParticipants += dailyParticipants;
for (int dayOffset = 0; dayOffset < 30; dayOffset++) {
for (int hour = 0; hour < 24; hour++) {
java.time.LocalDateTime timestamp = startDate.plusDays(dayOffset).plusHours(hour);
// 조회수는 참여자의 3~5배
int dailyViews = dailyParticipants * (3 + random.nextInt(3));
// 시간대별 참여자 수 변화 (낮 시간대 12~20시에 더 많음)
int hourMultiplier = (hour >= 12 && hour <= 20) ? 2 : 1;
int hourlyParticipants = (baseParticipant * hourMultiplier) + random.nextInt(baseParticipant + 1);
// 참여행동은 참여자의 1~2배
int dailyEngagement = dailyParticipants * (1 + random.nextInt(2));
cumulativeParticipants += hourlyParticipants;
// 전환수는 참여자의 50~80%
int dailyConversions = (int) (dailyParticipants * (0.5 + random.nextDouble() * 0.3));
// 조회수는 참여자의 3~5배
int hourlyViews = hourlyParticipants * (3 + random.nextInt(3));
// TimelineData 생성
com.kt.event.analytics.entity.TimelineData timelineData =
com.kt.event.analytics.entity.TimelineData.builder()
.eventId(eventId)
.timestamp(timestamp)
.participants(dailyParticipants)
.views(dailyViews)
.engagement(dailyEngagement)
.conversions(dailyConversions)
.cumulativeParticipants(cumulativeParticipants)
.build();
// 참여행동은 참여자의 1~2배
int hourlyEngagement = hourlyParticipants * (1 + random.nextInt(2));
timelineDataRepository.save(timelineData);
// 전환수는 참여자의 50~80%
int hourlyConversions = (int) (hourlyParticipants * (0.5 + random.nextDouble() * 0.3));
// TimelineData 생성
com.kt.event.analytics.entity.TimelineData timelineData =
com.kt.event.analytics.entity.TimelineData.builder()
.eventId(eventId)
.timestamp(timestamp)
.participants(hourlyParticipants)
.views(hourlyViews)
.engagement(hourlyEngagement)
.conversions(hourlyConversions)
.cumulativeParticipants(cumulativeParticipants)
.build();
timelineDataRepository.save(timelineData);
}
}
log.info("✅ TimelineData 생성 완료: eventId={}, 30일 데이터", eventId);
log.info("✅ TimelineData 생성 완료: eventId={}, 시작일={}-{:02d}-{:02d}, 30일 × 24시간 = 720건",
eventId, year, month, day);
}
log.info("✅ 전체 TimelineData 생성 완료: 3개 이벤트 × 30일 = 90건");
log.info("✅ 전체 TimelineData 생성 완료: 3개 이벤트 × 30일 × 24시간 = 2,160건");
}
/**
@@ -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,51 +25,19 @@ 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
// Actuator endpoints
.requestMatchers("/actuator/**").permitAll()
// Swagger UI endpoints
.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**").permitAll()
// Health check
.requestMatchers("/health").permitAll()
// Analytics API endpoints (테스트 및 개발 용도로 공개)
.requestMatchers("/api/**").permitAll()
// All other requests require authentication
.anyRequest().authenticated()
.anyRequest().permitAll()
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class)
.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 허용)
}
@@ -22,8 +22,11 @@ public class SwaggerConfig {
return new OpenAPI()
.info(apiInfo())
.addServersItem(new Server()
.url("http://localhost:8086")
.url("http://localhost:8086/api/v1/analytics")
.description("Local Development"))
.addServersItem(new Server()
.url("http://kt-event-marketing-api.20.214.196.128.nip.io/api/v1/analytics")
.description("AKS Development"))
.addServersItem(new Server()
.url("{protocol}://{host}:{port}")
.description("Custom Server")
@@ -22,7 +22,7 @@ import java.time.LocalDateTime;
@Tag(name = "Analytics", description = "이벤트 성과 분석 및 대시보드 API")
@Slf4j
@RestController
@RequestMapping("/api/v1/events")
@RequestMapping("/events")
@RequiredArgsConstructor
public class AnalyticsDashboardController {
@@ -31,31 +31,19 @@ public class AnalyticsDashboardController {
/**
* 성과 대시보드 조회
*
* @param eventId 이벤트 ID
* @param startDate 조회 시작 날짜
* @param endDate 조회 종료 날짜
* @param refresh 캐시 갱신 여부
* @return 성과 대시보드
* @param eventId 이벤트 ID
* @param refresh 캐시 갱신 여부
* @return 성과 대시보드 (이벤트 시작일 ~ 현재까지)
*/
@Operation(
summary = "성과 대시보드 조회",
description = "이벤트의 전체 성과를 통합하여 조회합니다."
description = "이벤트의 전체 성과를 통합하여 조회합니다. (이벤트 시작일 ~ 현재까지)"
)
@GetMapping("/{eventId}/analytics")
public ResponseEntity<ApiResponse<AnalyticsDashboardResponse>> getEventAnalytics(
@Parameter(description = "이벤트 ID", required = true)
@PathVariable String eventId,
@Parameter(description = "조회 시작 날짜 (ISO 8601 format)")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime startDate,
@Parameter(description = "조회 종료 날짜 (ISO 8601 format)")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime endDate,
@Parameter(description = "캐시 갱신 여부 (true인 경우 외부 API 호출)")
@RequestParam(required = false, defaultValue = "false")
Boolean refresh
@@ -63,7 +51,7 @@ public class AnalyticsDashboardController {
log.info("성과 대시보드 조회 API 호출: eventId={}, refresh={}", eventId, refresh);
AnalyticsDashboardResponse response = analyticsService.getDashboardData(
eventId, startDate, endDate, refresh
eventId, refresh
);
return ResponseEntity.ok(ApiResponse.success(response));
@@ -22,7 +22,7 @@ import java.util.List;
@Tag(name = "Channels", description = "채널별 성과 분석 API")
@Slf4j
@RestController
@RequestMapping("/api/v1/events")
@RequestMapping("/events")
@RequiredArgsConstructor
public class ChannelAnalyticsController {
@@ -0,0 +1,75 @@
package com.kt.event.analytics.controller;
import com.kt.event.analytics.config.SampleDataLoader;
import com.kt.event.common.dto.ApiResponse;
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.boot.ApplicationArguments;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 디버그 컨트롤러
*
* ⚠️ 개발/테스트 전용
*/
@Tag(name = "Debug", description = "디버그 API (개발/테스트 전용)")
@Slf4j
@RestController
@RequestMapping("/debug")
@RequiredArgsConstructor
public class DebugController {
private final SampleDataLoader sampleDataLoader;
/**
* 샘플 데이터 수동 생성
*/
@Operation(
summary = "샘플 데이터 수동 생성",
description = "SampleDataLoader를 수동으로 실행하여 샘플 데이터를 생성합니다."
)
@PostMapping("/reload-sample-data")
public ResponseEntity<ApiResponse<String>> reloadSampleData() {
try {
log.info("🔧 수동으로 샘플 데이터 생성 요청");
// SampleDataLoader 실행
sampleDataLoader.run(new ApplicationArguments() {
@Override
public String[] getSourceArgs() {
return new String[0];
}
@Override
public java.util.Set<String> getOptionNames() {
return java.util.Collections.emptySet();
}
@Override
public boolean containsOption(String name) {
return false;
}
@Override
public java.util.List<String> getOptionValues(String name) {
return null;
}
@Override
public java.util.List<String> getNonOptionArgs() {
return java.util.Collections.emptyList();
}
});
return ResponseEntity.ok(ApiResponse.success("샘플 데이터 생성 완료"));
} catch (Exception e) {
log.error("❌ 샘플 데이터 생성 실패", e);
return ResponseEntity.ok(ApiResponse.success("샘플 데이터 생성 실패: " + e.getMessage()));
}
}
}
@@ -19,7 +19,7 @@ import org.springframework.web.bind.annotation.*;
@Tag(name = "ROI", description = "투자 대비 수익률 분석 API")
@Slf4j
@RestController
@RequestMapping("/api/v1/events")
@RequestMapping("/events")
@RequiredArgsConstructor
public class RoiAnalyticsController {
@@ -24,7 +24,7 @@ import java.util.List;
@Tag(name = "Timeline", description = "시간대별 분석 API")
@Slf4j
@RestController
@RequestMapping("/api/v1/events")
@RequestMapping("/events")
@RequiredArgsConstructor
public class TimelineAnalyticsController {
@@ -33,16 +33,14 @@ public class TimelineAnalyticsController {
/**
* 시간대별 참여 추이
*
* @param eventId 이벤트 ID
* @param interval 시간 간격 단위
* @param startDate 조회 시작 날짜
* @param endDate 조회 종료 날짜
* @param metrics 조회할 지표 목록
* @return 시간대별 참여 추이
* @param eventId 이벤트 ID
* @param interval 시간 간격 단위
* @param metrics 조회할 지표 목록
* @return 시간대별 참여 추이 (이벤트 시작일 ~ 현재까지)
*/
@Operation(
summary = "시간대별 참여 추이",
description = "이벤트 기간 동안의 시간대별 참여 추이를 분석합니다."
description = "이벤트 기간 동안의 시간대별 참여 추이를 분석합니다. (이벤트 시작일 ~ 현재까지)"
)
@GetMapping("/{eventId}/analytics/timeline")
public ResponseEntity<ApiResponse<TimelineAnalyticsResponse>> getTimelineAnalytics(
@@ -53,16 +51,6 @@ public class TimelineAnalyticsController {
@RequestParam(required = false, defaultValue = "daily")
String interval,
@Parameter(description = "조회 시작 날짜 (ISO 8601 format)")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime startDate,
@Parameter(description = "조회 종료 날짜 (ISO 8601 format)")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime endDate,
@Parameter(description = "조회할 지표 목록 (쉼표로 구분)")
@RequestParam(required = false)
String metrics
@@ -74,7 +62,7 @@ public class TimelineAnalyticsController {
: null;
TimelineAnalyticsResponse response = timelineAnalyticsService.getTimelineAnalytics(
eventId, interval, startDate, endDate, metricList
eventId, interval, metricList
);
return ResponseEntity.ok(ApiResponse.success(response));
@@ -22,7 +22,7 @@ import java.time.LocalDateTime;
@Tag(name = "User Analytics", description = "사용자 전체 이벤트 통합 성과 분석 API")
@Slf4j
@RestController
@RequestMapping("/api/v1/users")
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserAnalyticsDashboardController {
@@ -31,31 +31,19 @@ public class UserAnalyticsDashboardController {
/**
* 사용자 전체 성과 대시보드 조회
*
* @param userId 사용자 ID
* @param startDate 조회 시작 날짜
* @param endDate 조회 종료 날짜
* @param refresh 캐시 갱신 여부
* @return 전체 통합 성과 대시보드
* @param userId 사용자 ID
* @param refresh 캐시 갱신 여부
* @return 전체 통합 성과 대시보드 (userId 기반 전체 이벤트 조회)
*/
@Operation(
summary = "사용자 전체 성과 대시보드 조회",
description = "사용자의 모든 이벤트 성과를 통합하여 조회합니다."
description = "사용자의 모든 이벤트 성과를 통합하여 조회합니다. (userId 기반 전체 이벤트 조회)"
)
@GetMapping("/{userId}/analytics")
public ResponseEntity<ApiResponse<UserAnalyticsDashboardResponse>> getUserAnalytics(
@Parameter(description = "사용자 ID", required = true)
@PathVariable String userId,
@Parameter(description = "조회 시작 날짜 (ISO 8601 format)")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime startDate,
@Parameter(description = "조회 종료 날짜 (ISO 8601 format)")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime endDate,
@Parameter(description = "캐시 갱신 여부")
@RequestParam(required = false, defaultValue = "false")
Boolean refresh
@@ -63,7 +51,7 @@ public class UserAnalyticsDashboardController {
log.info("사용자 전체 성과 대시보드 조회 API 호출: userId={}, refresh={}", userId, refresh);
UserAnalyticsDashboardResponse response = userAnalyticsService.getUserDashboardData(
userId, startDate, endDate, refresh
userId, refresh
);
return ResponseEntity.ok(ApiResponse.success(response));
@@ -22,7 +22,7 @@ import java.util.List;
@Tag(name = "User Channels", description = "사용자 전체 이벤트 채널별 성과 분석 API")
@Slf4j
@RestController
@RequestMapping("/api/v1/users")
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserChannelAnalyticsController {
@@ -30,17 +30,13 @@ public class UserChannelAnalyticsController {
@Operation(
summary = "사용자 전체 채널별 성과 분석",
description = "사용자의 모든 이벤트 채널 성과를 통합하여 분석합니다."
description = "사용자의 모든 이벤트 채널 성과를 통합하여 분석합니다. (전체 채널 무조건 표시)"
)
@GetMapping("/{userId}/analytics/channels")
public ResponseEntity<ApiResponse<UserChannelAnalyticsResponse>> getUserChannelAnalytics(
@Parameter(description = "사용자 ID", required = true)
@PathVariable String userId,
@Parameter(description = "조회할 채널 목록 (쉼표로 구분)")
@RequestParam(required = false)
String channels,
@Parameter(description = "정렬 기준")
@RequestParam(required = false, defaultValue = "participants")
String sortBy,
@@ -49,28 +45,14 @@ public class UserChannelAnalyticsController {
@RequestParam(required = false, defaultValue = "desc")
String order,
@Parameter(description = "조회 시작 날짜")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime startDate,
@Parameter(description = "조회 종료 날짜")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime endDate,
@Parameter(description = "캐시 갱신 여부")
@RequestParam(required = false, defaultValue = "false")
Boolean refresh
) {
log.info("사용자 채널 분석 API 호출: userId={}, sortBy={}", userId, sortBy);
List<String> channelList = channels != null && !channels.isBlank()
? Arrays.asList(channels.split(","))
: null;
UserChannelAnalyticsResponse response = userChannelAnalyticsService.getUserChannelAnalytics(
userId, channelList, sortBy, order, startDate, endDate, refresh
userId, sortBy, order, refresh
);
return ResponseEntity.ok(ApiResponse.success(response));
@@ -20,7 +20,7 @@ import java.time.LocalDateTime;
@Tag(name = "User ROI", description = "사용자 전체 이벤트 ROI 분석 API")
@Slf4j
@RestController
@RequestMapping("/api/v1/users")
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserRoiAnalyticsController {
@@ -28,7 +28,7 @@ public class UserRoiAnalyticsController {
@Operation(
summary = "사용자 전체 ROI 상세 분석",
description = "사용자의 모든 이벤트 ROI를 통합하여 분석합니다."
description = "사용자의 모든 이벤트 ROI를 통합하여 분석합니다. (userId 기반 전체 이벤트 조회)"
)
@GetMapping("/{userId}/analytics/roi")
public ResponseEntity<ApiResponse<UserRoiAnalyticsResponse>> getUserRoiAnalytics(
@@ -39,16 +39,6 @@ public class UserRoiAnalyticsController {
@RequestParam(required = false, defaultValue = "true")
Boolean includeProjection,
@Parameter(description = "조회 시작 날짜")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime startDate,
@Parameter(description = "조회 종료 날짜")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime endDate,
@Parameter(description = "캐시 갱신 여부")
@RequestParam(required = false, defaultValue = "false")
Boolean refresh
@@ -56,7 +46,7 @@ public class UserRoiAnalyticsController {
log.info("사용자 ROI 분석 API 호출: userId={}, includeProjection={}", userId, includeProjection);
UserRoiAnalyticsResponse response = userRoiAnalyticsService.getUserRoiAnalytics(
userId, includeProjection, startDate, endDate, refresh
userId, includeProjection, refresh
);
return ResponseEntity.ok(ApiResponse.success(response));
@@ -22,7 +22,7 @@ import java.util.List;
@Tag(name = "User Timeline", description = "사용자 전체 이벤트 시간대별 분석 API")
@Slf4j
@RestController
@RequestMapping("/api/v1/users")
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserTimelineAnalyticsController {
@@ -30,7 +30,7 @@ public class UserTimelineAnalyticsController {
@Operation(
summary = "사용자 전체 시간대별 참여 추이",
description = "사용자의 모든 이벤트 시간대별 데이터를 통합하여 분석합니다."
description = "사용자의 모든 이벤트 시간대별 데이터를 통합하여 분석합니다. (userId 기반 전체 이벤트 조회)"
)
@GetMapping("/{userId}/analytics/timeline")
public ResponseEntity<ApiResponse<UserTimelineAnalyticsResponse>> getUserTimelineAnalytics(
@@ -41,16 +41,6 @@ public class UserTimelineAnalyticsController {
@RequestParam(required = false, defaultValue = "daily")
String interval,
@Parameter(description = "조회 시작 날짜")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime startDate,
@Parameter(description = "조회 종료 날짜")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime endDate,
@Parameter(description = "조회할 지표 목록 (쉼표로 구분)")
@RequestParam(required = false)
String metrics,
@@ -66,7 +56,7 @@ public class UserTimelineAnalyticsController {
: null;
UserTimelineAnalyticsResponse response = userTimelineAnalyticsService.getUserTimelineAnalytics(
userId, interval, startDate, endDate, metricList, refresh
userId, interval, metricList, refresh
);
return ResponseEntity.ok(ApiResponse.success(response));
@@ -47,6 +47,21 @@ public class AnalyticsDashboardResponse {
*/
private RoiSummary roi;
/**
* 투자 비용 상세
*/
private InvestmentDetails investment;
/**
* 수익 상세
*/
private RevenueDetails revenue;
/**
* 비용 효율성 분석
*/
private CostEfficiency costEfficiency;
/**
* 마지막 업데이트 시간
*/
@@ -33,6 +33,16 @@ public class InvestmentDetails {
*/
private BigDecimal operation;
/**
* 경품 비용 (원)
*/
private BigDecimal prizeCost;
/**
* 채널 비용 (원) - distribution과 동일한 값
*/
private BigDecimal channelCost;
/**
* 총 투자 비용 (원)
*/
@@ -26,6 +26,16 @@ public class RevenueDetails {
*/
private BigDecimal expectedSales;
/**
* 신규 고객 매출 (원)
*/
private BigDecimal newCustomerRevenue;
/**
* 기존 고객 매출 (원)
*/
private BigDecimal existingCustomerRevenue;
/**
* 브랜드 가치 향상 추정액 (원)
*/
@@ -125,4 +125,11 @@ public class ChannelStats extends BaseTimeEntity {
@Column(name = "average_duration")
@Builder.Default
private Integer averageDuration = 0;
/**
* 참여자 수 증가
*/
public void incrementParticipants() {
this.participants++;
}
}
@@ -97,6 +97,18 @@ public class EventStats extends BaseTimeEntity {
@Column(length = 20)
private String status;
/**
* 이벤트 시작일
*/
@Column(name = "start_date")
private java.time.LocalDateTime startDate;
/**
* 이벤트 종료일 (null이면 진행중)
*/
@Column(name = "end_date")
private java.time.LocalDateTime endDate;
/**
* 참여자 수 증가
*/
@@ -0,0 +1,32 @@
package com.kt.event.analytics.infrastructure.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web Configuration
* CORS 설정 및 기타 웹 관련 설정
*
* @author System Architect
* @since 2025-10-30
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
/**
* CORS 설정
* - 모든 origin 허용 (개발 환경)
* - 모든 HTTP 메서드 허용
* - Credentials 허용
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
@@ -32,7 +32,7 @@ public class DistributionCompletedConsumer {
private final ObjectMapper objectMapper;
private final RedisTemplate<String, String> redisTemplate;
private static final String PROCESSED_DISTRIBUTIONS_KEY = "distribution_completed";
private static final String PROCESSED_DISTRIBUTIONS_KEY = "distribution_completed_v2";
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long IDEMPOTENCY_TTL_DAYS = 7;
@@ -109,10 +109,15 @@ public class DistributionCompletedConsumer {
channelStats.setImpressions(channel.getExpectedViews());
}
// 배포 비용 저장
if (channel.getDistributionCost() != null) {
channelStats.setDistributionCost(channel.getDistributionCost());
}
channelStatsRepository.save(channelStats);
log.debug("✅ 채널 통계 저장: eventId={}, channel={}, expectedViews={}",
eventId, channelName, channel.getExpectedViews());
log.debug("✅ 채널 통계 저장: eventId={}, channel={}, expectedViews={}, distributionCost={}",
eventId, channelName, channel.getExpectedViews(), channel.getDistributionCost());
} catch (Exception e) {
log.error("❌ 채널 통계 처리 실패: eventId={}, channel={}", eventId, channel.getChannel(), e);
@@ -12,6 +12,7 @@ import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.concurrent.TimeUnit;
/**
@@ -29,7 +30,7 @@ public class EventCreatedConsumer {
private final ObjectMapper objectMapper;
private final RedisTemplate<String, String> redisTemplate;
private static final String PROCESSED_EVENTS_KEY = "processed_events";
private static final String PROCESSED_EVENTS_KEY = "processed_events_v2";
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long IDEMPOTENCY_TTL_DAYS = 7;
@@ -61,11 +62,15 @@ public class EventCreatedConsumer {
.userId(event.getStoreId()) // MVP: 1 user = 1 store, storeId를 userId로 매핑
.totalParticipants(0)
.totalInvestment(event.getTotalInvestment())
.expectedRevenue(event.getExpectedRevenue() != null ? event.getExpectedRevenue() : BigDecimal.ZERO)
.status(event.getStatus())
.startDate(event.getStartDate())
.endDate(event.getEndDate())
.build();
eventStatsRepository.save(eventStats);
log.info("✅ 이벤트 통계 초기화 완료: eventId={}", eventId);
log.info("✅ 이벤트 통계 초기화 완료: eventId={}, userId={}, startDate={}, endDate={}",
eventId, eventStats.getUserId(), event.getStartDate(), event.getEndDate());
// 3. 캐시 무효화 (다음 조회 시 최신 데이터 반영)
String cacheKey = CACHE_KEY_PREFIX + eventId;
@@ -1,7 +1,9 @@
package com.kt.event.analytics.messaging.consumer;
import com.kt.event.analytics.entity.ChannelStats;
import com.kt.event.analytics.entity.EventStats;
import com.kt.event.analytics.messaging.event.ParticipantRegisteredEvent;
import com.kt.event.analytics.repository.ChannelStatsRepository;
import com.kt.event.analytics.repository.EventStatsRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
@@ -26,10 +28,11 @@ import java.util.concurrent.TimeUnit;
public class ParticipantRegisteredConsumer {
private final EventStatsRepository eventStatsRepository;
private final ChannelStatsRepository channelStatsRepository;
private final ObjectMapper objectMapper;
private final RedisTemplate<String, String> redisTemplate;
private static final String PROCESSED_PARTICIPANTS_KEY = "processed_participants";
private static final String PROCESSED_PARTICIPANTS_KEY = "processed_participants_v2";
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long IDEMPOTENCY_TTL_DAYS = 7;
@@ -47,11 +50,13 @@ public class ParticipantRegisteredConsumer {
ParticipantRegisteredEvent event = objectMapper.readValue(message, ParticipantRegisteredEvent.class);
String participantId = event.getParticipantId();
String eventId = event.getEventId();
String channel = event.getChannel();
// ✅ 1. 멱등성 체크 (중복 처리 방지)
Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_PARTICIPANTS_KEY, participantId);
// ✅ 1. 멱등성 체크 (중복 처리 방지) - eventId:participantId 조합으로 체크
String idempotencyKey = eventId + ":" + participantId;
Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_PARTICIPANTS_KEY, idempotencyKey);
if (Boolean.TRUE.equals(isProcessed)) {
log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): participantId={}", participantId);
log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): eventId={}, participantId={}", eventId, participantId);
return;
}
@@ -67,15 +72,29 @@ public class ParticipantRegisteredConsumer {
() -> log.warn("⚠️ 이벤트 통계 없음: eventId={}", eventId)
);
// 3. 캐시 무효화 (다음 조회 시 최신 참여자 수 반영)
// 3. 채널별 참여자 수 업데이트 - 비관적 락 적용
if (channel != null && !channel.isEmpty()) {
channelStatsRepository.findByEventIdAndChannelNameWithLock(eventId, channel)
.ifPresentOrElse(
channelStats -> {
channelStats.incrementParticipants();
channelStatsRepository.save(channelStats);
log.info("✅ 채널별 참여자 수 업데이트: eventId={}, channel={}, participants={}",
eventId, channel, channelStats.getParticipants());
},
() -> log.warn("⚠️ 채널 통계 없음: eventId={}, channel={}", eventId, channel)
);
}
// 4. 캐시 무효화 (다음 조회 시 최신 참여자 수 반영)
String cacheKey = CACHE_KEY_PREFIX + eventId;
redisTemplate.delete(cacheKey);
log.debug("🗑️ 캐시 무효화: {}", cacheKey);
// 4. 멱등성 처리 완료 기록 (7일 TTL)
redisTemplate.opsForSet().add(PROCESSED_PARTICIPANTS_KEY, participantId);
// 5. 멱등성 처리 완료 기록 (7일 TTL)
redisTemplate.opsForSet().add(PROCESSED_PARTICIPANTS_KEY, idempotencyKey);
redisTemplate.expire(PROCESSED_PARTICIPANTS_KEY, IDEMPOTENCY_TTL_DAYS, TimeUnit.DAYS);
log.debug("✅ 멱등성 기록: participantId={}", participantId);
log.debug("✅ 멱등성 기록: eventId={}, participantId={}", eventId, participantId);
} catch (Exception e) {
log.error("❌ ParticipantRegistered 이벤트 처리 실패: {}", e.getMessage(), e);
@@ -62,5 +62,10 @@ public class DistributionCompletedEvent {
* 예상 노출 수
*/
private Integer expectedViews;
/**
* 배포 비용 (원)
*/
private java.math.BigDecimal distributionCost;
}
}
@@ -6,6 +6,7 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 이벤트 생성 이벤트
@@ -36,8 +37,23 @@ public class EventCreatedEvent {
*/
private BigDecimal totalInvestment;
/**
* 예상 수익
*/
private BigDecimal expectedRevenue;
/**
* 이벤트 상태
*/
private String status;
/**
* 이벤트 시작일
*/
private LocalDateTime startDate;
/**
* 이벤트 종료일 (null이면 진행중)
*/
private LocalDateTime endDate;
}
@@ -1,7 +1,11 @@
package com.kt.event.analytics.repository;
import com.kt.event.analytics.entity.ChannelStats;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@@ -30,6 +34,18 @@ public interface ChannelStatsRepository extends JpaRepository<ChannelStats, Long
*/
Optional<ChannelStats> findByEventIdAndChannelName(String eventId, String channelName);
/**
* 이벤트 ID와 채널명으로 통계 조회 (비관적 락)
*
* @param eventId 이벤트 ID
* @param channelName 채널명
* @return 채널 통계
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM ChannelStats c WHERE c.eventId = :eventId AND c.channelName = :channelName")
Optional<ChannelStats> findByEventIdAndChannelNameWithLock(@Param("eventId") String eventId,
@Param("channelName") String channelName);
/**
* 여러 이벤트 ID로 모든 채널 통계 조회
*
@@ -47,12 +47,10 @@ public class AnalyticsService {
* 대시보드 데이터 조회
*
* @param eventId 이벤트 ID
* @param startDate 조회 시작 날짜 (선택)
* @param endDate 조회 종료 날짜 (선택)
* @param refresh 캐시 갱신 여부
* @return 대시보드 응답
* @param refresh 캐시 갱신 여부
* @return 대시보드 응답 (이벤트 시작일 ~ 현재까지)
*/
public AnalyticsDashboardResponse getDashboardData(String eventId, LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
public AnalyticsDashboardResponse getDashboardData(String eventId, boolean refresh) {
log.info("대시보드 데이터 조회 시작: eventId={}, refresh={}", eventId, refresh);
String cacheKey = CACHE_KEY_PREFIX + eventId;
@@ -91,7 +89,7 @@ public class AnalyticsService {
}
// 3. 대시보드 데이터 구성
AnalyticsDashboardResponse response = buildDashboardData(eventStats, channelStatsList, startDate, endDate);
AnalyticsDashboardResponse response = buildDashboardData(eventStats, channelStatsList);
// 4. Redis 캐싱 (1시간 TTL)
try {
@@ -110,10 +108,9 @@ public class AnalyticsService {
/**
* 대시보드 데이터 구성
*/
private AnalyticsDashboardResponse buildDashboardData(EventStats eventStats, List<ChannelStats> channelStatsList,
LocalDateTime startDate, LocalDateTime endDate) {
// 기간 정보
PeriodInfo period = buildPeriodInfo(startDate, endDate);
private AnalyticsDashboardResponse buildDashboardData(EventStats eventStats, List<ChannelStats> channelStatsList) {
// 기간 정보 (이벤트 시작일 ~ 현재)
PeriodInfo period = buildPeriodInfo(eventStats);
// 성과 요약
AnalyticsSummary summary = buildAnalyticsSummary(eventStats, channelStatsList);
@@ -124,6 +121,15 @@ public class AnalyticsService {
// ROI 요약
RoiSummary roiSummary = roiCalculator.calculateRoiSummary(eventStats);
// 투자 비용 상세
InvestmentDetails investment = buildInvestmentDetails(eventStats, channelStatsList);
// 수익 상세
RevenueDetails revenue = buildRevenueDetails(eventStats);
// 비용 효율성
CostEfficiency costEfficiency = buildCostEfficiency(eventStats);
return AnalyticsDashboardResponse.builder()
.eventId(eventStats.getEventId())
.eventTitle(eventStats.getEventTitle())
@@ -131,17 +137,21 @@ public class AnalyticsService {
.summary(summary)
.channelPerformance(channelPerformance)
.roi(roiSummary)
.investment(investment)
.revenue(revenue)
.costEfficiency(costEfficiency)
.lastUpdatedAt(LocalDateTime.now())
.dataSource("cached")
.build();
}
/**
* 기간 정보 구성
* 기간 정보 구성 (이벤트 시작일 ~ 종료일 또는 현재)
*/
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
private PeriodInfo buildPeriodInfo(EventStats eventStats) {
LocalDateTime start = eventStats.getStartDate();
LocalDateTime end = eventStats.getEndDate() != null ?
eventStats.getEndDate() : LocalDateTime.now();
long durationDays = ChronoUnit.DAYS.between(start, end);
@@ -215,4 +225,88 @@ public class AnalyticsService {
return summaries;
}
/**
* 투자 비용 상세 구성
*
* UserRoiAnalyticsService와 동일한 로직:
* - 실제 채널 배포 비용 집계
* - 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
*/
private InvestmentDetails buildInvestmentDetails(EventStats eventStats, List<ChannelStats> channelStatsList) {
java.math.BigDecimal totalInvestment = eventStats.getTotalInvestment();
// ChannelStats에서 실제 배포 비용 집계
java.math.BigDecimal actualDistribution = channelStatsList.stream()
.map(ChannelStats::getDistributionCost)
.reduce(java.math.BigDecimal.ZERO, java.math.BigDecimal::add);
// 나머지 비용 계산 (총 투자 - 실제 채널 배포 비용)
java.math.BigDecimal remaining = totalInvestment.subtract(actualDistribution);
// 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
java.math.BigDecimal prizeCost = remaining.multiply(java.math.BigDecimal.valueOf(0.50));
java.math.BigDecimal contentCreation = remaining.multiply(java.math.BigDecimal.valueOf(0.30));
java.math.BigDecimal operation = remaining.multiply(java.math.BigDecimal.valueOf(0.20));
return InvestmentDetails.builder()
.total(totalInvestment)
.contentCreation(contentCreation)
.operation(operation)
.distribution(actualDistribution)
.prizeCost(prizeCost)
.channelCost(actualDistribution) // 채널비용은 배포비용과 동일
.build();
}
/**
* 수익 상세 구성
*
* UserRoiAnalyticsService와 동일한 로직:
* - 직접 매출 70%, 예상 추가 매출 30%
* - 신규 고객 40%, 기존 고객 60%
*/
private RevenueDetails buildRevenueDetails(EventStats eventStats) {
java.math.BigDecimal totalRevenue = eventStats.getExpectedRevenue();
// 매출 분배: 직접 매출 70%, 예상 추가 매출 30%
java.math.BigDecimal directSales = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.70));
java.math.BigDecimal expectedSales = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.30));
// 신규 고객 40%, 기존 고객 60%
java.math.BigDecimal newCustomerRevenue = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.40));
java.math.BigDecimal existingCustomerRevenue = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.60));
return RevenueDetails.builder()
.total(totalRevenue)
.directSales(directSales)
.expectedSales(expectedSales)
.newCustomerRevenue(newCustomerRevenue)
.existingCustomerRevenue(existingCustomerRevenue)
.brandValue(java.math.BigDecimal.ZERO) // 브랜드 가치는 별도 계산 필요 시 추가
.build();
}
/**
* 비용 효율성 구성
*
* UserRoiAnalyticsService와 동일한 로직:
* - 참여자당 비용 = 총투자 ÷ 총참여자수
* - 참여자당 수익 = 총수익 ÷ 총참여자수
*/
private CostEfficiency buildCostEfficiency(EventStats eventStats) {
int totalParticipants = eventStats.getTotalParticipants();
java.math.BigDecimal totalInvestment = eventStats.getTotalInvestment();
java.math.BigDecimal totalRevenue = eventStats.getExpectedRevenue();
double costPerParticipant = totalParticipants > 0 ?
totalInvestment.doubleValue() / totalParticipants : 0.0;
double revenuePerParticipant = totalParticipants > 0 ?
totalRevenue.doubleValue() / totalParticipants : 0.0;
return CostEfficiency.builder()
.costPerParticipant(costPerParticipant)
.revenuePerParticipant(revenuePerParticipant)
.build();
}
}
@@ -60,43 +60,62 @@ public class ROICalculator {
/**
* 투자 비용 계산
*
* UserRoiAnalyticsService와 동일한 로직:
* - ChannelStats에서 실제 배포 비용 집계
* - 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
*/
private InvestmentDetails calculateInvestment(EventStats eventStats, List<ChannelStats> channelStats) {
BigDecimal distributionCost = channelStats.stream()
BigDecimal totalInvestment = eventStats.getTotalInvestment();
// ChannelStats에서 실제 배포 비용 집계
BigDecimal actualDistribution = channelStats.stream()
.map(ChannelStats::getDistributionCost)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal contentCreation = eventStats.getTotalInvestment()
.multiply(BigDecimal.valueOf(0.4)); // 전체 투자의 40%를 콘텐츠 제작비로 가정
// 나머지 비용 계산 (총 투자 - 실제 채널 배포 비용)
BigDecimal remaining = totalInvestment.subtract(actualDistribution);
BigDecimal operation = eventStats.getTotalInvestment()
.multiply(BigDecimal.valueOf(0.1)); // 10%를 운영비로 가정
// 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
BigDecimal prizeCost = remaining.multiply(BigDecimal.valueOf(0.50));
BigDecimal contentCreation = remaining.multiply(BigDecimal.valueOf(0.30));
BigDecimal operation = remaining.multiply(BigDecimal.valueOf(0.20));
return InvestmentDetails.builder()
.total(totalInvestment)
.contentCreation(contentCreation)
.distribution(distributionCost)
.operation(operation)
.total(eventStats.getTotalInvestment())
.distribution(actualDistribution)
.prizeCost(prizeCost)
.channelCost(actualDistribution) // 채널비용은 배포비용과 동일
.build();
}
/**
* 수익 계산
*
* UserRoiAnalyticsService와 동일한 로직:
* - 직접 매출 70%, 예상 추가 매출 30%
* - 신규 고객 40%, 기존 고객 60%
*/
private RevenueDetails calculateRevenue(EventStats eventStats) {
BigDecimal directSales = eventStats.getExpectedRevenue()
.multiply(BigDecimal.valueOf(0.66)); // 예상 수익의 66%를 직접 매출로 가정
BigDecimal totalRevenue = eventStats.getExpectedRevenue();
BigDecimal expectedSales = eventStats.getExpectedRevenue()
.multiply(BigDecimal.valueOf(0.34)); // 34%를 예상 추가 매출로 가정
// 매출 분배: 직접 매출 70%, 예상 추가 매출 30%
BigDecimal directSales = totalRevenue.multiply(BigDecimal.valueOf(0.70));
BigDecimal expectedSales = totalRevenue.multiply(BigDecimal.valueOf(0.30));
BigDecimal brandValue = BigDecimal.ZERO; // 브랜드 가치는 별도 계산 필요
// 신규 고객 40%, 기존 고객 60%
BigDecimal newCustomerRevenue = totalRevenue.multiply(BigDecimal.valueOf(0.40));
BigDecimal existingCustomerRevenue = totalRevenue.multiply(BigDecimal.valueOf(0.60));
return RevenueDetails.builder()
.total(totalRevenue)
.directSales(directSales)
.expectedSales(expectedSales)
.brandValue(brandValue)
.total(eventStats.getExpectedRevenue())
.newCustomerRevenue(newCustomerRevenue)
.existingCustomerRevenue(existingCustomerRevenue)
.brandValue(BigDecimal.ZERO) // 브랜드 가치는 별도 계산 필요 시 추가
.build();
}
@@ -26,20 +26,13 @@ public class TimelineAnalyticsService {
private final TimelineDataRepository timelineDataRepository;
/**
* 시간대별 참여 추이 조회
* 시간대별 참여 추이 조회 (이벤트 전체 기간)
*/
public TimelineAnalyticsResponse getTimelineAnalytics(String eventId, String interval,
LocalDateTime startDate, LocalDateTime endDate,
List<String> metrics) {
public TimelineAnalyticsResponse getTimelineAnalytics(String eventId, String interval, List<String> metrics) {
log.info("시간대별 참여 추이 조회: eventId={}, interval={}", eventId, interval);
// 시간대별 데이터 조회
List<TimelineData> timelineDataList;
if (startDate != null && endDate != null) {
timelineDataList = timelineDataRepository.findByEventIdAndTimestampBetween(eventId, startDate, endDate);
} else {
timelineDataList = timelineDataRepository.findByEventIdOrderByTimestampAsc(eventId);
}
// 시간대별 데이터 조회 (이벤트 전체 기간)
List<TimelineData> timelineDataList = timelineDataRepository.findByEventIdOrderByTimestampAsc(eventId);
// 시간대별 데이터 포인트 구성
List<TimelineDataPoint> dataPoints = buildTimelineDataPoints(timelineDataList);
@@ -44,13 +44,11 @@ public class UserAnalyticsService {
/**
* 사용자 전체 대시보드 데이터 조회
*
* @param userId 사용자 ID
* @param startDate 조회 시작 날짜 (선택)
* @param endDate 조회 종료 날짜 (선택)
* @param refresh 캐시 갱신 여부
* @return 사용자 통합 대시보드 응답
* @param userId 사용자 ID
* @param refresh 캐시 갱신 여부
* @return 사용자 통합 대시보드 응답 (userId 기반 전체 이벤트 조회)
*/
public UserAnalyticsDashboardResponse getUserDashboardData(String userId, LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
public UserAnalyticsDashboardResponse getUserDashboardData(String userId, boolean refresh) {
log.info("사용자 전체 대시보드 데이터 조회 시작: userId={}, refresh={}", userId, refresh);
String cacheKey = CACHE_KEY_PREFIX + userId;
@@ -75,7 +73,7 @@ public class UserAnalyticsService {
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
if (allEvents.isEmpty()) {
log.warn("사용자에 이벤트가 없음: userId={}", userId);
return buildEmptyResponse(userId, startDate, endDate);
return buildEmptyResponse(userId);
}
log.debug("사용자 이벤트 조회 완료: userId={}, 이벤트 수={}", userId, allEvents.size());
@@ -87,7 +85,7 @@ public class UserAnalyticsService {
List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
// 3. 통합 대시보드 데이터 구성
UserAnalyticsDashboardResponse response = buildUserDashboardData(userId, allEvents, allChannelStats, startDate, endDate);
UserAnalyticsDashboardResponse response = buildUserDashboardData(userId, allEvents, allChannelStats);
// 4. Redis 캐싱 (30분 TTL)
try {
@@ -104,10 +102,15 @@ public class UserAnalyticsService {
/**
* 빈 응답 생성 (이벤트가 없는 경우)
*/
private UserAnalyticsDashboardResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
private UserAnalyticsDashboardResponse buildEmptyResponse(String userId) {
LocalDateTime now = LocalDateTime.now();
return UserAnalyticsDashboardResponse.builder()
.userId(userId)
.period(buildPeriodInfo(startDate, endDate))
.period(PeriodInfo.builder()
.startDate(now)
.endDate(now)
.durationDays(0)
.build())
.totalEvents(0)
.activeEvents(0)
.overallSummary(buildEmptyAnalyticsSummary())
@@ -123,10 +126,9 @@ public class UserAnalyticsService {
* 사용자 통합 대시보드 데이터 구성
*/
private UserAnalyticsDashboardResponse buildUserDashboardData(String userId, List<EventStats> allEvents,
List<ChannelStats> allChannelStats,
LocalDateTime startDate, LocalDateTime endDate) {
// 기간 정보
PeriodInfo period = buildPeriodInfo(startDate, endDate);
List<ChannelStats> allChannelStats) {
// 기간 정보 (전체 이벤트의 최소/최대 날짜 기반)
PeriodInfo period = buildPeriodFromEvents(allEvents);
// 전체 이벤트 수 및 활성 이벤트 수
int totalEvents = allEvents.size();
@@ -299,16 +301,22 @@ public class UserAnalyticsService {
/**
* 기간 정보 구성
*
* 전체 이벤트 중 가장 빠른 시작일 ~ 현재까지의 기간 계산
*/
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
long durationDays = ChronoUnit.DAYS.between(start, end);
private PeriodInfo buildPeriodFromEvents(List<EventStats> events) {
LocalDateTime start = events.stream()
.map(EventStats::getStartDate)
.filter(Objects::nonNull)
.min(LocalDateTime::compareTo)
.orElse(LocalDateTime.now());
LocalDateTime end = LocalDateTime.now();
return PeriodInfo.builder()
.startDate(start)
.endDate(end)
.durationDays((int) durationDays)
.durationDays((int) ChronoUnit.DAYS.between(start, end))
.build();
}
@@ -42,10 +42,9 @@ public class UserChannelAnalyticsService {
private static final long CACHE_TTL = 1800; // 30분
/**
* 사용자 전체 채널 분석 데이터 조회
* 사용자 전체 채널 분석 데이터 조회 (전체 채널 무조건 표시)
*/
public UserChannelAnalyticsResponse getUserChannelAnalytics(String userId, List<String> channels, String sortBy, String order,
LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
public UserChannelAnalyticsResponse getUserChannelAnalytics(String userId, String sortBy, String order, boolean refresh) {
log.info("사용자 채널 분석 조회 시작: userId={}, refresh={}", userId, refresh);
String cacheKey = CACHE_KEY_PREFIX + userId;
@@ -66,14 +65,14 @@ public class UserChannelAnalyticsService {
// 2. 데이터 조회
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
if (allEvents.isEmpty()) {
return buildEmptyResponse(userId, startDate, endDate);
return buildEmptyResponse(userId);
}
List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
// 3. 응답 구성
UserChannelAnalyticsResponse response = buildChannelAnalyticsResponse(userId, allEvents, allChannelStats, channels, sortBy, order, startDate, endDate);
// 3. 응답 구성 (전체 채널)
UserChannelAnalyticsResponse response = buildChannelAnalyticsResponse(userId, allEvents, allChannelStats, sortBy, order);
// 4. 캐싱
try {
@@ -87,10 +86,15 @@ public class UserChannelAnalyticsService {
return response;
}
private UserChannelAnalyticsResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
private UserChannelAnalyticsResponse buildEmptyResponse(String userId) {
LocalDateTime now = LocalDateTime.now();
return UserChannelAnalyticsResponse.builder()
.userId(userId)
.period(buildPeriodInfo(startDate, endDate))
.period(PeriodInfo.builder()
.startDate(now)
.endDate(now)
.durationDays(0)
.build())
.totalEvents(0)
.channels(new ArrayList<>())
.comparison(ChannelComparison.builder().build())
@@ -100,15 +104,10 @@ public class UserChannelAnalyticsService {
}
private UserChannelAnalyticsResponse buildChannelAnalyticsResponse(String userId, List<EventStats> allEvents,
List<ChannelStats> allChannelStats, List<String> channels,
String sortBy, String order, LocalDateTime startDate, LocalDateTime endDate) {
// 채널 필터링
List<ChannelStats> filteredChannels = channels != null && !channels.isEmpty()
? allChannelStats.stream().filter(c -> channels.contains(c.getChannelName())).collect(Collectors.toList())
: allChannelStats;
// 채널별 집계
List<ChannelAnalytics> channelAnalyticsList = aggregateChannelAnalytics(filteredChannels);
List<ChannelStats> allChannelStats,
String sortBy, String order) {
// 채널별 집계 (전체 채널)
List<ChannelAnalytics> channelAnalyticsList = aggregateChannelAnalytics(allChannelStats);
// 정렬
channelAnalyticsList = sortChannels(channelAnalyticsList, sortBy, order);
@@ -118,7 +117,7 @@ public class UserChannelAnalyticsService {
return UserChannelAnalyticsResponse.builder()
.userId(userId)
.period(buildPeriodInfo(startDate, endDate))
.period(buildPeriodFromEvents(allEvents))
.totalEvents(allEvents.size())
.channels(channelAnalyticsList)
.comparison(comparison)
@@ -246,15 +245,24 @@ public class UserChannelAnalyticsService {
.build();
}
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
long durationDays = ChronoUnit.DAYS.between(start, end);
/**
* 전체 이벤트의 생성/수정 시간 기반으로 period 계산
*/
private PeriodInfo buildPeriodFromEvents(List<EventStats> events) {
LocalDateTime start = events.stream()
.map(EventStats::getCreatedAt)
.min(LocalDateTime::compareTo)
.orElse(LocalDateTime.now());
LocalDateTime end = events.stream()
.map(EventStats::getUpdatedAt)
.max(LocalDateTime::compareTo)
.orElse(LocalDateTime.now());
return PeriodInfo.builder()
.startDate(start)
.endDate(end)
.durationDays((int) durationDays)
.durationDays((int) ChronoUnit.DAYS.between(start, end))
.build();
}
}
@@ -1,7 +1,9 @@
package com.kt.event.analytics.service;
import com.kt.event.analytics.dto.response.*;
import com.kt.event.analytics.entity.ChannelStats;
import com.kt.event.analytics.entity.EventStats;
import com.kt.event.analytics.repository.ChannelStatsRepository;
import com.kt.event.analytics.repository.EventStatsRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -31,14 +33,14 @@ import java.util.stream.Collectors;
public class UserRoiAnalyticsService {
private final EventStatsRepository eventStatsRepository;
private final ChannelStatsRepository channelStatsRepository;
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
private static final String CACHE_KEY_PREFIX = "analytics:user:roi:";
private static final long CACHE_TTL = 1800;
public UserRoiAnalyticsResponse getUserRoiAnalytics(String userId, boolean includeProjection,
LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
public UserRoiAnalyticsResponse getUserRoiAnalytics(String userId, boolean includeProjection, boolean refresh) {
log.info("사용자 ROI 분석 조회 시작: userId={}, refresh={}", userId, refresh);
String cacheKey = CACHE_KEY_PREFIX + userId;
@@ -56,10 +58,10 @@ public class UserRoiAnalyticsService {
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
if (allEvents.isEmpty()) {
return buildEmptyResponse(userId, startDate, endDate);
return buildEmptyResponse(userId);
}
UserRoiAnalyticsResponse response = buildRoiResponse(userId, allEvents, includeProjection, startDate, endDate);
UserRoiAnalyticsResponse response = buildRoiResponse(userId, allEvents, includeProjection);
try {
String jsonData = objectMapper.writeValueAsString(response);
@@ -71,13 +73,32 @@ public class UserRoiAnalyticsService {
return response;
}
private UserRoiAnalyticsResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
private UserRoiAnalyticsResponse buildEmptyResponse(String userId) {
LocalDateTime now = LocalDateTime.now();
return UserRoiAnalyticsResponse.builder()
.userId(userId)
.period(buildPeriodInfo(startDate, endDate))
.period(PeriodInfo.builder()
.startDate(now)
.endDate(now)
.durationDays(0)
.build())
.totalEvents(0)
.overallInvestment(InvestmentDetails.builder().total(BigDecimal.ZERO).build())
.overallRevenue(RevenueDetails.builder().total(BigDecimal.ZERO).build())
.overallInvestment(InvestmentDetails.builder()
.total(BigDecimal.ZERO)
.contentCreation(BigDecimal.ZERO)
.operation(BigDecimal.ZERO)
.distribution(BigDecimal.ZERO)
.prizeCost(BigDecimal.ZERO)
.channelCost(BigDecimal.ZERO)
.build())
.overallRevenue(RevenueDetails.builder()
.total(BigDecimal.ZERO)
.directSales(BigDecimal.ZERO)
.expectedSales(BigDecimal.ZERO)
.newCustomerRevenue(BigDecimal.ZERO)
.existingCustomerRevenue(BigDecimal.ZERO)
.brandValue(BigDecimal.ZERO)
.build())
.overallRoi(RoiCalculation.builder()
.netProfit(BigDecimal.ZERO)
.roiPercentage(0.0)
@@ -88,8 +109,7 @@ public class UserRoiAnalyticsService {
.build();
}
private UserRoiAnalyticsResponse buildRoiResponse(String userId, List<EventStats> allEvents, boolean includeProjection,
LocalDateTime startDate, LocalDateTime endDate) {
private UserRoiAnalyticsResponse buildRoiResponse(String userId, List<EventStats> allEvents, boolean includeProjection) {
BigDecimal totalInvestment = allEvents.stream().map(EventStats::getTotalInvestment).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal totalRevenue = allEvents.stream().map(EventStats::getExpectedRevenue).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal totalProfit = totalRevenue.subtract(totalInvestment);
@@ -98,17 +118,44 @@ public class UserRoiAnalyticsService {
? totalProfit.divide(totalInvestment, 4, RoundingMode.HALF_UP).multiply(BigDecimal.valueOf(100)).doubleValue()
: 0.0;
// ChannelStats에서 실제 배포 비용 집계
List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
BigDecimal actualDistribution = allChannelStats.stream()
.map(ChannelStats::getDistributionCost)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// 나머지 비용 계산 (총 투자 - 실제 채널 배포 비용)
BigDecimal remaining = totalInvestment.subtract(actualDistribution);
// 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
BigDecimal prizeCost = remaining.multiply(BigDecimal.valueOf(0.50));
BigDecimal contentCreation = remaining.multiply(BigDecimal.valueOf(0.30));
BigDecimal operation = remaining.multiply(BigDecimal.valueOf(0.20));
InvestmentDetails investment = InvestmentDetails.builder()
.total(totalInvestment)
.contentCreation(totalInvestment.multiply(BigDecimal.valueOf(0.6)))
.operation(totalInvestment.multiply(BigDecimal.valueOf(0.2)))
.distribution(totalInvestment.multiply(BigDecimal.valueOf(0.2)))
.contentCreation(contentCreation)
.operation(operation)
.distribution(actualDistribution)
.prizeCost(prizeCost)
.channelCost(actualDistribution) // 채널비용은 배포비용과 동일
.build();
// 매출 분배: 직접 매출 70%, 예상 추가 매출 30% / 신규 고객 40%, 기존 고객 60%
BigDecimal directSales = totalRevenue.multiply(BigDecimal.valueOf(0.70));
BigDecimal expectedSales = totalRevenue.multiply(BigDecimal.valueOf(0.30));
BigDecimal newCustomerRevenue = totalRevenue.multiply(BigDecimal.valueOf(0.40));
BigDecimal existingCustomerRevenue = totalRevenue.multiply(BigDecimal.valueOf(0.60));
RevenueDetails revenue = RevenueDetails.builder()
.total(totalRevenue)
.directSales(totalRevenue.multiply(BigDecimal.valueOf(0.7)))
.expectedSales(totalRevenue.multiply(BigDecimal.valueOf(0.3)))
.directSales(directSales)
.expectedSales(expectedSales)
.newCustomerRevenue(newCustomerRevenue)
.existingCustomerRevenue(existingCustomerRevenue)
.brandValue(BigDecimal.ZERO) // 브랜드 가치는 별도 계산 필요 시 추가
.build();
RoiCalculation roiCalc = RoiCalculation.builder()
@@ -149,9 +196,12 @@ public class UserRoiAnalyticsService {
.sorted(Comparator.comparingDouble(UserRoiAnalyticsResponse.EventRoiSummary::getRoi).reversed())
.collect(Collectors.toList());
// 전체 이벤트의 최소/최대 날짜로 period 계산
PeriodInfo period = buildPeriodFromEvents(allEvents);
return UserRoiAnalyticsResponse.builder()
.userId(userId)
.period(buildPeriodInfo(startDate, endDate))
.period(period)
.totalEvents(allEvents.size())
.overallInvestment(investment)
.overallRevenue(revenue)
@@ -164,9 +214,20 @@ public class UserRoiAnalyticsService {
.build();
}
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
/**
* 전체 이벤트의 생성/수정 시간 기반으로 period 계산
*/
private PeriodInfo buildPeriodFromEvents(List<EventStats> events) {
LocalDateTime start = events.stream()
.map(EventStats::getCreatedAt)
.min(LocalDateTime::compareTo)
.orElse(LocalDateTime.now());
LocalDateTime end = events.stream()
.map(EventStats::getUpdatedAt)
.max(LocalDateTime::compareTo)
.orElse(LocalDateTime.now());
return PeriodInfo.builder()
.startDate(start)
.endDate(end)
@@ -37,7 +37,6 @@ public class UserTimelineAnalyticsService {
private static final long CACHE_TTL = 1800;
public UserTimelineAnalyticsResponse getUserTimelineAnalytics(String userId, String interval,
LocalDateTime startDate, LocalDateTime endDate,
List<String> metrics, boolean refresh) {
log.info("사용자 타임라인 분석 조회 시작: userId={}, interval={}, refresh={}", userId, interval, refresh);
@@ -56,15 +55,13 @@ public class UserTimelineAnalyticsService {
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
if (allEvents.isEmpty()) {
return buildEmptyResponse(userId, interval, startDate, endDate);
return buildEmptyResponse(userId, interval);
}
List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
List<TimelineData> allTimelineData = startDate != null && endDate != null
? timelineDataRepository.findByEventIdInAndTimestampBetween(eventIds, startDate, endDate)
: timelineDataRepository.findByEventIdInOrderByTimestampAsc(eventIds);
List<TimelineData> allTimelineData = timelineDataRepository.findByEventIdInOrderByTimestampAsc(eventIds);
UserTimelineAnalyticsResponse response = buildTimelineResponse(userId, allEvents, allTimelineData, interval, startDate, endDate);
UserTimelineAnalyticsResponse response = buildTimelineResponse(userId, allEvents, allTimelineData, interval);
try {
String jsonData = objectMapper.writeValueAsString(response);
@@ -76,10 +73,15 @@ public class UserTimelineAnalyticsService {
return response;
}
private UserTimelineAnalyticsResponse buildEmptyResponse(String userId, String interval, LocalDateTime startDate, LocalDateTime endDate) {
private UserTimelineAnalyticsResponse buildEmptyResponse(String userId, String interval) {
LocalDateTime now = LocalDateTime.now();
return UserTimelineAnalyticsResponse.builder()
.userId(userId)
.period(buildPeriodInfo(startDate, endDate))
.period(PeriodInfo.builder()
.startDate(now)
.endDate(now)
.durationDays(0)
.build())
.totalEvents(0)
.interval(interval != null ? interval : "daily")
.dataPoints(new ArrayList<>())
@@ -91,8 +93,7 @@ public class UserTimelineAnalyticsService {
}
private UserTimelineAnalyticsResponse buildTimelineResponse(String userId, List<EventStats> allEvents,
List<TimelineData> allTimelineData, String interval,
LocalDateTime startDate, LocalDateTime endDate) {
List<TimelineData> allTimelineData, String interval) {
Map<LocalDateTime, TimelineDataPoint> aggregatedData = new LinkedHashMap<>();
for (TimelineData data : allTimelineData) {
@@ -119,7 +120,7 @@ public class UserTimelineAnalyticsService {
return UserTimelineAnalyticsResponse.builder()
.userId(userId)
.period(buildPeriodInfo(startDate, endDate))
.period(buildPeriodFromEvents(allEvents))
.totalEvents(allEvents.size())
.interval(interval != null ? interval : "daily")
.dataPoints(dataPoints)
@@ -179,9 +180,20 @@ public class UserTimelineAnalyticsService {
.build() : PeakTimeInfo.builder().build();
}
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
/**
* 전체 이벤트의 생성/수정 시간 기반으로 period 계산
*/
private PeriodInfo buildPeriodFromEvents(List<EventStats> events) {
LocalDateTime start = events.stream()
.map(EventStats::getCreatedAt)
.min(LocalDateTime::compareTo)
.orElse(LocalDateTime.now());
LocalDateTime end = events.stream()
.map(EventStats::getUpdatedAt)
.max(LocalDateTime::compareTo)
.orElse(LocalDateTime.now());
return PeriodInfo.builder()
.startDate(start)
.endDate(end)
@@ -47,11 +47,13 @@ spring:
enabled: ${KAFKA_ENABLED:true}
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:20.249.182.13:9095,4.217.131.59:9095}
consumer:
group-id: ${KAFKA_CONSUMER_GROUP_ID:analytics-service}
group-id: ${KAFKA_CONSUMER_GROUP_ID:analytics-service-consumers-v3}
auto-offset-reset: earliest
enable-auto-commit: true
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
properties:
auto.offset.reset: earliest
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
@@ -75,6 +77,10 @@ server:
port: ${SERVER_PORT:8086}
servlet:
context-path: /api/v1/analytics
encoding:
charset: UTF-8
enabled: true
force: true
# JWT
jwt:
@@ -40,8 +40,10 @@ public enum ErrorCode {
EVENT_001("EVENT_001", "이벤트를 찾을 수 없습니다"),
EVENT_002("EVENT_002", "유효하지 않은 상태 전환입니다"),
EVENT_003("EVENT_003", "필수 데이터가 누락되었습니다"),
EVENT_004("EVENT_004", "이벤트 생성에 실패했습니다"),
EVENT_005("EVENT_005", "벤트 수정 권한이 없습니다"),
EVENT_004("EVENT_004", "유효하지 않은 eventId 형식입니다"),
EVENT_005("EVENT_005", "미 존재하는 eventId입니다"),
EVENT_006("EVENT_006", "이벤트 생성에 실패했습니다"),
EVENT_007("EVENT_007", "이벤트 수정 권한이 없습니다"),
// Job 에러 (JOB_XXX)
JOB_001("JOB_001", "Job을 찾을 수 없습니다"),
@@ -155,12 +155,12 @@ public class RegenerateImageService implements RegenerateImageUseCase {
private String generateImage(String prompt, com.kt.event.content.biz.domain.Platform platform) {
try {
// Mock 모드일 경우 Mock 데이터 반환
if (mockEnabled) {
log.info("[MOCK] 이미지 재생성 요청 (실제 API 호출 없음): prompt={}, platform={}", prompt, platform);
String mockUrl = generateMockImageUrl(platform);
log.info("[MOCK] 이미지 재생성 완료: url={}", mockUrl);
return mockUrl;
}
// if (mockEnabled) {
// log.info("[MOCK] 이미지 재생성 요청 (실제 API 호출 없음): prompt={}, platform={}", prompt, platform);
// String mockUrl = generateMockImageUrl(platform);
// log.info("[MOCK] 이미지 재생성 완료: url={}", mockUrl);
// return mockUrl;
// }
int width = platform.getWidth();
int height = platform.getHeight();
@@ -192,12 +192,12 @@ public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
private String generateImage(String prompt, Platform platform) {
try {
// Mock 모드일 경우 Mock 데이터 반환
if (mockEnabled) {
log.info("[MOCK] 이미지 생성 요청 (실제 API 호출 없음): prompt={}, platform={}", prompt, platform);
String mockUrl = generateMockImageUrl(platform);
log.info("[MOCK] 이미지 생성 완료: url={}", mockUrl);
return mockUrl;
}
// if (mockEnabled) {
// log.info("[MOCK] 이미지 생성 요청 (실제 API 호출 없음): prompt={}, platform={}", prompt, platform);
// String mockUrl = generateMockImageUrl(platform);
// log.info("[MOCK] 이미지 생성 완료: url={}", mockUrl);
// return mockUrl;
// }
// 플랫폼별 이미지 크기 설정 (Platform enum에서 가져옴)
int width = platform.getWidth();
+4 -4
View File
@@ -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
+1 -1
View File
@@ -56,7 +56,7 @@ spec:
number: 80
# AI Service
- path: /api/v1/ai-service
- path: /api/v1/ai
pathType: Prefix
backend:
service:
+390
View File
@@ -0,0 +1,390 @@
# AI Service 전체 메서드 워크플로우
## 1. AI 추천 생성 워크플로우 (Kafka 기반 비동기)
```mermaid
sequenceDiagram
participant ES as Event Service
participant Kafka as Kafka Topic
participant Consumer as AIJobConsumer
participant ARS as AIRecommendationService
participant JSS as JobStatusService
participant TAS as TrendAnalysisService
participant CS as CacheService
participant CAC as ClaudeApiClient
participant Claude as Claude API
participant Redis as Redis
%% 1. Event Service가 Kafka 메시지 발행
ES->>Kafka: Publish AIJobMessage<br/>(ai-event-generation-job topic)
%% 2. Kafka Consumer가 메시지 수신
Kafka->>Consumer: consume(AIJobMessage)
Note over Consumer: @KafkaListener<br/>groupId: ai-service-consumers
%% 3. AI 추천 생성 시작
Consumer->>ARS: generateRecommendations(message)
activate ARS
%% 4. Job 상태: PROCESSING (10%)
ARS->>JSS: updateJobStatus(jobId, PROCESSING, "트렌드 분석 중")
JSS->>CS: saveJobStatus(jobId, status)
CS->>Redis: SET ai:job:status:{jobId}
%% 5. 트렌드 분석
ARS->>ARS: analyzeTrend(message)
ARS->>CS: getTrend(industry, region)
CS->>Redis: GET ai:trend:{industry}:{region}
alt 캐시 HIT
Redis-->>CS: TrendAnalysis (cached)
CS-->>ARS: TrendAnalysis
else 캐시 MISS
ARS->>TAS: analyzeTrend(industry, region)
activate TAS
%% Circuit Breaker 적용
TAS->>TAS: circuitBreakerManager.executeWithCircuitBreaker()
TAS->>CAC: sendMessage(apiKey, version, request)
CAC->>Claude: POST /v1/messages
Note over Claude: Model: claude-sonnet-4-5<br/>System: "트렌드 분석 전문가"<br/>Prompt: 업종/지역/계절 트렌드
Claude-->>CAC: ClaudeResponse
CAC-->>TAS: ClaudeResponse
TAS->>TAS: parseResponse(responseText)
TAS-->>ARS: TrendAnalysis
deactivate TAS
%% 트렌드 캐싱
ARS->>CS: saveTrend(industry, region, analysis)
CS->>Redis: SET ai:trend:{industry}:{region} (TTL: 1시간)
end
%% 6. Job 상태: PROCESSING (50%)
ARS->>JSS: updateJobStatus(jobId, PROCESSING, "이벤트 추천안 생성 중")
JSS->>CS: saveJobStatus(jobId, status)
CS->>Redis: SET ai:job:status:{jobId}
%% 7. 이벤트 추천안 생성
ARS->>ARS: createRecommendations(message, trendAnalysis)
ARS->>ARS: circuitBreakerManager.executeWithCircuitBreaker()
ARS->>CAC: sendMessage(apiKey, version, request)
CAC->>Claude: POST /v1/messages
Note over Claude: Model: claude-sonnet-4-5<br/>System: "이벤트 기획 전문가"<br/>Prompt: 3가지 추천안 생성
Claude-->>CAC: ClaudeResponse
CAC-->>ARS: ClaudeResponse
ARS->>ARS: parseRecommendationResponse(responseText)
%% 8. Job 상태: PROCESSING (90%)
ARS->>JSS: updateJobStatus(jobId, PROCESSING, "결과 저장 중")
JSS->>CS: saveJobStatus(jobId, status)
CS->>Redis: SET ai:job:status:{jobId}
%% 9. 결과 저장
ARS->>CS: saveRecommendation(eventId, result)
CS->>Redis: SET ai:recommendation:{eventId} (TTL: 24시간)
%% 10. Job 상태: COMPLETED (100%)
ARS->>JSS: updateJobStatus(jobId, COMPLETED, "AI 추천 완료")
JSS->>CS: saveJobStatus(jobId, status)
CS->>Redis: SET ai:job:status:{jobId}
deactivate ARS
%% 11. Kafka ACK
Consumer->>Kafka: acknowledgment.acknowledge()
```
---
## 2. Job 상태 조회 워크플로우 (동기)
```mermaid
sequenceDiagram
participant ES as Event Service
participant Controller as InternalJobController
participant JSS as JobStatusService
participant CS as CacheService
participant Redis as Redis
%% 1. Event Service가 Job 상태 조회
ES->>Controller: GET /api/v1/ai-service/internal/jobs/{jobId}/status
%% 2. Job 상태 조회
Controller->>JSS: getJobStatus(jobId)
activate JSS
JSS->>CS: getJobStatus(jobId)
CS->>Redis: GET ai:job:status:{jobId}
alt 상태 존재
Redis-->>CS: JobStatusResponse
CS-->>JSS: Object (JobStatusResponse)
JSS->>JSS: objectMapper.convertValue()
JSS-->>Controller: JobStatusResponse
Controller-->>ES: 200 OK + JobStatusResponse
else 상태 없음
Redis-->>CS: null
CS-->>JSS: null
JSS-->>Controller: JobNotFoundException
Controller-->>ES: 404 Not Found
end
deactivate JSS
```
---
## 3. AI 추천 결과 조회 워크플로우 (동기)
```mermaid
sequenceDiagram
participant ES as Event Service
participant Controller as InternalRecommendationController
participant ARS as AIRecommendationService
participant CS as CacheService
participant Redis as Redis
%% 1. Event Service가 AI 추천 결과 조회
ES->>Controller: GET /api/v1/ai-service/internal/recommendations/{eventId}
%% 2. 추천 결과 조회
Controller->>ARS: getRecommendation(eventId)
activate ARS
ARS->>CS: getRecommendation(eventId)
CS->>Redis: GET ai:recommendation:{eventId}
alt 결과 존재
Redis-->>CS: AIRecommendationResult
CS-->>ARS: Object (AIRecommendationResult)
ARS->>ARS: objectMapper.convertValue()
ARS-->>Controller: AIRecommendationResult
Controller-->>ES: 200 OK + AIRecommendationResult
else 결과 없음
Redis-->>CS: null
CS-->>ARS: null
ARS-->>Controller: RecommendationNotFoundException
Controller-->>ES: 404 Not Found
end
deactivate ARS
```
---
## 4. 헬스체크 워크플로우 (동기)
```mermaid
sequenceDiagram
participant Client as Client/Actuator
participant Controller as HealthController
participant Redis as Redis
%% 1. 헬스체크 요청
Client->>Controller: GET /api/v1/ai-service/health
%% 2. Redis 상태 확인
Controller->>Controller: checkRedis()
alt RedisTemplate 존재
Controller->>Redis: PING
alt Redis 정상
Redis-->>Controller: PONG
Controller->>Controller: redisStatus = UP
else Redis 오류
Redis-->>Controller: Exception
Controller->>Controller: redisStatus = DOWN
end
else RedisTemplate 없음
Controller->>Controller: redisStatus = UNKNOWN
end
%% 3. 전체 상태 판단
alt Redis DOWN
Controller->>Controller: overallStatus = DEGRADED
else Redis UP/UNKNOWN
Controller->>Controller: overallStatus = UP
end
%% 4. 응답
Controller-->>Client: 200 OK + HealthCheckResponse
```
---
## 5. 주요 컴포넌트 메서드 목록
### 5.1 Controller Layer
#### InternalJobController
| 메서드 | HTTP | 엔드포인트 | 설명 |
|--------|------|-----------|------|
| `getJobStatus(jobId)` | GET | `/api/v1/ai-service/internal/jobs/{jobId}/status` | Job 상태 조회 |
| `createTestJob(jobId)` | GET | `/api/v1/ai-service/internal/jobs/debug/create-test-job/{jobId}` | 테스트 Job 생성 (디버그) |
#### InternalRecommendationController
| 메서드 | HTTP | 엔드포인트 | 설명 |
|--------|------|-----------|------|
| `getRecommendation(eventId)` | GET | `/api/v1/ai-service/internal/recommendations/{eventId}` | AI 추천 결과 조회 |
| `debugRedisKeys()` | GET | `/api/v1/ai-service/internal/recommendations/debug/redis-keys` | Redis 모든 키 조회 |
| `debugRedisKey(key)` | GET | `/api/v1/ai-service/internal/recommendations/debug/redis-key/{key}` | Redis 특정 키 조회 |
| `searchAllDatabases()` | GET | `/api/v1/ai-service/internal/recommendations/debug/search-all-databases` | 전체 DB 검색 |
| `createTestData(eventId)` | GET | `/api/v1/ai-service/internal/recommendations/debug/create-test-data/{eventId}` | 테스트 데이터 생성 |
#### HealthController
| 메서드 | HTTP | 엔드포인트 | 설명 |
|--------|------|-----------|------|
| `healthCheck()` | GET | `/api/v1/ai-service/health` | 서비스 헬스체크 |
| `checkRedis()` | - | (내부) | Redis 연결 확인 |
---
### 5.2 Service Layer
#### AIRecommendationService
| 메서드 | 호출자 | 설명 |
|--------|-------|------|
| `getRecommendation(eventId)` | Controller | Redis에서 추천 결과 조회 |
| `generateRecommendations(message)` | AIJobConsumer | AI 추천 생성 (전체 프로세스) |
| `analyzeTrend(message)` | 내부 | 트렌드 분석 (캐시 확인 포함) |
| `createRecommendations(message, trendAnalysis)` | 내부 | 이벤트 추천안 생성 |
| `callClaudeApiForRecommendations(message, trendAnalysis)` | 내부 | Claude API 호출 (추천안) |
| `buildRecommendationPrompt(message, trendAnalysis)` | 내부 | 추천안 프롬프트 생성 |
| `parseRecommendationResponse(responseText)` | 내부 | 추천안 응답 파싱 |
| `parseEventRecommendation(node)` | 내부 | EventRecommendation 파싱 |
| `parseRange(node)` | 내부 | Range 객체 파싱 |
| `extractJsonFromMarkdown(text)` | 내부 | Markdown에서 JSON 추출 |
#### TrendAnalysisService
| 메서드 | 호출자 | 설명 |
|--------|-------|------|
| `analyzeTrend(industry, region)` | AIRecommendationService | 트렌드 분석 수행 |
| `callClaudeApi(industry, region)` | 내부 | Claude API 호출 (트렌드) |
| `buildPrompt(industry, region)` | 내부 | 트렌드 분석 프롬프트 생성 |
| `parseResponse(responseText)` | 내부 | 트렌드 응답 파싱 |
| `extractJsonFromMarkdown(text)` | 내부 | Markdown에서 JSON 추출 |
| `parseTrendKeywords(arrayNode)` | 내부 | TrendKeyword 리스트 파싱 |
#### JobStatusService
| 메서드 | 호출자 | 설명 |
|--------|-------|------|
| `getJobStatus(jobId)` | Controller | Job 상태 조회 |
| `updateJobStatus(jobId, status, message)` | AIRecommendationService | Job 상태 업데이트 |
| `calculateProgress(status)` | 내부 | 상태별 진행률 계산 |
#### CacheService
| 메서드 | 호출자 | 설명 |
|--------|-------|------|
| `set(key, value, ttlSeconds)` | 내부 | 범용 캐시 저장 |
| `get(key)` | 내부 | 범용 캐시 조회 |
| `delete(key)` | 외부 | 캐시 삭제 |
| `saveJobStatus(jobId, status)` | JobStatusService | Job 상태 저장 |
| `getJobStatus(jobId)` | JobStatusService | Job 상태 조회 |
| `saveRecommendation(eventId, recommendation)` | AIRecommendationService | AI 추천 결과 저장 |
| `getRecommendation(eventId)` | AIRecommendationService | AI 추천 결과 조회 |
| `saveTrend(industry, region, trend)` | AIRecommendationService | 트렌드 분석 결과 저장 |
| `getTrend(industry, region)` | AIRecommendationService | 트렌드 분석 결과 조회 |
---
### 5.3 Consumer Layer
#### AIJobConsumer
| 메서드 | 트리거 | 설명 |
|--------|-------|------|
| `consume(message, topic, offset, ack)` | Kafka Message | Kafka 메시지 수신 및 처리 |
---
### 5.4 Client Layer
#### ClaudeApiClient (Feign)
| 메서드 | 호출자 | 설명 |
|--------|-------|------|
| `sendMessage(apiKey, anthropicVersion, request)` | TrendAnalysisService, AIRecommendationService | Claude API 호출 |
---
## 6. Redis 캐시 키 구조
| 키 패턴 | 설명 | TTL |
|--------|------|-----|
| `ai:job:status:{jobId}` | Job 상태 정보 | 24시간 (86400초) |
| `ai:recommendation:{eventId}` | AI 추천 결과 | 24시간 (86400초) |
| `ai:trend:{industry}:{region}` | 트렌드 분석 결과 | 1시간 (3600초) |
---
## 7. Claude API 호출 정보
### 7.1 트렌드 분석
- **URL**: `https://api.anthropic.com/v1/messages`
- **Model**: `claude-sonnet-4-5-20250929`
- **Max Tokens**: 4096
- **Temperature**: 0.7
- **System Prompt**: "당신은 마케팅 트렌드 분석 전문가입니다. 업종별, 지역별 트렌드를 분석하고 인사이트를 제공합니다."
- **응답 형식**: JSON (industryTrends, regionalTrends, seasonalTrends)
### 7.2 이벤트 추천안 생성
- **URL**: `https://api.anthropic.com/v1/messages`
- **Model**: `claude-sonnet-4-5-20250929`
- **Max Tokens**: 4096
- **Temperature**: 0.7
- **System Prompt**: "당신은 소상공인을 위한 마케팅 이벤트 기획 전문가입니다. 트렌드 분석을 바탕으로 실행 가능한 이벤트 추천안을 제공합니다."
- **응답 형식**: JSON (recommendations: 3가지 옵션)
---
## 8. Circuit Breaker 설정
### 적용 대상
- `claudeApi`: 모든 Claude API 호출
### 설정값
```yaml
failure-rate-threshold: 50%
slow-call-duration-threshold: 60초
sliding-window-size: 10
minimum-number-of-calls: 5
wait-duration-in-open-state: 60초
timeout-duration: 300초 (5분)
```
### Fallback 메서드
- `AIServiceFallback.getDefaultTrendAnalysis()`: 기본 트렌드 분석
- `AIServiceFallback.getDefaultRecommendations()`: 기본 추천안
---
## 9. 에러 처리
### Exception 종류
| Exception | HTTP Code | 발생 조건 |
|-----------|-----------|---------|
| `RecommendationNotFoundException` | 404 | Redis에 추천 결과 없음 |
| `JobNotFoundException` | 404 | Redis에 Job 상태 없음 |
| `AIServiceException` | 500 | AI 서비스 내부 오류 |
### 에러 응답 예시
```json
{
"timestamp": "2025-10-30T15:30:00",
"status": 404,
"error": "Not Found",
"message": "추천 결과를 찾을 수 없습니다: eventId=evt-123",
"path": "/api/v1/ai-service/internal/recommendations/evt-123"
}
```
---
## 10. 로깅 레벨
```yaml
com.kt.ai: DEBUG
org.springframework.kafka: INFO
org.springframework.data.redis: INFO
io.github.resilience4j: DEBUG
```
@@ -11,6 +11,11 @@
<entry key="KAKAO_API_URL" value="http://localhost:9006/api/kakao" />
<entry key="LOG_FILE" value="logs/distribution-service.log" />
<entry key="NAVER_API_URL" value="http://localhost:9005/api/naver" />
<entry key="NAVER_BLOG_BLOG_ID" value="bokchi_13" />
<entry key="NAVER_BLOG_HEADLESS" value="false" />
<entry key="NAVER_BLOG_PASSWORD" value="" />
<entry key="NAVER_BLOG_SESSION_PATH" value="playwright-sessions" />
<entry key="NAVER_BLOG_USERNAME" value="" />
<entry key="RINGOBIZ_API_URL" value="http://localhost:9002/api/ringobiz" />
<entry key="SERVER_PORT" value="8085" />
<entry key="URIDONGNETV_API_URL" value="http://localhost:9001/api/uridongnetv" />
+41 -5
View File
@@ -1,15 +1,40 @@
# Multi-stage build for Spring Boot application
FROM eclipse-temurin:21-jre-alpine AS builder
FROM eclipse-temurin:21-jre AS builder
WORKDIR /app
COPY build/libs/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
FROM eclipse-temurin:21-jre-alpine
FROM eclipse-temurin:21-jre
WORKDIR /app
# Create non-root user
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring
# Install Playwright essential dependencies only
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
libnss3 \
libnspr4 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libdbus-1-3 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libasound2t64 \
libpango-1.0-0 \
libcairo2 \
libatspi2.0-0 \
libxshmfence1 \
fonts-liberation \
libappindicator3-1 \
xdg-utils \
&& rm -rf /var/lib/apt/lists/*
# Create browser installation directory with proper permissions
RUN mkdir -p /app/playwright && chmod 777 /app/playwright
# Copy layers from builder
COPY --from=builder /app/dependencies/ ./
@@ -17,6 +42,17 @@ COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
# Set Playwright browsers path
ENV PLAYWRIGHT_BROWSERS_PATH=/app/playwright
# Create non-root user
RUN groupadd -r spring && useradd -r -g spring spring
# Change ownership to spring user
RUN chown -R spring:spring /app
USER spring:spring
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8085/distribution/actuator/health || exit 1
+248
View File
@@ -0,0 +1,248 @@
# 네이버 블로그 포스팅 설정 가이드
## 개요
Distribution Service는 Playwright를 사용하여 네이버 블로그에 자동으로 포스팅합니다.
## 사전 준비
### 1. Playwright 설치
처음 실행 시 Playwright 브라우저가 자동으로 다운로드됩니다. 수동으로 설치하려면:
```bash
# Windows (PowerShell)
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install chromium"
# Linux/Mac
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install chromium"
```
### 2. 네이버 계정 준비
- 네이버 계정 (아이디/비밀번호)
- 네이버 블로그 개설 (blog.naver.com에서 블로그 만들기)
- 블로그 ID 확인 (예: blog.naver.com/YOUR_BLOG_ID)
## 환경 변수 설정
### IntelliJ 실행 프로파일 설정
`.run/DistributionServiceApplication.run.xml` 파일에서 다음 환경 변수를 설정:
```xml
<env name="NAVER_BLOG_USERNAME" value="your_naver_id" />
<env name="NAVER_BLOG_PASSWORD" value="your_password" />
<env name="NAVER_BLOG_BLOG_ID" value="your_blog_id" />
<env name="NAVER_BLOG_HEADLESS" value="false" /> <!-- 브라우저 표시 여부 -->
<env name="NAVER_BLOG_SESSION_PATH" value="playwright-sessions" />
```
### 환경 변수 설명
| 환경 변수 | 설명 | 기본값 | 필수 |
|----------|------|--------|------|
| `NAVER_BLOG_USERNAME` | 네이버 아이디 | - | ✅ |
| `NAVER_BLOG_PASSWORD` | 네이버 비밀번호 | - | ✅ |
| `NAVER_BLOG_BLOG_ID` | 네이버 블로그 ID | - | ✅ |
| `NAVER_BLOG_HEADLESS` | Headless 모드 (true/false) | true | ❌ |
| `NAVER_BLOG_SESSION_PATH` | 세션 저장 경로 | playwright-sessions | ❌ |
### Headless 모드
- **false**: 브라우저 창이 표시되어 디버깅에 유용 (개발 환경 권장)
- **true**: 백그라운드 실행, 서버 환경에 적합 (운영 환경 권장)
## 사용 방법
### API 호출 예시
```bash
# 배포 요청
curl -X POST http://localhost:8085/distribution/api/v1/distributions \
-H "Content-Type: application/json" \
-d '{
"eventId": "EVT001",
"title": "신규 이벤트 안내",
"content": "이벤트 상세 내용입니다.",
"imageUrl": "https://example.com/event.jpg",
"channels": ["NAVER"]
}'
```
### 응답 예시
```json
{
"eventId": "EVT001",
"status": "SUCCESS",
"totalChannels": 1,
"successCount": 1,
"failureCount": 0,
"channels": [
{
"channel": "NAVER",
"success": true,
"distributionId": "NAVER-abc123",
"distributionUrl": "https://blog.naver.com/your_blog_id/222999999999",
"estimatedReach": 2000,
"executionTimeMs": 5234
}
],
"distributedAt": "2025-10-29T10:30:00"
}
```
## 세션 관리
### 자동 로그인
- 최초 실행 시 네이버에 로그인하고 세션이 저장됩니다
- 이후 요청은 저장된 세션을 사용하여 로그인 없이 진행됩니다
- 세션 파일 위치: `playwright-sessions/naver-blog-session.json`
### 세션 만료 시
세션이 만료되면 자동으로 재로그인을 시도합니다.
### 수동 세션 초기화
```bash
# 세션 파일 삭제
rm -rf playwright-sessions/naver-blog-session.json
```
## 문제 해결
### 1. 로그인 실패
**증상**: "Login failed" 에러 발생
**해결 방법**:
- 네이버 아이디/비밀번호 확인
- 네이버 로그인 보안 설정 확인 (캡차, 2단계 인증 등)
- Headless 모드를 false로 설정하여 브라우저 동작 확인
- 세션 파일 삭제 후 재시도
### 2. 브라우저 실행 실패
**증상**: "Failed to initialize Playwright" 에러
**해결 방법**:
```bash
# Playwright 브라우저 재설치
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install chromium"
```
### 3. 포스팅 실패
**증상**: 포스팅 URL이 반환되지 않음
**해결 방법**:
- Headless 모드를 false로 설정하여 UI 확인
- 네이버 블로그 에디터 구조 변경 여부 확인
- 로그 확인: `logs/distribution-service.log`
### 4. 성능 이슈
브라우저 자동화는 리소스를 많이 사용하므로:
- Resilience4j Bulkhead 설정으로 동시 실행 제한 (현재 10개)
- Circuit Breaker로 반복 실패 방지
- 실패 시 자동 재시도 (최대 3회)
## 보안 고려사항
### 1. 비밀번호 관리
- **절대로** 소스 코드에 비밀번호를 하드코딩하지 마세요
- 환경 변수 또는 시크릿 관리 서비스 사용
- Git에 `.run/*.xml` 파일을 커밋하지 마세요 (`.gitignore` 추가)
### 2. 세션 파일 보안
- `playwright-sessions/` 디렉토리를 `.gitignore`에 추가
- 서버 환경에서 파일 권한 설정 (chmod 600)
### 3. 네트워크 보안
- HTTPS만 사용
- 프록시 사용 시 안전한 프록시 설정
## 운영 환경 배포
### Docker 환경
```dockerfile
# Dockerfile에 Playwright 설치 추가
RUN apt-get update && apt-get install -y \
libnss3 \
libatk-bridge2.0-0 \
libdrm2 \
libxkbcommon0 \
libgbm1 \
libasound2
# Playwright 브라우저 설치
RUN mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install chromium"
```
### Kubernetes 환경
```yaml
apiVersion: v1
kind: Secret
metadata:
name: naver-blog-credentials
type: Opaque
stringData:
username: your_naver_id
password: your_password
blog-id: your_blog_id
---
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: distribution-service
env:
- name: NAVER_BLOG_USERNAME
valueFrom:
secretKeyRef:
name: naver-blog-credentials
key: username
- name: NAVER_BLOG_PASSWORD
valueFrom:
secretKeyRef:
name: naver-blog-credentials
key: password
- name: NAVER_BLOG_BLOG_ID
valueFrom:
secretKeyRef:
name: naver-blog-credentials
key: blog-id
- name: NAVER_BLOG_HEADLESS
value: "true"
```
## 제약사항
1. **동시 실행 제한**: Bulkhead 설정으로 최대 10개 동시 실행
2. **실행 시간**: 브라우저 자동화는 API 호출보다 느림 (평균 5-10초)
3. **네이버 정책**: 네이버 블로그 정책 변경 시 업데이트 필요
4. **UI 변경**: 네이버 블로그 UI 변경 시 코드 수정 필요
## 모니터링
### 로그 확인
```bash
# 실시간 로그
tail -f logs/distribution-service.log
# 에러만 필터
grep ERROR logs/distribution-service.log
```
### 주요 로그 메시지
- `Initializing Playwright for Naver Blog`: Playwright 초기화
- `Starting Naver login process`: 로그인 시작
- `Naver login successful`: 로그인 성공
- `Post published successfully`: 포스팅 성공
- `Failed to post to Naver blog`: 포스팅 실패
## 참고 자료
- [Playwright for Java](https://playwright.dev/java/)
- [네이버 블로그 고객센터](https://help.naver.com/service/5614/)
- [Resilience4j 문서](https://resilience4j.readme.io/)
## 지원
문제 발생 시:
1. 로그 파일 확인: `logs/distribution-service.log`
2. Headless 모드를 false로 설정하여 브라우저 동작 확인
3. GitHub Issue 등록 (로그 첨부)
+3
View File
@@ -15,6 +15,9 @@ dependencies {
implementation "io.github.resilience4j:resilience4j-retry:${resilience4jVersion}"
implementation "io.github.resilience4j:resilience4j-bulkhead:${resilience4jVersion}"
// Playwright for browser automation
implementation 'com.microsoft.playwright:playwright:1.41.0'
// Jackson for JSON
implementation 'com.fasterxml.jackson.core:jackson-databind'
}
@@ -1,27 +1,30 @@
package com.kt.distribution.adapter;
import com.kt.distribution.client.NaverBlogClient;
import com.kt.distribution.dto.ChannelDistributionResult;
import com.kt.distribution.dto.ChannelType;
import com.kt.distribution.dto.DistributionRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.util.UUID;
/**
* Naver Blog Adapter
* Naver Blog 포스팅 API 호출
* Naver Blog 포스팅 (Playwright 기반)
*
* @author System Architect
* @since 2025-10-23
* @author Backend Developer
* @since 2025-10-29
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "naver.blog.enabled", havingValue = "true", matchIfMissing = false)
public class NaverAdapter extends AbstractChannelAdapter {
@Value("${channel.apis.naver.url}")
private String apiUrl;
private final NaverBlogClient naverBlogClient;
@Override
public ChannelType getChannelType() {
@@ -30,16 +33,35 @@ public class NaverAdapter extends AbstractChannelAdapter {
@Override
protected ChannelDistributionResult executeDistribution(DistributionRequest request) {
log.debug("Calling Naver API: url={}, eventId={}", apiUrl, request.getEventId());
log.debug("Posting to Naver Blog: eventId={}, title={}",
request.getEventId(), request.getTitle());
// TODO: 실제 API 호출 (현재는 Mock)
String distributionId = "NAVER-" + UUID.randomUUID().toString();
try {
// 네이버 블로그에 포스팅
String postUrl = naverBlogClient.postToBlog(request);
String distributionId = "NAVER-" + UUID.randomUUID().toString();
return ChannelDistributionResult.builder()
.channel(ChannelType.NAVER)
.success(true)
.distributionId(distributionId)
.estimatedReach(2000) // 블로그 방문자 수 기반
.build();
log.info("Naver blog post created successfully: eventId={}, postUrl={}",
request.getEventId(), postUrl);
return ChannelDistributionResult.builder()
.channel(ChannelType.NAVER)
.success(true)
.distributionId(distributionId)
.postUrl(postUrl)
.estimatedReach(2000) // 블로그 방문자 수 기반
.build();
} catch (Exception e) {
log.error("Failed to post to Naver blog: eventId={}, error={}",
request.getEventId(), e.getMessage(), e);
return ChannelDistributionResult.builder()
.channel(ChannelType.NAVER)
.success(false)
.errorMessage("Naver blog posting failed: " + e.getMessage())
.estimatedReach(0)
.build();
}
}
}
@@ -0,0 +1,319 @@
package com.kt.distribution.client;
import com.kt.distribution.dto.DistributionRequest;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.LoadState;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* Naver Blog Client using Playwright
* 네이버 블로그 포스팅 자동화 클라이언트
*
* @author Backend Developer
* @since 2025-10-29
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "naver.blog.enabled", havingValue = "true", matchIfMissing = false)
public class NaverBlogClient {
@Value("${naver.blog.username:}")
private String username;
@Value("${naver.blog.password:}")
private String password;
@Value("${naver.blog.blog-id:}")
private String blogId;
@Value("${naver.blog.headless:false}")
private boolean headless;
@Value("${naver.blog.session-path:playwright-sessions}")
private String sessionPath;
private Playwright playwright;
private Browser browser;
private BrowserContext context;
/**
* Playwright 초기화
*/
@PostConstruct
public void init() {
try {
log.info("Initializing Playwright for Naver Blog");
playwright = Playwright.create();
browser = playwright.chromium().launch(new BrowserType.LaunchOptions()
.setHeadless(headless)
.setSlowMo(100)); // 안정성을 위한 느린 실행
// 세션 디렉토리 생성
File sessionDir = new File(sessionPath);
if (!sessionDir.exists()) {
sessionDir.mkdirs();
log.info("Created session directory: {}", sessionPath);
}
// 세션 파일 경로
Path sessionFilePath = Paths.get(sessionPath, "naver-blog-session.json");
// 세션 파일이 있으면 로드, 없으면 새로운 컨텍스트 생성
if (Files.exists(sessionFilePath)) {
log.info("Loading existing session from: {}", sessionFilePath);
context = browser.newContext(new Browser.NewContextOptions()
.setStorageStatePath(sessionFilePath));
} else {
log.info("No existing session found, creating new context");
context = browser.newContext();
}
log.info("Playwright initialized successfully");
} catch (Exception e) {
log.error("Failed to initialize Playwright", e);
throw new RuntimeException("Playwright initialization failed", e);
}
}
/**
* 네이버 블로그에 포스팅
*
* @param request DistributionRequest
* @return 포스팅 URL
* @throws Exception 포스팅 실패 시
*/
public String postToBlog(DistributionRequest request) throws Exception {
Page page = null;
try {
page = context.newPage();
// 타임아웃을 5분(300000ms)으로 설정
page.setDefaultTimeout(300000);
// 로그인 확인 및 처리
if (!isLoggedIn(page)) {
login(page);
}
// 블로그 글쓰기 페이지로 이동
String writeUrl = String.format("https://blog.naver.com/%s/postwrite", blogId);
page.navigate(writeUrl);
page.waitForLoadState(LoadState.NETWORKIDLE);
// 도움말 팝업이 있으면 닫기
try {
page.waitForTimeout(5000); // 충분히 대기 필요
Locator helpPanel = page.locator("[class*='help-panel']");
if (helpPanel.isVisible(new Locator.IsVisibleOptions().setTimeout(2000))) {
log.debug("Help dialog detected, closing it");
// 팝업 안의 닫기 버튼 찾기
Locator closeBtn = page.locator("button[class*='se-help-panel-close-button']");
closeBtn.click();
Thread.sleep(500);
log.debug("Help dialog closed");
} else{
log.debug("--------------------- 도움말 없음");
}
} catch (Exception e) {
log.debug("No help dialog found or already closed");
}
// 제목 입력
Locator titleInput = page.locator(".se-text-paragraph").first();
titleInput.click();
titleInput.pressSequentially(request.getTitle(), new Locator.PressSequentiallyOptions().setDelay(50));
log.debug("Title entered: {}", request.getTitle());
// 본문 입력
Locator editorInput = page.locator(".se-text-paragraph").nth(1);
editorInput.click();
titleInput.pressSequentially(request.getDescription(), new Locator.PressSequentiallyOptions().setDelay(50));
log.debug("Content entered");
// 이미지가 있으면 업로드
if (request.getImageUrl() != null && !request.getImageUrl().isEmpty()) {
uploadImage(page, request.getImageUrl());
}
// 발행 버튼 클릭
page.locator("button[class*='publish_btn']").click();
page.waitForLoadState(LoadState.NETWORKIDLE);
page.locator("button[class*='confirm_btn']").click();
page.waitForLoadState(LoadState.NETWORKIDLE);
page.waitForTimeout(5000); // 충분히 대기 필요
// 포스팅 URL 가져오기
String postUrl = page.url();
log.info("Post published successfully: {}", postUrl);
return postUrl;
} catch (Exception e) {
log.error("Failed to post to Naver blog: eventId={}, error={}",
request.getEventId(), e.getMessage(), e);
throw e;
} finally {
if (page != null) {
page.close();
}
}
}
/**
* 로그인 상태 확인
*
* @param page Page
* @return 로그인 여부
*/
private boolean isLoggedIn(Page page) {
try {
page.navigate("https://blog.naver.com");
page.waitForLoadState(LoadState.NETWORKIDLE);
// 로그인 버튼이 보이지 않으면 로그인된 상태
// ID 기반 선택자 사용으로 strict mode violation 방지
return !page.locator("#gnb_login_button").isVisible();
} catch (Exception e) {
log.warn("Failed to check login status", e);
return false;
}
}
/**
* 네이버 로그인 (수동 로그인 대기 방식)
*
* @param page Page
* @throws Exception 로그인 실패 시
*/
private void login(Page page) throws Exception {
try {
log.info("Starting Naver manual login process");
log.info("=================================================");
log.info("Please login manually in the browser window");
log.info("브라우저 창에서 수동으로 로그인해주세요");
log.info("=================================================");
// 네이버 로그인 페이지로 이동
page.navigate("https://nid.naver.com/nidlogin.login");
page.waitForLoadState(LoadState.NETWORKIDLE);
// 사용자가 수동으로 로그인할 때까지 대기 (URL이 변경될 때까지)
// 로그인 성공 시 URL이 nid.naver.com에서 벗어남
log.info("Waiting for manual login... (Timeout: 30 seconds)");
try {
// 30초 동안 URL이 nid.naver.com을 벗어날 때까지 대기
page.waitForURL(url -> !url.contains("nid.naver.com"),
new Page.WaitForURLOptions().setTimeout(30000));
log.info("Login URL changed, assuming login successful");
} catch (Exception e) {
log.error("Login timeout or failed", e);
throw new Exception("Manual login timeout or failed after 30 seconds");
}
// 추가 안정화 대기
page.waitForLoadState(LoadState.NETWORKIDLE);
Thread.sleep(2000); // 2초 추가 대기
// 세션 저장
context.storageState(new BrowserContext.StorageStateOptions()
.setPath(Paths.get(sessionPath, "naver-blog-session.json")));
log.info("Naver manual login successful, session saved");
log.info("Current URL: {}", page.url());
} catch (Exception e) {
log.error("Naver manual login process failed", e);
throw new Exception("Naver manual login failed: " + e.getMessage(), e);
}
}
/**
* 이미지 업로드
*
* @param page Page
* @param imageUrl 이미지 URL
*/
private void uploadImage(Page page, String imageUrl) {
try {
log.debug("Uploading image: {}", imageUrl);
// 이미지 업로드 버튼 클릭
page.locator("button[aria-label='사진']").click();
// URL로 이미지 추가 (실제 구현은 네이버 블로그 UI에 따라 조정 필요)
// 여기서는 간단히 로그만 남김
log.info("Image upload placeholder - URL: {}", imageUrl);
} catch (Exception e) {
log.warn("Failed to upload image: {}", e.getMessage());
}
}
/**
* Playwright 리소스 정리
*/
@PreDestroy
public void cleanup() {
try {
if (context != null) {
context.close();
}
if (browser != null) {
browser.close();
}
if (playwright != null) {
playwright.close();
}
log.info("Playwright resources cleaned up");
} catch (Exception e) {
log.error("Failed to cleanup Playwright resources", e);
}
}
/**
* 수동으로 브라우저 컨텍스트 새로고침
* 장시간 사용 시 세션 만료 방지용
*/
public void refreshContext() {
try {
if (context != null) {
context.close();
}
// 세션 파일 경로
Path sessionFilePath = Paths.get(sessionPath, "naver-blog-session.json");
// 세션 파일이 있으면 로드, 없으면 새로운 컨텍스트 생성
if (Files.exists(sessionFilePath)) {
log.info("Refreshing context with existing session");
context = browser.newContext(new Browser.NewContextOptions()
.setStorageStatePath(sessionFilePath));
} else {
log.info("Refreshing context without session");
context = browser.newContext();
}
log.info("Browser context refreshed");
} catch (Exception e) {
log.error("Failed to refresh context", e);
}
}
}
@@ -32,6 +32,11 @@ public class ChannelDistributionResult {
*/
private String distributionId;
/**
* 배포 URL (성공 시) - 실제 포스팅된 URL
*/
private String postUrl;
/**
* 예상 노출 수 (성공 시)
*/
@@ -225,6 +225,7 @@ public class DistributionService {
.channel(result.getChannel())
.status(result.isSuccess() ? "COMPLETED" : "FAILED")
.distributionId(result.getDistributionId())
.postUrl(result.getPostUrl())
.estimatedViews(result.getEstimatedReach())
.eventId(eventId)
.completedAt(completedAt)
@@ -126,10 +126,11 @@ channel:
# Naver Blog Configuration (Playwright 기반)
naver:
blog:
enabled: ${NAVER_BLOG_ENABLED:false}
username: ${NAVER_BLOG_USERNAME:}
password: ${NAVER_BLOG_PASSWORD:}
blog-id: ${NAVER_BLOG_ID:}
headless: ${NAVER_BLOG_HEADLESS:true}
headless: ${NAVER_BLOG_HEADLESS:false}
session-path: ${NAVER_BLOG_SESSION_PATH:playwright-sessions}
# Springdoc OpenAPI (Swagger)
@@ -6,7 +6,6 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* AI 이벤트 생성 작업 메시지 DTO
@@ -35,72 +34,42 @@ public class AIEventGenerationJobMessage {
*/
private String eventId;
/**
* 이벤트 목적
* - "신규 고객 유치"
* - "재방문 유도"
* - "매출 증대"
* - "브랜드 인지도 향상"
*/
private String objective;
/**
* 업종 (storeCategory와 동일)
*/
private String industry;
/**
* 지역 (시/구/동)
*/
private String region;
/**
* 매장명
*/
private String storeName;
/**
* 매장 업종
* 목표 고객층 (선택)
*/
private String storeCategory;
private String targetAudience;
/**
* 매장 설명
* 예산 (원) (선택)
*/
private String storeDescription;
private Integer budget;
/**
* 이벤트 목적
* 요청 시각
*/
private String objective;
/**
* 작업 상태 (PENDING, PROCESSING, COMPLETED, FAILED)
*/
private String status;
/**
* AI 추천 결과 데이터
*/
private AIRecommendationData aiRecommendation;
/**
* 에러 메시지 (실패 시)
*/
private String errorMessage;
/**
* 작업 생성 일시
*/
private LocalDateTime createdAt;
/**
* 작업 완료/실패 일시
*/
private LocalDateTime completedAt;
/**
* AI 추천 데이터 내부 클래스
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class AIRecommendationData {
private String eventTitle;
private String eventDescription;
private String eventType;
private List<String> targetKeywords;
private List<String> recommendedBenefits;
private String startDate;
private String endDate;
}
private LocalDateTime requestedAt;
}
@@ -24,11 +24,24 @@ import lombok.NoArgsConstructor;
@Schema(description = "AI 추천 요청")
public class AiRecommendationRequest {
@NotNull(message = "이벤트 목적은 필수입니다.")
@Schema(description = "이벤트 목적", required = true, example = "신규 고객 유치")
private String objective;
@NotNull(message = "매장 정보는 필수입니다.")
@Valid
@Schema(description = "매장 정보", required = true)
private StoreInfo storeInfo;
@Schema(description = "지역 정보", example = "서울특별시 강남구")
private String region;
@Schema(description = "타겟 고객층", example = "20-30대 직장인")
private String targetAudience;
@Schema(description = "예산 (원)", example = "500000")
private Integer budget;
/**
* 매장 정보
*/
@@ -19,6 +19,9 @@ import lombok.NoArgsConstructor;
@Builder
public class SelectObjectiveRequest {
@NotBlank(message = "이벤트 ID는 필수입니다.")
private String eventId;
@NotBlank(message = "이벤트 목적은 필수입니다.")
private String objective;
}
@@ -23,38 +23,25 @@ public class EventIdGenerator {
private static final int RANDOM_LENGTH = 8;
/**
* 이벤트 ID 생성
* 이벤트 ID 생성 (백엔드용)
*
* @param storeId 상점 ID (최대 15자 권장)
* 참고: 현재는 프론트엔드에서 eventId를 생성하므로 이 메서드는 거의 사용되지 않습니다.
*
* @param storeId 상점 ID
* @return 생성된 이벤트 ID
* @throws IllegalArgumentException storeId가 null이거나 비어있는 경우
*/
public String generate(String storeId) {
// 기본값 처리
if (storeId == null || storeId.isBlank()) {
throw new IllegalArgumentException("storeId는 필수입니다");
storeId = "unknown";
}
// storeId 길이 검증 (전체 길이 50자 제한)
// TODO: 프로덕션에서는 storeId 길이 제한 필요
// if (storeId.length() > 15) {
// throw new IllegalArgumentException("storeId는 15자 이하여야 합니다");
// }
String timestamp = LocalDateTime.now().format(TIMESTAMP_FORMATTER);
String randomPart = generateRandomPart();
// 형식: EVT-{storeId}-{timestamp}-{random}
// 예상 길이: 3 + 1 + 15 + 1 + 14 + 1 + 8 = 43자 (최대)
String eventId = String.format("%s-%s-%s-%s", PREFIX, storeId, timestamp, randomPart);
// 길이 검증
if (eventId.length() > 50) {
throw new IllegalStateException(
String.format("생성된 eventId 길이(%d)가 50자를 초과했습니다: %s",
eventId.length(), eventId)
);
}
return eventId;
}
@@ -72,7 +59,14 @@ public class EventIdGenerator {
}
/**
* eventId 형식 검증
* eventId 기본 검증
*
* 최소한의 검증만 수행합니다:
* - null/empty 체크
* - 길이 제한 체크 (VARCHAR(50) 제약)
*
* 프론트엔드에서 생성한 eventId를 신뢰하며,
* DB의 PRIMARY KEY 제약조건으로 중복을 방지합니다.
*
* @param eventId 검증할 이벤트 ID
* @return 유효하면 true, 아니면 false
@@ -82,32 +76,11 @@ public class EventIdGenerator {
return false;
}
// EVT-로 시작하는지 확인
if (!eventId.startsWith(PREFIX + "-")) {
return false;
}
// 길이 검증
// 길이 검증 (DB VARCHAR(50) 제약)
if (eventId.length() > 50) {
return false;
}
// 형식 검증: EVT-{storeId}-{14자리숫자}-{8자리영숫자}
String[] parts = eventId.split("-");
if (parts.length != 4) {
return false;
}
// timestamp 부분이 14자리 숫자인지 확인
if (parts[2].length() != 14 || !parts[2].matches("\\d{14}")) {
return false;
}
// random 부분이 8자리 영숫자인지 확인
if (parts[3].length() != 8 || !parts[3].matches("[a-z0-9]{8}")) {
return false;
}
return true;
}
}
@@ -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;
@@ -55,17 +58,20 @@ public class EventService {
*
* @param userId 사용자 ID
* @param storeId 매장 ID
* @param request 목적 선택 요청
* @param request 목적 선택 요청 (eventId 포함)
* @return 생성된 이벤트 응답
*/
@Transactional
public EventCreatedResponse createEvent(String userId, String storeId, SelectObjectiveRequest request) {
log.info("이벤트 생성 시작 - userId: {}, storeId: {}, objective: {}",
userId, storeId, request.getObjective());
log.info("이벤트 생성 시작 - userId: {}, storeId: {}, eventId: {}, objective: {}",
userId, storeId, request.getEventId(), request.getObjective());
// eventId 생성
String eventId = eventIdGenerator.generate(storeId);
log.info("생성된 eventId: {}", eventId);
String eventId = request.getEventId();
// 동일한 eventId가 이미 존재하는지 확인
if (eventRepository.findByEventId(eventId).isPresent()) {
throw new BusinessException(ErrorCode.EVENT_005);
}
// 이벤트 엔티티 생성
Event event = Event.builder()
@@ -305,17 +311,35 @@ public class EventService {
* AI 추천 요청
*
* @param userId 사용자 ID
* @param eventId 이벤트 ID
* @param request AI 추천 요청
* @param eventId 이벤트 ID (프론트엔드에서 생성한 ID)
* @param request AI 추천 요청 (objective 포함)
* @return Job 접수 응답
*/
@Transactional
public JobAcceptedResponse requestAiRecommendations(String userId, String eventId, AiRecommendationRequest request) {
log.info("AI 추천 요청 - userId: {}, eventId: {}", userId, eventId);
log.info("AI 추천 요청 - userId: {}, eventId: {}, objective: {}",
userId, eventId, request.getObjective());
// 이벤트 조회 및 권한 확인
// 이벤트 조회 또는 생성
Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001));
.orElseGet(() -> {
log.info("이벤트가 존재하지 않아 새로 생성합니다 - eventId: {}", eventId);
// storeId 추출 (eventId 형식: EVT-{storeId}-{timestamp}-{random})
String storeId = request.getStoreInfo().getStoreId();
// 새 이벤트 생성
Event newEvent = Event.builder()
.eventId(eventId)
.userId(userId)
.storeId(storeId)
.objective(request.getObjective())
.eventName("") // 초기에는 비어있음, AI 추천 후 설정
.status(EventStatus.DRAFT)
.build();
return eventRepository.save(newEvent);
});
// DRAFT 상태 확인
if (!event.isModifiable()) {
@@ -340,9 +364,11 @@ public class EventService {
userId,
eventId,
request.getStoreInfo().getStoreName(),
request.getStoreInfo().getCategory(),
request.getStoreInfo().getDescription(),
event.getObjective()
request.getStoreInfo().getCategory(), // industry
request.getRegion(), // region
event.getObjective(), // objective
request.getTargetAudience(), // targetAudience
request.getBudget() // budget
);
log.info("AI 추천 요청 완료 - jobId: {}", job.getJobId());
@@ -588,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);
}
}
}
@@ -82,7 +82,11 @@ public class JobIdGenerator {
}
/**
* jobId 형식 검증
* jobId 기본 검증
*
* 최소한의 검증만 수행합니다:
* - null/empty 체크
* - 길이 제한 체크 (VARCHAR(50) 제약)
*
* @param jobId 검증할 Job ID
* @return 유효하면 true, 아니면 false
@@ -92,32 +96,11 @@ public class JobIdGenerator {
return false;
}
// JOB-로 시작하는지 확인
if (!jobId.startsWith(PREFIX + "-")) {
return false;
}
// 길이 검증
// 길이 검증 (DB VARCHAR(50) 제약)
if (jobId.length() > 50) {
return false;
}
// 형식 검증: JOB-{type}-{timestamp}-{8자리영숫자}
String[] parts = jobId.split("-");
if (parts.length != 4) {
return false;
}
// timestamp 부분이 숫자인지 확인
if (!parts[2].matches("\\d+")) {
return false;
}
// random 부분이 8자리 영숫자인지 확인
if (parts[3].length() != 8 || !parts[3].matches("[a-z0-9]{8}")) {
return false;
}
return true;
}
}
@@ -37,7 +37,7 @@ public class KafkaConfig {
/**
* Kafka Producer 설정
* Producer에서 JSON 문자열을 보내므로 StringSerializer 사용
* Producer에서 객체를 직접 보내므로 JsonSerializer 사용
*
* @return ProducerFactory 인스턴스
*/
@@ -46,7 +46,10 @@ public class KafkaConfig {
Map<String, Object> config = new HashMap<>();
config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
// JSON 직렬화 시 타입 정보를 헤더에 추가하지 않음 (마이크로서비스 간 DTO 클래스 불일치 방지)
config.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false);
// Producer 성능 최적화 설정
config.put(ProducerConfig.ACKS_CONFIG, "all");
@@ -72,6 +72,7 @@ public class SecurityConfig {
/**
* CORS 설정
* 개발 환경에서 프론트엔드(localhost:3000)의 요청을 허용합니다.
* 쿠키 기반 인증을 위한 설정이 포함되어 있습니다.
*
* @return CorsConfigurationSource CORS 설정 소스
*/
@@ -82,7 +83,10 @@ public class SecurityConfig {
// 허용할 Origin (개발 환경)
configuration.setAllowedOrigins(Arrays.asList(
"http://localhost:3000",
"http://127.0.0.1:3000"
"http://127.0.0.1:3000",
"http://localhost:8081",
"http://localhost:8082",
"http://localhost:8083"
));
// 허용할 HTTP 메서드
@@ -90,7 +94,7 @@ public class SecurityConfig {
"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
));
// 허용할 헤더
// 허용할 헤더 (쿠키 포함)
configuration.setAllowedHeaders(Arrays.asList(
"Authorization",
"Content-Type",
@@ -98,19 +102,21 @@ public class SecurityConfig {
"Accept",
"Origin",
"Access-Control-Request-Method",
"Access-Control-Request-Headers"
"Access-Control-Request-Headers",
"Cookie"
));
// 인증 정보 포함 허용
// 인증 정보 포함 허용 (쿠키 전송을 위해 필수)
configuration.setAllowCredentials(true);
// Preflight 요청 캐시 시간 (초)
configuration.setMaxAge(3600L);
// 노출할 응답 헤더
// 노출할 응답 헤더 (쿠키 포함)
configuration.setExposedHeaders(Arrays.asList(
"Authorization",
"Content-Type"
"Content-Type",
"Set-Cookie"
));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
@@ -21,6 +21,11 @@ import java.util.Optional;
@Repository
public interface EventRepository extends JpaRepository<Event, String> {
/**
* 이벤트 ID로 조회
*/
Optional<Event> findByEventId(String eventId);
/**
* 사용자 ID와 이벤트 ID로 조회
*/
@@ -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);
}
@@ -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;
}
}
}
}
@@ -28,7 +28,8 @@ import org.springframework.transaction.annotation.Transactional;
* @since 2025-10-29
*/
@Slf4j
@Component
// TODO: 별도 response 토픽 사용 시 활성화
// @Component
@RequiredArgsConstructor
public class AIJobKafkaConsumer {
@@ -1,6 +1,5 @@
package com.kt.event.eventservice.infrastructure.kafka;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kt.event.eventservice.application.dto.kafka.AIEventGenerationJobMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -27,7 +26,6 @@ import java.util.concurrent.CompletableFuture;
public class AIJobKafkaProducer {
private final KafkaTemplate<String, Object> kafkaTemplate;
private final ObjectMapper objectMapper;
@Value("${app.kafka.topics.ai-event-generation-job:ai-event-generation-job}")
private String aiEventGenerationJobTopic;
@@ -39,29 +37,34 @@ public class AIJobKafkaProducer {
* @param userId 사용자 ID
* @param eventId 이벤트 ID (EVT-{storeId}-{yyyyMMddHHmmss}-{random8})
* @param storeName 매장명
* @param storeCategory 매장 업종
* @param storeDescription 매장 설명
* @param industry 업종 (매장 카테고리)
* @param region 지역
* @param objective 이벤트 목적
* @param targetAudience 목표 고객층 (선택)
* @param budget 예산 (선택)
*/
public void publishAIGenerationJob(
String jobId,
String userId,
String eventId,
String storeName,
String storeCategory,
String storeDescription,
String objective) {
String industry,
String region,
String objective,
String targetAudience,
Integer budget) {
AIEventGenerationJobMessage message = AIEventGenerationJobMessage.builder()
.jobId(jobId)
.userId(userId)
.eventId(eventId)
.storeName(storeName)
.storeCategory(storeCategory)
.storeDescription(storeDescription)
.industry(industry)
.region(region)
.objective(objective)
.status("PENDING")
.createdAt(LocalDateTime.now())
.targetAudience(targetAudience)
.budget(budget)
.requestedAt(LocalDateTime.now())
.build();
publishMessage(message);
@@ -74,11 +77,9 @@ public class AIJobKafkaProducer {
*/
public void publishMessage(AIEventGenerationJobMessage message) {
try {
// JSON 문자열로 변환
String jsonMessage = objectMapper.writeValueAsString(message);
// 객체를 직접 전송 (JsonSerializer가 자동으로 직렬화)
CompletableFuture<SendResult<String, Object>> future =
kafkaTemplate.send(aiEventGenerationJobTopic, message.getJobId(), jsonMessage);
kafkaTemplate.send(aiEventGenerationJobTopic, message.getJobId(), message);
future.whenComplete((result, ex) -> {
if (ex == null) {
@@ -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<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)
*
@@ -25,9 +25,8 @@ import org.springframework.web.bind.annotation.*;
* @since 2025-01-24
*/
@Slf4j
@CrossOrigin(origins = "http://localhost:3000")
@RequestMapping
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class ParticipationController {
@@ -35,9 +34,9 @@ public class ParticipationController {
/**
* 이벤트 참여
* POST /participations/{eventId}/participate
* POST /events/{eventId}/participate
*/
@PostMapping("/participations/{eventId}/participate")
@PostMapping("/events/{eventId}/participate")
public ResponseEntity<ApiResponse<ParticipationResponse>> participate(
@PathVariable String eventId,
@Valid @RequestBody ParticipationRequest request) {
@@ -61,15 +60,14 @@ public class ParticipationController {
/**
* 참여자 목록 조회
* GET /participations/{eventId}/participants
* GET /events/{eventId}/participants (프론트엔드 호환)
* GET /events/{eventId}/participants
*/
@Operation(
summary = "참여자 목록 조회",
description = "이벤트의 참여자 목록을 페이징하여 조회합니다. " +
"정렬 가능한 필드: createdAt(기본값), participantId, name, phoneNumber, bonusEntries, isWinner, wonAt"
)
@GetMapping({"/participations/{eventId}/participants", "/events/{eventId}/participants"})
@GetMapping({"/events/{eventId}/participants"})
public ResponseEntity<ApiResponse<PageResponse<ParticipationResponse>>> getParticipants(
@Parameter(description = "이벤트 ID", example = "evt_20250124_001")
@PathVariable String eventId,
@@ -90,10 +88,9 @@ public class ParticipationController {
/**
* 참여자 상세 조회
* GET /participations/{eventId}/participants/{participantId}
* GET /events/{eventId}/participants/{participantId} (프론트엔드 호환)
* GET /events/{eventId}/participants/{participantId}
*/
@GetMapping({"/participations/{eventId}/participants/{participantId}", "/events/{eventId}/participants/{participantId}"})
@GetMapping({"/events/{eventId}/participants/{participantId}"})
public ResponseEntity<ApiResponse<ParticipationResponse>> getParticipant(
@PathVariable String eventId,
@PathVariable String participantId) {
@@ -27,7 +27,7 @@ import org.springframework.web.bind.annotation.*;
@Slf4j
@CrossOrigin(origins = "http://localhost:3000")
@RestController
@RequestMapping("/api/v1")
@RequestMapping
@RequiredArgsConstructor
public class WinnerController {
@@ -35,9 +35,9 @@ public class WinnerController {
/**
* 당첨자 추첨
* POST /participations/{eventId}/draw-winners
* POST /events/{eventId}/draw-winners
*/
@PostMapping("/participations/{eventId}/draw-winners")
@PostMapping("/events/{eventId}/draw-winners")
public ResponseEntity<ApiResponse<DrawWinnersResponse>> drawWinners(
@PathVariable String eventId,
@Valid @RequestBody DrawWinnersRequest request) {
@@ -57,7 +57,7 @@ public class WinnerController {
description = "이벤트의 당첨자 목록을 페이징하여 조회합니다. " +
"정렬 가능한 필드: winnerRank(기본값), wonAt, participantId, name, phoneNumber, bonusEntries"
)
@GetMapping("/participations/{eventId}/winners")
@GetMapping("/events/{eventId}/winners")
public ResponseEntity<ApiResponse<PageResponse<ParticipationResponse>>> getWinners(
@Parameter(description = "이벤트 ID", example = "evt_20250124_001")
@PathVariable String eventId,
@@ -56,7 +56,7 @@ jwt:
# CORS 설정
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:*}
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io}
allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS,PATCH}
allowed-headers: ${CORS_ALLOWED_HEADERS:*}
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
+12
View File
@@ -0,0 +1,12 @@
{
"objective": "increase_sales",
"region": "Seoul Gangnam",
"targetAudience": "Office workers in 20-30s",
"budget": 500000,
"storeInfo": {
"storeId": "str_20250124_001",
"storeName": "Woojin Korean BBQ",
"category": "Restaurant",
"description": "Fresh Korean beef restaurant"
}
}
+33
View File
@@ -0,0 +1,33 @@
# Analytics Redis 초기화 스크립트
Write-Host "Analytics Redis 초기화 시작..." -ForegroundColor Cyan
# Redis 컨테이너 찾기
$redisContainer = docker ps --filter "ancestor=redis" --format "{{.Names}}" | Select-Object -First 1
if ($redisContainer) {
Write-Host "Redis 컨테이너 발견: $redisContainer" -ForegroundColor Green
# 멱등성 키 삭제
Write-Host "멱등성 키 삭제 중..." -ForegroundColor Yellow
docker exec $redisContainer redis-cli DEL processed_participants
docker exec $redisContainer redis-cli DEL processed_events
docker exec $redisContainer redis-cli DEL distribution_completed
# 캐시 삭제
Write-Host "Analytics 캐시 삭제 중..." -ForegroundColor Yellow
docker exec $redisContainer redis-cli --scan --pattern "analytics:*" | ForEach-Object {
docker exec $redisContainer redis-cli DEL $_
}
Write-Host "완료! 서버를 재시작해주세요." -ForegroundColor Green
} else {
Write-Host "Redis 컨테이너를 찾을 수 없습니다." -ForegroundColor Red
Write-Host "로컬 Redis를 시도합니다..." -ForegroundColor Yellow
redis-cli DEL processed_participants
redis-cli DEL processed_events
redis-cli DEL distribution_completed
Write-Host "완료! 서버를 재시작해주세요." -ForegroundColor Green
}
+303
View File
@@ -0,0 +1,303 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Tripgen Service Runner Script
Reads execution profiles from {service-name}/.run/{service-name}.run.xml and runs services accordingly.
Usage:
python run-config.py <service-name>
Examples:
python run-config.py user-service
python run-config.py location-service
python run-config.py trip-service
python run-config.py ai-service
"""
import os
import sys
import subprocess
import xml.etree.ElementTree as ET
from pathlib import Path
import argparse
def get_project_root():
"""Find project root directory"""
current_dir = Path(__file__).parent.absolute()
while current_dir.parent != current_dir:
if (current_dir / 'gradlew').exists() or (current_dir / 'gradlew.bat').exists():
return current_dir
current_dir = current_dir.parent
# If gradlew not found, assume parent directory of develop as project root
return Path(__file__).parent.parent.absolute()
def parse_run_configurations(project_root, service_name=None):
"""Parse run configuration files from .run directories"""
configurations = {}
if service_name:
# Parse specific service configuration
run_config_path = project_root / service_name / '.run' / f'{service_name}.run.xml'
if run_config_path.exists():
config = parse_single_run_config(run_config_path, service_name)
if config:
configurations[service_name] = config
else:
print(f"[ERROR] Cannot find run configuration: {run_config_path}")
else:
# Find all service directories
service_dirs = ['user-service', 'location-service', 'trip-service', 'ai-service']
for service in service_dirs:
run_config_path = project_root / service / '.run' / f'{service}.run.xml'
if run_config_path.exists():
config = parse_single_run_config(run_config_path, service)
if config:
configurations[service] = config
return configurations
def parse_single_run_config(config_path, service_name):
"""Parse a single run configuration file"""
try:
tree = ET.parse(config_path)
root = tree.getroot()
# Find configuration element
config = root.find('.//configuration[@type="GradleRunConfiguration"]')
if config is None:
print(f"[WARNING] No Gradle configuration found in {config_path}")
return None
# Extract environment variables
env_vars = {}
env_option = config.find('.//option[@name="env"]')
if env_option is not None:
env_map = env_option.find('map')
if env_map is not None:
for entry in env_map.findall('entry'):
key = entry.get('key')
value = entry.get('value')
if key and value:
env_vars[key] = value
# Extract task names
task_names = []
task_names_option = config.find('.//option[@name="taskNames"]')
if task_names_option is not None:
task_list = task_names_option.find('list')
if task_list is not None:
for option in task_list.findall('option'):
value = option.get('value')
if value:
task_names.append(value)
if env_vars or task_names:
return {
'env_vars': env_vars,
'task_names': task_names,
'config_path': str(config_path)
}
return None
except ET.ParseError as e:
print(f"[ERROR] XML parsing error in {config_path}: {e}")
return None
except Exception as e:
print(f"[ERROR] Error reading {config_path}: {e}")
return None
def get_gradle_command(project_root):
"""Return appropriate Gradle command for OS"""
if os.name == 'nt': # Windows
gradle_bat = project_root / 'gradlew.bat'
if gradle_bat.exists():
return str(gradle_bat)
return 'gradle.bat'
else: # Unix-like (Linux, macOS)
gradle_sh = project_root / 'gradlew'
if gradle_sh.exists():
return str(gradle_sh)
return 'gradle'
def run_service(service_name, config, project_root):
"""Run service"""
print(f"[START] Starting {service_name} service...")
# Set environment variables
env = os.environ.copy()
for key, value in config['env_vars'].items():
env[key] = value
print(f" [ENV] {key}={value}")
# Prepare Gradle command
gradle_cmd = get_gradle_command(project_root)
# Execute tasks
for task_name in config['task_names']:
print(f"\n[RUN] Executing: {task_name}")
cmd = [gradle_cmd, task_name]
try:
# Execute from project root directory
process = subprocess.Popen(
cmd,
cwd=project_root,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
bufsize=1,
encoding='utf-8',
errors='replace'
)
print(f"[CMD] Command: {' '.join(cmd)}")
print(f"[DIR] Working directory: {project_root}")
print("=" * 50)
# Real-time output
for line in process.stdout:
print(line.rstrip())
# Wait for process completion
process.wait()
if process.returncode == 0:
print(f"\n[SUCCESS] {task_name} execution completed")
else:
print(f"\n[FAILED] {task_name} execution failed (exit code: {process.returncode})")
return False
except KeyboardInterrupt:
print(f"\n[STOP] Interrupted by user")
process.terminate()
return False
except Exception as e:
print(f"\n[ERROR] Execution error: {e}")
return False
return True
def list_available_services(configurations):
"""List available services"""
print("[LIST] Available services:")
print("=" * 40)
for service_name, config in configurations.items():
if config['task_names']:
print(f" [SERVICE] {service_name}")
if 'config_path' in config:
print(f" +-- Config: {config['config_path']}")
for task in config['task_names']:
print(f" +-- Task: {task}")
print(f" +-- {len(config['env_vars'])} environment variables")
print()
def main():
"""Main function"""
parser = argparse.ArgumentParser(
description='Tripgen Service Runner Script',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python run-config.py user-service
python run-config.py location-service
python run-config.py trip-service
python run-config.py ai-service
python run-config.py --list
"""
)
parser.add_argument(
'service_name',
nargs='?',
help='Service name to run'
)
parser.add_argument(
'--list', '-l',
action='store_true',
help='List available services'
)
args = parser.parse_args()
# Find project root
project_root = get_project_root()
print(f"[INFO] Project root: {project_root}")
# Parse run configurations
print("[INFO] Reading run configuration files...")
configurations = parse_run_configurations(project_root)
if not configurations:
print("[ERROR] No execution configurations found")
return 1
print(f"[INFO] Found {len(configurations)} execution configurations")
# List services request
if args.list:
list_available_services(configurations)
return 0
# If service name not provided
if not args.service_name:
print("\n[ERROR] Please provide service name")
list_available_services(configurations)
print("Usage: python run-config.py <service-name>")
return 1
# Find service
service_name = args.service_name
# Try to parse specific service configuration if not found
if service_name not in configurations:
print(f"[INFO] Trying to find configuration for '{service_name}'...")
configurations = parse_run_configurations(project_root, service_name)
if service_name not in configurations:
print(f"[ERROR] Cannot find '{service_name}' service")
list_available_services(configurations)
return 1
config = configurations[service_name]
if not config['task_names']:
print(f"[ERROR] No executable tasks found for '{service_name}' service")
return 1
# Execute service
print(f"\n[TARGET] Starting '{service_name}' service execution")
print("=" * 50)
success = run_service(service_name, config, project_root)
if success:
print(f"\n[COMPLETE] '{service_name}' service started successfully!")
return 0
else:
print(f"\n[FAILED] Failed to start '{service_name}' service")
return 1
if __name__ == '__main__':
try:
exit_code = main()
sys.exit(exit_code)
except KeyboardInterrupt:
print("\n[STOP] Interrupted by user")
sys.exit(1)
except Exception as e:
print(f"\n[ERROR] Unexpected error occurred: {e}")
sys.exit(1)