diff --git a/smarketing-ai/app.py b/smarketing-ai/app.py
index 27be069..f252418 100644
--- a/smarketing-ai/app.py
+++ b/smarketing-ai/app.py
@@ -98,7 +98,7 @@ def create_app():
app.logger.error(traceback.format_exc())
return jsonify({'error': f'SNS 콘텐츠 생성 중 오류가 발생했습니다: {str(e)}'}), 500
- @app.route('/api/ai/poster', methods=['GET'])
+ @app.route('/api/ai/poster', methods=['POST'])
def generate_poster_content():
"""
홍보 포스터 생성 API
@@ -114,7 +114,7 @@ def create_app():
return jsonify({'error': '요청 데이터가 없습니다.'}), 400
# 필수 필드 검증
- required_fields = ['title', 'category', 'contentType', 'images']
+ required_fields = ['title', 'category', 'images']
for field in required_fields:
if field not in data:
return jsonify({'error': f'필수 필드가 누락되었습니다: {field}'}), 400
@@ -140,12 +140,9 @@ def create_app():
poster_request = PosterContentGetRequest(
title=data.get('title'),
category=data.get('category'),
- contentType=data.get('contentType'),
images=data.get('images', []),
photoStyle=data.get('photoStyle'),
requirement=data.get('requirement'),
- toneAndManner=data.get('toneAndManner'),
- emotionIntensity=data.get('emotionIntensity'),
menuName=data.get('menuName'),
eventName=data.get('eventName'),
startDate=start_date,
diff --git a/smarketing-ai/models/request_models.py b/smarketing-ai/models/request_models.py
index 3f6952d..b21a4e1 100644
--- a/smarketing-ai/models/request_models.py
+++ b/smarketing-ai/models/request_models.py
@@ -33,12 +33,9 @@ class PosterContentGetRequest:
"""홍보 포스터 생성 요청 모델"""
title: str
category: str
- contentType: str
images: List[str] # 이미지 URL 리스트
photoStyle: Optional[str] = None
requirement: Optional[str] = None
- toneAndManner: Optional[str] = None
- emotionIntensity: Optional[str] = None
menuName: Optional[str] = None
eventName: Optional[str] = None
startDate: Optional[date] = None # LocalDate -> date
diff --git a/smarketing-ai/services/sns_content_service.py b/smarketing-ai/services/sns_content_service.py
index 16fd8ba..3e37bb6 100644
--- a/smarketing-ai/services/sns_content_service.py
+++ b/smarketing-ai/services/sns_content_service.py
@@ -1380,7 +1380,7 @@ class SnsContentService:
'call_to_action': ['방문', '예약', '문의', '공감', '이웃추가'],
'image_placement_strategy': [
'매장 외관 → 인테리어 → 메뉴판 → 음식 → 분위기',
- '텍스트 2-3문장마다 이미지 배치',
+ '텍스트 2-3문장마다 입력받은 이미지 배치',
'이미지 설명은 간결하고 매력적으로',
'마지막에 대표 이미지로 마무리'
]
@@ -1560,6 +1560,9 @@ class SnsContentService:
if not images:
return None
+ # 🔥 핵심 수정: 실제 이미지 개수 계산
+ actual_image_count = len(request.images) if request.images else 0
+
# 이미지 타입별 분류
categorized_images = {
'매장외관': [],
@@ -1603,36 +1606,69 @@ class SnsContentService:
}
],
'image_sequence': [],
- 'usage_guide': []
+ 'usage_guide': [],
+ 'actual_image_count': actual_image_count # 🔥 실제 이미지 수 추가
}
- # 각 섹션에 적절한 이미지 배정
- # 인트로: 매장외관 또는 대표 음식
- if categorized_images['매장외관']:
- placement_plan['structure'][0]['recommended_images'].extend(categorized_images['매장외관'][:1])
- elif categorized_images['음식']:
- placement_plan['structure'][0]['recommended_images'].extend(categorized_images['음식'][:1])
+ # 🔥 핵심: 실제 이미지 수에 따라 배치 전략 조정
+ if actual_image_count == 1:
+ # 이미지 1개: 가장 대표적인 위치에 배치
+ if categorized_images['음식']:
+ placement_plan['structure'][2]['recommended_images'].extend(categorized_images['음식'][:1])
+ elif categorized_images['매장외관']:
+ placement_plan['structure'][0]['recommended_images'].extend(categorized_images['매장외관'][:1])
+ else:
+ placement_plan['structure'][0]['recommended_images'].extend(images[:1])
- # 매장 정보: 외관 + 인테리어
- placement_plan['structure'][1]['recommended_images'].extend(categorized_images['매장외관'])
- placement_plan['structure'][1]['recommended_images'].extend(categorized_images['인테리어'])
+ elif actual_image_count == 2:
+ # 이미지 2개: 인트로와 메뉴 소개에 각각 배치
+ if categorized_images['매장외관'] and categorized_images['음식']:
+ placement_plan['structure'][0]['recommended_images'].extend(categorized_images['매장외관'][:1])
+ placement_plan['structure'][2]['recommended_images'].extend(categorized_images['음식'][:1])
+ else:
+ placement_plan['structure'][0]['recommended_images'].extend(images[:1])
+ placement_plan['structure'][2]['recommended_images'].extend(images[1:2])
- # 메뉴 소개: 메뉴판 + 음식
- placement_plan['structure'][2]['recommended_images'].extend(categorized_images['메뉴판'])
- placement_plan['structure'][2]['recommended_images'].extend(categorized_images['음식'])
+ elif actual_image_count == 3:
+ # 이미지 3개: 인트로, 매장 정보, 메뉴 소개에 각각 배치
+ placement_plan['structure'][0]['recommended_images'].extend(images[:1])
+ placement_plan['structure'][1]['recommended_images'].extend(images[1:2])
+ placement_plan['structure'][2]['recommended_images'].extend(images[2:3])
- # 총평: 남은 음식 사진 또는 기타
- remaining_food = [img for img in categorized_images['음식']
- if img not in placement_plan['structure'][2]['recommended_images']]
- placement_plan['structure'][3]['recommended_images'].extend(remaining_food[:1])
- placement_plan['structure'][3]['recommended_images'].extend(categorized_images['기타'][:1])
+ else:
+ # 이미지 4개 이상: 기존 로직 유지하되 실제 이미지 수로 제한
+ remaining_images = images[:]
- # 전체 이미지 순서 생성
+ # 인트로: 매장외관 또는 대표 음식
+ if categorized_images['매장외관'] and remaining_images:
+ img = categorized_images['매장외관'][0]
+ placement_plan['structure'][0]['recommended_images'].append(img)
+ if img in remaining_images:
+ remaining_images.remove(img)
+ elif categorized_images['음식'] and remaining_images:
+ img = categorized_images['음식'][0]
+ placement_plan['structure'][0]['recommended_images'].append(img)
+ if img in remaining_images:
+ remaining_images.remove(img)
+
+ # 나머지 이미지를 순서대로 배치
+ section_index = 1
+ for img in remaining_images:
+ if section_index < len(placement_plan['structure']):
+ placement_plan['structure'][section_index]['recommended_images'].append(img)
+ section_index += 1
+ else:
+ break
+
+ # 전체 이미지 순서 생성 (실제 사용될 이미지만)
for section in placement_plan['structure']:
for img in section['recommended_images']:
if img not in placement_plan['image_sequence']:
placement_plan['image_sequence'].append(img)
+ # 🔥 핵심 수정: 실제 이미지 수만큼만 유지
+ placement_plan['image_sequence'] = placement_plan['image_sequence'][:actual_image_count]
+
# 사용 가이드 생성
placement_plan['usage_guide'] = [
"📸 이미지 배치 가이드라인:",
@@ -1674,6 +1710,15 @@ class SnsContentService:
"""
category_hashtags = self.category_keywords.get(request.category, {}).get('인스타그램', [])
+ # 🔥 핵심 추가: 실제 이미지 개수 계산
+ actual_image_count = len(request.images) if request.images else 0
+
+ # 🔥 핵심 추가: 이미지 태그 사용법에 개수 제한 명시
+ image_tag_usage = f"""**이미지 태그 사용법 (반드시 준수):**
+ - 총 {actual_image_count}개의 이미지만 사용 가능
+ - [IMAGE_{actual_image_count}]까지만 사용
+ - {actual_image_count}개를 초과하는 [IMAGE_X] 태그는 절대 사용 금지"""
+
prompt = f"""
당신은 인스타그램 마케팅 전문가입니다. 소상공인 음식점을 위한 매력적인 인스타그램 게시물을 작성해주세요.
**🍸 가게 정보:**
@@ -1688,6 +1733,8 @@ class SnsContentService:
- 이벤트: {request.eventName or '특별 이벤트'}
- 독자층: {request.target}
+{image_tag_usage}
+
**📱 인스타그램 특화 요구사항:**
- 글 구조: {platform_spec['content_structure']}
- 최대 길이: {platform_spec['max_length']}자
@@ -1709,9 +1756,20 @@ class SnsContentService:
1. 첫 문장은 반드시 관심을 끄는 후킹 문장으로 시작
2. 이모티콘을 적절히 활용하여 시각적 재미 추가
3. 스토리텔링을 통해 감정적 연결 유도
-4. 명확한 행동 유도 문구 포함 (팔로우, 댓글, 저장, 방문 등)
-5. 줄바꿈을 활용하여 가독성 향상
-6. 해시태그는 본문과 자연스럽게 연결되도록 배치
+4. 각 섹션마다 적절한 위치에 [IMAGE_X] 태그로 이미지 배치 위치 표시
+5. 명확한 행동 유도 문구 포함 (팔로우, 댓글, 저장, 방문 등)
+6. 줄바꿈을 활용하여 가독성 향상
+7. 해시태그는 본문과 자연스럽게 연결되도록 배치
+
+**⚠️ 중요한 제약사항:**
+- 반드시 제공된 {actual_image_count}개의 이미지 개수를 초과하지 마세요
+- [IMAGE_{actual_image_count}]까지만 사용하세요
+- 더 많은 이미지 태그를 사용하면 오류가 발생합니다
+
+**이미지 태그 사용법:**
+- [IMAGE_1]: 첫 번째 이미지 배치 위치
+- [IMAGE_2]: 두 번째 이미지 배치 위치
+- 각 이미지 태그 다음 줄에 이미지 설명 문구 작성
**필수 요구사항:**
{request.requirement} or '고객의 관심을 끌고 방문을 유도하는 매력적인 게시물'
@@ -1729,6 +1787,9 @@ class SnsContentService:
category_keywords = self.category_keywords.get(request.category, {}).get('네이버 블로그', [])
seo_keywords = platform_spec['seo_keywords']
+ # 🔥 핵심: 실제 이미지 개수 계산
+ actual_image_count = len(request.images) if request.images else 0
+
# 이미지 배치 정보 추가
image_placement_info = ""
if image_placement_plan:
@@ -1777,14 +1838,13 @@ class SnsContentService:
1. 검색자의 궁금증을 해결하는 정보 중심 작성
2. 구체적인 가격, 위치, 운영시간 등 실용 정보 포함
3. 개인적인 경험과 솔직한 후기 작성
-4. 각 섹션마다 적절한 위치에 [IMAGE_X] 태그로 이미지 배치 위치 표시
-5. 이미지마다 간단한 설명 문구 추가
-6. 지역 정보와 접근성 정보 포함
+4. 이미지마다 간단한 설명 문구 추가
+5. 지역 정보와 접근성 정보 포함
-**이미지 태그 사용법:**
-- [IMAGE_1]: 첫 번째 이미지 배치 위치
-- [IMAGE_2]: 두 번째 이미지 배치 위치
-- 각 이미지 태그 다음 줄에 이미지 설명 문구 작성
+**⚠️ 중요한 제약사항:**
+- 반드시 제공된 {actual_image_count}개의 이미지 개수를 초과하지 마세요
+- [IMAGE_{actual_image_count}]까지만 사용하세요
+- {actual_image_count}개를 초과하는 [IMAGE_X] 태그는 절대 사용 금지
**필수 요구사항:**
{request.requirement} or '유용한 정보를 제공하여 방문을 유도하는 신뢰성 있는 후기'
@@ -1792,6 +1852,7 @@ class SnsContentService:
네이버 검색에서 상위 노출되고, 실제로 도움이 되는 정보를 제공하는 블로그 포스트를 작성해주세요.
필수 요구사항을 반드시 참고하여 작성해주세요.
이미지 배치 위치를 [IMAGE_X] 태그로 명확히 표시해주세요.
+
"""
return prompt
@@ -1811,6 +1872,14 @@ class SnsContentService:
"""
import re
+ # 🔥 핵심 추가: 실제 이미지 개수 계산
+ actual_image_count = len(request.images) if request.images else 0
+
+ # 🔥 핵심 추가: [IMAGE_X] 패턴 찾기 및 초과 태그 제거
+ image_tags = re.findall(r'\[IMAGE_(\d+)\]', content)
+ found_tag_numbers = [int(tag) for tag in image_tags]
+ removed_tags = []
+
# 해시태그 개수 조정
hashtags = re.findall(r'#[\w가-힣]+', content)
if len(hashtags) > 15:
@@ -1867,6 +1936,14 @@ class SnsContentService:
# 이미지를 콘텐츠 맨 앞에 추가
content = images_html_content + content
+ # 🔥 핵심 수정: 인스타그램 본문에서 [IMAGE_X] 태그 모두 제거
+ import re
+ content = re.sub(r'\[IMAGE_\d+\]', '', content)
+
+ # 🔥 추가: 태그 제거 후 남은 빈 줄 정리
+ content = re.sub(r'\n\s*\n\s*\n', '\n\n', content) # 3개 이상의 연속 줄바꿈을 2개로
+ content = re.sub(r'
\s*
\s*
', '
', content) # 3개 이상의 연속
을 2개로
+
# 2. 네이버 블로그인 경우 이미지 태그를 실제 이미지로 변환
elif request.platform == '네이버 블로그' and image_placement_plan:
content = self._replace_image_tags_with_html(content, image_placement_plan, request.images)
diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/SecurityConfig.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/SecurityConfig.java
new file mode 100644
index 0000000..08a3949
--- /dev/null
+++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/SecurityConfig.java
@@ -0,0 +1,88 @@
+package com.won.smarketing.recommend.config;
+
+import com.won.smarketing.common.security.JwtAuthenticationFilter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+
+import java.util.Arrays;
+
+/**
+ * Spring Security 설정 클래스
+ * JWT 기반 인증 및 CORS 설정
+ */
+@Configuration
+@EnableWebSecurity
+@RequiredArgsConstructor
+public class SecurityConfig
+{
+
+ private final JwtAuthenticationFilter jwtAuthenticationFilter;
+
+ @Value("${allowed-origins}")
+ private String allowedOrigins;
+ /**
+ * Spring Security 필터 체인 설정
+ *
+ * @param http HttpSecurity 객체
+ * @return SecurityFilterChain
+ * @throws Exception 예외
+ */
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+ http
+ .csrf(AbstractHttpConfigurer::disable)
+ .cors(cors -> cors.configurationSource(corsConfigurationSource()))
+ .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers("/api/auth/**", "/api/member/register", "/api/member/check-duplicate/**",
+ "/api/member/validate-password", "/swagger-ui/**", "/v3/api-docs/**",
+ "/swagger-resources/**", "/webjars/**", "/actuator/**", "/health/**", "/error"
+ ).permitAll()
+ .anyRequest().authenticated()
+ )
+ .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
+
+ return http.build();
+ }
+
+ /**
+ * 패스워드 인코더 빈 등록
+ *
+ * @return BCryptPasswordEncoder
+ */
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+
+ /**
+ * CORS 설정
+ *
+ * @return CorsConfigurationSource
+ */
+ @Bean
+ public CorsConfigurationSource corsConfigurationSource() {
+ CorsConfiguration configuration = new CorsConfiguration();
+ configuration.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
+ configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
+ configuration.setAllowedHeaders(Arrays.asList("*"));
+ configuration.setAllowCredentials(true);
+
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", configuration);
+ return source;
+ }
+}
diff --git a/smarketing-java/ai-recommend/src/main/resources/application.yml b/smarketing-java/ai-recommend/src/main/resources/application.yml
index d392c82..3b84c68 100644
--- a/smarketing-java/ai-recommend/src/main/resources/application.yml
+++ b/smarketing-java/ai-recommend/src/main/resources/application.yml
@@ -26,10 +26,10 @@ spring:
external:
store-service:
- base-url: ${STORE_SERVICE_URL:http://localhost:8082}
+ base-url: ${STORE_SERVICE_URL:http://smarketing.20.249.184.228.nip.io}
timeout: ${STORE_SERVICE_TIMEOUT:5000}
python-ai-service:
- base-url: ${PYTHON_AI_SERVICE_URL:http://localhost:5001}
+ base-url: ${PYTHON_AI_SERVICE_URL:http://20.249.113.247:5001}
api-key: ${PYTHON_AI_API_KEY:dummy-key}
timeout: ${PYTHON_AI_TIMEOUT:30000}
@@ -70,4 +70,6 @@ info:
app:
name: ${APP_NAME:smarketing-recommend}
version: "1.0.0-MVP"
- description: "AI 마케팅 서비스 MVP - recommend"
\ No newline at end of file
+ description: "AI 마케팅 서비스 MVP - recommend"
+
+allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000}
\ No newline at end of file
diff --git a/smarketing-java/build.gradle b/smarketing-java/build.gradle
index e917ca4..01426b7 100644
--- a/smarketing-java/build.gradle
+++ b/smarketing-java/build.gradle
@@ -53,6 +53,15 @@ subprojects {
implementation 'com.azure:azure-messaging-eventhubs-checkpointstore-blob:1.19.0'
implementation 'com.azure:azure-identity:1.11.4'
+ // Azure Blob Storage 의존성 추가
+ implementation 'com.azure:azure-storage-blob:12.25.0'
+ implementation 'com.azure:azure-identity:1.11.1'
+
+ implementation 'com.fasterxml.jackson.core:jackson-core'
+ implementation 'com.fasterxml.jackson.core:jackson-databind'
+ implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
+ implementation 'org.springframework.boot:spring-boot-starter-webflux'
+
}
tasks.named('test') {
diff --git a/smarketing-java/deployment/Jenkinsfile b/smarketing-java/deployment/Jenkinsfile
index ce96650..a84cf56 100644
--- a/smarketing-java/deployment/Jenkinsfile
+++ b/smarketing-java/deployment/Jenkinsfile
@@ -39,6 +39,8 @@ podTemplate(
echo "Services: ${services}"
echo "Namespace: ${namespace}"
echo "Image Tag: ${imageTag}"
+ echo "Registry: ${props.registry}"
+ echo "Image Org: ${props.image_org}"
}
stage("Check Changes") {
@@ -176,22 +178,49 @@ podTemplate(
sh """
echo "=== 환경변수 설정 ==="
export namespace=${namespace}
- export allowed_origins=${props.allowed_origins}
- export jwt_secret_key=${props.jwt_secret_key}
- export postgres_user=${props.postgres_user}
- export postgres_password=${props.postgres_password}
+ export allowed_origins='${props.allowed_origins}'
+ export jwt_secret_key='${props.jwt_secret_key}'
+ export postgres_user='${props.POSTGRES_USER}'
+ export postgres_password='${props.POSTGRES_PASSWORD}'
export replicas=${props.replicas}
- # 리소스 요구사항 조정 (작게)
- export resources_requests_cpu=100m
- export resources_requests_memory=128Mi
- export resources_limits_cpu=500m
- export resources_limits_memory=512Mi
+
+ # PostgreSQL 환경변수 추가 (올바른 DB명으로 수정)
+ export postgres_host='${props.POSTGRES_HOST}'
+ export postgres_port='5432'
+ export postgres_db_member='MemberDB'
+ export postgres_db_store='StoreDB'
+ export postgres_db_marketing_content='MarketingContentDB'
+ export postgres_db_ai_recommend='AiRecommendationDB'
+
+ # Redis 환경변수 추가
+ export redis_host='${props.REDIS_HOST}'
+ export redis_port='6380'
+ export redis_password='${props.REDIS_PASSWORD}'
+
+ # 리소스 요구사항
+ export resources_requests_cpu='${props.resources_requests_cpu}'
+ export resources_requests_memory='${props.resources_requests_memory}'
+ export resources_limits_cpu='${props.resources_limits_cpu}'
+ export resources_limits_memory='${props.resources_limits_memory}'
# 이미지 경로 환경변수 설정
- export member_image_path=${props.registry}/${props.image_org}/member:${imageTag}
- export store_image_path=${props.registry}/${props.image_org}/store:${imageTag}
- export marketing_content_image_path=${props.registry}/${props.image_org}/marketing-content:${imageTag}
- export ai_recommend_image_path=${props.registry}/${props.image_org}/ai-recommend:${imageTag}
+ export member_image_path='${props.registry}/${props.image_org}/member:${imageTag}'
+ export store_image_path='${props.registry}/${props.image_org}/store:${imageTag}'
+ export marketing_content_image_path='${props.registry}/${props.image_org}/marketing-content:${imageTag}'
+ export ai_recommend_image_path='${props.registry}/${props.image_org}/ai-recommend:${imageTag}'
+
+ echo "=== 환경변수 확인 ==="
+ echo "namespace: \$namespace"
+ echo "postgres_host: \$postgres_host"
+ echo "postgres_port: \$postgres_port"
+ echo "postgres_user: \$postgres_user"
+ echo "postgres_db_member: \$postgres_db_member"
+ echo "postgres_db_store: \$postgres_db_store"
+ echo "postgres_db_marketing_content: \$postgres_db_marketing_content"
+ echo "postgres_db_ai_recommend: \$postgres_db_ai_recommend"
+ echo "redis_host: \$redis_host"
+ echo "redis_port: \$redis_port"
+ echo "replicas: \$replicas"
echo "=== Manifest 생성 ==="
envsubst < smarketing-java/deployment/${manifest}.template > smarketing-java/deployment/${manifest}
@@ -208,32 +237,63 @@ podTemplate(
kubectl config current-context
kubectl cluster-info | head -3
+ echo "=== 기존 ConfigMap 삭제 (타입 충돌 해결) ==="
+ kubectl delete configmap member-config store-config marketing-content-config ai-recommend-config -n ${namespace} --ignore-not-found=true
+
echo "=== PostgreSQL 서비스 확인 ==="
- kubectl get svc -n ${namespace} | grep postgresql || echo "PostgreSQL 서비스가 없습니다. 먼저 설치해주세요."
+ kubectl get svc -n ${namespace} | grep postgresql || echo "PostgreSQL 서비스를 찾을 수 없습니다."
+
+ echo "=== Redis 서비스 확인 ==="
+ kubectl get svc -n ${namespace} | grep redis || echo "Redis 서비스를 찾을 수 없습니다."
echo "=== Manifest 적용 ==="
kubectl apply -f smarketing-java/deployment/${manifest}
- echo "=== 배포 상태 확인 (60초 대기) ==="
+ echo "=== 배포 상태 확인 (30초 대기) ==="
+ sleep 30
kubectl -n ${namespace} get deployments
kubectl -n ${namespace} get pods
- echo "=== 각 서비스 배포 대기 (60초 timeout) ==="
- timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/member --timeout=60s || echo "member deployment 대기 타임아웃"
- timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/store --timeout=60s || echo "store deployment 대기 타임아웃"
- timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/marketing-content --timeout=60s || echo "marketing-content deployment 대기 타임아웃"
- timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/ai-recommend --timeout=60s || echo "ai-recommend deployment 대기 타임아웃"
+ echo "=== ConfigMap 확인 ==="
+ kubectl -n ${namespace} get configmap member-config -o yaml | grep -A 10 "data:"
+ kubectl -n ${namespace} get configmap ai-recommend-config -o yaml | grep -A 10 "data:"
- echo "=== 최종 상태 ==="
+ echo "=== Secret 확인 ==="
+ kubectl -n ${namespace} get secret member-secret -o yaml | grep -A 5 "data:"
+
+ echo "=== 각 서비스 배포 대기 (120초 timeout) ==="
+ timeout 120 kubectl -n ${namespace} wait --for=condition=available deployment/member --timeout=120s || echo "member deployment 대기 타임아웃"
+ timeout 120 kubectl -n ${namespace} wait --for=condition=available deployment/store --timeout=120s || echo "store deployment 대기 타임아웃"
+ timeout 120 kubectl -n ${namespace} wait --for=condition=available deployment/marketing-content --timeout=120s || echo "marketing-content deployment 대기 타임아웃"
+ timeout 120 kubectl -n ${namespace} wait --for=condition=available deployment/ai-recommend --timeout=120s || echo "ai-recommend deployment 대기 타임아웃"
+
+ echo "=== 최종 배포 상태 ==="
kubectl -n ${namespace} get all
+ echo "=== 각 서비스 Pod 로그 확인 (최근 20라인) ==="
+ for service in member store marketing-content ai-recommend; do
+ echo "=== \$service 서비스 로그 ==="
+ kubectl -n ${namespace} logs deployment/\$service --tail=20 || echo "\$service 로그를 가져올 수 없습니다"
+ echo ""
+ done
+
echo "=== 실패한 Pod 상세 정보 ==="
for pod in \$(kubectl -n ${namespace} get pods --field-selector=status.phase!=Running -o name 2>/dev/null || true); do
if [ ! -z "\$pod" ]; then
echo "=== 실패한 Pod: \$pod ==="
- kubectl -n ${namespace} describe \$pod | tail -20
+ kubectl -n ${namespace} describe \$pod | tail -30
+ echo "=== Pod 로그: \$pod ==="
+ kubectl -n ${namespace} logs \$pod --tail=50 || echo "로그를 가져올 수 없습니다"
+ echo "=========================================="
fi
done
+
+ echo "=== Ingress 상태 확인 ==="
+ kubectl -n ${namespace} get ingress
+ kubectl -n ${namespace} describe ingress smarketing-backend || echo "Ingress를 찾을 수 없습니다"
+
+ echo "=== 서비스 Endpoint 확인 ==="
+ kubectl -n ${namespace} get endpoints
"""
}
}
diff --git a/smarketing-java/deployment/argocd.yaml b/smarketing-java/deployment/argocd.yaml
new file mode 100644
index 0000000..21a6122
--- /dev/null
+++ b/smarketing-java/deployment/argocd.yaml
@@ -0,0 +1,28 @@
+## Globally shared configuration
+global:
+ # -- Default domain used by all components
+ ## Used for ingresses, certificates, SSO, notifications, etc.
+ ## IP는 외부에서 접근할 수 있는 ks8 node의 Public IP 또는
+ ## ingress-nginx-controller 서비스의 External IP이여야 함
+ domain: argo.20.249.184.228.nip.io
+
+ # -- 특정 노드에 배포시 지정
+ #nodeSelector:
+ #agentpool: argocd
+
+server:
+ ingress:
+ enabled: true
+ https: true
+ annotations:
+ kubernetes.io/ingress.class: nginx
+ tls:
+ - secretName: argocd-tls-smarketing-secret
+ extraArgs:
+ - --insecure # ArgoCD 서버가 TLS 종료를 Ingress에 위임
+
+configs:
+ params:
+ server.insecure: true # Ingress에서 TLS를 처리하므로 ArgoCD 서버는 HTTP로 통신
+certificate:
+ enabled: false # 자체 서명 인증서 사용 비활성화 (외부 인증서 사용 시)
diff --git a/smarketing-java/deployment/deploy.yaml.template b/smarketing-java/deployment/deploy.yaml.template
index 92e1068..1f54a76 100644
--- a/smarketing-java/deployment/deploy.yaml.template
+++ b/smarketing-java/deployment/deploy.yaml.template
@@ -8,16 +8,11 @@ data:
ALLOWED_ORIGINS: ${allowed_origins}
JPA_DDL_AUTO: update
JPA_SHOW_SQL: 'true'
- # 🔧 강화된 Actuator 설정
+ # Actuator 설정
MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: '*'
MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS: always
MANAGEMENT_ENDPOINT_HEALTH_ENABLED: 'true'
MANAGEMENT_ENDPOINTS_WEB_BASE_PATH: /actuator
- MANAGEMENT_SERVER_PORT: '8080'
- # Spring Security 비활성화 (Actuator용)
- SPRING_AUTOCONFIGURE_EXCLUDE: org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
- # 또는 Management port를 main port와 동일하게
- MANAGEMENT_SERVER_PORT: ''
---
apiVersion: v1
@@ -26,10 +21,14 @@ metadata:
name: member-config
namespace: ${namespace}
data:
- POSTGRES_DB: member
- POSTGRES_HOST: member-postgresql
- POSTGRES_PORT: '5432'
SERVER_PORT: '8081'
+ POSTGRES_HOST: ${postgres_host}
+ POSTGRES_PORT: '5432'
+ POSTGRES_DB: ${postgres_db_member}
+ REDIS_HOST: ${redis_host}
+ REDIS_PORT: '6380'
+ JPA_DDL_AUTO: 'create-drop'
+ JPA_SHOW_SQL: 'true'
---
apiVersion: v1
@@ -38,10 +37,14 @@ metadata:
name: store-config
namespace: ${namespace}
data:
- POSTGRES_DB: store
- POSTGRES_HOST: store-postgresql
- POSTGRES_PORT: '5432'
SERVER_PORT: '8082'
+ POSTGRES_HOST: ${postgres_host}
+ POSTGRES_PORT: '5432'
+ POSTGRES_DB: ${postgres_db_store}
+ REDIS_HOST: ${redis_host}
+ REDIS_PORT: '6380'
+ JPA_DDL_AUTO: 'create-drop'
+ JPA_SHOW_SQL: 'true'
---
apiVersion: v1
@@ -50,10 +53,14 @@ metadata:
name: marketing-content-config
namespace: ${namespace}
data:
- POSTGRES_DB: marketing_content
- POSTGRES_HOST: marketing-content-postgresql
- POSTGRES_PORT: '5432'
SERVER_PORT: '8083'
+ POSTGRES_HOST: ${postgres_host}
+ POSTGRES_PORT: '5432'
+ POSTGRES_DB: ${postgres_db_marketing_content}
+ REDIS_HOST: ${redis_host}
+ REDIS_PORT: '6380'
+ JPA_DDL_AUTO: 'create-drop'
+ JPA_SHOW_SQL: 'true'
---
apiVersion: v1
@@ -62,10 +69,14 @@ metadata:
name: ai-recommend-config
namespace: ${namespace}
data:
- POSTGRES_DB: ai_recommend
- POSTGRES_HOST: ai-recommend-postgresql
- POSTGRES_PORT: '5432'
SERVER_PORT: '8084'
+ POSTGRES_HOST: ${postgres_host}
+ POSTGRES_PORT: '5432'
+ POSTGRES_DB: ${postgres_db_ai_recommend}
+ REDIS_HOST: ${redis_host}
+ REDIS_PORT: '6380'
+ JPA_DDL_AUTO: 'create-drop'
+ JPA_SHOW_SQL: 'true'
---
# Secrets
@@ -87,8 +98,9 @@ metadata:
stringData:
JWT_ACCESS_TOKEN_VALIDITY: '3600000'
JWT_REFRESH_TOKEN_VALIDITY: '86400000'
- POSTGRES_PASSWORD: ${postgres_password}
POSTGRES_USER: ${postgres_user}
+ POSTGRES_PASSWORD: ${postgres_password}
+ REDIS_PASSWORD: ${redis_password}
type: Opaque
---
@@ -98,8 +110,9 @@ metadata:
name: store-secret
namespace: ${namespace}
stringData:
- POSTGRES_PASSWORD: ${postgres_password}
POSTGRES_USER: ${postgres_user}
+ POSTGRES_PASSWORD: ${postgres_password}
+ REDIS_PASSWORD: ${redis_password}
type: Opaque
---
@@ -109,8 +122,9 @@ metadata:
name: marketing-content-secret
namespace: ${namespace}
stringData:
- POSTGRES_PASSWORD: ${postgres_password}
POSTGRES_USER: ${postgres_user}
+ POSTGRES_PASSWORD: ${postgres_password}
+ REDIS_PASSWORD: ${redis_password}
type: Opaque
---
@@ -120,8 +134,9 @@ metadata:
name: ai-recommend-secret
namespace: ${namespace}
stringData:
- POSTGRES_PASSWORD: ${postgres_password}
POSTGRES_USER: ${postgres_user}
+ POSTGRES_PASSWORD: ${postgres_password}
+ REDIS_PASSWORD: ${redis_password}
type: Opaque
---
@@ -167,39 +182,6 @@ spec:
name: common-secret
- secretRef:
name: member-secret
- startupProbe:
- exec:
- command:
- - /bin/sh
- - -c
- - "nc -z member-postgresql 5432"
- initialDelaySeconds: 30
- periodSeconds: 10
- timeoutSeconds: 5
- failureThreshold: 10
- # 🔧 개선된 Health Check 설정
- livenessProbe:
- httpGet:
- path: /actuator/health
- port: 8081
- httpHeaders:
- - name: Accept
- value: application/json
- initialDelaySeconds: 120 # 2분으로 증가
- periodSeconds: 30
- timeoutSeconds: 10
- failureThreshold: 3
- readinessProbe:
- httpGet:
- path: /actuator/health/readiness
- port: 8081
- httpHeaders:
- - name: Accept
- value: application/json
- initialDelaySeconds: 60 # 1분으로 증가
- periodSeconds: 10
- timeoutSeconds: 5
- failureThreshold: 3
---
apiVersion: apps/v1
@@ -243,38 +225,7 @@ spec:
name: common-secret
- secretRef:
name: store-secret
- startupProbe:
- exec:
- command:
- - /bin/sh
- - -c
- - "nc -z store-postgresql 5432"
- initialDelaySeconds: 30
- periodSeconds: 10
- timeoutSeconds: 5
- failureThreshold: 10
- livenessProbe:
- httpGet:
- path: /actuator/health
- port: 8082
- httpHeaders:
- - name: Accept
- value: application/json
- initialDelaySeconds: 120
- periodSeconds: 30
- timeoutSeconds: 10
- failureThreshold: 3
- readinessProbe:
- httpGet:
- path: /actuator/health/readiness
- port: 8082
- httpHeaders:
- - name: Accept
- value: application/json
- initialDelaySeconds: 60
- periodSeconds: 10
- timeoutSeconds: 5
- failureThreshold: 3
+
---
apiVersion: apps/v1
@@ -318,38 +269,7 @@ spec:
name: common-secret
- secretRef:
name: marketing-content-secret
- startupProbe:
- exec:
- command:
- - /bin/sh
- - -c
- - "nc -z marketing-content-postgresql 5432"
- initialDelaySeconds: 30
- periodSeconds: 10
- timeoutSeconds: 5
- failureThreshold: 10
- livenessProbe:
- httpGet:
- path: /actuator/health
- port: 8083
- httpHeaders:
- - name: Accept
- value: application/json
- initialDelaySeconds: 120
- periodSeconds: 30
- timeoutSeconds: 10
- failureThreshold: 3
- readinessProbe:
- httpGet:
- path: /actuator/health/readiness
- port: 8083
- httpHeaders:
- - name: Accept
- value: application/json
- initialDelaySeconds: 60
- periodSeconds: 10
- timeoutSeconds: 5
- failureThreshold: 3
+
---
apiVersion: apps/v1
@@ -393,38 +313,7 @@ spec:
name: common-secret
- secretRef:
name: ai-recommend-secret
- startupProbe:
- exec:
- command:
- - /bin/sh
- - -c
- - "nc -z ai-recommend-postgresql 5432"
- initialDelaySeconds: 30
- periodSeconds: 10
- timeoutSeconds: 5
- failureThreshold: 10
- livenessProbe:
- httpGet:
- path: /actuator/health
- port: 8084
- httpHeaders:
- - name: Accept
- value: application/json
- initialDelaySeconds: 120
- periodSeconds: 30
- timeoutSeconds: 10
- failureThreshold: 3
- readinessProbe:
- httpGet:
- path: /actuator/health/readiness
- port: 8084
- httpHeaders:
- - name: Accept
- value: application/json
- initialDelaySeconds: 60
- periodSeconds: 10
- timeoutSeconds: 5
- failureThreshold: 3
+
---
# Services
@@ -487,14 +376,15 @@ spec:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
- name: smarketing-backend
+ name: smarketing-ingress
namespace: ${namespace}
annotations:
kubernetes.io/ingress.class: nginx
spec:
ingressClassName: nginx
rules:
- - http:
+ - host: smarketing.20.249.184.228.nip.io
+ http:
paths:
- path: /api/auth
pathType: Prefix
@@ -524,3 +414,4 @@ spec:
name: ai-recommend
port:
number: 80
+
diff --git a/smarketing-java/deployment/deploy_env_vars b/smarketing-java/deployment/deploy_env_vars
index 7a5d327..5e90919 100644
--- a/smarketing-java/deployment/deploy_env_vars
+++ b/smarketing-java/deployment/deploy_env_vars
@@ -8,8 +8,9 @@ registry=acrdigitalgarage02.azurecr.io
image_org=smarketing
# Application Settings
+ingress_host=smarketing.20.249.184.228.nip.io
replicas=1
-allowed_origins=http://20.249.171.38
+allowed_origins=http://20.249.154.194
# Security Settings
jwt_secret_key=8O2HQ13etL2BWZvYOiWsJ5uWFoLi6NBUG8divYVoCgtHVvlk3dqRksMl16toztDUeBTSIuOOPvHIrYq11G2BwQ
diff --git a/smarketing-java/deployment/member b/smarketing-java/deployment/member
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/smarketing-java/deployment/member
@@ -0,0 +1 @@
+
diff --git a/smarketing-java/marketing-content/build.gradle b/smarketing-java/marketing-content/build.gradle
index 715bc47..188d7bd 100644
--- a/smarketing-java/marketing-content/build.gradle
+++ b/smarketing-java/marketing-content/build.gradle
@@ -1,7 +1,4 @@
dependencies {
implementation project(':common')
runtimeOnly 'org.postgresql:postgresql'
-
- // WebClient를 위한 Spring WebFlux 의존성
- implementation 'org.springframework.boot:spring-boot-starter-webflux'
}
\ No newline at end of file
diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java
index 69c6213..94c894d 100644
--- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java
+++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java
@@ -6,30 +6,41 @@ import com.won.smarketing.content.domain.model.ContentStatus;
import com.won.smarketing.content.domain.model.ContentType;
import com.won.smarketing.content.domain.model.CreationConditions;
import com.won.smarketing.content.domain.model.Platform;
+import com.won.smarketing.content.domain.model.store.StoreWithMenuData;
import com.won.smarketing.content.domain.repository.ContentRepository;
import com.won.smarketing.content.domain.service.AiPosterGenerator;
+import com.won.smarketing.content.domain.service.BlobStorageService;
+import com.won.smarketing.content.domain.service.StoreDataProvider;
import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest;
import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse;
import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
-import java.time.LocalDateTime;
-import java.util.HashMap;
-import java.util.Map;
+import java.util.List;
/**
* 포스터 콘텐츠 서비스 구현체
* 홍보 포스터 생성 및 저장 기능 구현
*/
@Service
+@Slf4j
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PosterContentService implements PosterContentUseCase {
+ @Value("${azure.storage.container.poster-images:poster-images}")
+ private String posterImageContainer;
+
private final ContentRepository contentRepository;
private final AiPosterGenerator aiPosterGenerator;
+ private final BlobStorageService blobStorageService;
+ private final StoreDataProvider storeDataProvider;
/**
* 포스터 콘텐츠 생성
@@ -39,26 +50,24 @@ public class PosterContentService implements PosterContentUseCase {
*/
@Override
@Transactional
- public PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request) {
+ public PosterContentCreateResponse generatePosterContent(List images, PosterContentCreateRequest request) {
- String generatedPoster = aiPosterGenerator.generatePoster(request);
+ // 1. 이미지 blob storage에 저장하고 request 저장
+ List imageUrls = blobStorageService.uploadImage(images, posterImageContainer);
+ request.setImages(imageUrls);
+
+ // 매장 정보 호출
+ String userId = getCurrentUserId();
+ StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
- // 생성 조건 정보 구성
- CreationConditions conditions = CreationConditions.builder()
- .category(request.getCategory())
- .requirement(request.getRequirement())
- .eventName(request.getEventName())
- .startDate(request.getStartDate())
- .endDate(request.getEndDate())
- .photoStyle(request.getPhotoStyle())
- .build();
+ // 2. AI 요청
+ String generatedPoster = aiPosterGenerator.generatePoster(request, storeWithMenuData);
return PosterContentCreateResponse.builder()
.contentId(null) // 임시 생성이므로 ID 없음
.contentType(ContentType.POSTER.name())
.title(request.getTitle())
- .posterImage(generatedPoster)
- .posterSizes(new HashMap<>()) // 빈 맵 반환 (사이즈 변환 안함)
+ .content(generatedPoster)
.status(ContentStatus.DRAFT.name())
.build();
}
@@ -68,7 +77,6 @@ public class PosterContentService implements PosterContentUseCase {
*
* @param request 포스터 콘텐츠 저장 요청
*/
- @Override
@Transactional
public void savePosterContent(PosterContentSaveRequest request) {
// 생성 조건 구성
@@ -84,7 +92,7 @@ public class PosterContentService implements PosterContentUseCase {
// 콘텐츠 엔티티 생성
Content content = Content.builder()
.contentType(ContentType.POSTER)
- .platform(Platform.GENERAL)
+ .platform(Platform.POSTER)
.title(request.getTitle())
.content(request.getContent())
.images(request.getImages())
@@ -96,4 +104,11 @@ public class PosterContentService implements PosterContentUseCase {
// 저장
contentRepository.save(content);
}
+
+ /**
+ * 현재 로그인된 사용자 ID 조회
+ */
+ private String getCurrentUserId() {
+ return SecurityContextHolder.getContext().getAuthentication().getName();
+ }
}
\ No newline at end of file
diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java
index 6119226..2aa51d0 100644
--- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java
+++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java
@@ -14,6 +14,7 @@ import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest;
import com.won.smarketing.content.presentation.dto.SnsContentCreateResponse;
import com.won.smarketing.content.presentation.dto.SnsContentSaveRequest;
import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
@@ -34,6 +35,9 @@ public class SnsContentService implements SnsContentUseCase {
private final AiContentGenerator aiContentGenerator;
private final BlobStorageService blobStorageService;
+ @Value("${azure.storage.container.poster-images:content-images}")
+ private String contentImageContainer;
+
/**
* SNS 콘텐츠 생성
*
@@ -44,8 +48,10 @@ public class SnsContentService implements SnsContentUseCase {
@Transactional
public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request, List files) {
//파일들 주소 가져옴
- List urls = blobStorageService.uploadImage(files);
- request.setImages(urls);
+ if(files != null) {
+ List urls = blobStorageService.uploadImage(files, contentImageContainer);
+ request.setImages(urls);
+ }
// AI를 사용하여 SNS 콘텐츠 생성
String content = aiContentGenerator.generateSnsContent(request);
@@ -67,8 +73,6 @@ public class SnsContentService implements SnsContentUseCase {
CreationConditions conditions = CreationConditions.builder()
.category(request.getCategory())
.requirement(request.getRequirement())
- //.toneAndManner(request.getToneAndManner())
- //.emotionIntensity(request.getEmotionIntensity())
.eventName(request.getEventName())
.startDate(request.getStartDate())
.endDate(request.getEndDate())
@@ -76,7 +80,6 @@ public class SnsContentService implements SnsContentUseCase {
// 콘텐츠 엔티티 생성 및 저장
Content content = Content.builder()
-// .contentType(ContentType.SNS_POST)
.platform(Platform.fromString(request.getPlatform()))
.title(request.getTitle())
.content(request.getContent())
diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java
index 6bf2960..77a7496 100644
--- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java
+++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java
@@ -1,9 +1,13 @@
// marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java
package com.won.smarketing.content.application.usecase;
+import com.won.smarketing.content.domain.model.Content;
import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest;
import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse;
import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.List;
/**
* 포스터 콘텐츠 관련 UseCase 인터페이스
@@ -16,7 +20,7 @@ public interface PosterContentUseCase {
* @param request 포스터 콘텐츠 생성 요청
* @return 포스터 콘텐츠 생성 응답
*/
- PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request);
+ PosterContentCreateResponse generatePosterContent(List images, PosterContentCreateRequest request);
/**
* 포스터 콘텐츠 저장
diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/SecurityConfig.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/SecurityConfig.java
new file mode 100644
index 0000000..ca74fc1
--- /dev/null
+++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/SecurityConfig.java
@@ -0,0 +1,88 @@
+package com.won.smarketing.content.config;
+
+import com.won.smarketing.common.security.JwtAuthenticationFilter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+
+import java.util.Arrays;
+
+/**
+ * Spring Security 설정 클래스
+ * JWT 기반 인증 및 CORS 설정
+ */
+@Configuration
+@EnableWebSecurity
+@RequiredArgsConstructor
+public class SecurityConfig
+{
+
+ private final JwtAuthenticationFilter jwtAuthenticationFilter;
+
+ @Value("${allowed-origins}")
+ private String allowedOrigins;
+ /**
+ * Spring Security 필터 체인 설정
+ *
+ * @param http HttpSecurity 객체
+ * @return SecurityFilterChain
+ * @throws Exception 예외
+ */
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+ http
+ .csrf(AbstractHttpConfigurer::disable)
+ .cors(cors -> cors.configurationSource(corsConfigurationSource()))
+ .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers("/api/auth/**", "/api/member/register", "/api/member/check-duplicate/**",
+ "/api/member/validate-password", "/swagger-ui/**", "/v3/api-docs/**",
+ "/swagger-resources/**", "/webjars/**", "/actuator/**", "/health/**", "/error"
+ ).permitAll()
+ .anyRequest().authenticated()
+ )
+ .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
+
+ return http.build();
+ }
+
+ /**
+ * 패스워드 인코더 빈 등록
+ *
+ * @return BCryptPasswordEncoder
+ */
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+
+ /**
+ * CORS 설정
+ *
+ * @return CorsConfigurationSource
+ */
+ @Bean
+ public CorsConfigurationSource corsConfigurationSource() {
+ CorsConfiguration configuration = new CorsConfiguration();
+ configuration.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
+ configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
+ configuration.setAllowedHeaders(Arrays.asList("*"));
+ configuration.setAllowCredentials(true);
+
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", configuration);
+ return source;
+ }
+}
diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/WebClientConfig.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/WebClientConfig.java
index 72e1a78..8fb41fc 100644
--- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/WebClientConfig.java
+++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/WebClientConfig.java
@@ -1,4 +1,3 @@
-// marketing-content/src/main/java/com/won/smarketing/content/config/WebClientConfig.java
package com.won.smarketing.content.config;
import org.springframework.context.annotation.Bean;
@@ -20,8 +19,8 @@ public class WebClientConfig {
@Bean
public WebClient webClient() {
HttpClient httpClient = HttpClient.create()
- .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 50000)
- .responseTimeout(Duration.ofMillis(300000));
+ .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 15000) // 연결 타임아웃: 15초
+ .responseTimeout(Duration.ofMinutes(5)); // 응답 타임아웃: 5분
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java
index 32b4231..1a453ef 100644
--- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java
+++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java
@@ -27,42 +27,37 @@ import java.util.List;
@Builder
public class Content {
- // ==================== 기본키 및 식별자 ====================
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "content_id")
private Long id;
- // ==================== 콘텐츠 분류 ====================
private ContentType contentType;
+
private Platform platform;
- // ==================== 콘텐츠 내용 ====================
private String title;
+
private String content;
- // ==================== 멀티미디어 및 메타데이터 ====================
@Builder.Default
private List hashtags = new ArrayList<>();
@Builder.Default
private List images = new ArrayList<>();
- // ==================== 상태 관리 ====================
private ContentStatus status;
- // ==================== 생성 조건 ====================
private CreationConditions creationConditions;
- // ==================== 매장 정보 ====================
private Long storeId;
- // ==================== 프로모션 기간 ====================
private LocalDateTime promotionStartDate;
+
private LocalDateTime promotionEndDate;
- // ==================== 메타데이터 ====================
private LocalDateTime createdAt;
+
private LocalDateTime updatedAt;
public Content(ContentId of, ContentType contentType, Platform platform, String title, String content, List strings, List strings1, ContentStatus contentStatus, CreationConditions conditions, Long storeId, LocalDateTime createdAt, LocalDateTime updatedAt) {
diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java
index a284c2c..b90959e 100644
--- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java
+++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java
@@ -24,8 +24,6 @@ public class CreationConditions {
private String id;
private String category;
private String requirement;
-// private String toneAndManner;
-// private String emotionIntensity;
private String storeName;
private String storeType;
private String target;
diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java
index 66e266c..aea9446 100644
--- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java
+++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java
@@ -17,7 +17,7 @@ public enum Platform {
FACEBOOK("페이스북"),
KAKAO_STORY("카카오스토리"),
YOUTUBE("유튜브"),
- GENERAL("일반");
+ POSTER("포스터");
private final String displayName;
diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/store/MenuData.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/store/MenuData.java
new file mode 100644
index 0000000..d6597ad
--- /dev/null
+++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/store/MenuData.java
@@ -0,0 +1,21 @@
+package com.won.smarketing.content.domain.model.store;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 메뉴 데이터 값 객체
+ */
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class MenuData {
+ private Long menuId;
+ private String menuName;
+ private String category;
+ private Integer price;
+ private String description;
+}
diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/store/StoreData.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/store/StoreData.java
new file mode 100644
index 0000000..8ae13f4
--- /dev/null
+++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/store/StoreData.java
@@ -0,0 +1,22 @@
+package com.won.smarketing.content.domain.model.store;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 매장 데이터 값 객체
+ */
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class StoreData {
+ private Long storeId;
+ private String storeName;
+ private String businessType;
+ private String location;
+ private String description;
+ private Integer seatCount;
+}
diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/store/StoreWithMenuData.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/store/StoreWithMenuData.java
new file mode 100644
index 0000000..962969b
--- /dev/null
+++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/store/StoreWithMenuData.java
@@ -0,0 +1,13 @@
+package com.won.smarketing.content.domain.model.store;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@Builder
+public class StoreWithMenuData {
+ private StoreData storeData;
+ private List menuDataList;
+}
\ No newline at end of file
diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java
index a689d30..c550b6c 100644
--- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java
+++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java
@@ -1,5 +1,6 @@
package com.won.smarketing.content.domain.service;
+import com.won.smarketing.content.domain.model.store.StoreWithMenuData;
import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest;
import java.util.Map;
@@ -16,5 +17,5 @@ public interface AiPosterGenerator {
* @param request 포스터 생성 요청
* @return 생성된 포스터 이미지 URL
*/
- String generatePoster(PosterContentCreateRequest request);
+ String generatePoster(PosterContentCreateRequest request, StoreWithMenuData storeWithMenuData);
}
diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/BlobStorageService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/BlobStorageService.java
index 76ea929..92a6daf 100644
--- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/BlobStorageService.java
+++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/BlobStorageService.java
@@ -17,7 +17,7 @@ public interface BlobStorageService {
* @param file 업로드할 파일
* @return 업로드된 파일의 URL
*/
- List uploadImage(List file);
+ List uploadImage(List file, String containerName);
/**
diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/BlobStorageServiceImpl.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/BlobStorageServiceImpl.java
index 3bb63f1..c9b9d40 100644
--- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/BlobStorageServiceImpl.java
+++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/BlobStorageServiceImpl.java
@@ -34,12 +34,6 @@ public class BlobStorageServiceImpl implements BlobStorageService {
private final BlobServiceClient blobServiceClient;
- @Value("${azure.storage.container.poster-images:poster-images}")
- private String posterImageContainer;
-
- @Value("${azure.storage.container.content-images:content-images}")
- private String contentImageContainer;
-
@Value("${azure.storage.max-file-size:10485760}") // 10MB
private long maxFileSize;
@@ -60,7 +54,7 @@ public class BlobStorageServiceImpl implements BlobStorageService {
* @return 업로드된 파일의 URL
*/
@Override
- public List uploadImage(List files) {
+ public List uploadImage(List files, String containerName) {
// 파일 유효성 검증
validateImageFile(files);
List urls = new ArrayList<>();
@@ -70,10 +64,10 @@ public class BlobStorageServiceImpl implements BlobStorageService {
for(MultipartFile file : files) {
String fileName = generateMenuImageFileName(file.getOriginalFilename());
- ensureContainerExists(posterImageContainer);
+ ensureContainerExists(containerName);
// Blob 클라이언트 생성
- BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(posterImageContainer);
+ BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(containerName);
BlobClient blobClient = containerClient.getBlobClient(fileName);
// 파일 업로드 (간단한 방식)
@@ -158,12 +152,12 @@ public class BlobStorageServiceImpl implements BlobStorageService {
* @param files 검증할 파일
*/
private void validateImageFile(List files) {
- for (MultipartFile file : files) {
- // 파일 존재 여부 확인
- if (file == null || file.isEmpty()) {
- throw new BusinessException(ErrorCode.FILE_NOT_FOUND);
- }
+ // 파일 존재 여부 확인
+ if (files == null || files.isEmpty()) {
+ throw new BusinessException(ErrorCode.FILE_NOT_FOUND);
+ }
+ for (MultipartFile file : files) {
// 파일 크기 확인
if (file.getSize() > maxFileSize) {
throw new BusinessException(ErrorCode.FILE_SIZE_EXCEEDED);
diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/StoreDataProvider.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/StoreDataProvider.java
new file mode 100644
index 0000000..c28d33a
--- /dev/null
+++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/StoreDataProvider.java
@@ -0,0 +1,11 @@
+package com.won.smarketing.content.domain.service;
+
+import com.won.smarketing.content.domain.model.store.StoreWithMenuData;
+
+/**
+ * 매장 데이터 제공 도메인 서비스 인터페이스
+ */
+public interface StoreDataProvider {
+
+ StoreWithMenuData getStoreWithMenuData(String userId);
+}
diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java
index 1fc2020..9227d85 100644
--- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java
+++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java
@@ -1,5 +1,8 @@
package com.won.smarketing.content.infrastructure.external;
+import com.won.smarketing.content.domain.model.store.MenuData;
+import com.won.smarketing.content.domain.model.store.StoreData;
+import com.won.smarketing.content.domain.model.store.StoreWithMenuData;
import com.won.smarketing.content.domain.service.AiPosterGenerator; // 도메인 인터페이스 import
import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest;
import lombok.RequiredArgsConstructor;
@@ -11,7 +14,9 @@ import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
+import java.util.stream.Collectors;
/**
* Claude AI를 활용한 포스터 생성 구현체
@@ -34,12 +39,12 @@ public class PythonAiPosterGenerator implements AiPosterGenerator {
* @return 생성된 포스터 이미지 URL
*/
@Override
- public String generatePoster(PosterContentCreateRequest request) {
+ public String generatePoster(PosterContentCreateRequest request, StoreWithMenuData storeWithMenuData) {
try {
log.info("Python AI 포스터 서비스 호출: {}/api/ai/poster", aiServiceBaseUrl);
// 요청 데이터 구성
- Map requestBody = buildRequestBody(request);
+ Map requestBody = buildRequestBody(request, storeWithMenuData);
log.debug("포스터 생성 요청 데이터: {}", requestBody);
@@ -51,7 +56,7 @@ public class PythonAiPosterGenerator implements AiPosterGenerator {
.bodyValue(requestBody)
.retrieve()
.bodyToMono(Map.class)
- .timeout(Duration.ofSeconds(60)) // 포스터 생성은 시간이 오래 걸릴 수 있음
+ .timeout(Duration.ofSeconds(90))
.block();
// 응답에서 content(이미지 URL) 추출
@@ -75,9 +80,32 @@ public class PythonAiPosterGenerator implements AiPosterGenerator {
* Python 서비스의 PosterContentGetRequest 모델에 맞춤
* 카테고리,
*/
- private Map buildRequestBody(PosterContentCreateRequest request) {
+ private Map buildRequestBody(PosterContentCreateRequest request, StoreWithMenuData storeWithMenuData) {
Map requestBody = new HashMap<>();
+// TODO : 매장 정보 호출 후 request
+
+// StoreData storeData = storeWithMenuData.getStoreData();
+// List menuDataList = storeWithMenuData.getMenuDataList();
+//
+// List