mirror of
https://github.com/won-ktds/smarketing-backend.git
synced 2025-12-06 07:06:24 +00:00
Merge branch 'main' into dev
This commit is contained in:
commit
4440ed8873
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'<br>\s*<br>\s*<br>', '<br><br>', content) # 3개 이상의 연속 <br>을 2개로
|
||||
|
||||
# 2. 네이버 블로그인 경우 이미지 태그를 실제 이미지로 변환
|
||||
elif request.platform == '네이버 블로그' and image_placement_plan:
|
||||
content = self._replace_image_tags_with_html(content, image_placement_plan, request.images)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -71,3 +71,5 @@ info:
|
||||
name: ${APP_NAME:smarketing-recommend}
|
||||
version: "1.0.0-MVP"
|
||||
description: "AI 마케팅 서비스 MVP - recommend"
|
||||
|
||||
allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000}
|
||||
@ -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') {
|
||||
|
||||
104
smarketing-java/deployment/Jenkinsfile
vendored
104
smarketing-java/deployment/Jenkinsfile
vendored
@ -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
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
28
smarketing-java/deployment/argocd.yaml
Normal file
28
smarketing-java/deployment/argocd.yaml
Normal file
@ -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 # 자체 서명 인증서 사용 비활성화 (외부 인증서 사용 시)
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
1
smarketing-java/deployment/member
Normal file
1
smarketing-java/deployment/member
Normal file
@ -0,0 +1 @@
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
dependencies {
|
||||
implementation project(':common')
|
||||
runtimeOnly 'org.postgresql:postgresql'
|
||||
|
||||
// WebClient를 위한 Spring WebFlux 의존성
|
||||
implementation 'org.springframework.boot:spring-boot-starter-webflux'
|
||||
}
|
||||
@ -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<MultipartFile> images, PosterContentCreateRequest request) {
|
||||
|
||||
String generatedPoster = aiPosterGenerator.generatePoster(request);
|
||||
// 1. 이미지 blob storage에 저장하고 request 저장
|
||||
List<String> imageUrls = blobStorageService.uploadImage(images, posterImageContainer);
|
||||
request.setImages(imageUrls);
|
||||
|
||||
// 생성 조건 정보 구성
|
||||
CreationConditions conditions = CreationConditions.builder()
|
||||
.category(request.getCategory())
|
||||
.requirement(request.getRequirement())
|
||||
.eventName(request.getEventName())
|
||||
.startDate(request.getStartDate())
|
||||
.endDate(request.getEndDate())
|
||||
.photoStyle(request.getPhotoStyle())
|
||||
.build();
|
||||
// 매장 정보 호출
|
||||
String userId = getCurrentUserId();
|
||||
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@ -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<MultipartFile> files) {
|
||||
//파일들 주소 가져옴
|
||||
List<String> urls = blobStorageService.uploadImage(files);
|
||||
request.setImages(urls);
|
||||
if(files != null) {
|
||||
List<String> 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())
|
||||
|
||||
@ -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<MultipartFile> images, PosterContentCreateRequest request);
|
||||
|
||||
/**
|
||||
* 포스터 콘텐츠 저장
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
|
||||
@ -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<String> hashtags = new ArrayList<>();
|
||||
|
||||
@Builder.Default
|
||||
private List<String> 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<String> strings, List<String> strings1, ContentStatus contentStatus, CreationConditions conditions, Long storeId, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -17,7 +17,7 @@ public enum Platform {
|
||||
FACEBOOK("페이스북"),
|
||||
KAKAO_STORY("카카오스토리"),
|
||||
YOUTUBE("유튜브"),
|
||||
GENERAL("일반");
|
||||
POSTER("포스터");
|
||||
|
||||
private final String displayName;
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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<MenuData> menuDataList;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ public interface BlobStorageService {
|
||||
* @param file 업로드할 파일
|
||||
* @return 업로드된 파일의 URL
|
||||
*/
|
||||
List<String> uploadImage(List<MultipartFile> file);
|
||||
List<String> uploadImage(List<MultipartFile> file, String containerName);
|
||||
|
||||
|
||||
/**
|
||||
|
||||
@ -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<String> uploadImage(List<MultipartFile> files) {
|
||||
public List<String> uploadImage(List<MultipartFile> files, String containerName) {
|
||||
// 파일 유효성 검증
|
||||
validateImageFile(files);
|
||||
List<String> 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<MultipartFile> 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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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<String, Object> requestBody = buildRequestBody(request);
|
||||
Map<String, Object> 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<String, Object> buildRequestBody(PosterContentCreateRequest request) {
|
||||
private Map<String, Object> buildRequestBody(PosterContentCreateRequest request, StoreWithMenuData storeWithMenuData) {
|
||||
Map<String, Object> requestBody = new HashMap<>();
|
||||
|
||||
// TODO : 매장 정보 호출 후 request
|
||||
|
||||
// StoreData storeData = storeWithMenuData.getStoreData();
|
||||
// List<MenuData> menuDataList = storeWithMenuData.getMenuDataList();
|
||||
//
|
||||
// List<Map<String, Object>> menuList = menuDataList.stream()
|
||||
// .map(menu -> {
|
||||
// Map<String, Object> 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());
|
||||
|
||||
@ -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<MenuData> 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<MenuData> 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<MenuData> 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<MenuData> 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<MenuData> 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<MenuInfo> data;
|
||||
private String message;
|
||||
private boolean success;
|
||||
|
||||
public List<MenuInfo> getData() { return data; }
|
||||
public void setData(List<MenuInfo> 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<ApiResponse<SnsContentCreateResponse>> generateSnsContent(@Valid @RequestPart("request") String requestJson,
|
||||
@Valid @RequestPart("files") List<MultipartFile> images) throws JsonProcessingException {
|
||||
@Valid @RequestPart(name = "files", required = false) List<MultipartFile> images) throws JsonProcessingException {
|
||||
SnsContentCreateRequest request = objectMapper.readValue(requestJson, SnsContentCreateRequest.class);
|
||||
SnsContentCreateResponse response = snsContentUseCase.generateSnsContent(request, images);
|
||||
return ResponseEntity.ok(ApiResponse.success(response, "SNS 콘텐츠가 성공적으로 생성되었습니다."));
|
||||
@ -72,9 +72,16 @@ public class ContentController {
|
||||
* @return 생성된 포스터 콘텐츠 정보
|
||||
*/
|
||||
@Operation(summary = "홍보 포스터 생성", description = "AI를 활용하여 홍보 포스터를 생성합니다.")
|
||||
@PostMapping("/poster/generate")
|
||||
public ResponseEntity<ApiResponse<PosterContentCreateResponse>> generatePosterContent(@Valid @RequestBody PosterContentCreateRequest request) {
|
||||
PosterContentCreateResponse response = posterContentUseCase.generatePosterContent(request);
|
||||
@PostMapping(value = "/poster/generate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<ApiResponse<PosterContentCreateResponse>> generatePosterContent(
|
||||
@Parameter(content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE))
|
||||
@RequestPart(value = "images", required = false) List<MultipartFile> 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, "포스터 콘텐츠가 성공적으로 생성되었습니다."));
|
||||
}
|
||||
|
||||
|
||||
@ -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<String> images;
|
||||
|
||||
@Schema(description = "콘텐츠 카테고리", example = "이벤트")
|
||||
|
||||
@ -31,19 +31,9 @@ public class PosterContentCreateResponse {
|
||||
@Schema(description = "생성된 포스터 타입")
|
||||
private String contentType;
|
||||
|
||||
@Schema(description = "포스터 이미지 URL")
|
||||
private String posterImage;
|
||||
|
||||
@Schema(description = "원본 이미지 URL 목록")
|
||||
private List<String> originalImages;
|
||||
|
||||
@Schema(description = "이미지 스타일", example = "모던")
|
||||
private String imageStyle;
|
||||
|
||||
@Schema(description = "생성 상태", example = "DRAFT")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "포스터사이즈", example = "800x600")
|
||||
private Map<String, String> posterSizes;
|
||||
|
||||
}
|
||||
@ -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<String> 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;
|
||||
|
||||
|
||||
@ -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자 이하로 입력해주세요")
|
||||
|
||||
@ -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}
|
||||
@ -68,3 +72,6 @@ info:
|
||||
name: ${APP_NAME:smarketing-content}
|
||||
version: "1.0.0-MVP"
|
||||
description: "AI 마케팅 서비스 MVP - content"
|
||||
|
||||
|
||||
allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000}
|
||||
@ -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 필터 체인 설정
|
||||
*
|
||||
@ -45,7 +49,8 @@ public class SecurityConfig {
|
||||
.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()
|
||||
"/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);
|
||||
@ -54,3 +54,5 @@ info:
|
||||
name: ${APP_NAME:smarketing-member}
|
||||
version: "1.0.0-MVP"
|
||||
description: "AI 마케팅 서비스 MVP - member"
|
||||
|
||||
allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000}
|
||||
|
||||
@ -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'
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 = "따뜻한 분위기의 동네 카페입니다.")
|
||||
|
||||
@ -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 = "따뜻한 분위기의 동네 카페입니다.")
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -69,3 +69,5 @@ info:
|
||||
name: ${APP_NAME:smarketing-content}
|
||||
version: "1.0.0-MVP"
|
||||
description: "AI 마케팅 서비스 MVP - content"
|
||||
|
||||
allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000}
|
||||
Loading…
x
Reference in New Issue
Block a user