Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52b63fb0f0 | |||
| ac7fcbd2fe | |||
| 72728841db | |||
| a3381cc540 | |||
| 7ed2465d57 | |||
| 5cac8ccc12 | |||
| 6948b48498 | |||
| 3afee053d0 | |||
| 27a3111dd8 | |||
| 3465a35827 | |||
| 640e94bf17 | |||
| e8d0a1d4b4 | |||
| 857fa5501c | |||
| ab39c76585 | |||
| 1e38d52967 | |||
| 6205a98ca0 | |||
| ebd7ae12b6 | |||
| 2cd1ba76f5 |
@@ -8,7 +8,7 @@ stringData:
|
|||||||
AZURE_STORAGE_CONNECTION_STRING: "DefaultEndpointsProtocol=https;AccountName=blobkteventstorage;AccountKey=tcBN7mAfojbl0uGsOpU7RNuKNhHnzmwDiWjN31liSMVSrWaEK+HHnYKZrjBXXAC6ZPsuxUDlsf8x+AStd++QYg==;EndpointSuffix=core.windows.net"
|
AZURE_STORAGE_CONNECTION_STRING: "DefaultEndpointsProtocol=https;AccountName=blobkteventstorage;AccountKey=tcBN7mAfojbl0uGsOpU7RNuKNhHnzmwDiWjN31liSMVSrWaEK+HHnYKZrjBXXAC6ZPsuxUDlsf8x+AStd++QYg==;EndpointSuffix=core.windows.net"
|
||||||
|
|
||||||
# Replicate API Token
|
# Replicate API Token
|
||||||
REPLICATE_API_TOKEN: ""
|
REPLICATE_API_TOKEN: "r8_BsGCJtAg5U5kkMBXSe3pgMkPufSKnUR4NY9gJ"
|
||||||
|
|
||||||
# HuggingFace API Token
|
# HuggingFace API Token
|
||||||
HUGGINGFACE_API_TOKEN: ""
|
HUGGINGFACE_API_TOKEN: ""
|
||||||
|
|||||||
@@ -53,11 +53,6 @@ resources:
|
|||||||
- analytics-service-cm-analytics-service.yaml
|
- analytics-service-cm-analytics-service.yaml
|
||||||
- analytics-service-secret-analytics-service.yaml
|
- analytics-service-secret-analytics-service.yaml
|
||||||
|
|
||||||
# Common labels for all resources
|
|
||||||
commonLabels:
|
|
||||||
app.kubernetes.io/managed-by: kustomize
|
|
||||||
app.kubernetes.io/part-of: kt-event-marketing
|
|
||||||
|
|
||||||
# Image tag replacement (will be overridden by overlays)
|
# Image tag replacement (will be overridden by overlays)
|
||||||
images:
|
images:
|
||||||
- name: acrdigitalgarage01.azurecr.io/kt-event-marketing/user-service
|
- name: acrdigitalgarage01.azurecr.io/kt-event-marketing/user-service
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ namespace: kt-event-marketing
|
|||||||
bases:
|
bases:
|
||||||
- ../../base
|
- ../../base
|
||||||
|
|
||||||
# Environment-specific labels
|
|
||||||
commonLabels:
|
|
||||||
environment: dev
|
|
||||||
|
|
||||||
# Environment-specific patches
|
# Environment-specific patches
|
||||||
patchesStrategicMerge:
|
patchesStrategicMerge:
|
||||||
- user-service-patch.yaml
|
- user-service-patch.yaml
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
name: Backend CI/CD Pipeline
|
name: Backend CI/CD Pipeline
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
# push:
|
||||||
branches:
|
# branches:
|
||||||
- develop
|
# - develop
|
||||||
- main
|
# - main
|
||||||
paths:
|
# paths:
|
||||||
- '*-service/**'
|
# - '*-service/**'
|
||||||
- '.github/workflows/backend-cicd.yaml'
|
# - '.github/workflows/backend-cicd.yaml'
|
||||||
- '.github/kustomize/**'
|
# - '.github/kustomize/**'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- develop
|
- develop
|
||||||
|
|||||||
@@ -0,0 +1,620 @@
|
|||||||
|
# Develop 브랜치 변경사항 요약
|
||||||
|
|
||||||
|
**업데이트 일시**: 2025-10-30
|
||||||
|
**머지 브랜치**: feature/event → develop
|
||||||
|
**머지 커밋**: 3465a35
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 변경사항 통계
|
||||||
|
|
||||||
|
```
|
||||||
|
60개 파일 변경
|
||||||
|
+2,795 줄 추가
|
||||||
|
-222 줄 삭제
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 주요 변경사항
|
||||||
|
|
||||||
|
### 1. 비즈니스 친화적 ID 생성 시스템 구현
|
||||||
|
|
||||||
|
#### EventId 생성 로직
|
||||||
|
**파일**: `event-service/.../EventIdGenerator.java` (신규)
|
||||||
|
|
||||||
|
**ID 포맷**: `EVT-{store_id}-{timestamp}-{random}`
|
||||||
|
```
|
||||||
|
예시: EVT-str_dev_test_001-20251030001311-70eea424
|
||||||
|
```
|
||||||
|
|
||||||
|
**특징**:
|
||||||
|
- ✅ 비즈니스 의미를 담은 접두사 (EVT)
|
||||||
|
- ✅ 매장 식별자 포함 (store_id)
|
||||||
|
- ✅ 타임스탬프 기반 시간 추적 가능
|
||||||
|
- ✅ 랜덤 해시로 유일성 보장
|
||||||
|
- ✅ 사람이 읽기 쉬운 형식
|
||||||
|
|
||||||
|
**구현 내역**:
|
||||||
|
```java
|
||||||
|
public class EventIdGenerator {
|
||||||
|
private static final String PREFIX = "EVT";
|
||||||
|
|
||||||
|
public static String generate(String storeId) {
|
||||||
|
String cleanStoreId = sanitizeStoreId(storeId);
|
||||||
|
String timestamp = LocalDateTime.now()
|
||||||
|
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
|
||||||
|
String randomHash = UUID.randomUUID().toString()
|
||||||
|
.substring(0, 8);
|
||||||
|
|
||||||
|
return String.format("%s-%s-%s-%s",
|
||||||
|
PREFIX, cleanStoreId, timestamp, randomHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### JobId 생성 로직
|
||||||
|
**파일**: `event-service/.../JobIdGenerator.java` (신규)
|
||||||
|
|
||||||
|
**ID 포맷**: `JOB-{type}-{timestamp}-{random}`
|
||||||
|
```
|
||||||
|
예시: JOB-IMG-1761750847428-b88d2f54
|
||||||
|
```
|
||||||
|
|
||||||
|
**타입 코드**:
|
||||||
|
- `IMG`: 이미지 생성 작업
|
||||||
|
- `AI`: AI 추천 작업
|
||||||
|
- `REG`: 이미지 재생성 작업
|
||||||
|
|
||||||
|
**특징**:
|
||||||
|
- ✅ 작업 타입 식별 가능
|
||||||
|
- ✅ 타임스탬프로 작업 시간 추적
|
||||||
|
- ✅ UUID 기반 유일성 보장
|
||||||
|
- ✅ 로그 분석 및 디버깅 용이
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Kafka 메시지 구조 개선
|
||||||
|
|
||||||
|
#### 필드명 표준화 (snake_case → camelCase)
|
||||||
|
|
||||||
|
**변경 파일**:
|
||||||
|
- `AIEventGenerationJobMessage.java`
|
||||||
|
- `EventCreatedMessage.java`
|
||||||
|
- `ImageJobKafkaProducer.java`
|
||||||
|
- `AIJobKafkaProducer.java`
|
||||||
|
- 관련 Consumer 클래스들
|
||||||
|
|
||||||
|
**Before**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"job_id": "...",
|
||||||
|
"event_id": "...",
|
||||||
|
"store_id": "...",
|
||||||
|
"store_name": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jobId": "...",
|
||||||
|
"eventId": "...",
|
||||||
|
"storeId": "...",
|
||||||
|
"storeName": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**이점**:
|
||||||
|
- ✅ Java 네이밍 컨벤션 준수
|
||||||
|
- ✅ JSON 직렬화/역직렬화 간소화
|
||||||
|
- ✅ 프론트엔드와 일관된 필드명
|
||||||
|
- ✅ 코드 가독성 향상
|
||||||
|
|
||||||
|
**영향받는 메시지**:
|
||||||
|
1. **이미지 생성 작업 메시지** (`image-generation-job`)
|
||||||
|
- jobId, eventId, prompt, styles, platforms 등
|
||||||
|
|
||||||
|
2. **AI 이벤트 생성 작업 메시지** (`ai-event-generation-job`)
|
||||||
|
- jobId, eventId, objective, storeInfo 등
|
||||||
|
|
||||||
|
3. **이벤트 생성 완료 메시지** (`event-created`)
|
||||||
|
- eventId, storeId, storeName, objective 등
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 데이터베이스 스키마 및 마이그레이션
|
||||||
|
|
||||||
|
#### 신규 스키마 파일
|
||||||
|
**파일**: `develop/database/schema/create_event_tables.sql`
|
||||||
|
|
||||||
|
**테이블 구조**:
|
||||||
|
```sql
|
||||||
|
-- events 테이블
|
||||||
|
CREATE TABLE events (
|
||||||
|
id VARCHAR(100) PRIMARY KEY, -- EVT-{store_id}-{timestamp}-{hash}
|
||||||
|
user_id VARCHAR(50) NOT NULL,
|
||||||
|
store_id VARCHAR(50) NOT NULL,
|
||||||
|
store_name VARCHAR(200),
|
||||||
|
objective VARCHAR(50),
|
||||||
|
status VARCHAR(20),
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- jobs 테이블
|
||||||
|
CREATE TABLE jobs (
|
||||||
|
id VARCHAR(100) PRIMARY KEY, -- JOB-{type}-{timestamp}-{hash}
|
||||||
|
event_id VARCHAR(100),
|
||||||
|
job_type VARCHAR(50),
|
||||||
|
status VARCHAR(20),
|
||||||
|
progress INTEGER,
|
||||||
|
result_message TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ai_recommendations 테이블
|
||||||
|
CREATE TABLE ai_recommendations (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
event_id VARCHAR(100),
|
||||||
|
recommendation_text TEXT,
|
||||||
|
-- ... 기타 필드
|
||||||
|
);
|
||||||
|
|
||||||
|
-- generated_images 테이블
|
||||||
|
CREATE TABLE generated_images (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
event_id VARCHAR(100),
|
||||||
|
image_url TEXT,
|
||||||
|
style VARCHAR(50),
|
||||||
|
platform VARCHAR(50),
|
||||||
|
-- ... 기타 필드
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 마이그레이션 스크립트
|
||||||
|
**파일**: `develop/database/migration/alter_event_id_to_varchar.sql`
|
||||||
|
|
||||||
|
**목적**: 기존 BIGINT 타입의 ID를 VARCHAR로 변경
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Step 1: 백업 테이블 생성
|
||||||
|
CREATE TABLE events_backup AS SELECT * FROM events;
|
||||||
|
CREATE TABLE jobs_backup AS SELECT * FROM jobs;
|
||||||
|
|
||||||
|
-- Step 2: 기존 테이블 삭제
|
||||||
|
DROP TABLE IF EXISTS events CASCADE;
|
||||||
|
DROP TABLE IF EXISTS jobs CASCADE;
|
||||||
|
|
||||||
|
-- Step 3: 새 스키마로 테이블 재생성
|
||||||
|
-- (create_event_tables.sql 실행)
|
||||||
|
|
||||||
|
-- Step 4: 데이터 마이그레이션
|
||||||
|
-- (필요시 기존 데이터를 새 형식으로 변환하여 삽입)
|
||||||
|
```
|
||||||
|
|
||||||
|
**주의사항**:
|
||||||
|
- ⚠️ 프로덕션 환경에서는 반드시 백업 후 실행
|
||||||
|
- ⚠️ 외래 키 제약조건 재설정 필요
|
||||||
|
- ⚠️ 애플리케이션 코드와 동시 배포 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Content Service 통합 및 개선
|
||||||
|
|
||||||
|
#### Content Service 설정 업데이트
|
||||||
|
**파일**: `content-service/src/main/resources/application.yml`
|
||||||
|
|
||||||
|
**변경사항**:
|
||||||
|
```yaml
|
||||||
|
# JWT 설정 추가
|
||||||
|
jwt:
|
||||||
|
secret: ${JWT_SECRET:kt-event-marketing-jwt-secret...}
|
||||||
|
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000}
|
||||||
|
|
||||||
|
# Azure Blob Storage 설정 추가
|
||||||
|
azure:
|
||||||
|
storage:
|
||||||
|
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:...}
|
||||||
|
container-name: ${AZURE_CONTAINER_NAME:content-images}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 서비스 개선사항
|
||||||
|
**파일**: `content-service/.../RegenerateImageService.java`, `StableDiffusionImageGenerator.java`
|
||||||
|
|
||||||
|
**주요 개선**:
|
||||||
|
- ✅ 이미지 재생성 로직 추가 (28줄)
|
||||||
|
- ✅ Stable Diffusion 통합 개선 (28줄)
|
||||||
|
- ✅ Mock Mode 개선 (개발 환경)
|
||||||
|
- ✅ 에러 처리 강화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Event Service 리팩토링
|
||||||
|
|
||||||
|
#### DTO 구조 개선
|
||||||
|
**변경 파일**:
|
||||||
|
- Request DTO: `AiRecommendationRequest`, `SelectImageRequest` 등
|
||||||
|
- Response DTO: `EventCreatedResponse`, `EventDetailResponse` 등
|
||||||
|
- Kafka DTO: 모든 메시지 클래스
|
||||||
|
|
||||||
|
**주요 변경**:
|
||||||
|
1. **필드명 표준화**: snake_case → camelCase
|
||||||
|
2. **ID 타입 변경**: Long → String
|
||||||
|
3. **Nullable 필드 명시**: @Nullable 어노테이션 추가
|
||||||
|
4. **Validation 강화**: @NotNull, @NotBlank 등
|
||||||
|
|
||||||
|
#### Service Layer 개선
|
||||||
|
**파일**: `EventService.java`, `JobService.java`
|
||||||
|
|
||||||
|
**Before**:
|
||||||
|
```java
|
||||||
|
public EventCreatedResponse createEvent(CreateEventRequest request) {
|
||||||
|
Event event = new Event();
|
||||||
|
event.setId(generateSequentialId()); // Long 타입
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After**:
|
||||||
|
```java
|
||||||
|
public EventCreatedResponse createEvent(CreateEventRequest request) {
|
||||||
|
String eventId = EventIdGenerator.generate(request.getStoreId());
|
||||||
|
Event event = Event.builder()
|
||||||
|
.id(eventId) // String 타입
|
||||||
|
.storeId(request.getStoreId())
|
||||||
|
// ...
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**개선사항**:
|
||||||
|
- ✅ EventIdGenerator 사용
|
||||||
|
- ✅ Builder 패턴 적용
|
||||||
|
- ✅ 비즈니스 로직 분리
|
||||||
|
- ✅ 에러 처리 개선
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Kafka 연동 개선
|
||||||
|
|
||||||
|
#### Producer 개선
|
||||||
|
**파일**: `AIJobKafkaProducer.java`, `ImageJobKafkaProducer.java`
|
||||||
|
|
||||||
|
**주요 개선**:
|
||||||
|
```java
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class ImageJobKafkaProducer {
|
||||||
|
|
||||||
|
public void sendImageGenerationJob(ImageGenerationJobMessage message) {
|
||||||
|
log.info("이미지 생성 작업 메시지 발행 시작 - JobId: {}",
|
||||||
|
message.getJobId());
|
||||||
|
|
||||||
|
kafkaTemplate.send(topicName, message.getJobId(), message)
|
||||||
|
.whenComplete((result, ex) -> {
|
||||||
|
if (ex != null) {
|
||||||
|
log.error("메시지 발행 실패: {}", ex.getMessage());
|
||||||
|
} else {
|
||||||
|
log.info("메시지 발행 성공 - Offset: {}",
|
||||||
|
result.getRecordMetadata().offset());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**개선사항**:
|
||||||
|
- ✅ 상세한 로깅 추가
|
||||||
|
- ✅ 비동기 콜백 처리
|
||||||
|
- ✅ 에러 핸들링 강화
|
||||||
|
- ✅ 메시지 키 설정 (jobId)
|
||||||
|
|
||||||
|
#### Consumer 개선
|
||||||
|
**파일**: `ImageJobKafkaConsumer.java`, `AIJobKafkaConsumer.java`
|
||||||
|
|
||||||
|
**주요 개선**:
|
||||||
|
```java
|
||||||
|
@KafkaListener(
|
||||||
|
topics = "${app.kafka.topics.image-generation-job}",
|
||||||
|
groupId = "${spring.kafka.consumer.group-id}"
|
||||||
|
)
|
||||||
|
public void consumeImageJob(
|
||||||
|
@Payload ImageGenerationJobMessage message,
|
||||||
|
Acknowledgment ack
|
||||||
|
) {
|
||||||
|
log.info("이미지 작업 메시지 수신 - JobId: {}", message.getJobId());
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 메시지 처리
|
||||||
|
processImageJob(message);
|
||||||
|
|
||||||
|
// Manual Acknowledgment
|
||||||
|
ack.acknowledge();
|
||||||
|
log.info("메시지 처리 완료 - JobId: {}", message.getJobId());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("메시지 처리 실패: {}", e.getMessage());
|
||||||
|
// 재시도 로직 또는 DLQ 전송
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**개선사항**:
|
||||||
|
- ✅ Manual Acknowledgment 패턴
|
||||||
|
- ✅ 상세한 로깅
|
||||||
|
- ✅ 예외 처리 강화
|
||||||
|
- ✅ 메시지 재시도 메커니즘
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. 보안 및 인증 개선
|
||||||
|
|
||||||
|
#### JWT 토큰 처리 개선
|
||||||
|
**파일**: `common/security/JwtTokenProvider.java`, `UserPrincipal.java`
|
||||||
|
|
||||||
|
**주요 변경**:
|
||||||
|
```java
|
||||||
|
public class JwtTokenProvider {
|
||||||
|
|
||||||
|
public String getUserId(String token) {
|
||||||
|
Claims claims = parseToken(token);
|
||||||
|
return claims.get("userId", String.class); // 명시적 타입 변환
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStoreId(String token) {
|
||||||
|
Claims claims = parseToken(token);
|
||||||
|
return claims.get("storeId", String.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**개선사항**:
|
||||||
|
- ✅ 타입 안전성 향상
|
||||||
|
- ✅ null 처리 개선
|
||||||
|
- ✅ 토큰 파싱 로직 강화
|
||||||
|
- ✅ 에러 메시지 개선
|
||||||
|
|
||||||
|
#### 개발 환경 인증 필터
|
||||||
|
**파일**: `event-service/.../DevAuthenticationFilter.java`
|
||||||
|
|
||||||
|
**개선사항**:
|
||||||
|
- ✅ 개발 환경용 Mock 인증
|
||||||
|
- ✅ JWT 토큰 파싱 개선
|
||||||
|
- ✅ 로깅 추가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. 테스트 및 문서화
|
||||||
|
|
||||||
|
#### 통합 테스트 보고서
|
||||||
|
**파일**: `test/content-service-integration-test-results.md` (신규, 673줄)
|
||||||
|
|
||||||
|
**내용**:
|
||||||
|
- ✅ 9개 테스트 시나리오 실행 결과
|
||||||
|
- ✅ 성공률: 100% (9/9)
|
||||||
|
- ✅ HTTP 통신 검증
|
||||||
|
- ✅ Job 관리 메커니즘 검증
|
||||||
|
- ✅ EventId 기반 조회 검증
|
||||||
|
- ✅ 이미지 재생성 기능 검증
|
||||||
|
- ✅ 성능 분석 (평균 응답 시간 < 150ms)
|
||||||
|
|
||||||
|
#### 아키텍처 분석 문서
|
||||||
|
**파일**: `test/content-service-integration-analysis.md` (신규, 504줄)
|
||||||
|
|
||||||
|
**내용**:
|
||||||
|
- ✅ content-service API 구조 분석
|
||||||
|
- ✅ Redis 기반 Job 관리 메커니즘
|
||||||
|
- ✅ Kafka 연동 현황 분석
|
||||||
|
- ✅ 서비스 간 통신 구조
|
||||||
|
- ✅ 권장사항 및 개선 방향
|
||||||
|
|
||||||
|
#### Kafka 연동 테스트 보고서
|
||||||
|
**파일**: `test/test-kafka-integration-results.md` (신규, 348줄)
|
||||||
|
|
||||||
|
**내용**:
|
||||||
|
- ✅ event-service Kafka Producer/Consumer 검증
|
||||||
|
- ✅ Kafka 브로커 연결 테스트
|
||||||
|
- ✅ 메시지 발행/수신 검증
|
||||||
|
- ✅ Manual Acknowledgment 패턴 검증
|
||||||
|
- ✅ content-service Kafka Consumer 미구현 확인
|
||||||
|
|
||||||
|
#### API 테스트 결과
|
||||||
|
**파일**: `test/API-TEST-RESULT.md` (이동)
|
||||||
|
|
||||||
|
**내용**:
|
||||||
|
- ✅ 기존 API 테스트 결과
|
||||||
|
- ✅ test/ 폴더로 이동하여 정리
|
||||||
|
|
||||||
|
#### 테스트 자동화 스크립트
|
||||||
|
**파일**:
|
||||||
|
- `test-content-service.sh` (신규, 82줄)
|
||||||
|
- `run-content-service.sh` (신규, 80줄)
|
||||||
|
- `run-content-service.bat` (신규, 81줄)
|
||||||
|
|
||||||
|
**기능**:
|
||||||
|
- ✅ content-service 자동 테스트
|
||||||
|
- ✅ 서버 실행 스크립트 (Linux/Windows)
|
||||||
|
- ✅ 7가지 테스트 시나리오 자동 실행
|
||||||
|
- ✅ Health Check 및 API 검증
|
||||||
|
|
||||||
|
#### 테스트 데이터
|
||||||
|
**파일**:
|
||||||
|
- `test-integration-event.json`
|
||||||
|
- `test-integration-objective.json`
|
||||||
|
- `test-integration-ai-request.json`
|
||||||
|
- `test-image-generation.json`
|
||||||
|
- `test-ai-recommendation.json`
|
||||||
|
|
||||||
|
**목적**:
|
||||||
|
- ✅ 통합 테스트용 샘플 데이터
|
||||||
|
- ✅ API 테스트 자동화
|
||||||
|
- ✅ 재현 가능한 테스트 환경
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. 실행 환경 설정
|
||||||
|
|
||||||
|
#### IntelliJ 실행 프로파일 업데이트
|
||||||
|
**파일**:
|
||||||
|
- `.run/ContentServiceApplication.run.xml`
|
||||||
|
- `.run/AiServiceApplication.run.xml`
|
||||||
|
|
||||||
|
**변경사항**:
|
||||||
|
```xml
|
||||||
|
<envs>
|
||||||
|
<env name="SERVER_PORT" value="8084" />
|
||||||
|
<env name="REDIS_HOST" value="20.214.210.71" />
|
||||||
|
<env name="REDIS_PORT" value="6379" />
|
||||||
|
<env name="REDIS_PASSWORD" value="Hi5Jessica!" />
|
||||||
|
<env name="DB_HOST" value="4.217.131.139" />
|
||||||
|
<env name="DB_PORT" value="5432" />
|
||||||
|
<env name="REPLICATE_MOCK_ENABLED" value="true" />
|
||||||
|
<!-- JWT, Azure 설정 추가 -->
|
||||||
|
</envs>
|
||||||
|
```
|
||||||
|
|
||||||
|
**개선사항**:
|
||||||
|
- ✅ 환경 변수 명시적 설정
|
||||||
|
- ✅ Mock Mode 설정 추가
|
||||||
|
- ✅ 데이터베이스 연결 정보 명시
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Kafka 아키텍처 현황
|
||||||
|
|
||||||
|
### 현재 구현된 아키텍처
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ event-service │
|
||||||
|
│ (Port 8081) │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
├─── Kafka Producer ───→ Kafka Topic (image-generation-job)
|
||||||
|
│ │
|
||||||
|
│ │ (event-service Consumer가 수신)
|
||||||
|
│ ↓
|
||||||
|
│ ┌──────────────┐
|
||||||
|
│ │ event-service│
|
||||||
|
│ │ Consumer │
|
||||||
|
│ └──────────────┘
|
||||||
|
│
|
||||||
|
└─── Redis Job Data ───→ Redis Cache
|
||||||
|
↑
|
||||||
|
│
|
||||||
|
┌───────┴────────┐
|
||||||
|
│ content-service│
|
||||||
|
│ (Port 8084) │
|
||||||
|
└────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주요 발견사항
|
||||||
|
- ⚠️ **content-service에는 Kafka Consumer 미구현**
|
||||||
|
- ✅ Redis 기반 Job 관리로 서비스 간 통신
|
||||||
|
- ✅ event-service에서 Producer/Consumer 모두 구현
|
||||||
|
- ⚠️ 논리 아키텍처 설계와 실제 구현 불일치
|
||||||
|
|
||||||
|
### 권장사항
|
||||||
|
1. **단기**: 설계 문서를 실제 구현에 맞춰 업데이트
|
||||||
|
2. **중기**: API 문서 자동화 (Swagger/OpenAPI)
|
||||||
|
3. **장기**: content-service에 Kafka Consumer 추가 구현
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 성능 및 품질 지표
|
||||||
|
|
||||||
|
### API 응답 시간
|
||||||
|
```
|
||||||
|
Health Check: < 50ms
|
||||||
|
GET 요청: 50-100ms
|
||||||
|
POST 요청: 100-150ms
|
||||||
|
```
|
||||||
|
|
||||||
|
### Job 처리 시간 (Mock Mode)
|
||||||
|
```
|
||||||
|
이미지 4개 생성: ~0.2초
|
||||||
|
이미지 1개 재생성: ~0.1초
|
||||||
|
```
|
||||||
|
|
||||||
|
### 테스트 성공률
|
||||||
|
```
|
||||||
|
통합 테스트: 100% (9/9 성공)
|
||||||
|
Kafka 연동: 100% (event-service)
|
||||||
|
API 엔드포인트: 100% (전체 정상)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 코드 품질
|
||||||
|
```
|
||||||
|
추가된 코드: 2,795줄
|
||||||
|
제거된 코드: 222줄
|
||||||
|
순 증가: 2,573줄
|
||||||
|
변경된 파일: 60개
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 배포 준비 상태
|
||||||
|
|
||||||
|
### ✅ 완료된 작업
|
||||||
|
- [x] EventId/JobId 생성 로직 구현
|
||||||
|
- [x] Kafka 메시지 구조 개선
|
||||||
|
- [x] 데이터베이스 스키마 정의
|
||||||
|
- [x] content-service 통합 테스트 완료
|
||||||
|
- [x] API 문서화 및 테스트 보고서 작성
|
||||||
|
- [x] 테스트 자동화 스크립트 작성
|
||||||
|
|
||||||
|
### ⏳ 진행 예정 작업
|
||||||
|
- [ ] content-service Kafka Consumer 구현 (옵션)
|
||||||
|
- [ ] 프로덕션 환경 데이터베이스 마이그레이션
|
||||||
|
- [ ] Swagger/OpenAPI 문서 자동화
|
||||||
|
- [ ] 성능 모니터링 도구 설정
|
||||||
|
- [ ] 로그 수집 및 분석 시스템 구축
|
||||||
|
|
||||||
|
### ⚠️ 주의사항
|
||||||
|
1. **데이터베이스 마이그레이션**: 프로덕션 배포 전 백업 필수
|
||||||
|
2. **Kafka 메시지 호환성**: 기존 Consumer가 있다면 메시지 형식 변경 영향 확인
|
||||||
|
3. **ID 형식 변경**: 기존 데이터와의 호환성 검토 필요
|
||||||
|
4. **환경 변수**: 모든 환경에서 필요한 환경 변수 설정 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 주요 커밋 히스토리
|
||||||
|
|
||||||
|
```
|
||||||
|
3465a35 Merge branch 'feature/event' into develop
|
||||||
|
8ff79ca 테스트 결과 파일들을 test/ 폴더로 이동
|
||||||
|
336d811 content-service 통합 테스트 완료 및 보고서 작성
|
||||||
|
ee941e4 Event-AI Kafka 연동 개선 및 메시지 필드명 camelCase 변경
|
||||||
|
b71d27a 비즈니스 친화적 eventId 및 jobId 생성 로직 구현
|
||||||
|
34291e1 백엔드 서비스 구조 개선 및 데이터베이스 스키마 추가
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 관련 문서
|
||||||
|
|
||||||
|
1. **테스트 보고서**
|
||||||
|
- `test/content-service-integration-test-results.md`
|
||||||
|
- `test/test-kafka-integration-results.md`
|
||||||
|
- `test/API-TEST-RESULT.md`
|
||||||
|
|
||||||
|
2. **아키텍처 문서**
|
||||||
|
- `test/content-service-integration-analysis.md`
|
||||||
|
|
||||||
|
3. **데이터베이스**
|
||||||
|
- `develop/database/schema/create_event_tables.sql`
|
||||||
|
- `develop/database/migration/alter_event_id_to_varchar.sql`
|
||||||
|
|
||||||
|
4. **테스트 스크립트**
|
||||||
|
- `test-content-service.sh`
|
||||||
|
- `run-content-service.sh`
|
||||||
|
- `run-content-service.bat`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성자**: Backend Developer
|
||||||
|
**검토자**: System Architect
|
||||||
|
**최종 업데이트**: 2025-10-30 01:40
|
||||||
@@ -28,8 +28,6 @@ spring:
|
|||||||
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
|
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
|
||||||
properties:
|
properties:
|
||||||
spring.json.trusted.packages: "*"
|
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
|
max.poll.records: 10
|
||||||
session.timeout.ms: 30000
|
session.timeout.ms: 30000
|
||||||
listener:
|
listener:
|
||||||
|
|||||||
@@ -40,10 +40,8 @@ public enum ErrorCode {
|
|||||||
EVENT_001("EVENT_001", "이벤트를 찾을 수 없습니다"),
|
EVENT_001("EVENT_001", "이벤트를 찾을 수 없습니다"),
|
||||||
EVENT_002("EVENT_002", "유효하지 않은 상태 전환입니다"),
|
EVENT_002("EVENT_002", "유효하지 않은 상태 전환입니다"),
|
||||||
EVENT_003("EVENT_003", "필수 데이터가 누락되었습니다"),
|
EVENT_003("EVENT_003", "필수 데이터가 누락되었습니다"),
|
||||||
EVENT_004("EVENT_004", "유효하지 않은 eventId 형식입니다"),
|
EVENT_004("EVENT_004", "이벤트 생성에 실패했습니다"),
|
||||||
EVENT_005("EVENT_005", "이미 존재하는 eventId입니다"),
|
EVENT_005("EVENT_005", "이벤트 수정 권한이 없습니다"),
|
||||||
EVENT_006("EVENT_006", "이벤트 생성에 실패했습니다"),
|
|
||||||
EVENT_007("EVENT_007", "이벤트 수정 권한이 없습니다"),
|
|
||||||
|
|
||||||
// Job 에러 (JOB_XXX)
|
// Job 에러 (JOB_XXX)
|
||||||
JOB_001("JOB_001", "Job을 찾을 수 없습니다"),
|
JOB_001("JOB_001", "Job을 찾을 수 없습니다"),
|
||||||
|
|||||||
+6
-6
@@ -155,12 +155,12 @@ public class RegenerateImageService implements RegenerateImageUseCase {
|
|||||||
private String generateImage(String prompt, com.kt.event.content.biz.domain.Platform platform) {
|
private String generateImage(String prompt, com.kt.event.content.biz.domain.Platform platform) {
|
||||||
try {
|
try {
|
||||||
// Mock 모드일 경우 Mock 데이터 반환
|
// Mock 모드일 경우 Mock 데이터 반환
|
||||||
// if (mockEnabled) {
|
if (mockEnabled) {
|
||||||
// log.info("[MOCK] 이미지 재생성 요청 (실제 API 호출 없음): prompt={}, platform={}", prompt, platform);
|
log.info("[MOCK] 이미지 재생성 요청 (실제 API 호출 없음): prompt={}, platform={}", prompt, platform);
|
||||||
// String mockUrl = generateMockImageUrl(platform);
|
String mockUrl = generateMockImageUrl(platform);
|
||||||
// log.info("[MOCK] 이미지 재생성 완료: url={}", mockUrl);
|
log.info("[MOCK] 이미지 재생성 완료: url={}", mockUrl);
|
||||||
// return mockUrl;
|
return mockUrl;
|
||||||
// }
|
}
|
||||||
|
|
||||||
int width = platform.getWidth();
|
int width = platform.getWidth();
|
||||||
int height = platform.getHeight();
|
int height = platform.getHeight();
|
||||||
|
|||||||
+6
-6
@@ -192,12 +192,12 @@ public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
|
|||||||
private String generateImage(String prompt, Platform platform) {
|
private String generateImage(String prompt, Platform platform) {
|
||||||
try {
|
try {
|
||||||
// Mock 모드일 경우 Mock 데이터 반환
|
// Mock 모드일 경우 Mock 데이터 반환
|
||||||
// if (mockEnabled) {
|
if (mockEnabled) {
|
||||||
// log.info("[MOCK] 이미지 생성 요청 (실제 API 호출 없음): prompt={}, platform={}", prompt, platform);
|
log.info("[MOCK] 이미지 생성 요청 (실제 API 호출 없음): prompt={}, platform={}", prompt, platform);
|
||||||
// String mockUrl = generateMockImageUrl(platform);
|
String mockUrl = generateMockImageUrl(platform);
|
||||||
// log.info("[MOCK] 이미지 생성 완료: url={}", mockUrl);
|
log.info("[MOCK] 이미지 생성 완료: url={}", mockUrl);
|
||||||
// return mockUrl;
|
return mockUrl;
|
||||||
// }
|
}
|
||||||
|
|
||||||
// 플랫폼별 이미지 크기 설정 (Platform enum에서 가져옴)
|
// 플랫폼별 이미지 크기 설정 (Platform enum에서 가져옴)
|
||||||
int width = platform.getWidth();
|
int width = platform.getWidth();
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ data:
|
|||||||
EXCLUDE_REDIS: ""
|
EXCLUDE_REDIS: ""
|
||||||
|
|
||||||
# CORS Configuration
|
# CORS Configuration
|
||||||
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"
|
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,http://kt-event-marketing-api.20.214.196.128.nip.io,http://*.20.214.196.128.nip.io,https://kt-event-marketing.20.214.196.128.nip.io,https://kt-event-marketing-api.20.214.196.128.nip.io,https://*.20.214.196.128.nip.io"
|
||||||
CORS_ALLOWED_METHODS: "GET,POST,PUT,DELETE,OPTIONS,PATCH"
|
CORS_ALLOWED_METHODS: "GET,POST,PUT,DELETE,OPTIONS,PATCH"
|
||||||
CORS_ALLOWED_HEADERS: "*"
|
CORS_ALLOWED_HEADERS: "*"
|
||||||
CORS_ALLOW_CREDENTIALS: "true"
|
CORS_ALLOW_CREDENTIALS: "true"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public class OpenApiConfig {
|
|||||||
.email("support@kt-event-marketing.com")))
|
.email("support@kt-event-marketing.com")))
|
||||||
.servers(List.of(
|
.servers(List.of(
|
||||||
new Server()
|
new Server()
|
||||||
.url("http://localhost:8085")
|
.url("http://localhost:8085/api/v1/distribution")
|
||||||
.description("Local Development Server"),
|
.description("Local Development Server"),
|
||||||
new Server()
|
new Server()
|
||||||
.url("https://dev-api.kt-event-marketing.com/distribution/v1")
|
.url("https://dev-api.kt-event-marketing.com/distribution/v1")
|
||||||
@@ -48,7 +48,7 @@ public class OpenApiConfig {
|
|||||||
.url("https://api.kt-event-marketing.com/distribution/v1")
|
.url("https://api.kt-event-marketing.com/distribution/v1")
|
||||||
.description("Production Server"),
|
.description("Production Server"),
|
||||||
new Server()
|
new Server()
|
||||||
.url("http://kt-event-marketing-api.20.214.196.128.nip.io/api/v1")
|
.url("http://kt-event-marketing-api.20.214.196.128.nip.io/api/v1/distribution")
|
||||||
.description("VM Development Server")
|
.description("VM Development Server")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -18,8 +18,8 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Distribution Controller
|
* Distribution Controller
|
||||||
* POST api/v1/distribution/distribute - 다중 채널 배포 실행
|
* POST /distribute - 다중 채널 배포 실행
|
||||||
* GET api/v1/distribution/{eventId}/status - 배포 상태 조회
|
* GET /{eventId}/status - 배포 상태 조회
|
||||||
*
|
*
|
||||||
* @author System Architect
|
* @author System Architect
|
||||||
* @since 2025-10-23
|
* @since 2025-10-23
|
||||||
|
|||||||
@@ -123,6 +123,15 @@ channel:
|
|||||||
url: ${KAKAO_API_URL:http://localhost:9006/api/kakao}
|
url: ${KAKAO_API_URL:http://localhost:9006/api/kakao}
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
|
|
||||||
|
# Naver Blog Configuration (Playwright 기반)
|
||||||
|
naver:
|
||||||
|
blog:
|
||||||
|
username: ${NAVER_BLOG_USERNAME:}
|
||||||
|
password: ${NAVER_BLOG_PASSWORD:}
|
||||||
|
blog-id: ${NAVER_BLOG_ID:}
|
||||||
|
headless: ${NAVER_BLOG_HEADLESS:true}
|
||||||
|
session-path: ${NAVER_BLOG_SESSION_PATH:playwright-sessions}
|
||||||
|
|
||||||
# Springdoc OpenAPI (Swagger)
|
# Springdoc OpenAPI (Swagger)
|
||||||
springdoc:
|
springdoc:
|
||||||
api-docs:
|
api-docs:
|
||||||
|
|||||||
+56
-25
@@ -6,6 +6,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 이벤트 생성 작업 메시지 DTO
|
* AI 이벤트 생성 작업 메시지 DTO
|
||||||
@@ -34,42 +35,72 @@ public class AIEventGenerationJobMessage {
|
|||||||
*/
|
*/
|
||||||
private String eventId;
|
private String eventId;
|
||||||
|
|
||||||
/**
|
|
||||||
* 이벤트 목적
|
|
||||||
* - "신규 고객 유치"
|
|
||||||
* - "재방문 유도"
|
|
||||||
* - "매출 증대"
|
|
||||||
* - "브랜드 인지도 향상"
|
|
||||||
*/
|
|
||||||
private String objective;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 업종 (storeCategory와 동일)
|
|
||||||
*/
|
|
||||||
private String industry;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 지역 (시/구/동)
|
|
||||||
*/
|
|
||||||
private String region;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 매장명
|
* 매장명
|
||||||
*/
|
*/
|
||||||
private String storeName;
|
private String storeName;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 목표 고객층 (선택)
|
* 매장 업종
|
||||||
*/
|
*/
|
||||||
private String targetAudience;
|
private String storeCategory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 예산 (원) (선택)
|
* 매장 설명
|
||||||
*/
|
*/
|
||||||
private Integer budget;
|
private String storeDescription;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 요청 시각
|
* 이벤트 목적
|
||||||
*/
|
*/
|
||||||
private LocalDateTime requestedAt;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-13
@@ -24,24 +24,11 @@ import lombok.NoArgsConstructor;
|
|||||||
@Schema(description = "AI 추천 요청")
|
@Schema(description = "AI 추천 요청")
|
||||||
public class AiRecommendationRequest {
|
public class AiRecommendationRequest {
|
||||||
|
|
||||||
@NotNull(message = "이벤트 목적은 필수입니다.")
|
|
||||||
@Schema(description = "이벤트 목적", required = true, example = "신규 고객 유치")
|
|
||||||
private String objective;
|
|
||||||
|
|
||||||
@NotNull(message = "매장 정보는 필수입니다.")
|
@NotNull(message = "매장 정보는 필수입니다.")
|
||||||
@Valid
|
@Valid
|
||||||
@Schema(description = "매장 정보", required = true)
|
@Schema(description = "매장 정보", required = true)
|
||||||
private StoreInfo storeInfo;
|
private StoreInfo storeInfo;
|
||||||
|
|
||||||
@Schema(description = "지역 정보", example = "서울특별시 강남구")
|
|
||||||
private String region;
|
|
||||||
|
|
||||||
@Schema(description = "타겟 고객층", example = "20-30대 직장인")
|
|
||||||
private String targetAudience;
|
|
||||||
|
|
||||||
@Schema(description = "예산 (원)", example = "500000")
|
|
||||||
private Integer budget;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 매장 정보
|
* 매장 정보
|
||||||
*/
|
*/
|
||||||
|
|||||||
-3
@@ -19,9 +19,6 @@ import lombok.NoArgsConstructor;
|
|||||||
@Builder
|
@Builder
|
||||||
public class SelectObjectiveRequest {
|
public class SelectObjectiveRequest {
|
||||||
|
|
||||||
@NotBlank(message = "이벤트 ID는 필수입니다.")
|
|
||||||
private String eventId;
|
|
||||||
|
|
||||||
@NotBlank(message = "이벤트 목적은 필수입니다.")
|
@NotBlank(message = "이벤트 목적은 필수입니다.")
|
||||||
private String objective;
|
private String objective;
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-15
@@ -23,25 +23,38 @@ public class EventIdGenerator {
|
|||||||
private static final int RANDOM_LENGTH = 8;
|
private static final int RANDOM_LENGTH = 8;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트 ID 생성 (백엔드용)
|
* 이벤트 ID 생성
|
||||||
*
|
*
|
||||||
* 참고: 현재는 프론트엔드에서 eventId를 생성하므로 이 메서드는 거의 사용되지 않습니다.
|
* @param storeId 상점 ID (최대 15자 권장)
|
||||||
*
|
|
||||||
* @param storeId 상점 ID
|
|
||||||
* @return 생성된 이벤트 ID
|
* @return 생성된 이벤트 ID
|
||||||
|
* @throws IllegalArgumentException storeId가 null이거나 비어있는 경우
|
||||||
*/
|
*/
|
||||||
public String generate(String storeId) {
|
public String generate(String storeId) {
|
||||||
// 기본값 처리
|
|
||||||
if (storeId == null || storeId.isBlank()) {
|
if (storeId == null || storeId.isBlank()) {
|
||||||
storeId = "unknown";
|
throw new IllegalArgumentException("storeId는 필수입니다");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// storeId 길이 검증 (전체 길이 50자 제한)
|
||||||
|
// TODO: 프로덕션에서는 storeId 길이 제한 필요
|
||||||
|
// if (storeId.length() > 15) {
|
||||||
|
// throw new IllegalArgumentException("storeId는 15자 이하여야 합니다");
|
||||||
|
// }
|
||||||
|
|
||||||
String timestamp = LocalDateTime.now().format(TIMESTAMP_FORMATTER);
|
String timestamp = LocalDateTime.now().format(TIMESTAMP_FORMATTER);
|
||||||
String randomPart = generateRandomPart();
|
String randomPart = generateRandomPart();
|
||||||
|
|
||||||
// 형식: EVT-{storeId}-{timestamp}-{random}
|
// 형식: EVT-{storeId}-{timestamp}-{random}
|
||||||
|
// 예상 길이: 3 + 1 + 15 + 1 + 14 + 1 + 8 = 43자 (최대)
|
||||||
String eventId = String.format("%s-%s-%s-%s", PREFIX, storeId, timestamp, randomPart);
|
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;
|
return eventId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,14 +72,7 @@ public class EventIdGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* eventId 기본 검증
|
* eventId 형식 검증
|
||||||
*
|
|
||||||
* 최소한의 검증만 수행합니다:
|
|
||||||
* - null/empty 체크
|
|
||||||
* - 길이 제한 체크 (VARCHAR(50) 제약)
|
|
||||||
*
|
|
||||||
* 프론트엔드에서 생성한 eventId를 신뢰하며,
|
|
||||||
* DB의 PRIMARY KEY 제약조건으로 중복을 방지합니다.
|
|
||||||
*
|
*
|
||||||
* @param eventId 검증할 이벤트 ID
|
* @param eventId 검증할 이벤트 ID
|
||||||
* @return 유효하면 true, 아니면 false
|
* @return 유효하면 true, 아니면 false
|
||||||
@@ -76,11 +82,32 @@ public class EventIdGenerator {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 길이 검증 (DB VARCHAR(50) 제약)
|
// EVT-로 시작하는지 확인
|
||||||
|
if (!eventId.startsWith(PREFIX + "-")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 길이 검증
|
||||||
if (eventId.length() > 50) {
|
if (eventId.length() > 50) {
|
||||||
return false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-37
@@ -55,20 +55,17 @@ public class EventService {
|
|||||||
*
|
*
|
||||||
* @param userId 사용자 ID
|
* @param userId 사용자 ID
|
||||||
* @param storeId 매장 ID
|
* @param storeId 매장 ID
|
||||||
* @param request 목적 선택 요청 (eventId 포함)
|
* @param request 목적 선택 요청
|
||||||
* @return 생성된 이벤트 응답
|
* @return 생성된 이벤트 응답
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public EventCreatedResponse createEvent(String userId, String storeId, SelectObjectiveRequest request) {
|
public EventCreatedResponse createEvent(String userId, String storeId, SelectObjectiveRequest request) {
|
||||||
log.info("이벤트 생성 시작 - userId: {}, storeId: {}, eventId: {}, objective: {}",
|
log.info("이벤트 생성 시작 - userId: {}, storeId: {}, objective: {}",
|
||||||
userId, storeId, request.getEventId(), request.getObjective());
|
userId, storeId, request.getObjective());
|
||||||
|
|
||||||
String eventId = request.getEventId();
|
// eventId 생성
|
||||||
|
String eventId = eventIdGenerator.generate(storeId);
|
||||||
// 동일한 eventId가 이미 존재하는지 확인
|
log.info("생성된 eventId: {}", eventId);
|
||||||
if (eventRepository.findByEventId(eventId).isPresent()) {
|
|
||||||
throw new BusinessException(ErrorCode.EVENT_005);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이벤트 엔티티 생성
|
// 이벤트 엔티티 생성
|
||||||
Event event = Event.builder()
|
Event event = Event.builder()
|
||||||
@@ -308,35 +305,17 @@ public class EventService {
|
|||||||
* AI 추천 요청
|
* AI 추천 요청
|
||||||
*
|
*
|
||||||
* @param userId 사용자 ID
|
* @param userId 사용자 ID
|
||||||
* @param eventId 이벤트 ID (프론트엔드에서 생성한 ID)
|
* @param eventId 이벤트 ID
|
||||||
* @param request AI 추천 요청 (objective 포함)
|
* @param request AI 추천 요청
|
||||||
* @return Job 접수 응답
|
* @return Job 접수 응답
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public JobAcceptedResponse requestAiRecommendations(String userId, String eventId, AiRecommendationRequest request) {
|
public JobAcceptedResponse requestAiRecommendations(String userId, String eventId, AiRecommendationRequest request) {
|
||||||
log.info("AI 추천 요청 - userId: {}, eventId: {}, objective: {}",
|
log.info("AI 추천 요청 - userId: {}, eventId: {}", userId, eventId);
|
||||||
userId, eventId, request.getObjective());
|
|
||||||
|
|
||||||
// 이벤트 조회 또는 생성
|
// 이벤트 조회 및 권한 확인
|
||||||
Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
|
Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
|
||||||
.orElseGet(() -> {
|
.orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001));
|
||||||
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 상태 확인
|
// DRAFT 상태 확인
|
||||||
if (!event.isModifiable()) {
|
if (!event.isModifiable()) {
|
||||||
@@ -361,11 +340,9 @@ public class EventService {
|
|||||||
userId,
|
userId,
|
||||||
eventId,
|
eventId,
|
||||||
request.getStoreInfo().getStoreName(),
|
request.getStoreInfo().getStoreName(),
|
||||||
request.getStoreInfo().getCategory(), // industry
|
request.getStoreInfo().getCategory(),
|
||||||
request.getRegion(), // region
|
request.getStoreInfo().getDescription(),
|
||||||
event.getObjective(), // objective
|
event.getObjective()
|
||||||
request.getTargetAudience(), // targetAudience
|
|
||||||
request.getBudget() // budget
|
|
||||||
);
|
);
|
||||||
|
|
||||||
log.info("AI 추천 요청 완료 - jobId: {}", job.getJobId());
|
log.info("AI 추천 요청 완료 - jobId: {}", job.getJobId());
|
||||||
|
|||||||
+23
-6
@@ -82,11 +82,7 @@ public class JobIdGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* jobId 기본 검증
|
* jobId 형식 검증
|
||||||
*
|
|
||||||
* 최소한의 검증만 수행합니다:
|
|
||||||
* - null/empty 체크
|
|
||||||
* - 길이 제한 체크 (VARCHAR(50) 제약)
|
|
||||||
*
|
*
|
||||||
* @param jobId 검증할 Job ID
|
* @param jobId 검증할 Job ID
|
||||||
* @return 유효하면 true, 아니면 false
|
* @return 유효하면 true, 아니면 false
|
||||||
@@ -96,11 +92,32 @@ public class JobIdGenerator {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 길이 검증 (DB VARCHAR(50) 제약)
|
// JOB-로 시작하는지 확인
|
||||||
|
if (!jobId.startsWith(PREFIX + "-")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 길이 검증
|
||||||
if (jobId.length() > 50) {
|
if (jobId.length() > 50) {
|
||||||
return false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ public class KafkaConfig {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Kafka Producer 설정
|
* Kafka Producer 설정
|
||||||
* Producer에서 객체를 직접 보내므로 JsonSerializer 사용
|
* Producer에서 JSON 문자열을 보내므로 StringSerializer 사용
|
||||||
*
|
*
|
||||||
* @return ProducerFactory 인스턴스
|
* @return ProducerFactory 인스턴스
|
||||||
*/
|
*/
|
||||||
@@ -46,10 +46,7 @@ public class KafkaConfig {
|
|||||||
Map<String, Object> config = new HashMap<>();
|
Map<String, Object> config = new HashMap<>();
|
||||||
config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
|
config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
|
||||||
config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
|
config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
|
||||||
config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
|
config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
|
||||||
|
|
||||||
// JSON 직렬화 시 타입 정보를 헤더에 추가하지 않음 (마이크로서비스 간 DTO 클래스 불일치 방지)
|
|
||||||
config.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false);
|
|
||||||
|
|
||||||
// Producer 성능 최적화 설정
|
// Producer 성능 최적화 설정
|
||||||
config.put(ProducerConfig.ACKS_CONFIG, "all");
|
config.put(ProducerConfig.ACKS_CONFIG, "all");
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ public class SecurityConfig {
|
|||||||
/**
|
/**
|
||||||
* CORS 설정
|
* CORS 설정
|
||||||
* 개발 환경에서 프론트엔드(localhost:3000)의 요청을 허용합니다.
|
* 개발 환경에서 프론트엔드(localhost:3000)의 요청을 허용합니다.
|
||||||
* 쿠키 기반 인증을 위한 설정이 포함되어 있습니다.
|
|
||||||
*
|
*
|
||||||
* @return CorsConfigurationSource CORS 설정 소스
|
* @return CorsConfigurationSource CORS 설정 소스
|
||||||
*/
|
*/
|
||||||
@@ -83,10 +82,7 @@ public class SecurityConfig {
|
|||||||
// 허용할 Origin (개발 환경)
|
// 허용할 Origin (개발 환경)
|
||||||
configuration.setAllowedOrigins(Arrays.asList(
|
configuration.setAllowedOrigins(Arrays.asList(
|
||||||
"http://localhost:3000",
|
"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 메서드
|
// 허용할 HTTP 메서드
|
||||||
@@ -94,7 +90,7 @@ public class SecurityConfig {
|
|||||||
"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
|
"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
|
||||||
));
|
));
|
||||||
|
|
||||||
// 허용할 헤더 (쿠키 포함)
|
// 허용할 헤더
|
||||||
configuration.setAllowedHeaders(Arrays.asList(
|
configuration.setAllowedHeaders(Arrays.asList(
|
||||||
"Authorization",
|
"Authorization",
|
||||||
"Content-Type",
|
"Content-Type",
|
||||||
@@ -102,21 +98,19 @@ public class SecurityConfig {
|
|||||||
"Accept",
|
"Accept",
|
||||||
"Origin",
|
"Origin",
|
||||||
"Access-Control-Request-Method",
|
"Access-Control-Request-Method",
|
||||||
"Access-Control-Request-Headers",
|
"Access-Control-Request-Headers"
|
||||||
"Cookie"
|
|
||||||
));
|
));
|
||||||
|
|
||||||
// 인증 정보 포함 허용 (쿠키 전송을 위해 필수)
|
// 인증 정보 포함 허용
|
||||||
configuration.setAllowCredentials(true);
|
configuration.setAllowCredentials(true);
|
||||||
|
|
||||||
// Preflight 요청 캐시 시간 (초)
|
// Preflight 요청 캐시 시간 (초)
|
||||||
configuration.setMaxAge(3600L);
|
configuration.setMaxAge(3600L);
|
||||||
|
|
||||||
// 노출할 응답 헤더 (쿠키 포함)
|
// 노출할 응답 헤더
|
||||||
configuration.setExposedHeaders(Arrays.asList(
|
configuration.setExposedHeaders(Arrays.asList(
|
||||||
"Authorization",
|
"Authorization",
|
||||||
"Content-Type",
|
"Content-Type"
|
||||||
"Set-Cookie"
|
|
||||||
));
|
));
|
||||||
|
|
||||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
|||||||
-5
@@ -21,11 +21,6 @@ import java.util.Optional;
|
|||||||
@Repository
|
@Repository
|
||||||
public interface EventRepository extends JpaRepository<Event, String> {
|
public interface EventRepository extends JpaRepository<Event, String> {
|
||||||
|
|
||||||
/**
|
|
||||||
* 이벤트 ID로 조회
|
|
||||||
*/
|
|
||||||
Optional<Event> findByEventId(String eventId);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사용자 ID와 이벤트 ID로 조회
|
* 사용자 ID와 이벤트 ID로 조회
|
||||||
*/
|
*/
|
||||||
|
|||||||
+1
-2
@@ -28,8 +28,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
* @since 2025-10-29
|
* @since 2025-10-29
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
// TODO: 별도 response 토픽 사용 시 활성화
|
@Component
|
||||||
// @Component
|
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AIJobKafkaConsumer {
|
public class AIJobKafkaConsumer {
|
||||||
|
|
||||||
+15
-16
@@ -1,5 +1,6 @@
|
|||||||
package com.kt.event.eventservice.infrastructure.kafka;
|
package com.kt.event.eventservice.infrastructure.kafka;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.kt.event.eventservice.application.dto.kafka.AIEventGenerationJobMessage;
|
import com.kt.event.eventservice.application.dto.kafka.AIEventGenerationJobMessage;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -26,6 +27,7 @@ import java.util.concurrent.CompletableFuture;
|
|||||||
public class AIJobKafkaProducer {
|
public class AIJobKafkaProducer {
|
||||||
|
|
||||||
private final KafkaTemplate<String, Object> kafkaTemplate;
|
private final KafkaTemplate<String, Object> kafkaTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
@Value("${app.kafka.topics.ai-event-generation-job:ai-event-generation-job}")
|
@Value("${app.kafka.topics.ai-event-generation-job:ai-event-generation-job}")
|
||||||
private String aiEventGenerationJobTopic;
|
private String aiEventGenerationJobTopic;
|
||||||
@@ -37,34 +39,29 @@ public class AIJobKafkaProducer {
|
|||||||
* @param userId 사용자 ID
|
* @param userId 사용자 ID
|
||||||
* @param eventId 이벤트 ID (EVT-{storeId}-{yyyyMMddHHmmss}-{random8})
|
* @param eventId 이벤트 ID (EVT-{storeId}-{yyyyMMddHHmmss}-{random8})
|
||||||
* @param storeName 매장명
|
* @param storeName 매장명
|
||||||
* @param industry 업종 (매장 카테고리)
|
* @param storeCategory 매장 업종
|
||||||
* @param region 지역
|
* @param storeDescription 매장 설명
|
||||||
* @param objective 이벤트 목적
|
* @param objective 이벤트 목적
|
||||||
* @param targetAudience 목표 고객층 (선택)
|
|
||||||
* @param budget 예산 (선택)
|
|
||||||
*/
|
*/
|
||||||
public void publishAIGenerationJob(
|
public void publishAIGenerationJob(
|
||||||
String jobId,
|
String jobId,
|
||||||
String userId,
|
String userId,
|
||||||
String eventId,
|
String eventId,
|
||||||
String storeName,
|
String storeName,
|
||||||
String industry,
|
String storeCategory,
|
||||||
String region,
|
String storeDescription,
|
||||||
String objective,
|
String objective) {
|
||||||
String targetAudience,
|
|
||||||
Integer budget) {
|
|
||||||
|
|
||||||
AIEventGenerationJobMessage message = AIEventGenerationJobMessage.builder()
|
AIEventGenerationJobMessage message = AIEventGenerationJobMessage.builder()
|
||||||
.jobId(jobId)
|
.jobId(jobId)
|
||||||
.userId(userId)
|
.userId(userId)
|
||||||
.eventId(eventId)
|
.eventId(eventId)
|
||||||
.storeName(storeName)
|
.storeName(storeName)
|
||||||
.industry(industry)
|
.storeCategory(storeCategory)
|
||||||
.region(region)
|
.storeDescription(storeDescription)
|
||||||
.objective(objective)
|
.objective(objective)
|
||||||
.targetAudience(targetAudience)
|
.status("PENDING")
|
||||||
.budget(budget)
|
.createdAt(LocalDateTime.now())
|
||||||
.requestedAt(LocalDateTime.now())
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
publishMessage(message);
|
publishMessage(message);
|
||||||
@@ -77,9 +74,11 @@ public class AIJobKafkaProducer {
|
|||||||
*/
|
*/
|
||||||
public void publishMessage(AIEventGenerationJobMessage message) {
|
public void publishMessage(AIEventGenerationJobMessage message) {
|
||||||
try {
|
try {
|
||||||
// 객체를 직접 전송 (JsonSerializer가 자동으로 직렬화)
|
// JSON 문자열로 변환
|
||||||
|
String jsonMessage = objectMapper.writeValueAsString(message);
|
||||||
|
|
||||||
CompletableFuture<SendResult<String, Object>> future =
|
CompletableFuture<SendResult<String, Object>> future =
|
||||||
kafkaTemplate.send(aiEventGenerationJobTopic, message.getJobId(), message);
|
kafkaTemplate.send(aiEventGenerationJobTopic, message.getJobId(), jsonMessage);
|
||||||
|
|
||||||
future.whenComplete((result, ex) -> {
|
future.whenComplete((result, ex) -> {
|
||||||
if (ex == null) {
|
if (ex == null) {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
kubectl.kubernetes.io/last-applied-configuration: |
|
||||||
|
{"apiVersion":"v1","kind":"Service","metadata":{"annotations":{},"labels":{"app":"participation-service","app.kubernetes.io/managed-by":"kustomize","app.kubernetes.io/part-of":"kt-event-marketing","environment":"dev"},"name":"participation-service","namespace":"kt-event-marketing"},"spec":{"ports":[{"name":"http","port":80,"protocol":"TCP","targetPort":8084}],"selector":{"app":"participation-service","app.kubernetes.io/managed-by":"kustomize","app.kubernetes.io/part-of":"kt-event-marketing","environment":"dev"},"type":"ClusterIP"}}
|
||||||
|
creationTimestamp: "2025-10-28T08:59:06Z"
|
||||||
|
labels:
|
||||||
|
app: participation-service
|
||||||
|
app.kubernetes.io/managed-by: kustomize
|
||||||
|
app.kubernetes.io/part-of: kt-event-marketing
|
||||||
|
environment: dev
|
||||||
|
name: participation-service
|
||||||
|
namespace: kt-event-marketing
|
||||||
|
resourceVersion: "125107611"
|
||||||
|
uid: da5b7f82-37d3-41bd-ad87-e2864c8bcd18
|
||||||
|
spec:
|
||||||
|
clusterIP: 10.0.130.146
|
||||||
|
clusterIPs:
|
||||||
|
- 10.0.130.146
|
||||||
|
internalTrafficPolicy: Cluster
|
||||||
|
ipFamilies:
|
||||||
|
- IPv4
|
||||||
|
ipFamilyPolicy: SingleStack
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 80
|
||||||
|
protocol: TCP
|
||||||
|
targetPort: 8084
|
||||||
|
selector:
|
||||||
|
app: participation-service
|
||||||
|
app.kubernetes.io/managed-by: kustomize
|
||||||
|
app.kubernetes.io/part-of: kt-event-marketing
|
||||||
|
environment: dev
|
||||||
|
sessionAffinity: None
|
||||||
|
type: ClusterIP
|
||||||
|
status:
|
||||||
|
loadBalancer: {}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: participation-service
|
||||||
|
app.kubernetes.io/managed-by: kustomize
|
||||||
|
app.kubernetes.io/part-of: kt-event-marketing
|
||||||
|
environment: dev
|
||||||
|
name: participation-service
|
||||||
|
namespace: kt-event-marketing
|
||||||
|
spec:
|
||||||
|
clusterIP: 10.0.130.146
|
||||||
|
clusterIPs:
|
||||||
|
- 10.0.130.146
|
||||||
|
internalTrafficPolicy: Cluster
|
||||||
|
ipFamilies:
|
||||||
|
- IPv4
|
||||||
|
ipFamilyPolicy: SingleStack
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 80
|
||||||
|
protocol: TCP
|
||||||
|
targetPort: 8084
|
||||||
|
selector:
|
||||||
|
app: participation-service
|
||||||
|
sessionAffinity: None
|
||||||
|
type: ClusterIP
|
||||||
+18
-34
@@ -1,17 +1,13 @@
|
|||||||
package com.kt.event.participation.infrastructure.config;
|
package com.kt.event.participation.infrastructure.config;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
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.http.SessionCreationPolicy;
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.web.cors.CorsConfiguration;
|
|
||||||
import org.springframework.web.cors.CorsConfigurationSource;
|
|
||||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
|
||||||
|
|
||||||
import java.util.Arrays;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Security Configuration for Participation Service
|
* Security Configuration for Participation Service
|
||||||
@@ -24,43 +20,31 @@ import java.util.Arrays;
|
|||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
@Value("${cors.allowed-origins:http://localhost:*}")
|
|
||||||
private String allowedOrigins;
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
.csrf(csrf -> csrf.disable())
|
// CSRF 비활성화 (REST API는 CSRF 불필요)
|
||||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
|
||||||
|
// 세션 사용 안 함 (JWT 기반 인증)
|
||||||
|
.sessionManagement(session ->
|
||||||
|
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 모든 요청 허용 (테스트용)
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
// Actuator endpoints
|
|
||||||
.requestMatchers("/actuator/**").permitAll()
|
|
||||||
.anyRequest().permitAll()
|
.anyRequest().permitAll()
|
||||||
);
|
);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome DevTools 요청 등 정적 리소스 요청을 Spring Security에서 제외
|
||||||
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public WebSecurityCustomizer webSecurityCustomizer() {
|
||||||
CorsConfiguration configuration = new CorsConfiguration();
|
return (web) -> web.ignoring()
|
||||||
|
.requestMatchers("/.well-known/**");
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package com.kt.event.participation.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
-104
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+8
-6
@@ -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) {
|
||||||
|
|||||||
+4
-4
@@ -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:http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io}
|
allowed-origins: ${CORS_ALLOWED_ORIGINS:*}
|
||||||
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}
|
||||||
@@ -99,3 +99,13 @@ management:
|
|||||||
enabled: true
|
enabled: true
|
||||||
readinessState:
|
readinessState:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
# OpenAPI Documentation
|
||||||
|
springdoc:
|
||||||
|
api-docs:
|
||||||
|
path: /v3/api-docs
|
||||||
|
swagger-ui:
|
||||||
|
path: /swagger-ui.html
|
||||||
|
tags-sorter: alpha
|
||||||
|
operations-sorter: alpha
|
||||||
|
show-actuator: false
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
@@ -12,6 +12,10 @@ dependencies {
|
|||||||
// OpenFeign for external API calls (사업자번호 검증)
|
// OpenFeign for external API calls (사업자번호 검증)
|
||||||
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
|
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
|
||||||
|
|
||||||
|
// Flyway for database migration
|
||||||
|
implementation 'org.flywaydb:flyway-core'
|
||||||
|
implementation 'org.flywaydb:flyway-database-postgresql'
|
||||||
|
|
||||||
// H2 Database for development
|
// H2 Database for development
|
||||||
runtimeOnly 'com.h2database:h2'
|
runtimeOnly 'com.h2database:h2'
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ public class SecurityConfig {
|
|||||||
@Value("${cors.allowed-origins:http://localhost:*}")
|
@Value("${cors.allowed-origins:http://localhost:*}")
|
||||||
private String allowedOrigins;
|
private String allowedOrigins;
|
||||||
|
|
||||||
|
@Value("${cors.allowed-methods:GET,POST,PUT,DELETE,OPTIONS,PATCH}")
|
||||||
|
private String allowedMethods;
|
||||||
|
|
||||||
|
@Value("${cors.allowed-headers:*}")
|
||||||
|
private String allowedHeaders;
|
||||||
|
|
||||||
|
@Value("${cors.allow-credentials:true}")
|
||||||
|
private boolean allowCredentials;
|
||||||
|
|
||||||
|
@Value("${cors.max-age:3600}")
|
||||||
|
private long maxAge;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
return http
|
return http
|
||||||
@@ -45,8 +57,8 @@ public class SecurityConfig {
|
|||||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
// Public endpoints
|
// Public endpoints (context-path가 /api/v1/users이므로 상대 경로 사용)
|
||||||
.requestMatchers("/api/v1/users/register", "/api/v1/users/login").permitAll()
|
.requestMatchers("/register", "/login").permitAll()
|
||||||
// Actuator endpoints
|
// Actuator endpoints
|
||||||
.requestMatchers("/actuator/**").permitAll()
|
.requestMatchers("/actuator/**").permitAll()
|
||||||
// Swagger UI endpoints
|
// Swagger UI endpoints
|
||||||
@@ -65,24 +77,23 @@ public class SecurityConfig {
|
|||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
CorsConfiguration configuration = new CorsConfiguration();
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
|
|
||||||
// 환경변수에서 허용할 Origin 패턴 설정
|
// application.yml에서 설정한 Origin 목록 사용
|
||||||
String[] origins = allowedOrigins.split(",");
|
configuration.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
|
||||||
configuration.setAllowedOriginPatterns(Arrays.asList(origins));
|
|
||||||
|
|
||||||
// 허용할 HTTP 메소드
|
// 허용할 HTTP 메소드
|
||||||
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
configuration.setAllowedMethods(Arrays.asList(allowedMethods.split(",")));
|
||||||
|
|
||||||
// 허용할 헤더
|
// 허용할 헤더
|
||||||
configuration.setAllowedHeaders(Arrays.asList(
|
configuration.setAllowedHeaders(Arrays.asList(allowedHeaders.split(",")));
|
||||||
"Authorization", "Content-Type", "X-Requested-With", "Accept",
|
|
||||||
"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"
|
|
||||||
));
|
|
||||||
|
|
||||||
// 자격 증명 허용
|
// 자격 증명 허용
|
||||||
configuration.setAllowCredentials(true);
|
configuration.setAllowCredentials(allowCredentials);
|
||||||
|
|
||||||
// Pre-flight 요청 캐시 시간
|
// Pre-flight 요청 캐시 시간
|
||||||
configuration.setMaxAge(3600L);
|
configuration.setMaxAge(maxAge);
|
||||||
|
|
||||||
|
// Exposed Headers 추가
|
||||||
|
configuration.setExposedHeaders(Arrays.asList("Authorization", "Content-Type", "X-Total-Count"));
|
||||||
|
|
||||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
source.registerCorsConfiguration("/**", configuration);
|
source.registerCorsConfiguration("/**", configuration);
|
||||||
|
|||||||
@@ -26,10 +26,13 @@ public class SwaggerConfig {
|
|||||||
return new OpenAPI()
|
return new OpenAPI()
|
||||||
.info(apiInfo())
|
.info(apiInfo())
|
||||||
.addServersItem(new Server()
|
.addServersItem(new Server()
|
||||||
.url("http://localhost:8081")
|
.url("http://kt-event-marketing-api.20.214.196.128.nip.io/api/v1/users")
|
||||||
|
.description("Production Server (AKS Ingress)"))
|
||||||
|
.addServersItem(new Server()
|
||||||
|
.url("http://localhost:8081/api/v1/users")
|
||||||
.description("Local Development"))
|
.description("Local Development"))
|
||||||
.addServersItem(new Server()
|
.addServersItem(new Server()
|
||||||
.url("{protocol}://{host}:{port}")
|
.url("{protocol}://{host}:{port}/api/v1/users")
|
||||||
.description("Custom Server")
|
.description("Custom Server")
|
||||||
.variables(new io.swagger.v3.oas.models.servers.ServerVariables()
|
.variables(new io.swagger.v3.oas.models.servers.ServerVariables()
|
||||||
.addServerVariable("protocol", new io.swagger.v3.oas.models.servers.ServerVariable()
|
.addServerVariable("protocol", new io.swagger.v3.oas.models.servers.ServerVariable()
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import java.util.UUID;
|
|||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/users")
|
@RequestMapping("") // context-path가 /api/v1/users이므로 빈 문자열 사용
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Tag(name = "User", description = "사용자 인증 및 프로필 관리 API")
|
@Tag(name = "User", description = "사용자 인증 및 프로필 관리 API")
|
||||||
public class UserController {
|
public class UserController {
|
||||||
|
|||||||
@@ -31,7 +31,13 @@ spring:
|
|||||||
use_sql_comments: true
|
use_sql_comments: true
|
||||||
dialect: ${JPA_DIALECT:org.hibernate.dialect.PostgreSQLDialect}
|
dialect: ${JPA_DIALECT:org.hibernate.dialect.PostgreSQLDialect}
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: ${DDL_AUTO:update}
|
ddl-auto: ${DDL_AUTO:validate}
|
||||||
|
|
||||||
|
# Flyway Configuration
|
||||||
|
flyway:
|
||||||
|
enabled: ${FLYWAY_ENABLED:true}
|
||||||
|
baseline-on-migrate: ${FLYWAY_BASELINE:true}
|
||||||
|
locations: classpath:db/migration
|
||||||
|
|
||||||
# Auto-configuration exclusions for development without external services
|
# Auto-configuration exclusions for development without external services
|
||||||
autoconfigure:
|
autoconfigure:
|
||||||
@@ -76,7 +82,7 @@ jwt:
|
|||||||
|
|
||||||
# CORS Configuration
|
# CORS Configuration
|
||||||
cors:
|
cors:
|
||||||
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-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,http://kt-event-marketing-api.20.214.196.128.nip.io,http://*.kt-event-marketing-api.20.214.196.128.nip.io,http://*.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}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- Migration script to change user_id from BIGINT to UUID
|
||||||
|
-- WARNING: This will delete all existing data in users and stores tables
|
||||||
|
-- Make sure to backup your data before running this script!
|
||||||
|
|
||||||
|
-- Step 1: Drop dependent tables/constraints
|
||||||
|
DROP TABLE IF EXISTS stores CASCADE;
|
||||||
|
DROP TABLE IF EXISTS users CASCADE;
|
||||||
|
|
||||||
|
-- Step 2: Create users table with UUID
|
||||||
|
CREATE TABLE users (
|
||||||
|
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
phone_number VARCHAR(20) NOT NULL UNIQUE,
|
||||||
|
email VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
role VARCHAR(20) NOT NULL DEFAULT 'OWNER',
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
last_login_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Step 3: Create indexes on users table
|
||||||
|
CREATE UNIQUE INDEX idx_user_phone ON users(phone_number);
|
||||||
|
CREATE UNIQUE INDEX idx_user_email ON users(email);
|
||||||
|
|
||||||
|
-- Step 4: Create stores table with UUID foreign key
|
||||||
|
CREATE TABLE stores (
|
||||||
|
store_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
industry VARCHAR(50),
|
||||||
|
address VARCHAR(255) NOT NULL,
|
||||||
|
business_hours VARCHAR(255),
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_stores_user FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Step 5: Create index on stores table
|
||||||
|
CREATE INDEX idx_stores_user ON stores(user_id);
|
||||||
|
|
||||||
|
-- Enable UUID extension if not already enabled
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- Migration script V002: Change user_id and store_id from BIGINT to UUID
|
||||||
|
-- WARNING: This will delete all existing data in users and stores tables
|
||||||
|
-- Make sure to backup your data before running this script!
|
||||||
|
|
||||||
|
-- Step 1: Drop dependent tables/constraints
|
||||||
|
DROP TABLE IF EXISTS stores CASCADE;
|
||||||
|
DROP TABLE IF EXISTS users CASCADE;
|
||||||
|
|
||||||
|
-- Step 2: Create users table with UUID
|
||||||
|
CREATE TABLE users (
|
||||||
|
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
phone_number VARCHAR(20) NOT NULL UNIQUE,
|
||||||
|
email VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
role VARCHAR(20) NOT NULL DEFAULT 'OWNER',
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
last_login_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Step 3: Create indexes on users table
|
||||||
|
CREATE UNIQUE INDEX idx_user_phone ON users(phone_number);
|
||||||
|
CREATE UNIQUE INDEX idx_user_email ON users(email);
|
||||||
|
|
||||||
|
-- Step 4: Create stores table with UUID foreign key
|
||||||
|
CREATE TABLE stores (
|
||||||
|
store_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
industry VARCHAR(50),
|
||||||
|
address VARCHAR(255) NOT NULL,
|
||||||
|
business_hours VARCHAR(255),
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_stores_user FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Step 5: Create index on stores table
|
||||||
|
CREATE INDEX idx_stores_user ON stores(user_id);
|
||||||
|
|
||||||
|
-- Enable UUID extension if not already enabled
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
Reference in New Issue
Block a user