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> menuList = menuDataList.stream() +// .map(menu -> { +// Map menuMap = new HashMap<>(); +// menuMap.put("menu_id", menu.getMenuId()); +// menuMap.put("menu_name", menu.getMenuName()); +// menuMap.put("category", menu.getCategory()); +// menuMap.put("price", menu.getPrice()); +// menuMap.put("description", menu.getDescription()); +// return menuMap; +// }) +// .collect(Collectors.toList()); +// +// requestBody.put("store_name", storeData.getStoreName()); +// requestBody.put("business_type", storeData.getBusinessType()); +// requestBody.put("location", storeData.getLocation()); +// requestBody.put("seat_count", storeData.getSeatCount()); +// requestBody.put("menu_list", menuList); + // 기본 정보 requestBody.put("title", request.getTitle()); requestBody.put("category", request.getCategory()); diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/StoreApiDataProvider.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/StoreApiDataProvider.java new file mode 100644 index 0000000..8480161 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/StoreApiDataProvider.java @@ -0,0 +1,310 @@ +package com.won.smarketing.content.infrastructure.external; + +import com.won.smarketing.common.exception.BusinessException; +import com.won.smarketing.common.exception.ErrorCode; +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.StoreDataProvider; +import jakarta.servlet.http.HttpServletRequest; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientException; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 매장 API 데이터 제공자 구현체 + */ +@Slf4j +@Service // 추가된 어노테이션 +@RequiredArgsConstructor +public class StoreApiDataProvider implements StoreDataProvider { + + private final WebClient webClient; + + @Value("${external.store-service.base-url}") + private String storeServiceBaseUrl; + + @Value("${external.store-service.timeout}") + private int timeout; + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + public StoreWithMenuData getStoreWithMenuData(String userId) { + log.info("매장 정보와 메뉴 정보 통합 조회 시작: userId={}", userId); + + try { + // 매장 정보와 메뉴 정보를 병렬로 조회 + StoreData storeData = getStoreDataByUserId(userId); + List menuDataList = getMenusByStoreId(storeData.getStoreId()); + + StoreWithMenuData result = StoreWithMenuData.builder() + .storeData(storeData) + .menuDataList(menuDataList) + .build(); + + log.info("매장 정보와 메뉴 정보 통합 조회 완료: storeId={}, storeName={}, menuCount={}", + storeData.getStoreId(), storeData.getStoreName(), menuDataList.size()); + + return result; + + } catch (Exception e) { + log.error("매장 정보와 메뉴 정보 통합 조회 실패, Mock 데이터 반환: storeId={}", userId, e); + + // 실패 시 Mock 데이터 반환 + return StoreWithMenuData.builder() + .storeData(createMockStoreData(userId)) + .menuDataList(createMockMenuData(6L)) + .build(); + } + } + + public StoreData getStoreDataByUserId(String userId) { + try { + log.debug("매장 정보 실시간 조회: userId={}", userId); + return callStoreServiceByUserId(userId); + + } catch (Exception e) { + log.error("매장 정보 조회 실패, Mock 데이터 반환: userId={}, error={}", userId, e.getMessage()); + return createMockStoreData(userId); + } + } + + + public List getMenusByStoreId(Long storeId) { + log.info("매장 메뉴 조회 시작: storeId={}", storeId); + + try { + return callMenuService(storeId); + } catch (Exception e) { + log.error("메뉴 조회 실패, Mock 데이터 반환: storeId={}", storeId, e); + return createMockMenuData(storeId); + } + } + + private StoreData callStoreServiceByUserId(String userId) { + + try { + StoreApiResponse response = webClient + .get() + .uri(storeServiceBaseUrl + "/api/store") + .header("Authorization", "Bearer " + getCurrentJwtToken()) // JWT 토큰 추가 + .retrieve() + .bodyToMono(StoreApiResponse.class) + .timeout(Duration.ofMillis(timeout)) + .block(); + + log.info("response : {}", response.getData().getStoreName()); + log.info("response : {}", response.getData().getStoreId()); + + if (response != null && response.getData() != null) { + StoreApiResponse.StoreInfo storeInfo = response.getData(); + return StoreData.builder() + .storeId(storeInfo.getStoreId()) + .storeName(storeInfo.getStoreName()) + .businessType(storeInfo.getBusinessType()) + .location(storeInfo.getAddress()) + .description(storeInfo.getDescription()) + .seatCount(storeInfo.getSeatCount()) + .build(); + } + } catch (WebClientResponseException e) { + if (e.getStatusCode().value() == 404) { + throw new BusinessException(ErrorCode.STORE_NOT_FOUND); + } + log.error("매장 서비스 호출 실패: {}", e.getMessage()); + } + + return createMockStoreData(userId); + } + + private String getCurrentJwtToken() { + try { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + + if (attributes == null) { + log.warn("RequestAttributes를 찾을 수 없음 - HTTP 요청 컨텍스트 없음"); + return null; + } + + HttpServletRequest request = attributes.getRequest(); + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + String token = bearerToken.substring(BEARER_PREFIX.length()); + log.debug("JWT 토큰 추출 성공: {}...", token.substring(0, Math.min(10, token.length()))); + return token; + } else { + log.warn("Authorization 헤더에서 Bearer 토큰을 찾을 수 없음: {}", bearerToken); + return null; + } + + } catch (Exception e) { + log.error("JWT 토큰 추출 중 오류 발생: {}", e.getMessage()); + return null; + } + } + + private List callMenuService(Long storeId) { + try { + MenuApiResponse response = webClient + .get() + .uri(storeServiceBaseUrl + "/api/menu/store/" + storeId) + .retrieve() + .bodyToMono(MenuApiResponse.class) + .timeout(Duration.ofMillis(timeout)) + .block(); + + if (response != null && response.getData() != null && !response.getData().isEmpty()) { + List menuDataList = response.getData().stream() + .map(this::toMenuData) + .collect(Collectors.toList()); + + log.info("매장 메뉴 조회 성공: storeId={}, menuCount={}", storeId, menuDataList.size()); + return menuDataList; + } + } catch (WebClientResponseException e) { + if (e.getStatusCode().value() == 404) { + log.warn("매장의 메뉴 정보가 없습니다: storeId={}", storeId); + return Collections.emptyList(); + } + log.error("메뉴 서비스 호출 실패: storeId={}, error={}", storeId, e.getMessage()); + } catch (WebClientException e) { + log.error("메뉴 서비스 연결 실패: storeId={}, error={}", storeId, e.getMessage()); + } + + return createMockMenuData(storeId); + } + + /** + * MenuResponse를 MenuData로 변환 + */ + private MenuData toMenuData(MenuApiResponse.MenuInfo menuInfo) { + return MenuData.builder() + .menuId(menuInfo.getMenuId()) + .menuName(menuInfo.getMenuName()) + .category(menuInfo.getCategory()) + .price(menuInfo.getPrice()) + .description(menuInfo.getDescription()) + .build(); + } + + private StoreData createMockStoreData(String userId) { + return StoreData.builder() + .storeName("테스트 카페 " + userId) + .businessType("카페") + .location("서울시 강남구") + .build(); + } + + private List createMockMenuData(Long storeId) { + log.info("Mock 메뉴 데이터 생성: storeId={}", storeId); + + return List.of( + MenuData.builder() + .menuId(1L) + .menuName("아메리카노") + .category("음료") + .price(4000) + .description("깊고 진한 맛의 아메리카노") + .build(), + MenuData.builder() + .menuId(2L) + .menuName("카페라떼") + .category("음료") + .price(4500) + .description("부드러운 우유 거품이 올라간 카페라떼") + .build(), + MenuData.builder() + .menuId(3L) + .menuName("치즈케이크") + .category("디저트") + .price(6000) + .description("진한 치즈 맛의 수제 케이크") + + .build() + ); + } + + @Getter + private static class StoreApiResponse { + private int status; + private String message; + private StoreInfo data; + + public int getStatus() { return status; } + public void setStatus(int status) { this.status = status; } + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + public StoreInfo getData() { return data; } + public void setData(StoreInfo data) { this.data = data; } + + @Getter + static class StoreInfo { + private Long storeId; + private String storeName; + private String businessType; + private String address; + private String description; + private Integer seatCount; + } + } + + /** + * Menu API 응답 DTO (새로 추가) + */ + private static class MenuApiResponse { + private List data; + private String message; + private boolean success; + + public List getData() { return data; } + public void setData(List data) { this.data = data; } + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + public boolean isSuccess() { return success; } + public void setSuccess(boolean success) { this.success = success; } + + public static class MenuInfo { + private Long menuId; + private String menuName; + private String category; + private Integer price; + private String description; + private String image; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public Long getMenuId() { return menuId; } + public void setMenuId(Long menuId) { this.menuId = menuId; } + public String getMenuName() { return menuName; } + public void setMenuName(String menuName) { this.menuName = menuName; } + public String getCategory() { return category; } + public void setCategory(String category) { this.category = category; } + public Integer getPrice() { return price; } + public void setPrice(Integer price) { this.price = price; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public String getImage() { return image; } + public void setImage(String image) { this.image = image; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } + } + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java index fd40ffd..bb4bfc6 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java @@ -9,9 +9,10 @@ import com.won.smarketing.content.application.usecase.SnsContentUseCase; import com.won.smarketing.content.presentation.dto.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -26,17 +27,16 @@ import java.util.List; * SNS 콘텐츠 생성, 포스터 생성, 콘텐츠 관리 기능 제공 */ @Tag(name = "마케팅 콘텐츠 관리", description = "AI 기반 마케팅 콘텐츠 생성 및 관리 API") +@Slf4j @RestController @RequestMapping("/api/content") @RequiredArgsConstructor public class ContentController { - @Autowired - private ObjectMapper objectMapper; - private final SnsContentUseCase snsContentUseCase; private final PosterContentUseCase posterContentUseCase; private final ContentQueryUseCase contentQueryUseCase; + private final ObjectMapper objectMapper; /** * SNS 게시물 생성 @@ -46,7 +46,7 @@ public class ContentController { @Operation(summary = "SNS 게시물 생성", description = "AI를 활용하여 SNS 게시물을 생성합니다.") @PostMapping(path = "/sns/generate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> generateSnsContent(@Valid @RequestPart("request") String requestJson, - @Valid @RequestPart("files") List images) throws JsonProcessingException { + @Valid @RequestPart(name = "files", required = false) List images) throws JsonProcessingException { SnsContentCreateRequest request = objectMapper.readValue(requestJson, SnsContentCreateRequest.class); SnsContentCreateResponse response = snsContentUseCase.generateSnsContent(request, images); return ResponseEntity.ok(ApiResponse.success(response, "SNS 콘텐츠가 성공적으로 생성되었습니다.")); @@ -72,15 +72,22 @@ public class ContentController { * @return 생성된 포스터 콘텐츠 정보 */ @Operation(summary = "홍보 포스터 생성", description = "AI를 활용하여 홍보 포스터를 생성합니다.") - @PostMapping("/poster/generate") - public ResponseEntity> generatePosterContent(@Valid @RequestBody PosterContentCreateRequest request) { - PosterContentCreateResponse response = posterContentUseCase.generatePosterContent(request); + @PostMapping(value = "/poster/generate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> generatePosterContent( + @Parameter(content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)) + @RequestPart(value = "images", required = false) List images, + @RequestPart("request") String requestJson) throws JsonProcessingException { + + // JSON 파싱 + PosterContentCreateRequest request = objectMapper.readValue(requestJson, PosterContentCreateRequest.class); + + PosterContentCreateResponse response = posterContentUseCase.generatePosterContent(images, request); return ResponseEntity.ok(ApiResponse.success(response, "포스터 콘텐츠가 성공적으로 생성되었습니다.")); } /** * 홍보 포스터 저장 - * + * * @param request 포스터 콘텐츠 저장 요청 * @return 저장 성공 응답 */ diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java index 1cbf87d..65508ce 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java @@ -50,9 +50,7 @@ public class PosterContentCreateRequest { @Schema(description = "이미지 스타일", example = "모던") private String imageStyle; - @Schema(description = "업로드된 이미지 URL 목록", required = true) - @NotNull(message = "이미지는 1개 이상 필수입니다") - @Size(min = 1, message = "이미지는 1개 이상 업로드해야 합니다") + @Schema(description = "업로드된 이미지 URL 목록") private List images; @Schema(description = "콘텐츠 카테고리", example = "이벤트") diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateResponse.java index 0c02b68..5fa5c53 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateResponse.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateResponse.java @@ -31,19 +31,9 @@ public class PosterContentCreateResponse { @Schema(description = "생성된 포스터 타입") private String contentType; - @Schema(description = "포스터 이미지 URL") - private String posterImage; - - @Schema(description = "원본 이미지 URL 목록") - private List originalImages; - @Schema(description = "이미지 스타일", example = "모던") private String imageStyle; @Schema(description = "생성 상태", example = "DRAFT") private String status; - - @Schema(description = "포스터사이즈", example = "800x600") - private Map posterSizes; - } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java index 9cdf9e1..e05612c 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java @@ -1,8 +1,6 @@ -// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java package com.won.smarketing.content.presentation.dto; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -19,12 +17,7 @@ import java.util.List; @Schema(description = "포스터 콘텐츠 저장 요청") public class PosterContentSaveRequest { -// @Schema(description = "콘텐츠 ID", example = "1", required = true) -// @NotNull(message = "콘텐츠 ID는 필수입니다") -// private Long contentId; - - @Schema(description = "매장 ID", example = "1", required = true) - @NotNull(message = "매장 ID는 필수입니다") + @Schema(description = "매장 ID", example = "1") private Long storeId; @Schema(description = "제목", example = "특별 이벤트 안내") @@ -36,22 +29,12 @@ public class PosterContentSaveRequest { @Schema(description = "선택된 포스터 이미지 URL") private List images; - @Schema(description = "발행 상태", example = "PUBLISHED") - private String status; - - // CreationConditions에 필요한 필드들 @Schema(description = "콘텐츠 카테고리", example = "이벤트") private String category; @Schema(description = "구체적인 요구사항", example = "신메뉴 출시 이벤트 포스터를 만들어주세요") private String requirement; - @Schema(description = "톤앤매너", example = "전문적") - private String toneAndManner; - - @Schema(description = "감정 강도", example = "보통") - private String emotionIntensity; - @Schema(description = "이벤트명", example = "신메뉴 출시 이벤트") private String eventName; diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateRequest.java index f8bcdeb..271d604 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateRequest.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateRequest.java @@ -68,18 +68,6 @@ public class SnsContentCreateRequest { @Schema(description = "콘텐츠 타입", example = "SNS 게시물") private String contentType; -// @Schema(description = "톤앤매너", -// example = "친근함", -// allowableValues = {"친근함", "전문적", "유머러스", "감성적", "트렌디"}) -// private String toneAndManner; - -// @Schema(description = "감정 강도", -// example = "보통", -// allowableValues = {"약함", "보통", "강함"}) -// private String emotionIntensity; - - // ==================== 이벤트 정보 ==================== - @Schema(description = "이벤트명 (이벤트 콘텐츠인 경우)", example = "신메뉴 출시 이벤트") @Size(max = 200, message = "이벤트명은 200자 이하로 입력해주세요") diff --git a/smarketing-java/marketing-content/src/main/resources/application.yml b/smarketing-java/marketing-content/src/main/resources/application.yml index 819d127..bab1983 100644 --- a/smarketing-java/marketing-content/src/main/resources/application.yml +++ b/smarketing-java/marketing-content/src/main/resources/application.yml @@ -37,6 +37,10 @@ logging: external: ai-service: base-url: ${AI_SERVICE_BASE_URL:http://20.249.113.247:5001} + store-service: + base-url: ${STORE_SERVICE_URL:http://smarketing.20.249.184.228.nip.io} + timeout: ${STORE_SERVICE_TIMEOUT:5000} + azure: storage: account-name: ${AZURE_STORAGE_ACCOUNT_NAME:stdigitalgarage02} @@ -67,4 +71,7 @@ info: app: name: ${APP_NAME:smarketing-content} version: "1.0.0-MVP" - description: "AI 마케팅 서비스 MVP - content" \ No newline at end of file + description: "AI 마케팅 서비스 MVP - content" + + +allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000} \ No newline at end of file diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/config/SecurityConfig.java similarity index 88% rename from smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/config/SecurityConfig.java index 7b8f4f2..293caad 100644 --- a/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/config/SecurityConfig.java @@ -1,7 +1,8 @@ -package com.won.smarketing.common.config; +package com.won.smarketing.member.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; @@ -25,10 +26,13 @@ import java.util.Arrays; @Configuration @EnableWebSecurity @RequiredArgsConstructor -public class SecurityConfig { +public class SecurityConfig +{ private final JwtAuthenticationFilter jwtAuthenticationFilter; + @Value("${allowed-origins}") + private String allowedOrigins; /** * Spring Security 필터 체인 설정 * @@ -43,9 +47,10 @@ public class SecurityConfig { .cors(cors -> cors.configurationSource(corsConfigurationSource())) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/auth/**", "/api/member/register", "/api/member/check-duplicate/**", + .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() + "/swagger-resources/**", "/webjars/**", "/actuator/**", "/health/**", "/error" + ).permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); @@ -71,7 +76,7 @@ public class SecurityConfig { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOriginPatterns(Arrays.asList("*")); + configuration.setAllowedOrigins(Arrays.asList(allowedOrigins.split(","))); configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(Arrays.asList("*")); configuration.setAllowCredentials(true); diff --git a/smarketing-java/member/src/main/resources/application.yml b/smarketing-java/member/src/main/resources/application.yml index 92741bc..912bca3 100644 --- a/smarketing-java/member/src/main/resources/application.yml +++ b/smarketing-java/member/src/main/resources/application.yml @@ -53,4 +53,6 @@ info: app: name: ${APP_NAME:smarketing-member} version: "1.0.0-MVP" - description: "AI 마케팅 서비스 MVP - member" \ No newline at end of file + description: "AI 마케팅 서비스 MVP - member" + +allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000} diff --git a/smarketing-java/store/build.gradle b/smarketing-java/store/build.gradle index ef65d80..771a2fc 100644 --- a/smarketing-java/store/build.gradle +++ b/smarketing-java/store/build.gradle @@ -1,10 +1,4 @@ dependencies { implementation project(':common') runtimeOnly 'com.mysql:mysql-connector-j' - - // 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-databind:2.12.3' } \ No newline at end of file diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/config/SecurityConfig.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/config/SecurityConfig.java new file mode 100644 index 0000000..98fdc41 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/config/SecurityConfig.java @@ -0,0 +1,88 @@ +package com.won.smarketing.store.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/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java index ebb72c6..0cdbbf0 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java @@ -46,12 +46,12 @@ public class StoreCreateRequest { @Schema(description = "좌석 수", example = "20") private Integer seatCount; - @Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore") + @Schema(description = "SNS 계정 정보", example = "@mystore") @Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다") private String instaAccounts; @Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다") - @Schema(description = "블로그 계정 정보", example = "블로그: mystore") + @Schema(description = "블로그 계정 정보", example = "mystore") private String blogAccounts; @Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.") diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java index 9c0bce0..8bc2c95 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java @@ -47,10 +47,10 @@ public class StoreResponse { @Schema(description = "좌석 수", example = "20") private Integer seatCount; - @Schema(description = "블로그 계정 정보", example = "블로그: mystore") + @Schema(description = "블로그 계정 정보", example = "mystore") private String blogAccounts; - @Schema(description = "인스타 계정 정보", example = "인스타그램: @mystore") + @Schema(description = "인스타 계정 정보", example = "@mystore") private String instaAccounts; @Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.") diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java index 1d235b7..acb119d 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java @@ -43,11 +43,11 @@ public class StoreUpdateRequest { @Schema(description = "좌석 수", example = "20") private Integer seatCount; - @Schema(description = "인스타 계정 정보", example = "인스타그램: @mystore") + @Schema(description = "인스타 계정 정보", example = "@mystore") @Size(max = 500, message = "인스타 계정 정보는 500자 이하여야 합니다") private String instaAccounts; - @Schema(description = "블로그 계정 정보", example = "블로그: mystore") + @Schema(description = "블로그 계정 정보", example = "mystore") @Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다") private String blogAccounts; diff --git a/smarketing-java/store/src/main/resources/application.yml b/smarketing-java/store/src/main/resources/application.yml index 18a8934..42a2488 100644 --- a/smarketing-java/store/src/main/resources/application.yml +++ b/smarketing-java/store/src/main/resources/application.yml @@ -68,4 +68,6 @@ info: app: name: ${APP_NAME:smarketing-content} version: "1.0.0-MVP" - description: "AI 마케팅 서비스 MVP - content" \ No newline at end of file + description: "AI 마케팅 서비스 MVP - content" + +allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000} \ No newline at end of file