Merge branch 'main' into dev

This commit is contained in:
SeongRak Oh 2025-06-19 13:11:56 +09:00 committed by GitHub
commit 4440ed8873
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1065 additions and 351 deletions

View File

@ -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,

View File

@ -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

View File

@ -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)

View File

@ -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;
}
}

View File

@ -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"
description: "AI 마케팅 서비스 MVP - recommend"
allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000}

View File

@ -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') {

View File

@ -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
"""
}
}

View 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 # 자체 서명 인증서 사용 비활성화 (외부 인증서 사용 시)

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@

View File

@ -1,7 +1,4 @@
dependencies {
implementation project(':common')
runtimeOnly 'org.postgresql:postgresql'
// WebClient를 Spring WebFlux
implementation 'org.springframework.boot:spring-boot-starter-webflux'
}

View File

@ -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);
// 매장 정보 호출
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();
}
}

View File

@ -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())

View File

@ -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);
/**
* 포스터 콘텐츠 저장

View File

@ -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;
}
}

View File

@ -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))

View File

@ -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) {

View File

@ -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;

View File

@ -17,7 +17,7 @@ public enum Platform {
FACEBOOK("페이스북"),
KAKAO_STORY("카카오스토리"),
YOUTUBE("유튜브"),
GENERAL("일반");
POSTER("포스터");
private final String displayName;

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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);
/**

View File

@ -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);

View File

@ -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);
}

View File

@ -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());

View File

@ -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; }
}
}
}

View File

@ -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,15 +72,22 @@ 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, "포스터 콘텐츠가 성공적으로 생성되었습니다."));
}
/**
* 홍보 포스터 저장
*
*
* @param request 포스터 콘텐츠 저장 요청
* @return 저장 성공 응답
*/

View File

@ -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 = "이벤트")

View File

@ -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;
}

View File

@ -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;

View File

@ -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자 이하로 입력해주세요")

View File

@ -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"
description: "AI 마케팅 서비스 MVP - content"
allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000}

View File

@ -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);

View File

@ -53,4 +53,6 @@ info:
app:
name: ${APP_NAME:smarketing-member}
version: "1.0.0-MVP"
description: "AI 마케팅 서비스 MVP - member"
description: "AI 마케팅 서비스 MVP - member"
allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000}

View File

@ -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'
}

View File

@ -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;
}
}

View File

@ -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 = "따뜻한 분위기의 동네 카페입니다.")

View File

@ -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 = "따뜻한 분위기의 동네 카페입니다.")

View File

@ -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;

View File

@ -68,4 +68,6 @@ info:
app:
name: ${APP_NAME:smarketing-content}
version: "1.0.0-MVP"
description: "AI 마케팅 서비스 MVP - content"
description: "AI 마케팅 서비스 MVP - content"
allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000}