Merge branch 'develop' of https://github.com/ktds-dg0501/kt-event-marketing into develop
This commit is contained in:
commit
2663baf615
@ -4,6 +4,7 @@ import org.springframework.context.annotation.Bean;
|
|||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
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.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
@ -27,21 +28,22 @@ import java.util.List;
|
|||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
/**
|
|
||||||
* Security Filter Chain 설정
|
|
||||||
* - 모든 요청 허용 (내부 API)
|
|
||||||
* - CSRF 비활성화
|
|
||||||
* - Stateless 세션
|
|
||||||
*/
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
|
// CSRF 비활성화 (REST API는 CSRF 불필요)
|
||||||
.csrf(AbstractHttpConfigurer::disable)
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
|
|
||||||
|
// CORS 설정
|
||||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
|
||||||
|
// 세션 사용 안 함 (JWT 기반 인증)
|
||||||
|
.sessionManagement(session ->
|
||||||
|
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 모든 요청 허용 (테스트용)
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/health", "/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
|
|
||||||
.requestMatchers("/internal/**").permitAll() // Internal API
|
|
||||||
.anyRequest().permitAll()
|
.anyRequest().permitAll()
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -50,11 +52,14 @@ public class SecurityConfig {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* CORS 설정
|
* CORS 설정
|
||||||
|
* - 모든 Origin 허용 (Swagger UI 테스트를 위해)
|
||||||
|
* - 모든 HTTP Method 허용
|
||||||
|
* - 모든 Header 허용
|
||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
CorsConfiguration configuration = new CorsConfiguration();
|
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.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
|
||||||
configuration.setAllowedHeaders(List.of("*"));
|
configuration.setAllowedHeaders(List.of("*"));
|
||||||
configuration.setAllowCredentials(true);
|
configuration.setAllowCredentials(true);
|
||||||
@ -64,4 +69,13 @@ public class SecurityConfig {
|
|||||||
source.registerCorsConfiguration("/**", configuration);
|
source.registerCorsConfiguration("/**", configuration);
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome DevTools 요청 등 정적 리소스 요청을 Spring Security에서 제외
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public WebSecurityCustomizer webSecurityCustomizer() {
|
||||||
|
return (web) -> web.ignoring()
|
||||||
|
.requestMatchers("/.well-known/**");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,7 +53,7 @@ jwt:
|
|||||||
|
|
||||||
# CORS Configuration
|
# CORS Configuration
|
||||||
cors:
|
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-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS,PATCH}
|
||||||
allowed-headers: ${CORS_ALLOWED_HEADERS:*}
|
allowed-headers: ${CORS_ALLOWED_HEADERS:*}
|
||||||
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
|
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
|
||||||
|
|||||||
390
develop/dev/ai-service-workflow.md
Normal file
390
develop/dev/ai-service-workflow.md
Normal 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
|
||||||
|
```
|
||||||
@ -1,104 +0,0 @@
|
|||||||
package com.kt.event.participation.presentation.controller;
|
|
||||||
|
|
||||||
import com.kt.event.participation.domain.participant.Participant;
|
|
||||||
import com.kt.event.participation.domain.participant.ParticipantRepository;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 디버깅용 컨트롤러
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@CrossOrigin(origins = "http://localhost:3000")
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/debug")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class DebugController {
|
|
||||||
|
|
||||||
private final ParticipantRepository participantRepository;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 중복 참여 체크 테스트
|
|
||||||
*/
|
|
||||||
@GetMapping("/exists/{eventId}/{phoneNumber}")
|
|
||||||
public String testExists(@PathVariable String eventId, @PathVariable String phoneNumber) {
|
|
||||||
try {
|
|
||||||
log.info("디버그: 중복 체크 시작 - eventId: {}, phoneNumber: {}", eventId, phoneNumber);
|
|
||||||
|
|
||||||
boolean exists = participantRepository.existsByEventIdAndPhoneNumber(eventId, phoneNumber);
|
|
||||||
|
|
||||||
log.info("디버그: 중복 체크 결과 - exists: {}", exists);
|
|
||||||
|
|
||||||
long totalCount = participantRepository.count();
|
|
||||||
long eventCount = participantRepository.countByEventId(eventId);
|
|
||||||
|
|
||||||
return String.format(
|
|
||||||
"eventId: %s, phoneNumber: %s, exists: %s, totalCount: %d, eventCount: %d",
|
|
||||||
eventId, phoneNumber, exists, totalCount, eventCount
|
|
||||||
);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("디버그: 예외 발생", e);
|
|
||||||
return "ERROR: " + e.getMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 모든 참여자 데이터 조회
|
|
||||||
*/
|
|
||||||
@GetMapping("/participants")
|
|
||||||
public String getAllParticipants() {
|
|
||||||
try {
|
|
||||||
List<Participant> participants = participantRepository.findAll();
|
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
sb.append("Total participants: ").append(participants.size()).append("\n\n");
|
|
||||||
|
|
||||||
for (Participant p : participants) {
|
|
||||||
sb.append(String.format("ID: %s, EventID: %s, Phone: %s, Name: %s\n",
|
|
||||||
p.getParticipantId(), p.getEventId(), p.getPhoneNumber(), p.getName()));
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.toString();
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("디버그: 참여자 조회 예외 발생", e);
|
|
||||||
return "ERROR: " + e.getMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 전화번호의 참여 이력 조회
|
|
||||||
*/
|
|
||||||
@GetMapping("/phone/{phoneNumber}")
|
|
||||||
public String getByPhoneNumber(@PathVariable String phoneNumber) {
|
|
||||||
try {
|
|
||||||
List<Participant> participants = participantRepository.findAll();
|
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
sb.append("Participants with phone: ").append(phoneNumber).append("\n\n");
|
|
||||||
|
|
||||||
int count = 0;
|
|
||||||
for (Participant p : participants) {
|
|
||||||
if (phoneNumber.equals(p.getPhoneNumber())) {
|
|
||||||
sb.append(String.format("ID: %s, EventID: %s, Name: %s\n",
|
|
||||||
p.getParticipantId(), p.getEventId(), p.getName()));
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count == 0) {
|
|
||||||
sb.append("No participants found with this phone number.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.toString();
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("디버그: 전화번호별 조회 예외 발생", e);
|
|
||||||
return "ERROR: " + e.getMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -35,9 +35,9 @@ public class ParticipationController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트 참여
|
* 이벤트 참여
|
||||||
* POST /events/{eventId}/participate
|
* POST /participations/{eventId}/participate
|
||||||
*/
|
*/
|
||||||
@PostMapping("/events/{eventId}/participate")
|
@PostMapping("/participations/{eventId}/participate")
|
||||||
public ResponseEntity<ApiResponse<ParticipationResponse>> participate(
|
public ResponseEntity<ApiResponse<ParticipationResponse>> participate(
|
||||||
@PathVariable String eventId,
|
@PathVariable String eventId,
|
||||||
@Valid @RequestBody ParticipationRequest request) {
|
@Valid @RequestBody ParticipationRequest request) {
|
||||||
@ -61,14 +61,15 @@ public class ParticipationController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 참여자 목록 조회
|
* 참여자 목록 조회
|
||||||
* GET /events/{eventId}/participants
|
* GET /participations/{eventId}/participants
|
||||||
|
* GET /events/{eventId}/participants (프론트엔드 호환)
|
||||||
*/
|
*/
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "참여자 목록 조회",
|
summary = "참여자 목록 조회",
|
||||||
description = "이벤트의 참여자 목록을 페이징하여 조회합니다. " +
|
description = "이벤트의 참여자 목록을 페이징하여 조회합니다. " +
|
||||||
"정렬 가능한 필드: createdAt(기본값), participantId, name, phoneNumber, bonusEntries, isWinner, wonAt"
|
"정렬 가능한 필드: createdAt(기본값), participantId, name, phoneNumber, bonusEntries, isWinner, wonAt"
|
||||||
)
|
)
|
||||||
@GetMapping("/events/{eventId}/participants")
|
@GetMapping({"/participations/{eventId}/participants", "/events/{eventId}/participants"})
|
||||||
public ResponseEntity<ApiResponse<PageResponse<ParticipationResponse>>> getParticipants(
|
public ResponseEntity<ApiResponse<PageResponse<ParticipationResponse>>> getParticipants(
|
||||||
@Parameter(description = "이벤트 ID", example = "evt_20250124_001")
|
@Parameter(description = "이벤트 ID", example = "evt_20250124_001")
|
||||||
@PathVariable String eventId,
|
@PathVariable String eventId,
|
||||||
@ -89,9 +90,10 @@ public class ParticipationController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 참여자 상세 조회
|
* 참여자 상세 조회
|
||||||
* GET /events/{eventId}/participants/{participantId}
|
* GET /participations/{eventId}/participants/{participantId}
|
||||||
|
* GET /events/{eventId}/participants/{participantId} (프론트엔드 호환)
|
||||||
*/
|
*/
|
||||||
@GetMapping("/events/{eventId}/participants/{participantId}")
|
@GetMapping({"/participations/{eventId}/participants/{participantId}", "/events/{eventId}/participants/{participantId}"})
|
||||||
public ResponseEntity<ApiResponse<ParticipationResponse>> getParticipant(
|
public ResponseEntity<ApiResponse<ParticipationResponse>> getParticipant(
|
||||||
@PathVariable String eventId,
|
@PathVariable String eventId,
|
||||||
@PathVariable String participantId) {
|
@PathVariable String participantId) {
|
||||||
|
|||||||
@ -35,9 +35,9 @@ public class WinnerController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 당첨자 추첨
|
* 당첨자 추첨
|
||||||
* POST /events/{eventId}/draw-winners
|
* POST /participations/{eventId}/draw-winners
|
||||||
*/
|
*/
|
||||||
@PostMapping("/events/{eventId}/draw-winners")
|
@PostMapping("/participations/{eventId}/draw-winners")
|
||||||
public ResponseEntity<ApiResponse<DrawWinnersResponse>> drawWinners(
|
public ResponseEntity<ApiResponse<DrawWinnersResponse>> drawWinners(
|
||||||
@PathVariable String eventId,
|
@PathVariable String eventId,
|
||||||
@Valid @RequestBody DrawWinnersRequest request) {
|
@Valid @RequestBody DrawWinnersRequest request) {
|
||||||
@ -50,14 +50,14 @@ public class WinnerController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 당첨자 목록 조회
|
* 당첨자 목록 조회
|
||||||
* GET /events/{eventId}/winners
|
* GET /participations/{eventId}/winners
|
||||||
*/
|
*/
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "당첨자 목록 조회",
|
summary = "당첨자 목록 조회",
|
||||||
description = "이벤트의 당첨자 목록을 페이징하여 조회합니다. " +
|
description = "이벤트의 당첨자 목록을 페이징하여 조회합니다. " +
|
||||||
"정렬 가능한 필드: winnerRank(기본값), wonAt, participantId, name, phoneNumber, bonusEntries"
|
"정렬 가능한 필드: winnerRank(기본값), wonAt, participantId, name, phoneNumber, bonusEntries"
|
||||||
)
|
)
|
||||||
@GetMapping("/events/{eventId}/winners")
|
@GetMapping("/participations/{eventId}/winners")
|
||||||
public ResponseEntity<ApiResponse<PageResponse<ParticipationResponse>>> getWinners(
|
public ResponseEntity<ApiResponse<PageResponse<ParticipationResponse>>> getWinners(
|
||||||
@Parameter(description = "이벤트 ID", example = "evt_20250124_001")
|
@Parameter(description = "이벤트 ID", example = "evt_20250124_001")
|
||||||
@PathVariable String eventId,
|
@PathVariable String eventId,
|
||||||
|
|||||||
@ -56,7 +56,7 @@ jwt:
|
|||||||
|
|
||||||
# CORS 설정
|
# CORS 설정
|
||||||
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-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS,PATCH}
|
||||||
allowed-headers: ${CORS_ALLOWED_HEADERS:*}
|
allowed-headers: ${CORS_ALLOWED_HEADERS:*}
|
||||||
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
|
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user