Merge branch 'main' into marketing-contents

This commit is contained in:
박서은 2025-06-17 10:21:53 +09:00
commit 655efae5e3
77 changed files with 3466 additions and 1149 deletions

View File

@ -1,33 +0,0 @@
# 1. Dockerfile에 한글 폰트 추가
FROM python:3.11-slim
WORKDIR /app
# 시스템 패키지 및 한글 폰트 설치
RUN apt-get update && apt-get install -y \
fonts-dejavu-core \
fonts-noto-cjk \
fonts-nanum \
wget \
&& rm -rf /var/lib/apt/lists/*
# 추가 한글 폰트 다운로드 (선택사항)
RUN mkdir -p /app/fonts && \
wget -O /app/fonts/NotoSansKR-Bold.ttf \
"https://fonts.gstatic.com/s/notosanskr/v13/PbykFmXiEBPT4ITbgNA5Cgm20xz64px_1hVWr0wuPNGmlQNMEfD4.ttf"
# Python 의존성 설치
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 애플리케이션 코드 복사
COPY . .
# 업로드 및 포스터 디렉토리 생성
RUN mkdir -p uploads/temp uploads/posters templates/poster_templates
# 포트 노출
EXPOSE 5000
# 애플리케이션 실행
CMD ["python", "app.py"]

View File

@ -0,0 +1,131 @@
"""
마케팅 생성 API 엔드포인트
Java 서비스와 연동되는 API
"""
from flask import Blueprint, request, jsonify
from datetime import datetime
import logging
from services.marketing_tip_service import MarketingTipService
from models.marketing_tip_models import MarketingTipGenerateRequest, MarketingTipResponse
logger = logging.getLogger(__name__)
# Blueprint 생성
marketing_tip_bp = Blueprint('marketing_tip', __name__)
# 서비스 인스턴스
marketing_tip_service = MarketingTipService()
@marketing_tip_bp.route('/api/v1/generate-marketing-tip', methods=['POST'])
def generate_marketing_tip():
"""
AI 마케팅 생성 API
Java 서비스에서 호출하는 엔드포인트
"""
try:
# 요청 데이터 검증
if not request.is_json:
return jsonify({
'tip': '',
'status': 'error',
'message': 'Content-Type이 application/json이어야 합니다.',
'generated_at': '',
'store_name': '',
'business_type': '',
'ai_model': ''
}), 400
data = request.get_json()
if not data:
return jsonify({
'tip': '',
'status': 'error',
'message': '요청 데이터가 없습니다.',
'generated_at': '',
'store_name': '',
'business_type': '',
'ai_model': ''
}), 400
# 필수 필드 검증
if 'store_name' not in data or not data['store_name']:
return jsonify({
'tip': '',
'status': 'error',
'message': '매장명(store_name)은 필수입니다.',
'generated_at': '',
'store_name': '',
'business_type': '',
'ai_model': ''
}), 400
if 'business_type' not in data or not data['business_type']:
return jsonify({
'tip': '',
'status': 'error',
'message': '업종(business_type)은 필수입니다.',
'generated_at': '',
'store_name': '',
'business_type': '',
'ai_model': ''
}), 400
logger.info(f"마케팅 팁 생성 요청: {data.get('store_name', 'Unknown')}")
# 요청 모델 생성
try:
request_model = MarketingTipGenerateRequest(**data)
except ValueError as e:
return jsonify({
'tip': '',
'status': 'error',
'message': f'요청 데이터 형식이 올바르지 않습니다: {str(e)}',
'generated_at': '',
'store_name': data.get('store_name', ''),
'business_type': data.get('business_type', ''),
'ai_model': ''
}), 400
# 매장 정보 구성
store_data = {
'store_name': request_model.store_name,
'business_type': request_model.business_type,
'location': request_model.location or '',
'seat_count': request_model.seat_count or 0
}
# 마케팅 팁 생성
result = marketing_tip_service.generate_marketing_tip(
store_data=store_data,
)
logger.info(f"마케팅 팁 생성 완료: {result.get('store_name', 'Unknown')}")
return jsonify(result), 200
except Exception as e:
logger.error(f"마케팅 팁 생성 API 오류: {str(e)}")
return jsonify({
'tip': '죄송합니다. 일시적인 오류로 마케팅 팁을 생성할 수 없습니다. 잠시 후 다시 시도해주세요.',
'status': 'error',
'message': f'서버 오류가 발생했습니다: {str(e)}',
'generated_at': '',
'store_name': data.get('store_name', '') if 'data' in locals() else '',
'business_type': data.get('business_type', '') if 'data' in locals() else '',
'ai_model': 'error'
}), 500
@marketing_tip_bp.route('/api/v1/health', methods=['GET'])
def health_check():
"""
헬스체크 API
"""
return jsonify({
'status': 'healthy',
'service': 'marketing-tip-api',
'timestamp': datetime.now().isoformat()
}), 200

View File

@ -9,11 +9,10 @@ import os
from datetime import datetime from datetime import datetime
import traceback import traceback
from config.config import Config from config.config import Config
from services.poster_service import PosterService
from services.sns_content_service import SnsContentService from services.sns_content_service import SnsContentService
from services.poster_service import PosterService
from models.request_models import ContentRequest, PosterRequest, SnsContentGetRequest, PosterContentGetRequest from models.request_models import ContentRequest, PosterRequest, SnsContentGetRequest, PosterContentGetRequest
from services.poster_service_v3 import PosterServiceV3 from api.marketing_tip_api import marketing_tip_bp
def create_app(): def create_app():
"""Flask 애플리케이션 팩토리""" """Flask 애플리케이션 팩토리"""
@ -30,9 +29,11 @@ def create_app():
# 서비스 인스턴스 생성 # 서비스 인스턴스 생성
poster_service = PosterService() poster_service = PosterService()
poster_service_v3 = PosterServiceV3()
sns_content_service = SnsContentService() sns_content_service = SnsContentService()
# Blueprint 등록
app.register_blueprint(marketing_tip_bp)
@app.route('/health', methods=['GET']) @app.route('/health', methods=['GET'])
def health_check(): def health_check():
"""헬스 체크 API""" """헬스 체크 API"""
@ -152,12 +153,11 @@ def create_app():
) )
# 포스터 생성 (V3 사용) # 포스터 생성 (V3 사용)
result = poster_service_v3.generate_poster(poster_request) result = poster_service.generate_poster(poster_request)
if result['success']: if result['success']:
return jsonify({ return jsonify({
'content': result['content'], 'content': result['content'],
'analysis': result.get('analysis', {})
}) })
else: else:
return jsonify({'error': result['error']}), 500 return jsonify({'error': result['error']}), 500
@ -301,4 +301,7 @@ def create_app():
if __name__ == '__main__': if __name__ == '__main__':
app = create_app() app = create_app()
app.run(host='0.0.0.0', port=5001, debug=True) host = os.getenv('SERVER_HOST', '0.0.0.0')
port = int(os.getenv('SERVER_PORT', '5001'))
app.run(host=host, port=port, debug=True)

View File

@ -12,14 +12,23 @@ class Config:
"""애플리케이션 설정 클래스""" """애플리케이션 설정 클래스"""
# Flask 기본 설정 # Flask 기본 설정
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production' SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
# 파일 업로드 설정 # 파일 업로드 설정
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER') or 'uploads' UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER') or 'uploads'
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH') or 16 * 1024 * 1024) # 16MB MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH') or 16 * 1024 * 1536) # 16MB
# AI API 설정 # AI API 설정
CLAUDE_API_KEY = os.environ.get('CLAUDE_API_KEY') CLAUDE_API_KEY = os.environ.get('CLAUDE_API_KEY')
OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY') OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY')
# Azure Blob Storage 설정
AZURE_STORAGE_ACCOUNT_NAME = os.environ.get('AZURE_STORAGE_ACCOUNT_NAME') or 'stdigitalgarage02'
AZURE_STORAGE_ACCOUNT_KEY = os.environ.get('AZURE_STORAGE_ACCOUNT_KEY')
AZURE_STORAGE_CONTAINER_NAME = os.environ.get('AZURE_STORAGE_CONTAINER_NAME') or 'ai-content'
# 지원되는 파일 확장자 # 지원되는 파일 확장자
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
# 템플릿 설정 # 템플릿 설정
POSTER_TEMPLATE_PATH = 'templates/poster_templates' POSTER_TEMPLATE_PATH = 'templates/poster_templates'

View File

@ -0,0 +1,15 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 애플리케이션 코드 복사
COPY . .
# 포트 노출
EXPOSE 5001
# 애플리케이션 실행
CMD ["python", "app.py"]

View File

@ -0,0 +1,11 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: smarketing-config
namespace: smarketing
data:
SERVER_HOST: "0.0.0.0"
SERVER_PORT: "5001"
UPLOAD_FOLDER: "/app/uploads"
MAX_CONTENT_LENGTH: "16777216" # 16MB
ALLOWED_EXTENSIONS: "png,jpg,jpeg,gif,webp"

View File

@ -0,0 +1,47 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: smarketing
namespace: smarketing
labels:
app: smarketing
spec:
replicas: 1
selector:
matchLabels:
app: smarketing
template:
metadata:
labels:
app: smarketing
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: smarketing
image: acrdigitalgarage02.azurecr.io/smarketing-ai:latest
imagePullPolicy: Always
ports:
- containerPort: 5001
resources:
requests:
cpu: 256m
memory: 512Mi
limits:
cpu: 1024m
memory: 2048Mi
envFrom:
- configMapRef:
name: smarketing-config
- secretRef:
name: smarketing-secret
volumeMounts:
- name: upload-storage
mountPath: /app/uploads
- name: temp-storage
mountPath: /app/uploads/temp
volumes:
- name: upload-storage
emptyDir: {}
- name: temp-storage
emptyDir: {}

View File

@ -0,0 +1,26 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: smarketing-ingress
namespace: smarketing
annotations:
kubernetes.io/ingress.class: nginx
nginx.ingress.kubernetes.io/proxy-body-size: "16m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, PUT, DELETE, OPTIONS"
nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
nginx.ingress.kubernetes.io/cors-allow-origin: "*"
nginx.ingress.kubernetes.io/enable-cors: "true"
spec:
rules:
- host: smarketing.20.249.184.228.nip.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: smarketing-service
port:
number: 80

View File

@ -0,0 +1,10 @@
apiVersion: v1
kind: Secret
metadata:
name: smarketing-secret
namespace: smarketing
type: Opaque
stringData:
SECRET_KEY: "your-secret-key-change-in-production"
CLAUDE_API_KEY: "your-claude-api-key"
OPENAI_API_KEY: "your-openai-api-key"

View File

@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: smarketing-service
namespace: smarketing
labels:
app: smarketing
spec:
type: LoadBalancer
ports:
- port: 5001
targetPort: 5001
protocol: TCP
name: http
selector:
app: smarketing

View File

@ -0,0 +1,93 @@
"""
마케팅 API 요청/응답 모델
"""
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any, List
from datetime import datetime
class MenuInfo(BaseModel):
"""메뉴 정보 모델"""
menu_id: int = Field(..., description="메뉴 ID")
menu_name: str = Field(..., description="메뉴명")
category: str = Field(..., description="메뉴 카테고리")
price: int = Field(..., description="가격")
description: Optional[str] = Field(None, description="메뉴 설명")
class Config:
schema_extra = {
"example": {
"store_name": "더블샷 카페",
"business_type": "카페",
"location": "서울시 강남구 역삼동",
"seat_count": 30,
"menu_list": [
{
"menu_id": 1,
"menu_name": "아메리카노",
"category": "음료",
"price": 4000,
"description": "깊고 진한 맛의 아메리카노"
},
{
"menu_id": 2,
"menu_name": "카페라떼",
"category": "음료",
"price": 4500,
"description": "부드러운 우유 거품이 올라간 카페라떼"
},
{
"menu_id": 3,
"menu_name": "치즈케이크",
"category": "디저트",
"price": 6000,
"description": "진한 치즈 맛의 수제 케이크"
}
],
"additional_requirement": "젊은 고객층을 타겟으로 한 마케팅"
}
}
class MarketingTipGenerateRequest(BaseModel):
"""마케팅 팁 생성 요청 모델"""
store_name: str = Field(..., description="매장명")
business_type: str = Field(..., description="업종")
location: Optional[str] = Field(None, description="위치")
seat_count: Optional[int] = Field(None, description="좌석 수")
menu_list: Optional[List[MenuInfo]] = Field(default=[], description="메뉴 목록")
class Config:
schema_extra = {
"example": {
"store_name": "더블샷 카페",
"business_type": "카페",
"location": "서울시 강남구 역삼동",
"seat_count": 30,
}
}
class MarketingTipResponse(BaseModel):
"""마케팅 팁 응답 모델"""
tip: str = Field(..., description="생성된 마케팅 팁")
status: str = Field(..., description="응답 상태 (success, fallback, error)")
message: str = Field(..., description="응답 메시지")
generated_at: str = Field(..., description="생성 시간")
store_name: str = Field(..., description="매장명")
business_type: str = Field(..., description="업종")
ai_model: str = Field(..., description="사용된 AI 모델")
class Config:
schema_extra = {
"example": {
"tip": "☕ 더블샷 카페 여름 마케팅 전략\n\n💡 핵심 포인트:\n1. 여름 한정 시원한 음료 개발\n2. SNS 이벤트로 젊은 고객층 공략\n3. 더위 피할 수 있는 쾌적한 환경 어필",
"status": "success",
"message": "AI 마케팅 팁이 성공적으로 생성되었습니다.",
"generated_at": "2024-06-13T15:30:00",
"store_name": "더블샷 카페",
"business_type": "카페",
"ai_model": "claude"
}
}

View File

@ -7,6 +7,7 @@ from typing import List, Optional
from datetime import date from datetime import date
@dataclass @dataclass
class SnsContentGetRequest: class SnsContentGetRequest:
"""SNS 게시물 생성 요청 모델""" """SNS 게시물 생성 요청 모델"""

View File

@ -6,3 +6,4 @@ anthropic>=0.25.0
openai>=1.12.0 openai>=1.12.0
python-dotenv==1.0.0 python-dotenv==1.0.0
Werkzeug==3.0.1 Werkzeug==3.0.1
azure-storage-blob>=12.19.0

View File

@ -0,0 +1,331 @@
"""
마케팅 생성 서비스
Java 서비스에서 요청받은 매장 정보를 기반으로 AI 마케팅 팁을 생성
"""
import os
import logging
from typing import Dict, Any, Optional
import anthropic
import openai
from datetime import datetime
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class MarketingTipService:
"""마케팅 팁 생성 서비스 클래스"""
def __init__(self):
"""서비스 초기화"""
self.claude_api_key = os.getenv('CLAUDE_API_KEY')
self.openai_api_key = os.getenv('OPENAI_API_KEY')
# Claude 클라이언트 초기화
if self.claude_api_key:
self.claude_client = anthropic.Anthropic(api_key=self.claude_api_key)
else:
self.claude_client = None
logger.warning("Claude API 키가 설정되지 않았습니다.")
# OpenAI 클라이언트 초기화
if self.openai_api_key:
self.openai_client = openai.OpenAI(api_key=self.openai_api_key)
else:
self.openai_client = None
logger.warning("OpenAI API 키가 설정되지 않았습니다.")
def generate_marketing_tip(self, store_data: Dict[str, Any], additional_requirement: Optional[str] = None) -> Dict[str, Any]:
"""
매장 정보를 기반으로 AI 마케팅 생성
Args:
store_data: 매장 정보 (store_name, business_type, location )
Returns:
생성된 마케팅 팁과 메타데이터
"""
try:
logger.info(f"마케팅 팁 생성 시작: {store_data.get('store_name', 'Unknown')}")
# 1. 프롬프트 생성
prompt = self._create_marketing_prompt(store_data, additional_requirement)
# 2. AI 서비스 호출 (Claude 우선, 실패 시 OpenAI)
tip_content = self._call_ai_service(prompt)
# 3. 응답 데이터 구성
response = {
'tip': tip_content,
'status': 'success',
'message': 'AI 마케팅 팁이 성공적으로 생성되었습니다.',
'generated_at': datetime.now().isoformat(),
'store_name': store_data.get('store_name', ''),
'business_type': store_data.get('business_type', ''),
'ai_model': 'claude' if self.claude_client else 'openai'
}
logger.info(f"마케팅 팁 생성 완료: {store_data.get('store_name', 'Unknown')}")
logger.info(f"마케팅 팁 생성 완료: {response}")
return response
except Exception as e:
logger.error(f"마케팅 팁 생성 실패: {str(e)}")
# 실패 시 Fallback 팁 반환
fallback_tip = self._create_fallback_tip(store_data, additional_requirement)
return {
'tip': fallback_tip,
'status': 'fallback',
'message': 'AI 서비스 호출 실패로 기본 팁을 제공합니다.',
'generated_at': datetime.now().isoformat(),
'store_name': store_data.get('store_name', ''),
'business_type': store_data.get('business_type', ''),
'ai_model': 'fallback'
}
def _create_marketing_prompt(self, store_data: Dict[str, Any], additional_requirement: Optional[str]) -> str:
"""마케팅 팁 생성을 위한 프롬프트 생성"""
store_name = store_data.get('store_name', '매장')
business_type = store_data.get('business_type', '소상공인')
location = store_data.get('location', '')
seat_count = store_data.get('seat_count', 0)
menu_list = store_data.get('menu_list', [])
prompt = f"""
당신은 소상공인 마케팅 전문가입니다.
현재 유행하고 성공한 마케팅 예시를 검색하여 확인 , 참고하여 아래 내용을 작성해주세요.
당신의 임무는 매장 정보를 바탕으로, 적은 비용으로 효과를 있는 현실적이고 실행 가능한 마케팅 팁을 제안하는 것입니다.
지역성, 지역의 현재 날씨 확인하고, 현재 트렌드까지 고려해주세요.
소상공인을 위한 실용적인 마케팅 팁을 생성해주세요.
매장 정보:
- 매장명: {store_name}
- 업종: {business_type}
- 위치: {location}
- 좌석 : {seat_count}
"""
# 🔥 메뉴 정보 추가
if menu_list and len(menu_list) > 0:
prompt += f"\n메뉴 정보:\n"
for menu in menu_list:
menu_name = menu.get('menu_name', '')
category = menu.get('category', '')
price = menu.get('price', 0)
description = menu.get('description', '')
prompt += f"- {menu_name} ({category}): {price:,}원 - {description}\n"
prompt += """
아래 조건을 모두 충족하는 마케팅 팁을 하나 생성해주세요:
1. **실행 가능성**: 소상공인이 실제로 적용할 있는 현실적인 방법
2. **비용 효율성**: 적은 비용으로 높은 효과를 기대할 있는 전략
3. **구체성**: 실행 단계가 명확하고 구체적일
4. **시의성**: 현재 계절, 유행, 트렌드를 반영
5. **지역성**: 지역 특성 현재 날씨를 고려할
응답 형식 (300 내외, 간결하게):
html 형식으로 출력
핵심 마케팅 팁은 제목없이 한번 상단에 보여주세요
부제목과 내용은 분리해서 출력
아래의 부제목 앞에는 이모지 포함
- 핵심 마케팅 (1)
- 실행 방법 (1)
- 예상 비용과 기대 효과
- 주의사항 또는 유의점
- 참고했던 실제 성공한 마케팅
- 오늘의 응원의 문장 (간결하게 1)
심호흡하고, 단계별로 차근차근 생각해서 정확하고 실현 가능한 아이디어를 제시해주세요.
"""
return prompt
def _call_ai_service(self, prompt: str) -> str:
"""AI 서비스 호출"""
# Claude API 우선 시도
if self.claude_client:
try:
response = self.claude_client.messages.create(
model="claude-3-sonnet-20240229",
max_tokens=1000,
temperature=0.7,
messages=[
{
"role": "user",
"content": prompt
}
]
)
if response.content and len(response.content) > 0:
logger.info(f"마케팅 팁 생성 완료: {response.content}")
return response.content[0].text.strip()
except Exception as e:
logger.warning(f"Claude API 호출 실패: {str(e)}")
# OpenAI API 시도
if self.openai_client:
try:
response = self.openai_client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[
{
"role": "system",
"content": "당신은 소상공인을 위한 마케팅 전문가입니다. 실용적이고 구체적인 마케팅 조언을 제공해주세요."
},
{
"role": "user",
"content": prompt
}
],
max_tokens=800,
temperature=0.7
)
if response.choices and len(response.choices) > 0:
return response.choices[0].message.content.strip()
except Exception as e:
logger.warning(f"OpenAI API 호출 실패: {str(e)}")
# 모든 AI 서비스 호출 실패
raise Exception("모든 AI 서비스 호출에 실패했습니다.")
def _create_fallback_tip(self, store_data: Dict[str, Any], additional_requirement: Optional[str]) -> str:
"""AI 서비스 실패 시 규칙 기반 Fallback 팁 생성"""
store_name = store_data.get('store_name', '매장')
business_type = store_data.get('business_type', '')
location = store_data.get('location', '')
menu_list = store_data.get('menu_list', [])
if menu_list and len(menu_list) > 0:
# 가장 비싼 메뉴 찾기 (시그니처 메뉴로 가정)
expensive_menu = max(menu_list, key=lambda x: x.get('price', 0), default=None)
# 카테고리별 메뉴 분석
categories = {}
for menu in menu_list:
category = menu.get('category', '기타')
if category not in categories:
categories[category] = []
categories[category].append(menu)
main_category = max(categories.keys(), key=lambda x: len(categories[x])) if categories else '메뉴'
if expensive_menu:
signature_menu = expensive_menu.get('menu_name', '시그니처 메뉴')
return f"""🎯 {store_name} 메뉴 기반 마케팅 전략
💡 핵심 전략:
- SNS를 활용한 홍보 강화
- 고객 리뷰 관리 적극 활용
- 지역 커뮤니티 참여로 인지도 향상
📱 실행 방법:
1. 인스타그램/네이버 블로그 정기 포스팅
2. 고객 만족도 조사 피드백 반영
3. 주변 상권과의 협력 이벤트 기획
💰 예상 효과: 매출 10-15% 증가 가능
주의사항: 꾸준한 실행과 고객 소통이 핵심"""
# 업종별 기본 팁
if '카페' in business_type or '커피' in business_type:
return f"""{store_name} 카페 마케팅 전략
💡 핵심 포인트:
1. 시그니처 음료 개발 SNS 홍보
2. 계절별 한정 메뉴로 재방문 유도
3. 인스타그램 포토존 설치
📱 실행 방법:
- 매주 신메뉴 또는 이벤트 인스타 포스팅
- 고객 사진 리포스트로 참여 유도
- 해시태그 #근처카페 #데이트코스 활용
💰 비용: 5-10만원 내외
📈 기대효과: 젊은 고객층 20% 증가"""
elif '음식점' in business_type or '식당' in business_type:
return f"""🍽️ {store_name} 음식점 마케팅 전략
💡 핵심 포인트:
1. 대표 메뉴 스토리텔링
2. 배달앱 리뷰 관리 강화
3. 단골 고객 혜택 프로그램
📱 실행 방법:
- 요리 과정 영상으로 신뢰도 구축
- 리뷰 적극 답변으로 고객 관리
- 방문 횟수별 할인 혜택 제공
💰 비용: 3-7만원 내외
📈 기대효과: 재방문율 25% 향상"""
elif '베이커리' in business_type or '빵집' in business_type:
return f"""🍞 {store_name} 베이커리 마케팅 전략
💡 핵심 포인트:
1. 구운 타이밍 알림 서비스
2. 계절 한정 출시
3. 포장 디자인으로 선물용 어필
📱 실행 방법:
- 네이버 톡톡으로 완성 시간 안내
- 명절/기념일 특별 한정 판매
- 예쁜 포장지로 브랜딩 강화
💰 비용: 5-8만원 내외
📈 기대효과: 단골 고객 30% 증가"""
# 지역별 특성 고려
if location:
location_tip = ""
if '강남' in location or '서초' in location:
location_tip = "\n🏢 강남권 특화: 직장인 대상 점심 세트메뉴 강화"
elif '홍대' in location or '신촌' in location:
location_tip = "\n🎓 대학가 특화: 학생 할인 및 그룹 이벤트 진행"
elif '강북' in location or '노원' in location:
location_tip = "\n🏘️ 주거지역 특화: 가족 단위 고객 대상 패키지 상품"
return f"""🎯 {store_name} 지역 맞춤 마케팅
💡 기본 전략:
- 온라인 리뷰 관리 강화
- 단골 고객 혜택 프로그램
- 지역 커뮤니티 참여{location_tip}
📱 실행 방법:
1. 구글/네이버 지도 정보 최신화
2. 동네 맘카페 홍보 참여
3. 주변 상권과 상생 이벤트
💰 비용: 3-5만원
📈 기대효과: 인지도 매출 향상"""
# 기본 범용 팁
return f"""🎯 {store_name} 기본 마케팅 전략
💡 핵심 3가지:
1. 온라인 존재감 강화 (SNS, 리뷰 관리)
2. 고객 소통 피드백 활용
3. 차별화된 서비스 제공
📱 실행 방법:
- 네이버 플레이스, 구글 정보 최신화
- 고객 불만 신속 해결로 신뢰 구축
- 작은 이벤트라도 꾸준히 진행
💰 비용: 거의 무료 (시간 투자 위주)
📈 기대효과: 꾸준한 성장과 단골 확보
핵심은 지속성입니다!"""

View File

@ -1,23 +1,32 @@
""" """
포스터 생성 서비스 포스터 생성 서비스 V3
OpenAI사용한 이미지 생성 (한글 프롬프트) OpenAI DALL-E사용한 이미지 생성 (메인 메뉴 이미지 1 + 프롬프트 예시 링크 10)
""" """
import os import os
from typing import Dict, Any from typing import Dict, Any, List
from datetime import datetime
from utils.ai_client import AIClient from utils.ai_client import AIClient
from utils.image_processor import ImageProcessor from utils.image_processor import ImageProcessor
from models.request_models import PosterContentGetRequest from models.request_models import PosterContentGetRequest
class PosterService: class PosterService:
"""포스터 생성 서비스 클래스"""
def __init__(self): def __init__(self):
"""서비스 초기화""" """서비스 초기화"""
self.ai_client = AIClient() self.ai_client = AIClient()
self.image_processor = ImageProcessor() self.image_processor = ImageProcessor()
# Azure Blob Storage 예시 이미지 링크 10개 (카페 음료 관련)
self.example_images = [
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example1.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example2.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example3.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example4.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example5.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example6.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example7.png"
]
# 포토 스타일별 프롬프트 # 포토 스타일별 프롬프트
self.photo_styles = { self.photo_styles = {
'미니멀': '미니멀하고 깔끔한 디자인, 단순함, 여백 활용', '미니멀': '미니멀하고 깔끔한 디자인, 단순함, 여백 활용',
@ -32,9 +41,7 @@ class PosterService:
self.category_styles = { self.category_styles = {
'음식': '음식 사진, 먹음직스러운, 맛있어 보이는', '음식': '음식 사진, 먹음직스러운, 맛있어 보이는',
'매장': '레스토랑 인테리어, 아늑한 분위기', '매장': '레스토랑 인테리어, 아늑한 분위기',
'이벤트': '홍보용 디자인, 눈길을 끄는', '이벤트': '홍보용 디자인, 눈길을 끄는'
'메뉴': '메뉴 디자인, 정리된 레이아웃',
'할인': '세일 포스터, 할인 디자인'
} }
# 톤앤매너별 디자인 스타일 # 톤앤매너별 디자인 스타일
@ -54,21 +61,27 @@ class PosterService:
def generate_poster(self, request: PosterContentGetRequest) -> Dict[str, Any]: def generate_poster(self, request: PosterContentGetRequest) -> Dict[str, Any]:
""" """
포스터 생성 (OpenAI 이미지 URL 반환) 포스터 생성 (메인 이미지 1 분석 + 예시 링크 7 프롬프트 제공)
""" """
try: try:
# 참조 이미지 분석 (있는 경우) # 메인 이미지 확인
image_analysis = self._analyze_reference_images(request.images) if not request.images:
return {'success': False, 'error': '메인 메뉴 이미지가 제공되지 않았습니다.'}
# 포스터 생성 프롬프트 생성 main_image_url = request.images[0] # 첫 번째 이미지가 메인 메뉴
prompt = self._create_poster_prompt(request, image_analysis)
# 메인 이미지 분석
main_image_analysis = self._analyze_main_image(main_image_url)
# 포스터 생성 프롬프트 생성 (예시 링크 10개 포함)
prompt = self._create_poster_prompt_v3(request, main_image_analysis)
# OpenAI로 이미지 생성 # OpenAI로 이미지 생성
image_url = self.ai_client.generate_image_with_openai(prompt, "1024x1024") image_url = self.ai_client.generate_image_with_openai(prompt, "1024x1536")
return { return {
'success': True, 'success': True,
'content': image_url 'content': image_url,
} }
except Exception as e: except Exception as e:
@ -77,117 +90,113 @@ class PosterService:
'error': str(e) 'error': str(e)
} }
def _analyze_reference_images(self, image_urls: list) -> Dict[str, Any]: def _analyze_main_image(self, image_url: str) -> Dict[str, Any]:
""" """
참조 이미지들 분석 메인 메뉴 이미지 분석
""" """
if not image_urls:
return {'total_images': 0, 'results': []}
analysis_results = []
temp_files = [] temp_files = []
try: try:
for image_url in image_urls: # 이미지 다운로드
# 이미지 다운로드 temp_path = self.ai_client.download_image_from_url(image_url)
temp_path = self.ai_client.download_image_from_url(image_url) if temp_path:
if temp_path: temp_files.append(temp_path)
temp_files.append(temp_path)
try: # 이미지 분석
# 이미지 분석 image_info = self.image_processor.get_image_info(temp_path)
image_description = self.ai_client.analyze_image(temp_path) image_description = self.ai_client.analyze_image(temp_path)
# 색상 분석 colors = self.image_processor.analyze_colors(temp_path, 5)
colors = self.image_processor.analyze_colors(temp_path, 3)
analysis_results.append({ return {
'url': image_url, 'url': image_url,
'description': image_description, 'info': image_info,
'dominant_colors': colors 'description': image_description,
}) 'dominant_colors': colors,
except Exception as e: 'is_food': self.image_processor.is_food_image(temp_path)
analysis_results.append({ }
'url': image_url, else:
'error': str(e) return {
}) 'url': image_url,
'error': '이미지 다운로드 실패'
}
except Exception as e:
return { return {
'total_images': len(image_urls), 'url': image_url,
'results': analysis_results 'error': str(e)
} }
finally: def _create_poster_prompt_v3(self, request: PosterContentGetRequest,
# 임시 파일 정리 main_analysis: Dict[str, Any]) -> str:
for temp_file in temp_files:
try:
os.remove(temp_file)
except:
pass
def _create_poster_prompt(self, request: PosterContentGetRequest, image_analysis: Dict[str, Any]) -> str:
""" """
포스터 생성을 위한 AI 프롬프트 생성 (한글) 포스터 생성을 위한 AI 프롬프트 생성 (한글, 글자 완전 제외, 메인 이미지 기반 + 예시 링크 7 포함)
""" """
# 기본 스타일 설정
photo_style = self.photo_styles.get(request.photoStyle, '현대적이고 깔끔한 디자인')
category_style = self.category_styles.get(request.category, '홍보용 디자인')
tone_style = self.tone_styles.get(request.toneAndManner, '친근하고 따뜻한 느낌')
emotion_design = self.emotion_designs.get(request.emotionIntensity, '적당히 활기찬 디자인')
# 참조 이미지 설명 # 메인 이미지 정보 활용
reference_descriptions = [] main_description = main_analysis.get('description', '맛있는 음식')
for result in image_analysis.get('results', []): main_colors = main_analysis.get('dominant_colors', [])
if 'description' in result: image_info = main_analysis.get('info', {})
reference_descriptions.append(result['description'])
# 색상 정보 # 이미지 크기 및 비율 정보
color_info = "" aspect_ratio = image_info.get('aspect_ratio', 1.0) if image_info else 1.0
if image_analysis.get('results'): image_orientation = "가로형" if aspect_ratio > 1.2 else "세로형" if aspect_ratio < 0.8 else "정사각형"
colors = image_analysis['results'][0].get('dominant_colors', [])
if colors: # 색상 정보를 텍스트로 변환
color_info = f"참조 색상 팔레트: {colors[:3]}을 활용한 조화로운 색감" color_description = ""
if main_colors:
color_rgb = main_colors[:3] # 상위 3개 색상
color_description = f"주요 색상 RGB 값: {color_rgb}를 기반으로 한 조화로운 색감"
# 예시 이미지 링크들을 문자열로 변환
example_links = "\n".join([f"- {link}" for link in self.example_images])
prompt = f""" prompt = f"""
한국의 음식점/카페를 위한 전문적인 홍보 포스터를 디자인해주세요. ## 카페 홍보 포스터 디자인 요청
**메인 콘텐츠:** ### 📋 기본 정보
- 제목: "{request.title}" 카테고리: {request.category}
- 카테고리: {request.category} 콘텐츠 타입: {request.contentType}
- 콘텐츠 타입: {request.contentType} 메뉴명: {request.menuName or '없음'}
메뉴 정보: {main_description}
**디자인 스타일 요구사항:** ### 📅 이벤트 기간
- 포토 스타일: {photo_style} 시작일: {request.startDate or '지금'}
- 카테고리 스타일: {category_style} 종료일: {request.endDate or '한정 기간'}
- 톤앤매너: {tone_style} 이벤트 시작일과 종료일은 필수로 포스터에 명시해주세요.
- 감정 강도: {emotion_design}
**메뉴 정보:** ### 🎨 디자인 요구사항
- 메뉴명: {request.menuName or '없음'} 메인 이미지 처리
- 기존 메인 이미지는 변경하지 않고 그대로 유지
- 포스터 전체 크기의 1/3 이하로 배치
- 이미지와 조화로운 작은 장식 이미지 추가
- 크기: {image_orientation}
**이벤트 정보:** 텍스트 요소
- 이벤트명: {request.eventName or '특별 프로모션'} - 메뉴명 (필수)
- 시작일: {request.startDate or '지금'} - 간단한 추가 홍보 문구 (새로 생성, 한글) 혹은 "{request.requirement or '눈길을 끄는 전문적인 디자인'}"라는 요구사항에 맞는 문구
- 종료일: {request.endDate or '한정 기간'} - 메뉴명 추가되는 문구는 1줄만 작성
**특별 요구사항:**
{request.requirement or '눈길을 끄는 전문적인 디자인'}
**참조 이미지 설명:** 텍스트 배치 규칙
{chr(10).join(reference_descriptions) if reference_descriptions else '참조 이미지 없음'} - 글자가 이미지 경계를 벗어나지 않도록 주의
- 모서리에 너무 가깝게 배치하지
- 적당한 크기로 가독성 확보
- 아기자기한 한글 폰트 사용
{color_info} ### 🎨 디자인 스타일
참조 이미지
{example_links} URL을 참고하여 비슷한 스타일로 제작
**디자인 가이드라인:** 색상 가이드
- 한국 음식점/카페에 적합한 깔끔하고 현대적인 레이아웃 {color_description}
- 한글 텍스트 요소를 자연스럽게 포함 전체적인 디자인 방향
- 가독성이 좋은 전문적인 타이포그래피
- 명확한 대비로 읽기 쉽게 구성 타겟: 한국 카페 고객층
- 소셜미디어 공유에 적합한 크기 스타일: 화려하고 매력적인 디자인
- 저작권이 없는 오리지널 디자인 목적: 소셜미디어 공유용 (적합한 크기)
- 음식점에 어울리는 맛있어 보이는 색상 조합 톤앤매너: 맛있어 보이는 색상, 방문 유도하는 비주얼
- 고객의 시선을 끄는 매력적인 비주얼
### 🎯 최종 목표
고객들이 "이 카페에 가보고 싶다!"라고 생각하게 만드는 시각적으로 매력적인 홍보 포스터 제작
"""
고객들이 음식점을 방문하고 싶게 만드는 시각적으로 매력적인 포스터를 만들어주세요.
텍스트는 한글로, 전체적인 분위기는 한국적 감성에 맞게 디자인해주세요.
"""
return prompt return prompt

View File

@ -1,382 +0,0 @@
"""
하이브리드 포스터 생성 서비스
DALL-E: 텍스트 없는 아름다운 배경 생성
PIL: 완벽한 한글 텍스트 오버레이
"""
import os
from typing import Dict, Any
from datetime import datetime
from PIL import Image, ImageDraw, ImageFont, ImageEnhance
import requests
import io
from utils.ai_client import AIClient
from utils.image_processor import ImageProcessor
from models.request_models import PosterContentGetRequest
class PosterServiceV2:
"""하이브리드 포스터 생성 서비스"""
def __init__(self):
"""서비스 초기화"""
self.ai_client = AIClient()
self.image_processor = ImageProcessor()
def generate_poster(self, request: PosterContentGetRequest) -> Dict[str, Any]:
"""
하이브리드 포스터 생성
1. DALL-E로 텍스트 없는 배경 생성
2. PIL로 완벽한 한글 텍스트 오버레이
"""
try:
# 1. 참조 이미지 분석
image_analysis = self._analyze_reference_images(request.images)
# 2. DALL-E로 텍스트 없는 배경 생성
background_prompt = self._create_background_only_prompt(request, image_analysis)
background_url = self.ai_client.generate_image_with_openai(background_prompt, "1024x1024")
# 3. 배경 이미지 다운로드
background_image = self._download_and_load_image(background_url)
# 4. AI로 텍스트 컨텐츠 생성
text_content = self._generate_text_content(request)
# 5. PIL로 한글 텍스트 오버레이
final_poster = self._add_perfect_korean_text(background_image, text_content, request)
# 6. 최종 이미지 저장
poster_url = self._save_final_poster(final_poster)
return {
'success': True,
'content': poster_url
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
def _create_background_only_prompt(self, request: PosterContentGetRequest, image_analysis: Dict[str, Any]) -> str:
"""텍스트 완전 제외 배경 전용 프롬프트"""
# 참조 이미지 설명
reference_descriptions = []
for result in image_analysis.get('results', []):
if 'description' in result:
reference_descriptions.append(result['description'])
prompt = f"""
Create a beautiful text-free background design for a Korean restaurant promotional poster.
ABSOLUTE REQUIREMENTS:
- NO TEXT, NO LETTERS, NO WORDS, NO CHARACTERS of any kind
- Pure visual background design only
- Professional Korean food business aesthetic
- Leave clear areas for text overlay (top 20% and bottom 30%)
DESIGN STYLE:
- Category: {request.category} themed design
- Photo Style: {request.photoStyle or 'modern'} aesthetic
- Mood: {request.toneAndManner or 'friendly'} atmosphere
- Intensity: {request.emotionIntensity or 'medium'} visual impact
VISUAL ELEMENTS TO INCLUDE:
- Korean traditional patterns or modern geometric designs
- Food-related visual elements (ingredients, cooking utensils, abstract food shapes)
- Warm, appetizing color palette
- Professional restaurant branding feel
- Clean, modern layout structure
REFERENCE CONTEXT:
{chr(10).join(reference_descriptions) if reference_descriptions else 'Clean, professional food business design'}
COMPOSITION:
- Central visual focus area
- Clear top section for main title
- Clear bottom section for details
- Balanced negative space
- High-end restaurant poster aesthetic
STRICTLY AVOID:
- Any form of text (Korean, English, numbers, symbols)
- Menu boards or signs with text
- Price displays
- Written content of any kind
- Typography elements
Create a premium, appetizing background that will make customers want to visit the restaurant.
Focus on visual appeal, color harmony, and professional food business branding.
"""
return prompt
def _download_and_load_image(self, image_url: str) -> Image.Image:
"""이미지 URL에서 PIL 이미지로 로드"""
response = requests.get(image_url, timeout=30)
response.raise_for_status()
return Image.open(io.BytesIO(response.content))
def _generate_text_content(self, request: PosterContentGetRequest) -> Dict[str, str]:
"""AI로 포스터 텍스트 컨텐츠 생성"""
prompt = f"""
한국 음식점 홍보 포스터용 텍스트를 생성해주세요.
포스터 정보:
- 제목: {request.title}
- 카테고리: {request.category}
- 메뉴명: {request.menuName or ''}
- 이벤트명: {request.eventName or ''}
- 시작일: {request.startDate or ''}
- 종료일: {request.endDate or ''}
다음 형식으로만 답변해주세요:
메인제목: [임팩트 있는 제목 8 이내]
서브제목: [설명 문구 15 이내]
기간정보: [기간 표시]
액션문구: [행동유도 8 이내]
"""
try:
ai_response = self.ai_client.generate_text(prompt, max_tokens=150)
return self._parse_text_content(ai_response, request)
except:
return self._create_fallback_content(request)
def _parse_text_content(self, ai_response: str, request: PosterContentGetRequest) -> Dict[str, str]:
"""AI 응답 파싱"""
content = {
'main_title': request.title[:8],
'sub_title': '',
'period_info': '',
'action_text': '지금 확인!'
}
lines = ai_response.split('\n')
for line in lines:
line = line.strip()
if '메인제목:' in line:
content['main_title'] = line.split('메인제목:')[1].strip()
elif '서브제목:' in line:
content['sub_title'] = line.split('서브제목:')[1].strip()
elif '기간정보:' in line:
content['period_info'] = line.split('기간정보:')[1].strip()
elif '액션문구:' in line:
content['action_text'] = line.split('액션문구:')[1].strip()
return content
def _create_fallback_content(self, request: PosterContentGetRequest) -> Dict[str, str]:
"""AI 실패시 기본 컨텐츠"""
return {
'main_title': request.title[:8] if request.title else '특별 이벤트',
'sub_title': request.eventName or request.menuName or '맛있는 음식',
'period_info': f"{request.startDate} ~ {request.endDate}" if request.startDate and request.endDate else '',
'action_text': '지금 방문!'
}
def _add_perfect_korean_text(self, background: Image.Image, content: Dict[str, str], request: PosterContentGetRequest) -> Image.Image:
"""완벽한 한글 텍스트 오버레이"""
# 배경 이미지 복사
poster = background.copy()
draw = ImageDraw.Draw(poster)
width, height = poster.size
# 한글 폰트 로드 (여러 경로 시도)
fonts = self._load_korean_fonts()
# 텍스트 색상 결정 (배경 분석 기반)
text_color = self._determine_text_color(background)
shadow_color = (0, 0, 0) if text_color == (255, 255, 255) else (255, 255, 255)
# 1. 메인 제목 (상단)
if content['main_title']:
self._draw_text_with_effects(
draw, content['main_title'],
fonts['title'], text_color, shadow_color,
width // 2, height * 0.15, 'center'
)
# 2. 서브 제목
if content['sub_title']:
self._draw_text_with_effects(
draw, content['sub_title'],
fonts['subtitle'], text_color, shadow_color,
width // 2, height * 0.75, 'center'
)
# 3. 기간 정보
if content['period_info']:
self._draw_text_with_effects(
draw, content['period_info'],
fonts['small'], text_color, shadow_color,
width // 2, height * 0.82, 'center'
)
# 4. 액션 문구 (강조 배경)
if content['action_text']:
self._draw_call_to_action(
draw, content['action_text'],
fonts['subtitle'], width, height
)
return poster
def _load_korean_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]:
"""한글 폰트 로드 (여러 경로 시도)"""
font_paths = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/System/Library/Fonts/Arial.ttf", # macOS
"C:/Windows/Fonts/arial.ttf", # Windows
"/usr/share/fonts/TTF/arial.ttf" # Linux
]
fonts = {}
for font_path in font_paths:
try:
fonts['title'] = ImageFont.truetype(font_path, 60)
fonts['subtitle'] = ImageFont.truetype(font_path, 32)
fonts['small'] = ImageFont.truetype(font_path, 24)
break
except:
continue
# 폰트 로드 실패시 기본 폰트
if not fonts:
fonts = {
'title': ImageFont.load_default(),
'subtitle': ImageFont.load_default(),
'small': ImageFont.load_default()
}
return fonts
def _determine_text_color(self, image: Image.Image) -> tuple:
"""배경 이미지 분석하여 텍스트 색상 결정"""
# 이미지 상단과 하단의 평균 밝기 계산
top_region = image.crop((0, 0, image.width, image.height // 4))
bottom_region = image.crop((0, image.height * 3 // 4, image.width, image.height))
def get_brightness(img_region):
grayscale = img_region.convert('L')
pixels = list(grayscale.getdata())
return sum(pixels) / len(pixels)
top_brightness = get_brightness(top_region)
bottom_brightness = get_brightness(bottom_region)
avg_brightness = (top_brightness + bottom_brightness) / 2
# 밝으면 검은색, 어두우면 흰색 텍스트
return (50, 50, 50) if avg_brightness > 128 else (255, 255, 255)
def _draw_text_with_effects(self, draw, text, font, color, shadow_color, x, y, align='center'):
"""그림자 효과가 있는 텍스트 그리기"""
if not text:
return
# 텍스트 크기 계산
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# 위치 조정
if align == 'center':
x = x - text_width // 2
# 배경 박스 (가독성 향상)
padding = 10
box_coords = [
x - padding, y - padding,
x + text_width + padding, y + text_height + padding
]
draw.rectangle(box_coords, fill=(0, 0, 0, 180))
# 그림자 효과
shadow_offset = 2
draw.text((x + shadow_offset, y + shadow_offset), text, fill=shadow_color, font=font)
# 메인 텍스트
draw.text((x, y), text, fill=color, font=font)
def _draw_call_to_action(self, draw, text, font, width, height):
"""강조된 액션 버튼 스타일 텍스트"""
if not text:
return
# 버튼 위치 (하단 중앙)
button_y = height * 0.88
# 텍스트 크기
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# 버튼 배경
button_width = text_width + 40
button_height = text_height + 20
button_x = (width - button_width) // 2
# 버튼 그리기
button_coords = [
button_x, button_y - 10,
button_x + button_width, button_y + button_height
]
draw.rounded_rectangle(button_coords, radius=25, fill=(255, 107, 107))
# 텍스트 그리기
text_x = (width - text_width) // 2
text_y = button_y + 5
draw.text((text_x, text_y), text, fill=(255, 255, 255), font=font)
def _save_final_poster(self, poster: Image.Image) -> str:
"""최종 포스터 저장"""
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"hybrid_poster_{timestamp}.png"
filepath = os.path.join('uploads', 'temp', filename)
os.makedirs(os.path.dirname(filepath), exist_ok=True)
poster.save(filepath, 'PNG', quality=95)
return f"http://localhost:5001/uploads/temp/{filename}"
def _analyze_reference_images(self, image_urls: list) -> Dict[str, Any]:
"""참조 이미지 분석 (기존 코드와 동일)"""
if not image_urls:
return {'total_images': 0, 'results': []}
analysis_results = []
temp_files = []
try:
for image_url in image_urls:
temp_path = self.ai_client.download_image_from_url(image_url)
if temp_path:
temp_files.append(temp_path)
try:
image_description = self.ai_client.analyze_image(temp_path)
colors = self.image_processor.analyze_colors(temp_path, 3)
analysis_results.append({
'url': image_url,
'description': image_description,
'dominant_colors': colors
})
except Exception as e:
analysis_results.append({
'url': image_url,
'error': str(e)
})
return {
'total_images': len(image_urls),
'results': analysis_results
}
finally:
for temp_file in temp_files:
try:
os.remove(temp_file)
except:
pass

View File

@ -1,204 +0,0 @@
"""
포스터 생성 서비스 V3
OpenAI DALL-E를 사용한 이미지 생성 (메인 메뉴 이미지 1 + 프롬프트 예시 링크 10)
"""
import os
from typing import Dict, Any, List
from datetime import datetime
from utils.ai_client import AIClient
from utils.image_processor import ImageProcessor
from models.request_models import PosterContentGetRequest
class PosterServiceV3:
"""포스터 생성 서비스 V3 클래스"""
def __init__(self):
"""서비스 초기화"""
self.ai_client = AIClient()
self.image_processor = ImageProcessor()
# Azure Blob Storage 예시 이미지 링크 10개 (카페 음료 관련)
self.example_images = [
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example1.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example2.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example3.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example4.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example5.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example6.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example7.png"
]
# 포토 스타일별 프롬프트
self.photo_styles = {
'미니멀': '미니멀하고 깔끔한 디자인, 단순함, 여백 활용',
'모던': '현대적이고 세련된 디자인, 깔끔한 레이아웃',
'빈티지': '빈티지 느낌, 레트로 스타일, 클래식한 색감',
'컬러풀': '다채로운 색상, 밝고 생동감 있는 컬러',
'우아한': '우아하고 고급스러운 느낌, 세련된 분위기',
'캐주얼': '친근하고 편안한 느낌, 접근하기 쉬운 디자인'
}
# 카테고리별 이미지 스타일
self.category_styles = {
'음식': '음식 사진, 먹음직스러운, 맛있어 보이는',
'매장': '레스토랑 인테리어, 아늑한 분위기',
'이벤트': '홍보용 디자인, 눈길을 끄는',
'메뉴': '메뉴 디자인, 정리된 레이아웃',
'할인': '세일 포스터, 할인 디자인',
'음료': '시원하고 상쾌한, 맛있어 보이는 음료'
}
# 톤앤매너별 디자인 스타일
self.tone_styles = {
'친근한': '따뜻하고 친근한 색감, 부드러운 느낌',
'정중한': '격식 있고 신뢰감 있는 디자인',
'재미있는': '밝고 유쾌한 분위기, 활기찬 색상',
'전문적인': '전문적이고 신뢰할 수 있는 디자인'
}
# 감정 강도별 디자인
self.emotion_designs = {
'약함': '은은하고 차분한 색감, 절제된 표현',
'보통': '적당히 활기찬 색상, 균형잡힌 디자인',
'강함': '강렬하고 임팩트 있는 색상, 역동적인 디자인'
}
def generate_poster(self, request: PosterContentGetRequest) -> Dict[str, Any]:
"""
포스터 생성 (메인 이미지 1 분석 + 예시 링크 10 프롬프트 제공)
"""
try:
# 메인 이미지 확인
if not request.images:
return {'success': False, 'error': '메인 메뉴 이미지가 제공되지 않았습니다.'}
main_image_url = request.images[0] # 첫 번째 이미지가 메인 메뉴
# 메인 이미지 분석
main_image_analysis = self._analyze_main_image(main_image_url)
# 포스터 생성 프롬프트 생성 (예시 링크 10개 포함)
prompt = self._create_poster_prompt_v3(request, main_image_analysis)
# OpenAI로 이미지 생성
image_url = self.ai_client.generate_image_with_openai(prompt, "1024x1024")
return {
'success': True,
'content': image_url,
'analysis': {
'main_image': main_image_analysis,
'example_images_used': len(self.example_images)
}
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
def _analyze_main_image(self, image_url: str) -> Dict[str, Any]:
"""
메인 메뉴 이미지 분석
"""
temp_files = []
try:
# 이미지 다운로드
temp_path = self.ai_client.download_image_from_url(image_url)
if temp_path:
temp_files.append(temp_path)
# 이미지 분석
image_info = self.image_processor.get_image_info(temp_path)
image_description = self.ai_client.analyze_image(temp_path)
colors = self.image_processor.analyze_colors(temp_path, 5)
return {
'url': image_url,
'info': image_info,
'description': image_description,
'dominant_colors': colors,
'is_food': self.image_processor.is_food_image(temp_path)
}
else:
return {
'url': image_url,
'error': '이미지 다운로드 실패'
}
except Exception as e:
return {
'url': image_url,
'error': str(e)
}
finally:
# 임시 파일 정리
for temp_file in temp_files:
try:
os.remove(temp_file)
except:
pass
def _create_poster_prompt_v3(self, request: PosterContentGetRequest,
main_analysis: Dict[str, Any]) -> str:
"""
V3 포스터 생성을 위한 AI 프롬프트 생성 (한글, 글자 완전 제외, 메인 이미지 기반 + 예시 링크 10 포함)
"""
# 기본 스타일 설정
photo_style = self.photo_styles.get(request.photoStyle, '현대적이고 깔끔한 디자인')
category_style = self.category_styles.get(request.category, '홍보용 디자인')
tone_style = self.tone_styles.get(request.toneAndManner, '친근하고 따뜻한 느낌')
emotion_design = self.emotion_designs.get(request.emotionIntensity, '적당히 활기찬 디자인')
# 메인 이미지 정보 활용
main_description = main_analysis.get('description', '맛있는 음식')
main_colors = main_analysis.get('dominant_colors', [])
main_image_url = main_analysis.get('url', '')
image_info = main_analysis.get('info', {})
is_food = main_analysis.get('is_food', False)
# 이미지 크기 및 비율 정보
aspect_ratio = image_info.get('aspect_ratio', 1.0) if image_info else 1.0
image_orientation = "가로형" if aspect_ratio > 1.2 else "세로형" if aspect_ratio < 0.8 else "정사각형"
# 색상 정보를 텍스트로 변환
color_description = ""
if main_colors:
color_rgb = main_colors[:3] # 상위 3개 색상
color_description = f"주요 색상 RGB 값: {color_rgb}를 기반으로 한 조화로운 색감"
# 예시 이미지 링크들을 문자열로 변환
example_links = "\n".join([f"- {link}" for link in self.example_images])
prompt = f"""
메인 이미지 URL을 참조하여, "글이 없는" 심플한 카페 포스터를 디자인해주세요.
**핵심 기준 이미지:**
메인 이미지 URL: {main_image_url}
이미지 URL에 들어가 이미지를 다운로드 , 이미지를 그대로 반영한 홍보 포스터를 디자인해주세요.
심플한 배경이 중요합니다.
AI가 생성하지 않은 것처럼 현실적인 요소를 반영해주세요.
**절대 필수 조건:**
- 어떤 형태의 텍스트, 글자, 문자, 숫자도 절대 포함하지 !!!! - 가장 중요
- 위의 메인 이미지를 임의 변경 없이, 포스터의 중심 요소로 포함할
- 하나의 포스터만 생성해주세요
- 메인 이미지의 색감과 분위기를 살려서 심플한 포스터 디자인
- 메인 이미지가 돋보이도록 배경과 레이아웃 구성
- 확실하지도 않은 문자 절대 생성 x
**특별 요구사항:**
{request.requirement}
**반드시 제외할 요소:**
- 모든 형태의 텍스트 (한글, 영어, 숫자, 기호)
- 메뉴판, 가격표, 간판
- 글자가 적힌 모든 요소
- 브랜드 로고나 문자
"""
return prompt

42
smarketing-ai/test.py Normal file
View File

@ -0,0 +1,42 @@
"""
마케팅 API 테스트 스크립트
"""
import requests
import json
def test_marketing_tip_api():
"""마케팅 팁 API 테스트"""
# 테스트 데이터
test_data = {
"store_name": "더블샷 카페",
"business_type": "카페",
"location": "서울시 강남구 역삼동",
"seat_count": 30,
}
# API 호출
url = "http://localhost:5001/api/v1/generate-marketing-tip"
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer dummy-key"
}
try:
response = requests.post(url, json=test_data, headers=headers)
print(f"Status Code: {response.status_code}")
print(f"Response: {json.dumps(response.json(), ensure_ascii=False, indent=2)}")
if response.status_code == 200:
print("✅ 테스트 성공!")
else:
print("❌ 테스트 실패!")
except Exception as e:
print(f"❌ 테스트 오류: {str(e)}")
if __name__ == "__main__":
test_marketing_tip_api()

View File

@ -10,6 +10,7 @@ import anthropic
import openai import openai
from PIL import Image from PIL import Image
import io import io
from utils.blob_storage import BlobStorageClient
class AIClient: class AIClient:
@ -20,6 +21,9 @@ class AIClient:
self.claude_api_key = os.getenv('CLAUDE_API_KEY') self.claude_api_key = os.getenv('CLAUDE_API_KEY')
self.openai_api_key = os.getenv('OPENAI_API_KEY') self.openai_api_key = os.getenv('OPENAI_API_KEY')
# Blob Storage 클라이언트 초기화
self.blob_client = BlobStorageClient()
# Claude 클라이언트 초기화 # Claude 클라이언트 초기화
if self.claude_api_key: if self.claude_api_key:
self.claude_client = anthropic.Anthropic(api_key=self.claude_api_key) self.claude_client = anthropic.Anthropic(api_key=self.claude_api_key)
@ -64,33 +68,38 @@ class AIClient:
print(f"이미지 다운로드 실패 {image_url}: {e}") print(f"이미지 다운로드 실패 {image_url}: {e}")
return None return None
def generate_image_with_openai(self, prompt: str, size: str = "1024x1024") -> str: def generate_image_with_openai(self, prompt: str, size: str = "1024x1536") -> str:
""" """
OpenAI DALL-E사용하여 이미지 생성 gpt사용하여 이미지 생성
Args: Args:
prompt: 이미지 생성 프롬프트 prompt: 이미지 생성 프롬프트
size: 이미지 크기 (1024x1024, 1792x1024, 1024x1792) size: 이미지 크기 (1024x1536)
Returns: Returns:
생성이미지 URL Azure Blob Storage에 저장이미지 URL
""" """
try: try:
if not self.openai_client: if not self.openai_client:
raise Exception("OpenAI API 키가 설정되지 않았습니다.") raise Exception("OpenAI API 키가 설정되지 않았습니다.")
response = self.openai_client.images.generate( response = self.openai_client.images.generate(
model="dall-e-3", model="gpt-image-1",
prompt=prompt, prompt=prompt,
size="1024x1024", size=size,
quality="hd", # 고품질 설정
style="vivid", # 또는 "natural"
n=1, n=1,
) )
return response.data[0].url # base64 이미지 데이터 추출
b64_data = response.data[0].b64_json
image_data = base64.b64decode(b64_data)
# Azure Blob Storage에 업로드
blob_url = self.blob_client.upload_image(image_data, 'png')
print(f"✅ 이미지 생성 및 업로드 완료: {blob_url}")
return blob_url
except Exception as e: except Exception as e:
print(f"OpenAI 이미지 생성 실패: {e}") raise Exception(f"이미지 생성 실패: {str(e)}")
raise Exception(f"이미지 생성 중 오류가 발생했습니다: {str(e)}")
def generate_text(self, prompt: str, max_tokens: int = 1000) -> str: def generate_text(self, prompt: str, max_tokens: int = 1000) -> str:
""" """

View File

@ -0,0 +1,117 @@
"""
Azure Blob Storage 유틸리티
이미지 업로드 URL 생성 기능 제공
"""
import os
from datetime import datetime
from typing import Optional
from azure.storage.blob import BlobServiceClient, ContentSettings
from config.config import Config
class BlobStorageClient:
"""Azure Blob Storage 클라이언트 클래스"""
def __init__(self):
"""Blob Storage 클라이언트 초기화"""
self.account_name = Config.AZURE_STORAGE_ACCOUNT_NAME
self.account_key = Config.AZURE_STORAGE_ACCOUNT_KEY
self.container_name = Config.AZURE_STORAGE_CONTAINER_NAME
if not self.account_key:
raise ValueError("Azure Storage Account Key가 설정되지 않았습니다.")
# Connection String 생성
connection_string = f"DefaultEndpointsProtocol=https;AccountName={self.account_name};AccountKey={self.account_key};EndpointSuffix=core.windows.net"
# Blob Service Client 초기화
self.blob_service_client = BlobServiceClient.from_connection_string(connection_string)
def upload_image(self, image_data: bytes, file_extension: str = 'png') -> str:
"""
이미지를 Blob Storage에 업로드
Args:
image_data: 업로드할 이미지 바이트 데이터
file_extension: 파일 확장자 (기본값: 'png')
Returns:
업로드된 이미지의 Public URL
"""
try:
# 파일명 생성: poster_YYYYMMDDHHMMSS.png
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
blob_name = f"poster_{timestamp}.{file_extension}"
# Content Type 설정
content_settings = ContentSettings(content_type=f'image/{file_extension}')
# Blob 업로드
blob_client = self.blob_service_client.get_blob_client(
container=self.container_name,
blob=blob_name
)
blob_client.upload_blob(
image_data,
content_settings=content_settings,
overwrite=True
)
# Public URL 생성
blob_url = f"https://{self.account_name}.blob.core.windows.net/{self.container_name}/{blob_name}"
print(f"✅ 이미지 업로드 완료: {blob_url}")
return blob_url
except Exception as e:
print(f"❌ Blob Storage 업로드 실패: {str(e)}")
raise Exception(f"이미지 업로드 실패: {str(e)}")
def upload_file(self, file_path: str) -> str:
"""
로컬 파일을 Blob Storage에 업로드
Args:
file_path: 업로드할 로컬 파일 경로
Returns:
업로드된 파일의 Public URL
"""
try:
# 파일 확장자 추출
file_extension = os.path.splitext(file_path)[1][1:].lower()
# 파일 읽기
with open(file_path, 'rb') as file:
file_data = file.read()
# 업로드
return self.upload_image(file_data, file_extension)
except Exception as e:
print(f"❌ 파일 업로드 실패: {str(e)}")
raise Exception(f"파일 업로드 실패: {str(e)}")
def delete_blob(self, blob_name: str) -> bool:
"""
Blob 삭제
Args:
blob_name: 삭제할 Blob 이름
Returns:
삭제 성공 여부
"""
try:
blob_client = self.blob_service_client.get_blob_client(
container=self.container_name,
blob=blob_name
)
blob_client.delete_blob()
print(f"✅ Blob 삭제 완료: {blob_name}")
return True
except Exception as e:
print(f"❌ Blob 삭제 실패: {str(e)}")
return False

View File

@ -4,23 +4,26 @@ import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode; import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.recommend.application.usecase.MarketingTipUseCase; import com.won.smarketing.recommend.application.usecase.MarketingTipUseCase;
import com.won.smarketing.recommend.domain.model.MarketingTip; import com.won.smarketing.recommend.domain.model.MarketingTip;
import com.won.smarketing.recommend.domain.model.MenuData;
import com.won.smarketing.recommend.domain.model.StoreData; import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
import com.won.smarketing.recommend.domain.repository.MarketingTipRepository; import com.won.smarketing.recommend.domain.repository.MarketingTipRepository;
import com.won.smarketing.recommend.domain.service.StoreDataProvider;
import com.won.smarketing.recommend.domain.service.AiTipGenerator; import com.won.smarketing.recommend.domain.service.AiTipGenerator;
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest; import com.won.smarketing.recommend.domain.service.StoreDataProvider;
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse; import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** import java.time.LocalDateTime;
* 마케팅 서비스 구현체 import java.util.List;
*/ import java.util.Optional;
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@ -32,70 +35,134 @@ public class MarketingTipService implements MarketingTipUseCase {
private final AiTipGenerator aiTipGenerator; private final AiTipGenerator aiTipGenerator;
@Override @Override
public MarketingTipResponse generateMarketingTips(MarketingTipRequest request) { public MarketingTipResponse provideMarketingTip() {
log.info("마케팅 팁 생성 시작: storeId={}", request.getStoreId()); String userId = getCurrentUserId();
log.info("마케팅 팁 제공: userId={}", userId);
try { try {
// 1. 매장 정보 조회 // 1. 사용자의 매장 정보 조회
StoreData storeData = storeDataProvider.getStoreData(request.getStoreId()); StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
log.debug("매장 정보 조회 완료: {}", storeData.getStoreName());
// 2. Python AI 서비스로 생성 (매장 정보 + 추가 요청사항 전달) // 2. 1시간 이내에 생성된 마케팅 팁이 있는지 DB에서 확인
String aiGeneratedTip = aiTipGenerator.generateTip(storeData, request.getAdditionalRequirement()); Optional<MarketingTip> recentTip = findRecentMarketingTip(storeWithMenuData.getStoreData().getStoreId());
log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length())));
// 3. 도메인 객체 생성 저장 if (recentTip.isPresent()) {
MarketingTip marketingTip = MarketingTip.builder() log.info("1시간 이내에 생성된 마케팅 팁 발견: tipId={}", recentTip.get().getId().getValue());
.storeId(request.getStoreId()) log.info("1시간 이내에 생성된 마케팅 팁 발견: getTipContent()={}", recentTip.get().getTipContent());
.tipContent(aiGeneratedTip) return convertToResponse(recentTip.get(), storeWithMenuData.getStoreData(), true);
.storeData(storeData) }
.build();
MarketingTip savedTip = marketingTipRepository.save(marketingTip); // 3. 1시간 이내 팁이 없으면 새로 생성
log.info("마케팅 팁 저장 완료: tipId={}", savedTip.getId().getValue()); log.info("1시간 이내 마케팅 팁이 없어 새로 생성합니다: userId={}, storeId={}", userId, storeWithMenuData.getStoreData().getStoreId());
MarketingTip newTip = createNewMarketingTip(storeWithMenuData);
return convertToResponse(savedTip); return convertToResponse(newTip, storeWithMenuData.getStoreData(), false);
} catch (Exception e) { } catch (Exception e) {
log.error("마케팅 팁 생성 중 오류: storeId={}", request.getStoreId(), e); log.error("마케팅 팁 조회/생성 중 오류: userId={}", userId, e);
throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR); throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR);
} }
} }
@Override /**
@Transactional(readOnly = true) * DB에서 1시간 이내 생성된 마케팅 조회
@Cacheable(value = "marketingTipHistory", key = "#storeId + '_' + #pageable.pageNumber + '_' + #pageable.pageSize") */
public Page<MarketingTipResponse> getMarketingTipHistory(Long storeId, Pageable pageable) { private Optional<MarketingTip> findRecentMarketingTip(Long storeId) {
log.info("마케팅 팁 이력 조회: storeId={}", storeId); log.debug("DB에서 1시간 이내 마케팅 팁 조회: storeId={}", storeId);
Page<MarketingTip> tips = marketingTipRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable); // 최근 생성된 1개 조회
Pageable pageable = PageRequest.of(0, 1);
Page<MarketingTip> recentTips = marketingTipRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable);
return tips.map(this::convertToResponse); if (recentTips.isEmpty()) {
log.debug("매장의 마케팅 팁이 존재하지 않음: storeId={}", storeId);
return Optional.empty();
}
MarketingTip mostRecentTip = recentTips.getContent().get(0);
LocalDateTime oneHourAgo = LocalDateTime.now().minusHours(1);
// 1시간 이내에 생성된 팁인지 확인
if (mostRecentTip.getCreatedAt().isAfter(oneHourAgo)) {
log.debug("1시간 이내 마케팅 팁 발견: tipId={}, 생성시간={}",
mostRecentTip.getId().getValue(), mostRecentTip.getCreatedAt());
return Optional.of(mostRecentTip);
}
log.debug("가장 최근 팁이 1시간 이전에 생성됨: tipId={}, 생성시간={}",
mostRecentTip.getId().getValue(), mostRecentTip.getCreatedAt());
return Optional.empty();
} }
@Override /**
@Transactional(readOnly = true) * 새로운 마케팅 생성
public MarketingTipResponse getMarketingTip(Long tipId) { */
log.info("마케팅 팁 상세 조회: tipId={}", tipId); private MarketingTip createNewMarketingTip(StoreWithMenuData storeWithMenuData) {
log.info("새로운 마케팅 팁 생성 시작: storeName={}", storeWithMenuData.getStoreData().getStoreName());
MarketingTip marketingTip = marketingTipRepository.findById(tipId) // AI 서비스로 생성
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR)); String aiGeneratedTip = aiTipGenerator.generateTip(storeWithMenuData);
log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length())));
return convertToResponse(marketingTip); // 도메인 객체 생성 저장
MarketingTip marketingTip = MarketingTip.builder()
.storeId(storeWithMenuData.getStoreData().getStoreId())
.tipContent(aiGeneratedTip)
.storeWithMenuData(storeWithMenuData)
.createdAt(LocalDateTime.now())
.build();
MarketingTip savedTip = marketingTipRepository.save(marketingTip);
log.info("새로운 마케팅 팁 저장 완료: tipId={}", savedTip.getId().getValue());
log.info("새로운 마케팅 팁 저장 완료: savedTip.getTipContent()={}", savedTip.getTipContent());
return savedTip;
} }
private MarketingTipResponse convertToResponse(MarketingTip marketingTip) { /**
* 마케팅 팁을 응답 DTO로 변환 (전체 내용 포함)
*/
private MarketingTipResponse convertToResponse(MarketingTip marketingTip, StoreData storeData, boolean isRecentlyCreated) {
String tipSummary = generateTipSummary(marketingTip.getTipContent());
return MarketingTipResponse.builder() return MarketingTipResponse.builder()
.tipId(marketingTip.getId().getValue()) .tipId(marketingTip.getId().getValue())
.storeId(marketingTip.getStoreId()) .tipSummary(tipSummary)
.storeName(marketingTip.getStoreData().getStoreName()) .tipContent(marketingTip.getTipContent()) // 🆕 전체 내용 포함
.tipContent(marketingTip.getTipContent())
.storeInfo(MarketingTipResponse.StoreInfo.builder() .storeInfo(MarketingTipResponse.StoreInfo.builder()
.storeName(marketingTip.getStoreData().getStoreName()) .storeName(storeData.getStoreName())
.businessType(marketingTip.getStoreData().getBusinessType()) .businessType(storeData.getBusinessType())
.location(marketingTip.getStoreData().getLocation()) .location(storeData.getLocation())
.build()) .build())
.createdAt(marketingTip.getCreatedAt()) .createdAt(marketingTip.getCreatedAt())
.updatedAt(marketingTip.getUpdatedAt())
.isRecentlyCreated(isRecentlyCreated)
.build(); .build();
} }
/**
* 마케팅 요약 생성 ( 50자 또는 번째 문장)
*/
private String generateTipSummary(String fullContent) {
if (fullContent == null || fullContent.trim().isEmpty()) {
return "마케팅 팁이 생성되었습니다.";
}
// 번째 문장으로 요약 (마침표 기준)
String[] sentences = fullContent.split("[.!?]");
String firstSentence = sentences.length > 0 ? sentences[0].trim() : fullContent;
// 50자 제한
if (firstSentence.length() > 50) {
return firstSentence.substring(0, 47) + "...";
}
return firstSentence;
}
/**
* 현재 로그인된 사용자 ID 조회
*/
private String getCurrentUserId() {
return SecurityContextHolder.getContext().getAuthentication().getName();
}
} }

View File

@ -1,27 +1,12 @@
package com.won.smarketing.recommend.application.usecase; package com.won.smarketing.recommend.application.usecase;
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse; import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
/**
* 마케팅 유즈케이스 인터페이스
*/
public interface MarketingTipUseCase { public interface MarketingTipUseCase {
/** /**
* AI 마케팅 생성 * 마케팅 제공
* 1시간 이내 팁이 있으면 기존 사용, 없으면 새로 생성
*/ */
MarketingTipResponse generateMarketingTips(MarketingTipRequest request); MarketingTipResponse provideMarketingTip();
/**
* 마케팅 이력 조회
*/
Page<MarketingTipResponse> getMarketingTipHistory(Long storeId, Pageable pageable);
/**
* 마케팅 상세 조회
*/
MarketingTipResponse getMarketingTip(Long tipId);
} }

View File

@ -18,8 +18,8 @@ public class WebClientConfig {
@Bean @Bean
public WebClient webClient() { public WebClient webClient() {
HttpClient httpClient = HttpClient.create() HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000)
.responseTimeout(Duration.ofMillis(5000)); .responseTimeout(Duration.ofMillis(30000));
return WebClient.builder() return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient)) .clientConnector(new ReactorClientHttpConnector(httpClient))

View File

@ -4,6 +4,7 @@ import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import org.springframework.cglib.core.Local;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@ -18,15 +19,17 @@ public class MarketingTip {
private TipId id; private TipId id;
private Long storeId; private Long storeId;
private String tipSummary;
private String tipContent; private String tipContent;
private StoreData storeData; private StoreWithMenuData storeWithMenuData;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public static MarketingTip create(Long storeId, String tipContent, StoreData storeData) { public static MarketingTip create(Long storeId, String tipContent, StoreWithMenuData storeWithMenuData) {
return MarketingTip.builder() return MarketingTip.builder()
.storeId(storeId) .storeId(storeId)
.tipContent(tipContent) .tipContent(tipContent)
.storeData(storeData) .storeWithMenuData(storeWithMenuData)
.createdAt(LocalDateTime.now()) .createdAt(LocalDateTime.now())
.build(); .build();
} }

View File

@ -0,0 +1,21 @@
package com.won.smarketing.recommend.domain.model;
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

@ -13,7 +13,10 @@ import lombok.NoArgsConstructor;
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
public class StoreData { public class StoreData {
private Long storeId;
private String storeName; private String storeName;
private String businessType; private String businessType;
private String location; private String location;
private String description;
private Integer seatCount;
} }

View File

@ -0,0 +1,13 @@
package com.won.smarketing.recommend.domain.model;
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,6 +1,7 @@
package com.won.smarketing.recommend.domain.service; package com.won.smarketing.recommend.domain.service;
import com.won.smarketing.recommend.domain.model.StoreData; import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
/** /**
* AI 생성 도메인 서비스 인터페이스 (단순화) * AI 생성 도메인 서비스 인터페이스 (단순화)
@ -10,9 +11,8 @@ public interface AiTipGenerator {
/** /**
* Python AI 서비스를 통한 마케팅 생성 * Python AI 서비스를 통한 마케팅 생성
* *
* @param storeData 매장 정보 * @param storeWithMenuData 매장 메뉴 정보
* @param additionalRequirement 추가 요청사항
* @return AI가 생성한 마케팅 * @return AI가 생성한 마케팅
*/ */
String generateTip(StoreData storeData, String additionalRequirement); String generateTip(StoreWithMenuData storeWithMenuData);
} }

View File

@ -1,11 +1,13 @@
package com.won.smarketing.recommend.domain.service; package com.won.smarketing.recommend.domain.service;
import com.won.smarketing.recommend.domain.model.StoreData; import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
import java.util.List;
/** /**
* 매장 데이터 제공 도메인 서비스 인터페이스 * 매장 데이터 제공 도메인 서비스 인터페이스
*/ */
public interface StoreDataProvider { public interface StoreDataProvider {
StoreData getStoreData(Long storeId); StoreWithMenuData getStoreWithMenuData(String userId);
} }

View File

@ -1,7 +1,10 @@
package com.won.smarketing.recommend.infrastructure.external; package com.won.smarketing.recommend.infrastructure.external;
import com.won.smarketing.recommend.domain.model.MenuData;
import com.won.smarketing.recommend.domain.model.StoreData; import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
import com.won.smarketing.recommend.domain.service.AiTipGenerator; import com.won.smarketing.recommend.domain.service.AiTipGenerator;
import lombok.Getter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@ -9,7 +12,11 @@ import org.springframework.stereotype.Service; // 이 어노테이션이 누락
import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors;
/** /**
* Python AI 생성 구현체 (날씨 정보 제거) * Python AI 생성 구현체 (날씨 정보 제거)
@ -31,37 +38,44 @@ public class PythonAiTipGenerator implements AiTipGenerator {
private int timeout; private int timeout;
@Override @Override
public String generateTip(StoreData storeData, String additionalRequirement) { public String generateTip(StoreWithMenuData storeWithMenuData) {
try { try {
log.debug("Python AI 서비스 호출: store={}", storeData.getStoreName()); log.debug("Python AI 서비스 직접 호출: store={}", storeWithMenuData.getStoreData().getStoreName());
return callPythonAiService(storeWithMenuData);
// Python AI 서비스 사용 가능 여부 확인
if (isPythonServiceAvailable()) {
return callPythonAiService(storeData, additionalRequirement);
} else {
log.warn("Python AI 서비스 사용 불가, Fallback 처리");
return createFallbackTip(storeData, additionalRequirement);
}
} catch (Exception e) { } catch (Exception e) {
log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage()); log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage());
return createFallbackTip(storeData, additionalRequirement); return createFallbackTip(storeWithMenuData);
} }
} }
private boolean isPythonServiceAvailable() { private String callPythonAiService(StoreWithMenuData storeWithMenuData) {
return !pythonAiServiceApiKey.equals("dummy-key");
}
private String callPythonAiService(StoreData storeData, String additionalRequirement) {
try { try {
// Python AI 서비스로 전송할 데이터 (날씨 정보 제거, 매장 정보만 전달)
Map<String, Object> requestData = Map.of( StoreData storeData = storeWithMenuData.getStoreData();
"store_name", storeData.getStoreName(), List<MenuData> menuDataList = storeWithMenuData.getMenuDataList();
"business_type", storeData.getBusinessType(),
"location", storeData.getLocation(), // 메뉴 데이터를 Map 형태로 변환
"additional_requirement", additionalRequirement != null ? additionalRequirement : "" 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());
// Python AI 서비스로 전송할 데이터 (매장 정보 + 메뉴 정보)
Map<String, Object> requestData = new HashMap<>();
requestData.put("store_name", storeData.getStoreName());
requestData.put("business_type", storeData.getBusinessType());
requestData.put("location", storeData.getLocation());
requestData.put("seat_count", storeData.getSeatCount());
requestData.put("menu_list", menuList);
log.debug("Python AI 서비스 요청 데이터: {}", requestData); log.debug("Python AI 서비스 요청 데이터: {}", requestData);
@ -84,22 +98,16 @@ public class PythonAiTipGenerator implements AiTipGenerator {
log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage()); log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage());
} }
return createFallbackTip(storeData, additionalRequirement); return createFallbackTip(storeWithMenuData);
} }
/** /**
* 규칙 기반 Fallback 생성 (날씨 정보 없이 매장 정보만 활용) * 규칙 기반 Fallback 생성 (날씨 정보 없이 매장 정보만 활용)
*/ */
private String createFallbackTip(StoreData storeData, String additionalRequirement) { private String createFallbackTip(StoreWithMenuData storeWithMenuData) {
String businessType = storeData.getBusinessType(); String businessType = storeWithMenuData.getStoreData().getBusinessType();
String storeName = storeData.getStoreName(); String storeName = storeWithMenuData.getStoreData().getStoreName();
String location = storeData.getLocation(); String location = storeWithMenuData.getStoreData().getLocation();
// 추가 요청사항이 있는 경우 우선 반영
if (additionalRequirement != null && !additionalRequirement.trim().isEmpty()) {
return String.format("%s에서 %s를 중심으로 한 특별한 서비스로 고객들을 맞이해보세요!",
storeName, additionalRequirement);
}
// 업종별 기본 생성 // 업종별 기본 생성
if (businessType.contains("카페")) { if (businessType.contains("카페")) {
@ -123,16 +131,13 @@ public class PythonAiTipGenerator implements AiTipGenerator {
return String.format("%s만의 특별함을 살린 고객 맞춤 서비스로 단골 고객을 늘려보세요!", storeName); return String.format("%s만의 특별함을 살린 고객 맞춤 서비스로 단골 고객을 늘려보세요!", storeName);
} }
@Getter
private static class PythonAiResponse { private static class PythonAiResponse {
private String tip; private String tip;
private String status; private String status;
private String message; private String message;
private LocalDateTime generatedTip;
public String getTip() { return tip; } private String businessType;
public void setTip(String tip) { this.tip = tip; } private String aiModel;
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
} }
} }

View File

@ -2,17 +2,29 @@ package com.won.smarketing.recommend.infrastructure.external;
import com.won.smarketing.common.exception.BusinessException; import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode; import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.recommend.domain.model.MenuData;
import com.won.smarketing.recommend.domain.model.StoreData; import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
import com.won.smarketing.recommend.domain.service.StoreDataProvider; import com.won.smarketing.recommend.domain.service.StoreDataProvider;
import jakarta.servlet.http.HttpServletRequest;
import lombok.Getter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.Cacheable; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service; // 어노테이션이 누락되어 있었음 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.WebClient;
import org.springframework.web.reactive.function.client.WebClientException;
import org.springframework.web.reactive.function.client.WebClientResponseException; import org.springframework.web.reactive.function.client.WebClientResponseException;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/** /**
* 매장 API 데이터 제공자 구현체 * 매장 API 데이터 제공자 구현체
@ -30,46 +42,85 @@ public class StoreApiDataProvider implements StoreDataProvider {
@Value("${external.store-service.timeout}") @Value("${external.store-service.timeout}")
private int timeout; private int timeout;
@Override private static final String AUTHORIZATION_HEADER = "Authorization";
@Cacheable(value = "storeData", key = "#storeId") private static final String BEARER_PREFIX = "Bearer ";
public StoreData getStoreData(Long storeId) {
try {
log.debug("매장 정보 조회 시도: storeId={}", storeId);
// 외부 서비스 연결 시도, 실패 Mock 데이터 반환 public StoreWithMenuData getStoreWithMenuData(String userId) {
if (isStoreServiceAvailable()) { log.info("매장 정보와 메뉴 정보 통합 조회 시작: userId={}", userId);
return callStoreService(storeId);
} else { try {
log.warn("매장 서비스 연결 불가, Mock 데이터 반환: storeId={}", storeId); // 매장 정보와 메뉴 정보를 병렬로 조회
return createMockStoreData(storeId); 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) { } catch (Exception e) {
log.error("매장 정보 조회 실패, Mock 데이터 반환: storeId={}", storeId, e); log.error("매장 정보와 메뉴 정보 통합 조회 실패, Mock 데이터 반환: storeId={}", userId, e);
return createMockStoreData(storeId);
// 실패 Mock 데이터 반환
return StoreWithMenuData.builder()
.storeData(createMockStoreData(userId))
.menuDataList(createMockMenuData(6L))
.build();
} }
} }
private boolean isStoreServiceAvailable() { public StoreData getStoreDataByUserId(String userId) {
return !storeServiceBaseUrl.equals("http://localhost:8082"); try {
log.debug("매장 정보 실시간 조회: userId={}", userId);
return callStoreServiceByUserId(userId);
} catch (Exception e) {
log.error("매장 정보 조회 실패, Mock 데이터 반환: userId={}, error={}", userId, e.getMessage());
return createMockStoreData(userId);
}
} }
private StoreData callStoreService(Long storeId) {
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 { try {
StoreApiResponse response = webClient StoreApiResponse response = webClient
.get() .get()
.uri(storeServiceBaseUrl + "/api/store/" + storeId) .uri(storeServiceBaseUrl + "/api/store")
.header("Authorization", "Bearer " + getCurrentJwtToken()) // JWT 토큰 추가
.retrieve() .retrieve()
.bodyToMono(StoreApiResponse.class) .bodyToMono(StoreApiResponse.class)
.timeout(Duration.ofMillis(timeout)) .timeout(Duration.ofMillis(timeout))
.block(); .block();
log.info("response : {}", response.getData().getStoreName());
log.info("response : {}", response.getData().getStoreId());
if (response != null && response.getData() != null) { if (response != null && response.getData() != null) {
StoreApiResponse.StoreInfo storeInfo = response.getData(); StoreApiResponse.StoreInfo storeInfo = response.getData();
return StoreData.builder() return StoreData.builder()
.storeId(storeInfo.getStoreId())
.storeName(storeInfo.getStoreName()) .storeName(storeInfo.getStoreName())
.businessType(storeInfo.getBusinessType()) .businessType(storeInfo.getBusinessType())
.location(storeInfo.getAddress()) .location(storeInfo.getAddress())
.description(storeInfo.getDescription())
.seatCount(storeInfo.getSeatCount())
.build(); .build();
} }
} catch (WebClientResponseException e) { } catch (WebClientResponseException e) {
@ -79,17 +130,118 @@ public class StoreApiDataProvider implements StoreDataProvider {
log.error("매장 서비스 호출 실패: {}", e.getMessage()); log.error("매장 서비스 호출 실패: {}", e.getMessage());
} }
return createMockStoreData(storeId); return createMockStoreData(userId);
} }
private StoreData createMockStoreData(Long storeId) { 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() return StoreData.builder()
.storeName("테스트 카페 " + storeId) .storeName("테스트 카페 " + userId)
.businessType("카페") .businessType("카페")
.location("서울시 강남구") .location("서울시 강남구")
.build(); .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 static class StoreApiResponse {
private int status; private int status;
private String message; private String message;
@ -102,23 +254,58 @@ public class StoreApiDataProvider implements StoreDataProvider {
public StoreInfo getData() { return data; } public StoreInfo getData() { return data; }
public void setData(StoreInfo data) { this.data = data; } public void setData(StoreInfo data) { this.data = data; }
@Getter
static class StoreInfo { static class StoreInfo {
private Long storeId; private Long storeId;
private String storeName; private String storeName;
private String businessType; private String businessType;
private String address; private String address;
private String phoneNumber; private String description;
private Integer seatCount;
}
}
public Long getStoreId() { return storeId; } /**
public void setStoreId(Long storeId) { this.storeId = storeId; } * Menu API 응답 DTO (새로 추가)
public String getStoreName() { return storeName; } */
public void setStoreName(String storeName) { this.storeName = storeName; } private static class MenuApiResponse {
public String getBusinessType() { return businessType; } private List<MenuInfo> data;
public void setBusinessType(String businessType) { this.businessType = businessType; } private String message;
public String getAddress() { return address; } private boolean success;
public void setAddress(String address) { this.address = address; }
public String getPhoneNumber() { return phoneNumber; } public List<MenuInfo> getData() { return data; }
public void setPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; } 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

@ -8,13 +8,14 @@ import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener; import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
* 마케팅 JPA 엔티티 (날씨 정보 제거) * 마케팅 JPA 엔티티
*/ */
@Entity @Entity
@Table(name = "marketing_tips") @Table(name = "marketing_tips")
@ -27,53 +28,54 @@ public class MarketingTipEntity {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "tip_id", nullable = false)
private Long id; private Long id;
@Column(name = "user_id", nullable = false, length = 50)
private String userId;
@Column(name = "store_id", nullable = false) @Column(name = "store_id", nullable = false)
private Long storeId; private Long storeId;
@Column(name = "tip_content", nullable = false, length = 2000) @Column(name = "tip_summary")
private String tipSummary;
@Lob
@Column(name = "tip_content", nullable = false, columnDefinition = "TEXT")
private String tipContent; private String tipContent;
// 매장 정보만 저장 @Column(name = "ai_model")
@Column(name = "store_name", length = 200) private String aiModel;
private String storeName;
@Column(name = "business_type", length = 100)
private String businessType;
@Column(name = "store_location", length = 500)
private String storeLocation;
@CreatedDate @CreatedDate
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
public static MarketingTipEntity fromDomain(MarketingTip marketingTip) { @LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
public static MarketingTipEntity fromDomain(MarketingTip marketingTip, String userId) {
return MarketingTipEntity.builder() return MarketingTipEntity.builder()
.id(marketingTip.getId() != null ? marketingTip.getId().getValue() : null) .id(marketingTip.getId() != null ? marketingTip.getId().getValue() : null)
.userId(userId)
.storeId(marketingTip.getStoreId()) .storeId(marketingTip.getStoreId())
.tipContent(marketingTip.getTipContent()) .tipContent(marketingTip.getTipContent())
.storeName(marketingTip.getStoreData().getStoreName()) .tipSummary(marketingTip.getTipSummary())
.businessType(marketingTip.getStoreData().getBusinessType())
.storeLocation(marketingTip.getStoreData().getLocation())
.createdAt(marketingTip.getCreatedAt()) .createdAt(marketingTip.getCreatedAt())
.updatedAt(marketingTip.getUpdatedAt())
.build(); .build();
} }
public MarketingTip toDomain() {
StoreData storeData = StoreData.builder()
.storeName(this.storeName)
.businessType(this.businessType)
.location(this.storeLocation)
.build();
public MarketingTip toDomain(StoreData storeData) {
return MarketingTip.builder() return MarketingTip.builder()
.id(this.id != null ? TipId.of(this.id) : null) .id(this.id != null ? TipId.of(this.id) : null)
.storeId(this.storeId) .storeId(this.storeId)
.tipSummary(this.tipSummary)
.tipContent(this.tipContent) .tipContent(this.tipContent)
.storeData(storeData)
.createdAt(this.createdAt) .createdAt(this.createdAt)
.updatedAt(this.updatedAt)
.build(); .build();
} }
} }

View File

@ -7,12 +7,34 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.Optional;
/** /**
* 마케팅 JPA 레포지토리 * 마케팅 JPA 레포지토리
*/ */
@Repository @Repository
public interface MarketingTipJpaRepository extends JpaRepository<MarketingTipEntity, Long> { public interface MarketingTipJpaRepository extends JpaRepository<MarketingTipEntity, Long> {
/**
* 매장별 마케팅 조회 (기존 - storeId 기반)
*/
@Query("SELECT m FROM MarketingTipEntity m WHERE m.storeId = :storeId ORDER BY m.createdAt DESC") @Query("SELECT m FROM MarketingTipEntity m WHERE m.storeId = :storeId ORDER BY m.createdAt DESC")
Page<MarketingTipEntity> findByStoreIdOrderByCreatedAtDesc(@Param("storeId") Long storeId, Pageable pageable); Page<MarketingTipEntity> findByStoreIdOrderByCreatedAtDesc(@Param("storeId") Long storeId, Pageable pageable);
/**
* 사용자별 마케팅 조회 (새로 추가 - userId 기반)
*/
@Query("SELECT m FROM MarketingTipEntity m WHERE m.userId = :userId ORDER BY m.createdAt DESC")
Page<MarketingTipEntity> findByUserIdOrderByCreatedAtDesc(@Param("userId") String userId, Pageable pageable);
/**
* 사용자의 가장 최근 마케팅 조회
*/
@Query("SELECT m FROM MarketingTipEntity m WHERE m.userId = :userId ORDER BY m.createdAt DESC LIMIT 1")
Optional<MarketingTipEntity> findTopByUserIdOrderByCreatedAtDesc(@Param("userId") String userId);
/**
* 특정 팁이 해당 사용자의 것인지 확인
*/
boolean existsByIdAndUserId(Long id, String userId);
} }

View File

@ -1,39 +1,88 @@
package com.won.smarketing.recommend.infrastructure.persistence; package com.won.smarketing.recommend.infrastructure.persistence;
import com.won.smarketing.recommend.domain.model.MarketingTip; import com.won.smarketing.recommend.domain.model.MarketingTip;
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
import com.won.smarketing.recommend.domain.repository.MarketingTipRepository; import com.won.smarketing.recommend.domain.repository.MarketingTipRepository;
import com.won.smarketing.recommend.domain.service.StoreDataProvider;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.Optional; import java.util.Optional;
/** @Slf4j
* 마케팅 레포지토리 구현체
*/
@Repository @Repository
@RequiredArgsConstructor @RequiredArgsConstructor
public class MarketingTipRepositoryImpl implements MarketingTipRepository { public class MarketingTipRepositoryImpl implements MarketingTipRepository {
private final MarketingTipJpaRepository jpaRepository; private final MarketingTipJpaRepository jpaRepository;
private final StoreDataProvider storeDataProvider;
@Override @Override
public MarketingTip save(MarketingTip marketingTip) { public MarketingTip save(MarketingTip marketingTip) {
MarketingTipEntity entity = MarketingTipEntity.fromDomain(marketingTip); String userId = getCurrentUserId();
MarketingTipEntity entity = MarketingTipEntity.fromDomain(marketingTip, userId);
MarketingTipEntity savedEntity = jpaRepository.save(entity); MarketingTipEntity savedEntity = jpaRepository.save(entity);
return savedEntity.toDomain();
// Store 정보는 다시 조회해서 Domain에 설정
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
return savedEntity.toDomain(storeWithMenuData.getStoreData());
} }
@Override @Override
public Optional<MarketingTip> findById(Long tipId) { public Optional<MarketingTip> findById(Long tipId) {
return jpaRepository.findById(tipId) return jpaRepository.findById(tipId)
.map(MarketingTipEntity::toDomain); .map(entity -> {
// Store 정보를 API로 조회
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(entity.getUserId());
return entity.toDomain(storeWithMenuData.getStoreData());
});
} }
@Override @Override
public Page<MarketingTip> findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable) { public Page<MarketingTip> findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable) {
return jpaRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable) // 기존 메서드는 호환성을 위해 유지하지만, 내부적으로는 userId로 조회
.map(MarketingTipEntity::toDomain); String userId = getCurrentUserId();
return findByUserIdOrderByCreatedAtDesc(userId, pageable);
}
/**
* 사용자별 마케팅 조회 (새로 추가)
*/
public Page<MarketingTip> findByUserIdOrderByCreatedAtDesc(String userId, Pageable pageable) {
Page<MarketingTipEntity> entities = jpaRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable);
// Store 정보는 번만 조회 (같은 userId이므로)
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
return entities.map(entity -> entity.toDomain(storeWithMenuData.getStoreData()));
}
/**
* 사용자의 가장 최근 마케팅 조회
*/
public Optional<MarketingTip> findMostRecentByUserId(String userId) {
return jpaRepository.findTopByUserIdOrderByCreatedAtDesc(userId)
.map(entity -> {
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
return entity.toDomain(storeWithMenuData.getStoreData());
});
}
/**
* 특정 팁이 해당 사용자의 것인지 확인
*/
public boolean isOwnedByUser(Long tipId, String userId) {
return jpaRepository.existsByIdAndUserId(tipId, userId);
}
/**
* 현재 로그인된 사용자 ID 조회
*/
private String getCurrentUserId() {
return SecurityContextHolder.getContext().getAuthentication().getName();
} }
} }

View File

@ -2,22 +2,18 @@ package com.won.smarketing.recommend.presentation.controller;
import com.won.smarketing.common.dto.ApiResponse; import com.won.smarketing.common.dto.ApiResponse;
import com.won.smarketing.recommend.application.usecase.MarketingTipUseCase; import com.won.smarketing.recommend.application.usecase.MarketingTipUseCase;
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse; import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid; import jakarta.validation.Valid;
/** /**
* AI 마케팅 추천 컨트롤러 * AI 마케팅 추천 컨트롤러 (단일 API)
*/ */
@Tag(name = "AI 추천", description = "AI 기반 마케팅 팁 추천 API") @Tag(name = "AI 추천", description = "AI 기반 마케팅 팁 추천 API")
@Slf4j @Slf4j
@ -29,49 +25,17 @@ public class RecommendationController {
private final MarketingTipUseCase marketingTipUseCase; private final MarketingTipUseCase marketingTipUseCase;
@Operation( @Operation(
summary = "AI 마케팅 팁 생성", summary = "마케팅 팁 조회/생성",
description = "매장 정보를 기반으로 Python AI 서비스에서 마케팅 팁을 생성합니다." description = "마케팅 팁 전체 내용 조회. 1시간 이내 생성된 팁이 있으면 기존 것 사용, 없으면 새로 생성"
) )
@PostMapping("/marketing-tips") @PostMapping("/marketing-tips")
public ResponseEntity<ApiResponse<MarketingTipResponse>> generateMarketingTips( public ResponseEntity<ApiResponse<MarketingTipResponse>> provideMarketingTip() {
@Parameter(description = "마케팅 팁 생성 요청") @Valid @RequestBody MarketingTipRequest request) {
log.info("AI 마케팅 팁 생성 요청: storeId={}", request.getStoreId()); log.info("마케팅 팁 제공 요청");
MarketingTipResponse response = marketingTipUseCase.generateMarketingTips(request); MarketingTipResponse response = marketingTipUseCase.provideMarketingTip();
log.info("AI 마케팅 팁 생성 완료: tipId={}", response.getTipId());
return ResponseEntity.ok(ApiResponse.success(response));
}
@Operation(
summary = "마케팅 팁 이력 조회",
description = "특정 매장의 마케팅 팁 생성 이력을 조회합니다."
)
@GetMapping("/marketing-tips")
public ResponseEntity<ApiResponse<Page<MarketingTipResponse>>> getMarketingTipHistory(
@Parameter(description = "매장 ID") @RequestParam Long storeId,
Pageable pageable) {
log.info("마케팅 팁 이력 조회: storeId={}, page={}", storeId, pageable.getPageNumber());
Page<MarketingTipResponse> response = marketingTipUseCase.getMarketingTipHistory(storeId, pageable);
return ResponseEntity.ok(ApiResponse.success(response));
}
@Operation(
summary = "마케팅 팁 상세 조회",
description = "특정 마케팅 팁의 상세 정보를 조회합니다."
)
@GetMapping("/marketing-tips/{tipId}")
public ResponseEntity<ApiResponse<MarketingTipResponse>> getMarketingTip(
@Parameter(description = "팁 ID") @PathVariable Long tipId) {
log.info("마케팅 팁 상세 조회: tipId={}", tipId);
MarketingTipResponse response = marketingTipUseCase.getMarketingTip(tipId);
log.info("마케팅 팁 제공 완료: tipId={}", response.getTipId());
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
} }
} }

View File

@ -1,26 +0,0 @@
package com.won.smarketing.recommend.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
@Schema(description = "마케팅 팁 생성 요청")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MarketingTipRequest {
@Schema(description = "매장 ID", example = "1", required = true)
@NotNull(message = "매장 ID는 필수입니다")
@Positive(message = "매장 ID는 양수여야 합니다")
private Long storeId;
@Schema(description = "추가 요청사항", example = "여름철 음료 프로모션에 집중해주세요")
private String additionalRequirement;
}

View File

@ -8,43 +8,50 @@ import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Schema(description = "마케팅 팁 응답") /**
* 마케팅 응답 DTO (요약 + 상세 통합)
*/
@Data @Data
@Builder @Builder
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Schema(description = "마케팅 팁 응답")
public class MarketingTipResponse { public class MarketingTipResponse {
@Schema(description = "팁 ID", example = "1") @Schema(description = "팁 ID", example = "1")
private Long tipId; private Long tipId;
@Schema(description = "매장 ID", example = "1") @Schema(description = "마케팅 팁 요약 (1줄)", example = "가을 시즌 특별 음료로 고객들의 관심을 끌어보세요!")
private Long storeId; private String tipSummary;
@Schema(description = "매장명", example = "카페 봄날") @Schema(description = "마케팅 팁 전체 내용", example = "가을이 다가오면서 고객들은 따뜻하고 계절감 있는 음료를 찾게 됩니다...")
private String storeName;
@Schema(description = "AI 생성 마케팅 팁 내용")
private String tipContent; private String tipContent;
@Schema(description = "매장 정보") @Schema(description = "매장 정보")
private StoreInfo storeInfo; private StoreInfo storeInfo;
@Schema(description = "생성 ") @Schema(description = "생성 ", example = "2025-06-13T14:30:00")
private LocalDateTime createdAt; private LocalDateTime createdAt;
@Schema(description = "수정 시간", example = "2025-06-13T14:30:00")
private LocalDateTime updatedAt;
@Schema(description = "1시간 이내 생성 여부", example = "true")
private boolean isRecentlyCreated;
@Data @Data
@Builder @Builder
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Schema(description = "매장 정보")
public static class StoreInfo { public static class StoreInfo {
@Schema(description = "매장명", example = "카페 봄날") @Schema(description = "매장명", example = "민코의 카페")
private String storeName; private String storeName;
@Schema(description = "업종", example = "카페") @Schema(description = "업종", example = "카페")
private String businessType; private String businessType;
@Schema(description = "위치", example = "서울시 강남구") @Schema(description = "위치", example = "서울시 강남구 테헤란로 123")
private String location; private String location;
} }
} }

View File

@ -12,7 +12,7 @@ spring:
password: ${POSTGRES_PASSWORD:postgres} password: ${POSTGRES_PASSWORD:postgres}
jpa: jpa:
hibernate: hibernate:
ddl-auto: ${JPA_DDL_AUTO:update} ddl-auto: ${JPA_DDL_AUTO:create-drop}
show-sql: ${JPA_SHOW_SQL:true} show-sql: ${JPA_SHOW_SQL:true}
properties: properties:
hibernate: hibernate:
@ -29,7 +29,7 @@ external:
base-url: ${STORE_SERVICE_URL:http://localhost:8082} base-url: ${STORE_SERVICE_URL:http://localhost:8082}
timeout: ${STORE_SERVICE_TIMEOUT:5000} timeout: ${STORE_SERVICE_TIMEOUT:5000}
python-ai-service: python-ai-service:
base-url: ${PYTHON_AI_SERVICE_URL:http://localhost:8090} base-url: ${PYTHON_AI_SERVICE_URL:http://localhost:5001}
api-key: ${PYTHON_AI_API_KEY:dummy-key} api-key: ${PYTHON_AI_API_KEY:dummy-key}
timeout: ${PYTHON_AI_TIMEOUT:30000} timeout: ${PYTHON_AI_TIMEOUT:30000}
@ -42,13 +42,6 @@ management:
health: health:
show-details: always show-details: always
springdoc:
swagger-ui:
path: /swagger-ui.html
operations-sorter: method
api-docs:
path: /api-docs
logging: logging:
level: level:
com.won.smarketing.recommend: ${LOG_LEVEL:DEBUG} com.won.smarketing.recommend: ${LOG_LEVEL:DEBUG}

View File

@ -3,6 +3,10 @@ plugins {
id 'org.springframework.boot' version '3.2.0' id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.4' id 'io.spring.dependency-management' version '1.1.4'
} }
// bootJar
bootJar {
enabled = false
}
allprojects { allprojects {
group = 'com.won.smarketing' group = 'com.won.smarketing'

View File

@ -35,6 +35,15 @@ public enum ErrorCode {
RECOMMENDATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "R001", "추천 생성에 실패했습니다."), RECOMMENDATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "R001", "추천 생성에 실패했습니다."),
EXTERNAL_API_ERROR(HttpStatus.SERVICE_UNAVAILABLE, "R002", "외부 API 호출에 실패했습니다."), EXTERNAL_API_ERROR(HttpStatus.SERVICE_UNAVAILABLE, "R002", "외부 API 호출에 실패했습니다."),
FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "F001", "파일을 찾을 수 없습니다."),
FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "F002", "파일 업로드에 실패했습니다."),
FILE_SIZE_EXCEEDED(HttpStatus.NOT_FOUND, "F003", "파일 크기가 제한을 초과했습니다."),
INVALID_FILE_EXTENSION(HttpStatus.NOT_FOUND, "F004", "지원하지 않는 파일 확장자입니다."),
INVALID_FILE_TYPE(HttpStatus.NOT_FOUND, "F005", "지원하지 않는 파일 형식입니다."),
INVALID_FILE_NAME(HttpStatus.NOT_FOUND, "F006", "잘못된 파일명입니다."),
INVALID_FILE_URL(HttpStatus.NOT_FOUND, "F007", "잘못된 파일 URL입니다."),
STORAGE_CONTAINER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "F008", "스토리지 컨테이너 오류가 발생했습니다."),
// 공통 오류 // 공통 오류
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "G001", "서버 내부 오류가 발생했습니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "G001", "서버 내부 오류가 발생했습니다."),
INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "G002", "잘못된 입력값입니다."), INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "G002", "잘못된 입력값입니다."),

217
smarketing-java/deployment/Jenkinsfile vendored Normal file
View File

@ -0,0 +1,217 @@
def PIPELINE_ID = "${env.BUILD_NUMBER}"
def getImageTag() {
def dateFormat = new java.text.SimpleDateFormat('yyyyMMddHHmmss')
def currentDate = new Date()
return dateFormat.format(currentDate)
}
podTemplate(
label: "${PIPELINE_ID}",
serviceAccount: 'jenkins',
containers: [
containerTemplate(name: 'gradle', image: 'gradle:jdk17', ttyEnabled: true, command: 'cat'),
containerTemplate(name: 'docker', image: 'docker:20.10.16-dind', ttyEnabled: true, privileged: true),
containerTemplate(name: 'azure-cli', image: 'hiondal/azure-kubectl:latest', command: 'cat', ttyEnabled: true),
containerTemplate(name: 'envsubst', image: "hiondal/envsubst", command: 'sleep', args: '1h')
],
volumes: [
emptyDirVolume(mountPath: '/home/gradle/.gradle', memory: false),
emptyDirVolume(mountPath: '/root/.azure', memory: false),
emptyDirVolume(mountPath: '/var/run', memory: false)
]
) {
node(PIPELINE_ID) {
def props
def imageTag = getImageTag()
def manifest = "deploy.yaml"
def namespace
def services = ['member', 'store', 'marketing-content', 'ai-recommend']
stage("Get Source") {
checkout scm
// smarketing-java 하위에 있는 설정 파일 읽기
props = readProperties file: "smarketing-java/deployment/deploy_env_vars"
namespace = "${props.namespace}"
echo "=== Build Information ==="
echo "Services: ${services}"
echo "Namespace: ${namespace}"
echo "Image Tag: ${imageTag}"
}
stage("Setup AKS") {
container('azure-cli') {
withCredentials([azureServicePrincipal('azure-credentials')]) {
sh """
echo "=== Azure 로그인 ==="
az login --service-principal -u \$AZURE_CLIENT_ID -p \$AZURE_CLIENT_SECRET -t \$AZURE_TENANT_ID
az account set --subscription 2513dd36-7978-48e3-9a7c-b221d4874f66
echo "=== AKS 인증정보 가져오기 ==="
az aks get-credentials --resource-group rg-digitalgarage-01 --name aks-digitalgarage-01 --overwrite-existing
echo "=== 네임스페이스 생성 ==="
kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply -f -
echo "=== Image Pull Secret 생성 ==="
kubectl create secret docker-registry acr-secret \\
--docker-server=${props.registry} \\
--docker-username=acrdigitalgarage02 \\
--docker-password=\$(az acr credential show --name acrdigitalgarage02 --query passwords[0].value -o tsv) \\
--namespace=${namespace} \\
--dry-run=client -o yaml | kubectl apply -f -
echo "=== 클러스터 상태 확인 ==="
kubectl get nodes
kubectl get ns ${namespace}
"""
}
}
}
stage('Build Applications') {
container('gradle') {
sh """
echo "=== smarketing-java 디렉토리로 이동 ==="
cd smarketing-java
echo "=== gradlew 권한 설정 ==="
chmod +x gradlew
echo "=== 전체 서비스 빌드 ==="
./gradlew :member:clean :member:build -x test
./gradlew :store:clean :store:build -x test
./gradlew :marketing-content:clean :marketing-content:build -x test
./gradlew :ai-recommend:clean :ai-recommend:build -x test
echo "=== 빌드 결과 확인 ==="
find . -name "*.jar" -path "*/build/libs/*" | grep -v 'plain.jar'
"""
}
}
stage('Build & Push Images') {
container('docker') {
sh """
echo "=== Docker 데몬 시작 대기 ==="
timeout 30 sh -c 'until docker info; do sleep 1; done'
"""
// 🔧 ACR Credential을 Jenkins에서 직접 사용
withCredentials([usernamePassword(
credentialsId: 'acr-credentials',
usernameVariable: 'ACR_USERNAME',
passwordVariable: 'ACR_PASSWORD'
)]) {
sh """
echo "=== Docker로 ACR 로그인 ==="
echo "\$ACR_PASSWORD" | docker login ${props.registry} --username \$ACR_USERNAME --password-stdin
"""
services.each { service ->
script {
def buildDir = "smarketing-java/${service}"
def fullImageName = "${props.registry}/${props.image_org}/${service}:${imageTag}"
echo "Building image for ${service}: ${fullImageName}"
// 실제 JAR 파일명 동적 탐지
def actualJarFile = sh(
script: """
cd ${buildDir}/build/libs
ls *.jar | grep -v 'plain.jar' | head -1
""",
returnStdout: true
).trim()
if (!actualJarFile) {
error "${service} JAR 파일을 찾을 수 없습니다"
}
echo "발견된 JAR 파일: ${actualJarFile}"
sh """
echo "=== ${service} 이미지 빌드 ==="
docker build \\
--build-arg BUILD_LIB_DIR="${buildDir}/build/libs" \\
--build-arg ARTIFACTORY_FILE="${actualJarFile}" \\
-f smarketing-java/deployment/container/Dockerfile \\
-t ${fullImageName} .
echo "=== ${service} 이미지 푸시 ==="
docker push ${fullImageName}
echo "Successfully built and pushed: ${fullImageName}"
"""
}
}
}
}
}
stage('Generate & Apply Manifest') {
container('envsubst') {
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 replicas=${props.replicas}
# 리소스 요구사항 조정 (작게)
export resources_requests_cpu=100m
export resources_requests_memory=128Mi
export resources_limits_cpu=500m
export resources_limits_memory=512Mi
# 이미지 경로 환경변수 설정
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 "=== Manifest 생성 ==="
envsubst < smarketing-java/deployment/${manifest}.template > smarketing-java/deployment/${manifest}
echo "=== Generated Manifest File ==="
cat smarketing-java/deployment/${manifest}
echo "==============================="
"""
}
container('azure-cli') {
sh """
echo "=== PostgreSQL 서비스 확인 ==="
kubectl get svc -n ${namespace} | grep postgresql || echo "PostgreSQL 서비스가 없습니다. 먼저 설치해주세요."
echo "=== Manifest 적용 ==="
kubectl apply -f smarketing-java/deployment/${manifest}
echo "=== 배포 상태 확인 (60초 대기) ==="
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 "=== 최종 상태 ==="
kubectl -n ${namespace} get all
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
fi
done
"""
}
}
}
}

View File

@ -0,0 +1,44 @@
# Build stage
FROM eclipse-temurin:17-jre AS builder
ARG BUILD_LIB_DIR
ARG ARTIFACTORY_FILE
WORKDIR /app
COPY ${BUILD_LIB_DIR}/${ARTIFACTORY_FILE} app.jar
# Run stage
FROM eclipse-temurin:17-jre
# Install necessary packages
RUN apt-get update && apt-get install -y \
curl \
netcat-traditional \
&& rm -rf /var/lib/apt/lists/*
ENV USERNAME k8s
ENV ARTIFACTORY_HOME /home/${USERNAME}
ENV JAVA_OPTS=""
# Add a non-root user
RUN groupadd -r ${USERNAME} && useradd -r -g ${USERNAME} ${USERNAME} && \
mkdir -p ${ARTIFACTORY_HOME} && \
chown ${USERNAME}:${USERNAME} ${ARTIFACTORY_HOME}
WORKDIR ${ARTIFACTORY_HOME}
# Copy JAR from builder stage
COPY --from=builder /app/app.jar app.jar
RUN chown ${USERNAME}:${USERNAME} app.jar
# Switch to non-root user
USER ${USERNAME}
# Expose port
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
CMD curl -f http://localhost:8080/actuator/health || exit 1
# Run the application
ENTRYPOINT ["sh", "-c"]
CMD ["java ${JAVA_OPTS} -jar app.jar"]

View File

@ -0,0 +1,475 @@
# ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: common-config
namespace: ${namespace}
data:
ALLOWED_ORIGINS: ${allowed_origins}
JPA_DDL_AUTO: update
JPA_SHOW_SQL: 'true'
---
apiVersion: v1
kind: ConfigMap
metadata:
name: member-config
namespace: ${namespace}
data:
POSTGRES_DB: member
POSTGRES_HOST: member-postgresql
POSTGRES_PORT: '5432'
SERVER_PORT: '8081'
---
apiVersion: v1
kind: ConfigMap
metadata:
name: store-config
namespace: ${namespace}
data:
POSTGRES_DB: store
POSTGRES_HOST: store-postgresql
POSTGRES_PORT: '5432'
SERVER_PORT: '8082'
---
apiVersion: v1
kind: ConfigMap
metadata:
name: marketing-content-config
namespace: ${namespace}
data:
POSTGRES_DB: marketing_content
POSTGRES_HOST: marketing-content-postgresql
POSTGRES_PORT: '5432'
SERVER_PORT: '8083'
---
apiVersion: v1
kind: ConfigMap
metadata:
name: ai-recommend-config
namespace: ${namespace}
data:
POSTGRES_DB: ai_recommend
POSTGRES_HOST: ai-recommend-postgresql
POSTGRES_PORT: '5432'
SERVER_PORT: '8084'
---
# Secrets
apiVersion: v1
kind: Secret
metadata:
name: common-secret
namespace: ${namespace}
stringData:
JWT_SECRET_KEY: ${jwt_secret_key}
type: Opaque
---
apiVersion: v1
kind: Secret
metadata:
name: member-secret
namespace: ${namespace}
stringData:
JWT_ACCESS_TOKEN_VALIDITY: '3600000'
JWT_REFRESH_TOKEN_VALIDITY: '86400000'
POSTGRES_PASSWORD: ${postgres_password}
POSTGRES_USER: ${postgres_user}
type: Opaque
---
apiVersion: v1
kind: Secret
metadata:
name: store-secret
namespace: ${namespace}
stringData:
POSTGRES_PASSWORD: ${postgres_password}
POSTGRES_USER: ${postgres_user}
type: Opaque
---
apiVersion: v1
kind: Secret
metadata:
name: marketing-content-secret
namespace: ${namespace}
stringData:
POSTGRES_PASSWORD: ${postgres_password}
POSTGRES_USER: ${postgres_user}
type: Opaque
---
apiVersion: v1
kind: Secret
metadata:
name: ai-recommend-secret
namespace: ${namespace}
stringData:
POSTGRES_PASSWORD: ${postgres_password}
POSTGRES_USER: ${postgres_user}
type: Opaque
---
# Deployments
apiVersion: apps/v1
kind: Deployment
metadata:
name: member
namespace: ${namespace}
labels:
app: member
spec:
replicas: ${replicas}
selector:
matchLabels:
app: member
template:
metadata:
labels:
app: member
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: member
image: ${member_image_path}
imagePullPolicy: Always
ports:
- containerPort: 8081
resources:
requests:
cpu: ${resources_requests_cpu}
memory: ${resources_requests_memory}
limits:
cpu: ${resources_limits_cpu}
memory: ${resources_limits_memory}
envFrom:
- configMapRef:
name: common-config
- configMapRef:
name: member-config
- secretRef:
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
livenessProbe:
httpGet:
path: /actuator/health
port: 8081
initialDelaySeconds: 60
periodSeconds: 30
readinessProbe:
httpGet:
path: /actuator/health
port: 8081
initialDelaySeconds: 30
periodSeconds: 5
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: store
namespace: ${namespace}
labels:
app: store
spec:
replicas: ${replicas}
selector:
matchLabels:
app: store
template:
metadata:
labels:
app: store
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: store
image: ${store_image_path}
imagePullPolicy: Always
ports:
- containerPort: 8082
resources:
requests:
cpu: ${resources_requests_cpu}
memory: ${resources_requests_memory}
limits:
cpu: ${resources_limits_cpu}
memory: ${resources_limits_memory}
envFrom:
- configMapRef:
name: common-config
- configMapRef:
name: store-config
- secretRef:
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
initialDelaySeconds: 60
periodSeconds: 30
readinessProbe:
httpGet:
path: /actuator/health
port: 8082
initialDelaySeconds: 30
periodSeconds: 5
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: marketing-content
namespace: ${namespace}
labels:
app: marketing-content
spec:
replicas: ${replicas}
selector:
matchLabels:
app: marketing-content
template:
metadata:
labels:
app: marketing-content
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: marketing-content
image: ${marketing_content_image_path}
imagePullPolicy: Always
ports:
- containerPort: 8083
resources:
requests:
cpu: ${resources_requests_cpu}
memory: ${resources_requests_memory}
limits:
cpu: ${resources_limits_cpu}
memory: ${resources_limits_memory}
envFrom:
- configMapRef:
name: common-config
- configMapRef:
name: marketing-content-config
- secretRef:
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
initialDelaySeconds: 60
periodSeconds: 30
readinessProbe:
httpGet:
path: /actuator/health
port: 8083
initialDelaySeconds: 30
periodSeconds: 5
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ai-recommend
namespace: ${namespace}
labels:
app: ai-recommend
spec:
replicas: ${replicas}
selector:
matchLabels:
app: ai-recommend
template:
metadata:
labels:
app: ai-recommend
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: ai-recommend
image: ${ai_recommend_image_path}
imagePullPolicy: Always
ports:
- containerPort: 8084
resources:
requests:
cpu: ${resources_requests_cpu}
memory: ${resources_requests_memory}
limits:
cpu: ${resources_limits_cpu}
memory: ${resources_limits_memory}
envFrom:
- configMapRef:
name: common-config
- configMapRef:
name: ai-recommend-config
- secretRef:
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
initialDelaySeconds: 60
periodSeconds: 30
readinessProbe:
httpGet:
path: /actuator/health
port: 8084
initialDelaySeconds: 30
periodSeconds: 5
---
# Services
apiVersion: v1
kind: Service
metadata:
name: member
namespace: ${namespace}
spec:
selector:
app: member
ports:
- port: 80
targetPort: 8081
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: store
namespace: ${namespace}
spec:
selector:
app: store
ports:
- port: 80
targetPort: 8082
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: marketing-content
namespace: ${namespace}
spec:
selector:
app: marketing-content
ports:
- port: 80
targetPort: 8083
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: ai-recommend
namespace: ${namespace}
spec:
selector:
app: ai-recommend
ports:
- port: 80
targetPort: 8084
type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: smarketing-backend
namespace: ${namespace}
annotations:
kubernetes.io/ingress.class: nginx
spec:
ingressClassName: nginx
rules:
- http:
paths:
- path: /api/auth
pathType: Prefix
backend:
service:
name: member
port:
number: 80
- path: /api/store
pathType: Prefix
backend:
service:
name: store
port:
number: 80
- path: /api/content
pathType: Prefix
backend:
service:
name: marketing-content
port:
number: 80
- path: /api/recommend
pathType: Prefix
backend:
service:
name: ai-recommend
port:
number: 80

View File

@ -0,0 +1,23 @@
# Team Settings
teamid=kros235
root_project=smarketing-backend
namespace=smarketing
# Container Registry Settings
registry=acrdigitalgarage02.azurecr.io
image_org=smarketing
# Application Settings
replicas=1
allowed_origins=http://20.249.171.38
# Security Settings
jwt_secret_key=8O2HQ13etL2BWZvYOiWsJ5uWFoLi6NBUG8divYVoCgtHVvlk3dqRksMl16toztDUeBTSIuOOPvHIrYq11G2BwQ
postgres_user=admin
postgres_password=Hi5Jessica!
# Resource Settings (리소스 요구사항 줄임)
resources_requests_cpu=100m
resources_requests_memory=128Mi
resources_limits_cpu=500m
resources_limits_memory=512Mi

81
smarketing-java/member/Jenkinsfile vendored Normal file
View File

@ -0,0 +1,81 @@
pipeline {
agent any
environment {
ACR_LOGIN_SERVER = 'acrsmarketing17567.azurecr.io'
IMAGE_NAME = 'member'
MANIFEST_REPO = 'https://github.com/won-ktds/smarketing-manifest.git'
MANIFEST_PATH = 'member/deployment.yaml'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Build') {
steps {
dir('member') {
sh './gradlew clean build -x test'
}
}
}
stage('Test') {
steps {
dir('member') {
sh './gradlew test'
}
}
}
stage('Build Docker Image') {
steps {
script {
def imageTag = "${BUILD_NUMBER}-${env.GIT_COMMIT.substring(0,8)}"
def fullImageName = "${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${imageTag}"
dir('member') {
sh "docker build -t ${fullImageName} ."
}
withCredentials([usernamePassword(credentialsId: 'acr-credentials', usernameVariable: 'ACR_USERNAME', passwordVariable: 'ACR_PASSWORD')]) {
sh "docker login ${ACR_LOGIN_SERVER} -u ${ACR_USERNAME} -p ${ACR_PASSWORD}"
sh "docker push ${fullImageName}"
}
env.IMAGE_TAG = imageTag
env.FULL_IMAGE_NAME = fullImageName
}
}
}
stage('Update Manifest') {
steps {
withCredentials([usernamePassword(credentialsId: 'github-credentials', usernameVariable: 'GIT_USERNAME', passwordVariable: 'GIT_TOKEN')]) {
sh '''
git clone https://${GIT_TOKEN}@github.com/won-ktds/smarketing-manifest.git manifest-repo
cd manifest-repo
# Update image tag in deployment.yaml
sed -i "s|image: .*|image: ${FULL_IMAGE_NAME}|g" ${MANIFEST_PATH}
git config user.email "jenkins@smarketing.com"
git config user.name "Jenkins"
git add .
git commit -m "Update ${IMAGE_NAME} image to ${IMAGE_TAG}"
git push origin main
'''
}
}
}
}
post {
always {
cleanWs()
}
}
}

View File

@ -37,8 +37,8 @@ public class RegisterRequest {
@Size(max = 50, message = "이름은 50자 이하여야 합니다") @Size(max = 50, message = "이름은 50자 이하여야 합니다")
private String name; private String name;
@Schema(description = "사업자등록번호", example = "123-45-67890") @Schema(description = "사업자등록번호", example = "1234567890")
@Pattern(regexp = "^\\d{3}-\\d{2}-\\d{5}$", message = "사업자등록번호 형식이 올바르지 않습니다 (000-00-00000)") @Pattern(regexp = "^\\d{10}$", message = "사업자등록번호는 10자리 숫자여야 합니다")
private String businessNumber; private String businessNumber;
@Schema(description = "이메일", example = "user@example.com", required = true) @Schema(description = "이메일", example = "user@example.com", required = true)

View File

@ -26,7 +26,7 @@ public class Member {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id") @Column(name = "member_id")
private Long id; private Long id;
@Column(name = "user_id", nullable = false, unique = true, length = 50) @Column(name = "user_id", nullable = false, unique = true, length = 50)
@ -38,7 +38,7 @@ public class Member {
@Column(name = "name", nullable = false, length = 50) @Column(name = "name", nullable = false, length = 50)
private String name; private String name;
@Column(name = "business_number", length = 12) @Column(name = "business_number", length = 15, unique = true)
private String businessNumber; private String businessNumber;
@Column(name = "email", nullable = false, unique = true, length = 100) @Column(name = "email", nullable = false, unique = true, length = 100)

View File

@ -52,6 +52,9 @@ public class AuthServiceImpl implements AuthService {
// 패스워드 검증 // 패스워드 검증
if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) { if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) {
System.out.println(passwordEncoder.encode(request.getPassword()));
System.out.println(passwordEncoder.encode(member.getPassword()));
throw new BusinessException(ErrorCode.INVALID_PASSWORD); throw new BusinessException(ErrorCode.INVALID_PASSWORD);
} }
@ -59,6 +62,8 @@ public class AuthServiceImpl implements AuthService {
String accessToken = jwtTokenProvider.generateAccessToken(member.getUserId()); String accessToken = jwtTokenProvider.generateAccessToken(member.getUserId());
String refreshToken = jwtTokenProvider.generateRefreshToken(member.getUserId()); String refreshToken = jwtTokenProvider.generateRefreshToken(member.getUserId());
log.info("{} access token 발급: {}", request.getUserId(), accessToken);
// 리프레시 토큰을 Redis에 저장 (7일) // 리프레시 토큰을 Redis에 저장 (7일)
redisTemplate.opsForValue().set( redisTemplate.opsForValue().set(
REFRESH_TOKEN_PREFIX + member.getUserId(), REFRESH_TOKEN_PREFIX + member.getUserId(),
@ -93,17 +98,8 @@ public class AuthServiceImpl implements AuthService {
if (jwtTokenProvider.validateToken(refreshToken)) { if (jwtTokenProvider.validateToken(refreshToken)) {
String userId = jwtTokenProvider.getUserIdFromToken(refreshToken); String userId = jwtTokenProvider.getUserIdFromToken(refreshToken);
// Redis에서 리프레시 토큰 삭제
redisTemplate.delete(REFRESH_TOKEN_PREFIX + userId); redisTemplate.delete(REFRESH_TOKEN_PREFIX + userId);
// 리프레시 토큰을 블랙리스트에 추가
redisTemplate.opsForValue().set(
BLACKLIST_PREFIX + refreshToken,
"logout",
7,
TimeUnit.DAYS
);
log.info("로그아웃 완료: {}", userId); log.info("로그아웃 완료: {}", userId);
} }
} catch (Exception ex) { } catch (Exception ex) {
@ -156,13 +152,8 @@ public class AuthServiceImpl implements AuthService {
TimeUnit.DAYS TimeUnit.DAYS
); );
// 기존 리프레시 토큰을 블랙리스트에 추가 // 기존 리프레시 토큰 삭제
redisTemplate.opsForValue().set( redisTemplate.delete(REFRESH_TOKEN_PREFIX + userId);
BLACKLIST_PREFIX + refreshToken,
"refreshed",
7,
TimeUnit.DAYS
);
log.info("토큰 갱신 완료: {}", userId); log.info("토큰 갱신 완료: {}", userId);

View File

@ -0,0 +1,18 @@
INSERT INTO members (member_id, user_id, password, name, business_number, email, created_at, updated_at)
VALUES
(DEFAULT, 'testuser1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '김소상', '123-45-67890', 'test1@smarketing.com', NOW(), NOW()),
(DEFAULT, 'testuser2', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '이점주', '234-56-78901', 'test2@smarketing.com', NOW(), NOW()),
(DEFAULT, 'testuser3', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '박카페', '345-67-89012', 'test3@smarketing.com', NOW(), NOW()),
(DEFAULT, 'cafeowner1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '최카페', '456-78-90123', 'cafe@smarketing.com', NOW(), NOW()),
(DEFAULT, 'restaurant1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '정식당', '567-89-01234', 'restaurant@smarketing.com', NOW(), NOW())
ON CONFLICT (user_id) DO NOTHING;
-- 이메일 중복 방지를 위한 추가 체크
INSERT INTO members (member_id, user_id, password, name, business_number, email, created_at, updated_at)
VALUES
(DEFAULT, 'bakery1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '김베이커리', '678-90-12345', 'bakery@smarketing.com', NOW(), NOW()),
(DEFAULT, 'chicken1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '한치킨', '789-01-23456', 'chicken@smarketing.com', NOW(), NOW()),
(DEFAULT, 'pizza1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '이피자', '890-12-34567', 'pizza@smarketing.com', NOW(), NOW()),
(DEFAULT, 'dessert1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '달디저트', '901-23-45678', 'dessert@smarketing.com', NOW(), NOW()),
(DEFAULT, 'beauty1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '미뷰티샵', '012-34-56789', 'beauty@smarketing.com', NOW(), NOW())
ON CONFLICT (user_id) DO NOTHING;

View File

@ -1,4 +1,8 @@
dependencies { dependencies {
implementation project(':common') implementation project(':common')
runtimeOnly 'com.mysql:mysql-connector-j' 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'
} }

View File

@ -0,0 +1,72 @@
// store/src/main/java/com/won/smarketing/store/config/AzureBlobStorageConfig.java
package com.won.smarketing.store.config;
import com.azure.identity.DefaultAzureCredentialBuilder;
import com.azure.storage.blob.BlobServiceClient;
import com.azure.storage.blob.BlobServiceClientBuilder;
import com.azure.storage.common.StorageSharedKeyCredential;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Azure Blob Storage 설정 클래스
* Azure Blob Storage와의 연결을 위한 설정
*/
@Configuration
@Slf4j
public class AzureBlobStorageConfig {
@Value("${azure.storage.account-name}")
private String accountName;
@Value("${azure.storage.account-key:}")
private String accountKey;
@Value("${azure.storage.endpoint:}")
private String endpoint;
/**
* Azure Blob Storage Service Client 생성
*
* @return BlobServiceClient 인스턴스
*/
@Bean
public BlobServiceClient blobServiceClient() {
try {
// Managed Identity 사용 (Azure 환경에서 권장)
if (accountKey == null || accountKey.isEmpty()) {
log.info("Azure Blob Storage 연결 - Managed Identity 사용");
return new BlobServiceClientBuilder()
.endpoint(getEndpoint())
.credential(new DefaultAzureCredentialBuilder().build())
.buildClient();
}
// Account Key 사용 (개발 환경용)
log.info("Azure Blob Storage 연결 - Account Key 사용");
StorageSharedKeyCredential credential = new StorageSharedKeyCredential(accountName, accountKey);
return new BlobServiceClientBuilder()
.endpoint(getEndpoint())
.credential(credential)
.buildClient();
} catch (Exception e) {
log.error("Azure Blob Storage 클라이언트 생성 실패", e);
throw new RuntimeException("Azure Blob Storage 연결 실패", e);
}
}
/**
* Storage Account 엔드포인트 URL 생성
*
* @return 엔드포인트 URL
*/
private String getEndpoint() {
if (endpoint != null && !endpoint.isEmpty()) {
return endpoint;
}
return String.format("https://%s.blob.core.windows.net", accountName);
}
}

View File

@ -0,0 +1,155 @@
// store/src/main/java/com/won/smarketing/store/controller/ImageController.java
package com.won.smarketing.store.controller;
import com.won.smarketing.store.dto.ImageUploadResponse;
import com.won.smarketing.store.dto.MenuResponse;
import com.won.smarketing.store.dto.StoreResponse;
import com.won.smarketing.store.service.BlobStorageService;
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.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
/**
* 이미지 업로드 API 컨트롤러
* 메뉴 이미지, 매장 이미지 업로드 기능 제공
*/
@RestController
@RequestMapping("/api/images")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "이미지 업로드 API", description = "메뉴 및 매장 이미지 업로드 관리")
public class ImageController {
private final BlobStorageService blobStorageService;
/**
* 메뉴 이미지 업로드
*
* @param menuId 메뉴 ID
* @param file 업로드할 이미지 파일
* @return 업로드 결과
*/
@PostMapping(value = "/menu/{menuId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "메뉴 이미지 업로드", description = "메뉴의 이미지를 Azure Blob Storage에 업로드합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "이미지 업로드 성공",
content = @Content(schema = @Schema(implementation = ImageUploadResponse.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청 (파일 형식, 크기 등)"),
@ApiResponse(responseCode = "404", description = "메뉴를 찾을 수 없음"),
@ApiResponse(responseCode = "500", description = "서버 오류")
})
public ResponseEntity<MenuResponse> uploadMenuImage(
@Parameter(description = "메뉴 ID", required = true)
@PathVariable Long menuId,
@Parameter(description = "업로드할 이미지 파일", required = true)
@RequestParam("file") MultipartFile file) {
log.info("메뉴 이미지 업로드 요청 - 메뉴 ID: {}, 파일: {}", menuId, file.getOriginalFilename());
MenuResponse response = blobStorageService.uploadMenuImage(file, menuId);
return ResponseEntity.ok(response);
}
/**
* 매장 이미지 업로드
*
* @param storeId 매장 ID
* @param file 업로드할 이미지 파일
* @return 업로드 결과
*/
@PostMapping(value = "/store/{storeId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "매장 이미지 업로드", description = "매장의 이미지를 Azure Blob Storage에 업로드합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "이미지 업로드 성공",
content = @Content(schema = @Schema(implementation = ImageUploadResponse.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청 (파일 형식, 크기 등)"),
@ApiResponse(responseCode = "404", description = "매장을 찾을 수 없음"),
@ApiResponse(responseCode = "500", description = "서버 오류")
})
public ResponseEntity<StoreResponse> uploadStoreImage(
@Parameter(description = "매장 ID", required = true)
@PathVariable Long storeId,
@Parameter(description = "업로드할 이미지 파일", required = true)
@RequestParam("file") MultipartFile file) {
log.info("매장 이미지 업로드 요청 - 매장 ID: {}, 파일: {}", storeId, file.getOriginalFilename());
StoreResponse response = blobStorageService.uploadStoreImage(file, storeId);
return ResponseEntity.ok(response);
}
/**
* 이미지 삭제
*
* @param imageUrl 삭제할 이미지 URL
* @return 삭제 결과
*/
//@DeleteMapping
//@Operation(summary = "이미지 삭제", description = "Azure Blob Storage에서 이미지를 삭제합니다.")
// @ApiResponses(value = {
// @ApiResponse(responseCode = "200", description = "이미지 삭제 성공"),
// @ApiResponse(responseCode = "400", description = "잘못된 요청"),
// @ApiResponse(responseCode = "404", description = "이미지를 찾을 수 없음"),
// @ApiResponse(responseCode = "500", description = "서버 오류")
// })
// public ResponseEntity<ImageUploadResponse> deleteImage(
// @Parameter(description = "삭제할 이미지 URL", required = true)
// @RequestParam String imageUrl) {
//
// log.info("이미지 삭제 요청 - URL: {}", imageUrl);
//
// try {
// boolean deleted = blobStorageService.deleteFile(imageUrl);
//
// ImageUploadResponse response = ImageUploadResponse.builder()
// .imageUrl(imageUrl)
// .success(deleted)
// .message(deleted ? "이미지 삭제가 완료되었습니다." : "삭제할 이미지를 찾을 수 없습니다.")
// .build();
//
// return ResponseEntity.ok(response);
//
// } catch (Exception e) {
// log.error("이미지 삭제 실패 - URL: {}", imageUrl, e);
//
// ImageUploadResponse response = ImageUploadResponse.builder()
// .imageUrl(imageUrl)
// .success(false)
// .message("이미지 삭제에 실패했습니다: " + e.getMessage())
// .build();
//
// return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
// }
// }
/**
* URL에서 파일명 추출
*
* @param url 파일 URL
* @return 파일명
*/
private String extractFileNameFromUrl(String url) {
if (url == null || url.isEmpty()) {
return null;
}
try {
return url.substring(url.lastIndexOf('/') + 1);
} catch (Exception e) {
log.warn("URL에서 파일명 추출 실패: {}", url);
return null;
}
}
}

View File

@ -1,18 +1,27 @@
package com.won.smarketing.store.controller; package com.won.smarketing.store.controller;
import com.won.smarketing.common.dto.ApiResponse; import com.won.smarketing.common.dto.ApiResponse;
import com.won.smarketing.store.dto.ImageUploadResponse;
import com.won.smarketing.store.dto.MenuCreateRequest; import com.won.smarketing.store.dto.MenuCreateRequest;
import com.won.smarketing.store.dto.MenuResponse; import com.won.smarketing.store.dto.MenuResponse;
import com.won.smarketing.store.dto.MenuUpdateRequest; import com.won.smarketing.store.dto.MenuUpdateRequest;
import com.won.smarketing.store.service.BlobStorageService;
import com.won.smarketing.store.service.MenuService; import com.won.smarketing.store.service.MenuService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.web.multipart.MultipartFile;
import java.util.List; import java.util.List;
/** /**
@ -43,15 +52,15 @@ public class MenuController {
/** /**
* 메뉴 목록 조회 * 메뉴 목록 조회
* *
* @param category 메뉴 카테고리 (선택사항) * @param storeId 메뉴 카테고리
* @return 메뉴 목록 * @return 메뉴 목록
*/ */
@Operation(summary = "메뉴 목록 조회", description = "메뉴 목록을 조회합니다. 카테고리별 필터링 가능합니다.") @Operation(summary = "메뉴 목록 조회", description = "메뉴 목록을 조회합니다. 카테고리별 필터링 가능합니다.")
@GetMapping @GetMapping
public ResponseEntity<ApiResponse<List<MenuResponse>>> getMenus( public ResponseEntity<ApiResponse<List<MenuResponse>>> getMenus(
@Parameter(description = "메뉴 카테고리") @Parameter(description = "가게 ID")
@RequestParam(required = false) String category) { @RequestParam(required = true) Long storeId) {
List<MenuResponse> response = menuService.getMenus(category); List<MenuResponse> response = menuService.getMenus(storeId);
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
} }

View File

@ -4,10 +4,12 @@ import com.won.smarketing.common.dto.ApiResponse;
import com.won.smarketing.store.dto.SalesResponse; import com.won.smarketing.store.dto.SalesResponse;
import com.won.smarketing.store.service.SalesService; import com.won.smarketing.store.service.SalesService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@ -26,12 +28,16 @@ public class SalesController {
/** /**
* 매출 정보 조회 * 매출 정보 조회
* *
* @param storeId 가게 ID
* @return 매출 정보 (오늘, 월간, 전일 대비) * @return 매출 정보 (오늘, 월간, 전일 대비)
*/ */
@Operation(summary = "매출 조회", description = "오늘 매출, 월간 매출, 전일 대비 매출 정보를 조회합니다.") @Operation(summary = "매출 조회", description = "오늘 매출, 월간 매출, 전일 대비 매출 정보를 조회합니다.")
@GetMapping @GetMapping("/{storeId}")
public ResponseEntity<ApiResponse<SalesResponse>> getSales() { public ResponseEntity<ApiResponse<SalesResponse>> getSales(
SalesResponse response = salesService.getSales(); @Parameter(description = "가게 ID", required = true)
@PathVariable Long storeId
) {
SalesResponse response = salesService.getSales(storeId);
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
} }
} }

View File

@ -2,6 +2,7 @@ package com.won.smarketing.store.controller;
import com.won.smarketing.common.dto.ApiResponse; import com.won.smarketing.common.dto.ApiResponse;
import com.won.smarketing.store.dto.StoreCreateRequest; import com.won.smarketing.store.dto.StoreCreateRequest;
import com.won.smarketing.store.dto.StoreCreateResponse;
import com.won.smarketing.store.dto.StoreResponse; import com.won.smarketing.store.dto.StoreResponse;
import com.won.smarketing.store.dto.StoreUpdateRequest; import com.won.smarketing.store.dto.StoreUpdateRequest;
import com.won.smarketing.store.service.StoreService; import com.won.smarketing.store.service.StoreService;
@ -34,8 +35,8 @@ public class StoreController {
*/ */
@Operation(summary = "매장 등록", description = "새로운 매장 정보를 등록합니다.") @Operation(summary = "매장 등록", description = "새로운 매장 정보를 등록합니다.")
@PostMapping("/register") @PostMapping("/register")
public ResponseEntity<ApiResponse<StoreResponse>> register(@Valid @RequestBody StoreCreateRequest request) { public ResponseEntity<ApiResponse<StoreCreateResponse>> register(@Valid @RequestBody StoreCreateRequest request) {
StoreResponse response = storeService.register(request); StoreCreateResponse response = storeService.register(request);
return ResponseEntity.ok(ApiResponse.success(response, "매장이 성공적으로 등록되었습니다.")); return ResponseEntity.ok(ApiResponse.success(response, "매장이 성공적으로 등록되었습니다."));
} }
@ -58,17 +59,17 @@ public class StoreController {
/** /**
* 매장 정보 수정 * 매장 정보 수정
* *
* @param storeId 수정할 매장 ID * //@param storeId 수정할 매장 ID
* @param request 매장 수정 요청 정보 * @param request 매장 수정 요청 정보
* @return 수정된 매장 정보 * @return 수정된 매장 정보
*/ */
@Operation(summary = "매장 수정", description = "매장 정보를 수정합니다.") @Operation(summary = "매장 수정", description = "매장 정보를 수정합니다.")
@PutMapping("/{storeId}") @PutMapping()
public ResponseEntity<ApiResponse<StoreResponse>> updateStore( public ResponseEntity<ApiResponse<StoreResponse>> updateStore(
@Parameter(description = "매장 ID", required = true) @Parameter(description = "매장 ID", required = true)
@PathVariable Long storeId, // @PathVariable Long storeId,
@Valid @RequestBody StoreUpdateRequest request) { @Valid @RequestBody StoreUpdateRequest request) {
StoreResponse response = storeService.updateStore(storeId, request); StoreResponse response = storeService.updateStore(request);
return ResponseEntity.ok(ApiResponse.success(response, "매장 정보가 성공적으로 수정되었습니다.")); return ResponseEntity.ok(ApiResponse.success(response, "매장 정보가 성공적으로 수정되었습니다."));
} }
} }

View File

@ -0,0 +1,25 @@
// store/src/main/java/com/won/smarketing/store/dto/ImageUploadRequest.java
package com.won.smarketing.store.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.constraints.NotNull;
/**
* 이미지 업로드 요청 DTO
* 이미지 파일 업로드 필요한 정보를 전달합니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "이미지 업로드 요청")
public class ImageUploadRequest {
@Schema(description = "업로드할 이미지 파일", required = true)
@NotNull(message = "이미지 파일은 필수입니다")
private MultipartFile file;
}

View File

@ -0,0 +1,37 @@
// store/src/main/java/com/won/smarketing/store/dto/ImageUploadResponse.java
package com.won.smarketing.store.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 이미지 업로드 응답 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "이미지 업로드 응답")
public class ImageUploadResponse {
@Schema(description = "업로드된 이미지 URL", example = "https://storage.blob.core.windows.net/menu-images/menu_123_20241201_143000_abc12345.jpg")
private String imageUrl;
@Schema(description = "원본 파일명", example = "americano.jpg")
private String originalFileName;
@Schema(description = "저장된 파일명", example = "menu_123_20241201_143000_abc12345.jpg")
private String savedFileName;
@Schema(description = "파일 크기 (바이트)", example = "1024000")
private Long fileSize;
@Schema(description = "업로드 성공 여부", example = "true")
private boolean success;
@Schema(description = "메시지", example = "이미지 업로드가 완료되었습니다.")
private String message;
}

View File

@ -39,10 +39,6 @@ public class MenuCreateRequest {
@Schema(description = "메뉴 설명", example = "진한 맛의 아메리카노") @Schema(description = "메뉴 설명", example = "진한 맛의 아메리카노")
@Size(max = 500, message = "메뉴 설명은 500자 이하여야 합니다") @Size(max = 500, message = "메뉴 설명은 500자 이하여야 합니다")
private String description; private String description;
@Schema(description = "이미지 URL", example = "https://example.com/americano.jpg")
@Size(max = 500, message = "이미지 URL은 500자 이하여야 합니다")
private String image;
} }

View File

@ -8,6 +8,7 @@ import lombok.NoArgsConstructor;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import org.springframework.web.multipart.MultipartFile;
/** /**
* 메뉴 수정 요청 DTO * 메뉴 수정 요청 DTO
@ -34,7 +35,4 @@ public class MenuUpdateRequest {
@Schema(description = "메뉴 설명", example = "진한 원두의 깊은 맛") @Schema(description = "메뉴 설명", example = "진한 원두의 깊은 맛")
private String description; private String description;
@Schema(description = "메뉴 이미지 URL", example = "https://example.com/americano.jpg")
private String image;
} }

View File

@ -1,5 +1,6 @@
package com.won.smarketing.store.dto; package com.won.smarketing.store.dto;
import com.won.smarketing.store.entity.Sales;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
@ -7,6 +8,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List;
/** /**
* 매출 응답 DTO * 매출 응답 DTO
@ -33,4 +35,7 @@ public class SalesResponse {
@Schema(description = "목표 매출 대비 달성율 (%)", example = "85.2") @Schema(description = "목표 매출 대비 달성율 (%)", example = "85.2")
private BigDecimal goalAchievementRate; private BigDecimal goalAchievementRate;
@Schema(description = "일년 동안의 매출액")
private List<Sales> yearSales;
} }

View File

@ -48,7 +48,11 @@ public class StoreCreateRequest {
@Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore") @Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore")
@Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다") @Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다")
private String snsAccounts; private String instaAccounts;
@Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다")
@Schema(description = "블로그 계정 정보", example = "블로그: mystore")
private String blogAccounts;
@Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.") @Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.")
@Size(max = 1000, message = "매장 설명은 1000자 이하여야 합니다") @Size(max = 1000, message = "매장 설명은 1000자 이하여야 합니다")

View File

@ -0,0 +1,56 @@
package com.won.smarketing.store.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 매장 응답 DTO
* 매장 정보를 클라이언트에게 전달합니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "매장 응답")
public class StoreCreateResponse {
@Schema(description = "매장 ID", example = "1")
private Long storeId;
// @Schema(description = "매장명", example = "맛있는 카페")
// private String storeName;
//
// @Schema(description = "업종", example = "카페")
// private String businessType;
//
// @Schema(description = "주소", example = "서울시 강남구 테헤란로 123")
// private String address;
//
// @Schema(description = "전화번호", example = "02-1234-5678")
// private String phoneNumber;
//
// @Schema(description = "영업시간", example = "09:00 - 22:00")
// private String businessHours;
//
// @Schema(description = "휴무일", example = "매주 일요일")
// private String closedDays;
//
// @Schema(description = "좌석 수", example = "20")
// private Integer seatCount;
//
// @Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore")
// private String snsAccounts;
//
// @Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.")
// private String description;
//
// @Schema(description = "등록일시", example = "2024-01-15T10:30:00")
// private LocalDateTime createdAt;
//
// @Schema(description = "수정일시", example = "2024-01-15T10:30:00")
// private LocalDateTime updatedAt;
}

View File

@ -1,6 +1,7 @@
package com.won.smarketing.store.dto; package com.won.smarketing.store.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.Column;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
@ -28,6 +29,9 @@ public class StoreResponse {
@Schema(description = "업종", example = "카페") @Schema(description = "업종", example = "카페")
private String businessType; private String businessType;
@Schema(description = "가게 사진")
private String storeImage;
@Schema(description = "주소", example = "서울시 강남구 테헤란로 123") @Schema(description = "주소", example = "서울시 강남구 테헤란로 123")
private String address; private String address;
@ -43,8 +47,11 @@ public class StoreResponse {
@Schema(description = "좌석 수", example = "20") @Schema(description = "좌석 수", example = "20")
private Integer seatCount; private Integer seatCount;
@Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore") @Schema(description = "블로그 계정 정보", example = "블로그: mystore")
private String snsAccounts; private String blogAccounts;
@Schema(description = "인스타 계정 정보", example = "인스타그램: @mystore")
private String instaAccounts;
@Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.") @Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.")
private String description; private String description;

View File

@ -43,9 +43,13 @@ public class StoreUpdateRequest {
@Schema(description = "좌석 수", example = "20") @Schema(description = "좌석 수", example = "20")
private Integer seatCount; private Integer seatCount;
@Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore") @Schema(description = "인스타 계정 정보", example = "인스타그램: @mystore")
@Size(max = 500, message = "인스타 계정 정보는 500자 이하여야 합니다")
private String instaAccounts;
@Schema(description = "블로그 계정 정보", example = "블로그: mystore")
@Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다") @Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다")
private String snsAccounts; private String blogAccounts;
@Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.") @Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.")
@Size(max = 1000, message = "매장 설명은 1000자 이하여야 합니다") @Size(max = 1000, message = "매장 설명은 1000자 이하여야 합니다")

View File

@ -27,7 +27,7 @@ public class Menu {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "menu_id") @Column(name = "menu_id")
private Long id; private Long menuId;
@Column(name = "store_id", nullable = false) @Column(name = "store_id", nullable = false)
private Long storeId; private Long storeId;
@ -62,10 +62,9 @@ public class Menu {
* @param category 카테고리 * @param category 카테고리
* @param price 가격 * @param price 가격
* @param description 설명 * @param description 설명
* @param image 이미지 URL
*/ */
public void updateMenu(String menuName, String category, Integer price, public void updateMenu(String menuName, String category, Integer price,
String description, String image) { String description) {
if (menuName != null && !menuName.trim().isEmpty()) { if (menuName != null && !menuName.trim().isEmpty()) {
this.menuName = menuName; this.menuName = menuName;
} }
@ -76,6 +75,16 @@ public class Menu {
this.price = price; this.price = price;
} }
this.description = description; this.description = description;
this.image = image;
} }
/**
* 메뉴 이미지 URL 업데이트
*
* @param imageUrl 새로운 이미지 URL
*/
public void updateImage(String imageUrl) {
this.image = imageUrl;
this.updatedAt = LocalDateTime.now();
}
} }

View File

@ -54,12 +54,18 @@ public class Store {
@Column(name = "seat_count") @Column(name = "seat_count")
private Integer seatCount; private Integer seatCount;
@Column(name = "sns_accounts", length = 500) @Column(name = "insta_accounts", length = 500)
private String snsAccounts; private String instaAccounts;
@Column(name = "blog_accounts", length = 500)
private String blogAccounts;
@Column(name = "description", length = 1000) @Column(name = "description", length = 1000)
private String description; private String description;
@Column(name = "store_image", length = 1000)
private String storeImage;
@CreatedDate @CreatedDate
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@ -78,12 +84,13 @@ public class Store {
* @param businessHours 영업시간 * @param businessHours 영업시간
* @param closedDays 휴무일 * @param closedDays 휴무일
* @param seatCount 좌석 * @param seatCount 좌석
* @param snsAccounts SNS 계정 정보 * @param instaAccounts SNS 계정 정보
* @param blogAccounts SNS 계정 정보
* @param description 설명 * @param description 설명
*/ */
public void updateStore(String storeName, String businessType, String address, public void updateStore(String storeName, String businessType, String address,
String phoneNumber, String businessHours, String closedDays, String phoneNumber, String businessHours, String closedDays,
Integer seatCount, String snsAccounts, String description) { Integer seatCount, String instaAccounts, String blogAccounts, String description) {
if (storeName != null && !storeName.trim().isEmpty()) { if (storeName != null && !storeName.trim().isEmpty()) {
this.storeName = storeName; this.storeName = storeName;
} }
@ -97,7 +104,18 @@ public class Store {
this.businessHours = businessHours; this.businessHours = businessHours;
this.closedDays = closedDays; this.closedDays = closedDays;
this.seatCount = seatCount; this.seatCount = seatCount;
this.snsAccounts = snsAccounts; this.instaAccounts = instaAccounts;
this.blogAccounts = blogAccounts;
this.description = description; this.description = description;
} }
/**
* 메뉴 이미지 URL 업데이트
*
* @param imageUrl 새로운 이미지 URL
*/
public void updateImage(String imageUrl) {
this.storeImage = imageUrl;
this.updatedAt = LocalDateTime.now();
}
} }

View File

@ -5,6 +5,8 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Optional;
/** /**
* 메뉴 정보 데이터 접근을 위한 Repository * 메뉴 정보 데이터 접근을 위한 Repository
@ -12,21 +14,12 @@ import java.util.List;
*/ */
@Repository @Repository
public interface MenuRepository extends JpaRepository<Menu, Long> { public interface MenuRepository extends JpaRepository<Menu, Long> {
// /**
/** // * 전체 메뉴 조회 (메뉴명 오름차순)
* 카테고리별 메뉴 조회 (메뉴명 오름차순) // *
* // * @return 메뉴 목록
* @param category 메뉴 카테고리 // */
* @return 메뉴 목록 // List<Menu> findAllByOrderByMenuNameAsc(Long );
*/
List<Menu> findByCategoryOrderByMenuNameAsc(String category);
/**
* 전체 메뉴 조회 (메뉴명 오름차순)
*
* @return 메뉴 목록
*/
List<Menu> findAllByOrderByMenuNameAsc();
/** /**
* 매장별 메뉴 조회 * 매장별 메뉴 조회

View File

@ -8,7 +8,9 @@ import org.springframework.stereotype.Repository;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* 매출 정보 데이터 접근을 위한 Repository * 매출 정보 데이터 접근을 위한 Repository
@ -64,4 +66,20 @@ public interface SalesRepository extends JpaRepository<Sales, Long> {
"AND EXTRACT(YEAR FROM sales_date) = EXTRACT(YEAR FROM CURRENT_DATE) " + "AND EXTRACT(YEAR FROM sales_date) = EXTRACT(YEAR FROM CURRENT_DATE) " +
"AND EXTRACT(MONTH FROM sales_date) = EXTRACT(MONTH FROM CURRENT_DATE)", nativeQuery = true) "AND EXTRACT(MONTH FROM sales_date) = EXTRACT(MONTH FROM CURRENT_DATE)", nativeQuery = true)
BigDecimal findMonthSalesByStoreIdNative(@Param("storeId") Long storeId); BigDecimal findMonthSalesByStoreIdNative(@Param("storeId") Long storeId);
/**
* 매장의 최근 365일 매출 데이터 조회 (날짜와 함께)
*
* @param storeId 매장 ID
* @return 최근 365일 매출 데이터 (날짜 오름차순)
*/
@Query("SELECT s FROM Sales s " +
"WHERE s.storeId = :storeId " +
"AND s.salesDate >= :startDate " +
"AND s.salesDate <= :endDate " +
"ORDER BY s.salesDate ASC")
List<Sales> findSalesDataLast365Days(
@Param("storeId") Long storeId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
} }

View File

@ -0,0 +1,55 @@
// store/src/main/java/com/won/smarketing/store/service/BlobStorageService.java
package com.won.smarketing.store.service;
import com.won.smarketing.store.dto.MenuResponse;
import com.won.smarketing.store.dto.StoreResponse;
import org.springframework.web.multipart.MultipartFile;
/**
* Azure Blob Storage 서비스 인터페이스
* 파일 업로드, 다운로드, 삭제 기능 정의
*/
public interface BlobStorageService {
/**
* 이미지 파일 업로드
*
* @param file 업로드할 파일
* @param containerName 컨테이너 이름
* @param fileName 저장할 파일명
* @return 업로드된 파일의 URL
*/
String uploadImage(MultipartFile file, String containerName, String fileName);
/**
* 메뉴 이미지 업로드 (편의 메서드)
*
* @param file 업로드할 파일
* @return 업로드된 파일의 URL
*/
MenuResponse uploadMenuImage(MultipartFile file, Long menuId);
/**
* 매장 이미지 업로드 (편의 메서드)
*
* @param file 업로드할 파일
* @param storeId 매장 ID
* @return 업로드된 파일의 URL
*/
StoreResponse uploadStoreImage(MultipartFile file, Long storeId);
/**
* 파일 삭제
*
* @param fileUrl 삭제할 파일의 URL
* @return 삭제 성공 여부
*/
//boolean deleteFile(String fileUrl);
/**
* 컨테이너 존재 여부 확인 생성
*
* @param containerName 컨테이너 이름
*/
void ensureContainerExists(String containerName);
}

View File

@ -0,0 +1,332 @@
// store/src/main/java/com/won/smarketing/store/service/BlobStorageServiceImpl.java
package com.won.smarketing.store.service;
import com.azure.core.util.BinaryData;
import com.azure.storage.blob.BlobClient;
import com.azure.storage.blob.BlobContainerClient;
import com.azure.storage.blob.BlobServiceClient;
import com.azure.storage.blob.models.BlobHttpHeaders;
import com.azure.storage.blob.models.PublicAccessType;
import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.store.dto.MenuResponse;
import com.won.smarketing.store.dto.StoreResponse;
import com.won.smarketing.store.entity.Menu;
import com.won.smarketing.store.entity.Store;
import com.won.smarketing.store.repository.MenuRepository;
import com.won.smarketing.store.repository.StoreRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
/**
* Azure Blob Storage 서비스 구현체
* 이미지 파일 업로드, 삭제 기능 구현
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class BlobStorageServiceImpl implements BlobStorageService {
private final BlobServiceClient blobServiceClient;
private final MenuRepository menuRepository;
private final StoreRepository storeRepository;
@Value("${azure.storage.container.menu-images:menu-images}")
private String menuImageContainer;
@Value("${azure.storage.container.store-images:store-images}")
private String storeImageContainer;
@Value("${azure.storage.max-file-size:10485760}") // 10MB
private long maxFileSize;
// 허용되는 이미지 확장자
private static final List<String> ALLOWED_EXTENSIONS = Arrays.asList(
"jpg", "jpeg", "png", "gif", "bmp", "webp"
);
// 허용되는 MIME 타입
private static final List<String> ALLOWED_MIME_TYPES = Arrays.asList(
"image/jpeg", "image/png", "image/gif", "image/bmp", "image/webp"
);
/**
* 이미지 파일 업로드
*
* @param file 업로드할 파일
* @param containerName 컨테이너 이름
* @param fileName 저장할 파일명
* @return 업로드된 파일의 URL
*/
@Override
public String uploadImage(MultipartFile file, String containerName, String fileName) {
// 파일 유효성 검증
validateImageFile(file);
try {
// 컨테이너 존재 확인 생성
ensureContainerExists(containerName);
// Blob 클라이언트 생성
BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(containerName);
BlobClient blobClient = containerClient.getBlobClient(fileName);
// 파일 업로드 (간단한 방식)
BinaryData binaryData = BinaryData.fromBytes(file.getBytes());
// 파일 업로드 실행 (덮어쓰기 허용)
blobClient.upload(binaryData, true);
// Content-Type 설정
BlobHttpHeaders headers = new BlobHttpHeaders().setContentType(file.getContentType());
blobClient.setHttpHeaders(headers);
String fileUrl = blobClient.getBlobUrl();
log.info("이미지 업로드 성공: {}", fileUrl);
return fileUrl;
} catch (IOException e) {
log.error("이미지 업로드 실패 - 파일 읽기 오류: {}", e.getMessage());
throw new BusinessException(ErrorCode.FILE_UPLOAD_FAILED);
} catch (Exception e) {
log.error("이미지 업로드 실패: {}", e.getMessage());
throw new BusinessException(ErrorCode.FILE_UPLOAD_FAILED);
}
}
/**
* 메뉴 이미지 업로드
*
* @param file 업로드할 파일
* @return 업로드된 파일의 URL
*/
@Override
public MenuResponse uploadMenuImage(MultipartFile file, Long menuId) {
String fileName = generateMenuImageFileName(file.getOriginalFilename());
//메뉴id로 데이터를 찾아서
Menu menu = menuRepository.findById(menuId)
.orElseThrow(() -> new BusinessException(ErrorCode.MENU_NOT_FOUND));
// 기존 이미지가 있다면 삭제
if (menu.getImage() != null && !menu.getImage().isEmpty()) {
deleteFile(menu.getImage());
}
//새로 올리고
String fileUrl = uploadImage(file, menuImageContainer, fileName);
//메뉴에 다시 저장
menu.updateImage(fileUrl);
menuRepository.save(menu);
return MenuResponse.builder()
.menuId(menu.getMenuId())
.menuName(menu.getMenuName())
.category(menu.getCategory())
.price(menu.getPrice())
.image(fileUrl)
.description(menu.getDescription())
.createdAt(menu.getCreatedAt())
.updatedAt(menu.getUpdatedAt())
.build();
}
/**
* 매장 이미지 업로드
*
* @param file 업로드할 파일
* @param storeId 매장 ID
* @return 업로드된 파일의 URL
*/
@Override
public StoreResponse uploadStoreImage(MultipartFile file, Long storeId) {
String fileName = generateStoreImageFileName(storeId, file.getOriginalFilename());
Store store = storeRepository.findById(storeId)
.orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND));
// 기존 이미지가 있다면 삭제
if (store.getStoreImage() != null && !store.getStoreImage().isEmpty()) {
deleteFile(store.getStoreImage());
}
//새로 올리고
String fileUrl = uploadImage(file, storeImageContainer, fileName);
store.updateImage(fileUrl);
storeRepository.save(store);
return StoreResponse.builder()
.storeId(store.getId())
.storeName(store.getStoreName())
.businessType(store.getBusinessType())
.address(store.getAddress())
.phoneNumber(store.getPhoneNumber())
.businessHours(store.getBusinessHours())
.closedDays(store.getClosedDays())
.seatCount(store.getSeatCount())
.blogAccounts(store.getBlogAccounts())
.instaAccounts(store.getInstaAccounts())
.storeImage(fileUrl)
.description(store.getDescription())
.createdAt(store.getCreatedAt())
.updatedAt(store.getUpdatedAt())
.build();
}
/**
* 파일 삭제
*
* @param fileUrl 삭제할 파일의 URL
*/
// @Override
public void deleteFile(String fileUrl) {
try {
// URL에서 컨테이너명과 파일명 추출
String[] urlParts = extractContainerAndFileName(fileUrl);
String containerName = urlParts[0];
String fileName = urlParts[1];
BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(containerName);
BlobClient blobClient = containerClient.getBlobClient(fileName);
boolean deleted = blobClient.deleteIfExists();
if (deleted) {
log.info("파일 삭제 성공: {}", fileUrl);
} else {
log.warn("파일이 존재하지 않음: {}", fileUrl);
}
} catch (Exception e) {
log.error("파일 삭제 실패: {}", e.getMessage());
}
}
/**
* 컨테이너 존재 여부 확인 생성
*
* @param containerName 컨테이너 이름
*/
@Override
public void ensureContainerExists(String containerName) {
try {
BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(containerName);
if (!containerClient.exists()) {
containerClient.createWithResponse(null, PublicAccessType.BLOB, null, null);
log.info("컨테이너 생성 완료: {}", containerName);
}
} catch (Exception e) {
log.error("컨테이너 생성 실패: {}", e.getMessage());
throw new BusinessException(ErrorCode.STORAGE_CONTAINER_ERROR);
}
}
/**
* 이미지 파일 유효성 검증
*
* @param file 검증할 파일
*/
private void validateImageFile(MultipartFile file) {
// 파일 존재 여부 확인
if (file == null || file.isEmpty()) {
throw new BusinessException(ErrorCode.FILE_NOT_FOUND);
}
// 파일 크기 확인
if (file.getSize() > maxFileSize) {
throw new BusinessException(ErrorCode.FILE_SIZE_EXCEEDED);
}
// 파일 확장자 확인
String originalFilename = file.getOriginalFilename();
if (originalFilename == null) {
throw new BusinessException(ErrorCode.INVALID_FILE_NAME);
}
String extension = getFileExtension(originalFilename).toLowerCase();
if (!ALLOWED_EXTENSIONS.contains(extension)) {
throw new BusinessException(ErrorCode.INVALID_FILE_EXTENSION);
}
// MIME 타입 확인
String contentType = file.getContentType();
if (contentType == null || !ALLOWED_MIME_TYPES.contains(contentType)) {
throw new BusinessException(ErrorCode.INVALID_FILE_TYPE);
}
}
/**
* 메뉴 이미지 파일명 생성
*
* @param originalFilename 원본 파일명
* @return 생성된 파일명
*/
private String generateMenuImageFileName(String originalFilename) {
String extension = getFileExtension(originalFilename);
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
String uuid = UUID.randomUUID().toString().substring(0, 8);
return String.format("menu_%s_%s.%s", timestamp, uuid, extension);
}
/**
* 매장 이미지 파일명 생성
*
* @param storeId 매장 ID
* @param originalFilename 원본 파일명
* @return 생성된 파일명
*/
private String generateStoreImageFileName(Long storeId, String originalFilename) {
String extension = getFileExtension(originalFilename);
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
String uuid = UUID.randomUUID().toString().substring(0, 8);
return String.format("store_%d_%s_%s.%s", storeId, timestamp, uuid, extension);
}
/**
* 파일 확장자 추출
*
* @param filename 파일명
* @return 확장자
*/
private String getFileExtension(String filename) {
int lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex == -1) {
return "";
}
return filename.substring(lastDotIndex + 1);
}
/**
* URL에서 컨테이너명과 파일명 추출
*
* @param fileUrl 파일 URL
* @return [컨테이너명, 파일명] 배열
*/
private String[] extractContainerAndFileName(String fileUrl) {
// URL 형식: https://accountname.blob.core.windows.net/container/filename
try {
String[] parts = fileUrl.split("/");
String containerName = parts[parts.length - 2];
String fileName = parts[parts.length - 1];
return new String[]{containerName, fileName};
} catch (Exception e) {
throw new BusinessException(ErrorCode.INVALID_FILE_URL);
}
}
}

View File

@ -1,8 +1,10 @@
package com.won.smarketing.store.service; package com.won.smarketing.store.service;
import com.won.smarketing.store.dto.ImageUploadResponse;
import com.won.smarketing.store.dto.MenuCreateRequest; import com.won.smarketing.store.dto.MenuCreateRequest;
import com.won.smarketing.store.dto.MenuResponse; import com.won.smarketing.store.dto.MenuResponse;
import com.won.smarketing.store.dto.MenuUpdateRequest; import com.won.smarketing.store.dto.MenuUpdateRequest;
import org.springframework.web.multipart.MultipartFile;
import java.util.List; import java.util.List;
@ -23,10 +25,10 @@ public interface MenuService {
/** /**
* 메뉴 목록 조회 * 메뉴 목록 조회
* *
* @param category 메뉴 카테고리 (선택사항) * @param storeId 가게 ID
* @return 메뉴 목록 * @return 메뉴 목록
*/ */
List<MenuResponse> getMenus(String category); List<MenuResponse> getMenus(Long storeId);
/** /**
* 메뉴 정보 수정 * 메뉴 정보 수정
@ -43,4 +45,13 @@ public interface MenuService {
* @param menuId 메뉴 ID * @param menuId 메뉴 ID
*/ */
void deleteMenu(Long menuId); void deleteMenu(Long menuId);
// /**
// * 메뉴 이미지 업로드
// *
// * @param menuId 메뉴 ID
// * @param file 업로드할 이미지 파일
// * @return 이미지 업로드 결과
// */
// ImageUploadResponse uploadMenuImage(Long menuId, MultipartFile file);
} }

View File

@ -2,6 +2,7 @@ package com.won.smarketing.store.service;
import com.won.smarketing.common.exception.BusinessException; import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode; import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.store.dto.ImageUploadResponse;
import com.won.smarketing.store.dto.MenuCreateRequest; import com.won.smarketing.store.dto.MenuCreateRequest;
import com.won.smarketing.store.dto.MenuResponse; import com.won.smarketing.store.dto.MenuResponse;
import com.won.smarketing.store.dto.MenuUpdateRequest; import com.won.smarketing.store.dto.MenuUpdateRequest;
@ -10,6 +11,7 @@ import com.won.smarketing.store.repository.MenuRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -41,7 +43,6 @@ public class MenuServiceImpl implements MenuService {
.category(request.getCategory()) .category(request.getCategory())
.price(request.getPrice()) .price(request.getPrice())
.description(request.getDescription()) .description(request.getDescription())
.image(request.getImage())
.build(); .build();
Menu savedMenu = menuRepository.save(menu); Menu savedMenu = menuRepository.save(menu);
@ -51,18 +52,14 @@ public class MenuServiceImpl implements MenuService {
/** /**
* 메뉴 목록 조회 * 메뉴 목록 조회
* *
* @param category 메뉴 카테고리 (선택사항) * @param storeId 가게 ID
* @return 메뉴 목록 * @return 메뉴 목록
*/ */
@Override @Override
public List<MenuResponse> getMenus(String category) { public List<MenuResponse> getMenus(Long storeId) {
List<Menu> menus; List<Menu> menus;
if (category != null && !category.trim().isEmpty()) { menus = menuRepository.findByStoreId(storeId);
menus = menuRepository.findByCategoryOrderByMenuNameAsc(category);
} else {
menus = menuRepository.findAllByOrderByMenuNameAsc();
}
return menus.stream() return menus.stream()
.map(this::toMenuResponse) .map(this::toMenuResponse)
@ -79,6 +76,7 @@ public class MenuServiceImpl implements MenuService {
@Override @Override
@Transactional @Transactional
public MenuResponse updateMenu(Long menuId, MenuUpdateRequest request) { public MenuResponse updateMenu(Long menuId, MenuUpdateRequest request) {
Menu menu = menuRepository.findById(menuId) Menu menu = menuRepository.findById(menuId)
.orElseThrow(() -> new BusinessException(ErrorCode.MENU_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.MENU_NOT_FOUND));
@ -87,8 +85,7 @@ public class MenuServiceImpl implements MenuService {
request.getMenuName(), request.getMenuName(),
request.getCategory(), request.getCategory(),
request.getPrice(), request.getPrice(),
request.getDescription(), request.getDescription()
request.getImage()
); );
Menu updatedMenu = menuRepository.save(menu); Menu updatedMenu = menuRepository.save(menu);
@ -117,14 +114,53 @@ public class MenuServiceImpl implements MenuService {
*/ */
private MenuResponse toMenuResponse(Menu menu) { private MenuResponse toMenuResponse(Menu menu) {
return MenuResponse.builder() return MenuResponse.builder()
.menuId(menu.getId()) .menuId(menu.getMenuId())
.menuName(menu.getMenuName()) .menuName(menu.getMenuName())
.category(menu.getCategory()) .category(menu.getCategory())
.price(menu.getPrice()) .price(menu.getPrice())
.description(menu.getDescription()) .description(menu.getDescription())
.image(menu.getImage())
.createdAt(menu.getCreatedAt()) .createdAt(menu.getCreatedAt())
.updatedAt(menu.getUpdatedAt()) .updatedAt(menu.getUpdatedAt())
.build(); .build();
} }
// /**
// * 메뉴 이미지 업로드
// *
// * @param menuId 메뉴 ID
// * @param file 업로드할 이미지 파일
// * @return 이미지 업로드 결과
// */
// @Override
// @Transactional
// public ImageUploadResponse uploadMenuImage(Long menuId, MultipartFile file) {
// // 메뉴 존재 여부 확인
// Menu menu = menuRepository.findById(menuId)
// .orElseThrow(() -> new BusinessException(ErrorCode.MENU_NOT_FOUND));
//
// try {
// // 기존 이미지가 있다면 삭제
// if (menu.getImage() != null && !menu.getImage().isEmpty()) {
// blobStorageService.deleteFile(menu.getImage());
// }
//
// // 이미지 업로드
// String imageUrl = blobStorageService.uploadMenuImage(file, menuId);
//
// // 메뉴 엔티티의 이미지 URL 업데이트
// menu.updateImage(imageUrl);
// menuRepository.save(menu);
//
// return ImageUploadResponse.builder()
// .imageUrl(imageUrl)
// .originalFileName(file.getOriginalFilename())
// .fileSize(file.getSize())
// .success(true)
// .message("메뉴 이미지 업로드가 완료되었습니다.")
// .build();
//
// } catch (Exception e) {
// throw new BusinessException(ErrorCode.FILE_UPLOAD_FAILED);
// }
// }
} }

View File

@ -13,5 +13,5 @@ public interface SalesService {
* *
* @return 매출 정보 * @return 매출 정보
*/ */
SalesResponse getSales(); SalesResponse getSales(Long storeId);
} }

View File

@ -3,6 +3,7 @@ package com.won.smarketing.store.service;
import com.won.smarketing.store.dto.SalesResponse; import com.won.smarketing.store.dto.SalesResponse;
import com.won.smarketing.store.entity.Sales; import com.won.smarketing.store.entity.Sales;
import com.won.smarketing.store.repository.SalesRepository; import com.won.smarketing.store.repository.SalesRepository;
import com.won.smarketing.store.repository.StoreRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@ -10,6 +11,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
/** /**
* 매출 관리 서비스 구현체 * 매출 관리 서비스 구현체
@ -28,10 +30,7 @@ public class SalesServiceImpl implements SalesService {
* @return 매출 정보 (오늘, 월간, 전일 대비) * @return 매출 정보 (오늘, 월간, 전일 대비)
*/ */
@Override @Override
public SalesResponse getSales() { public SalesResponse getSales(Long storeId) {
// TODO: 현재는 더미 데이터 반환, 실제로는 현재 로그인한 사용자의 매장 ID를 사용해야
Long storeId = 1L; // 임시로 설정
// 오늘 매출 계산 // 오늘 매출 계산
BigDecimal todaySales = calculateSalesByDate(storeId, LocalDate.now()); BigDecimal todaySales = calculateSalesByDate(storeId, LocalDate.now());
@ -44,9 +43,12 @@ public class SalesServiceImpl implements SalesService {
// 전일 대비 매출 변화량 계산 // 전일 대비 매출 변화량 계산
BigDecimal previousDayComparison = todaySales.subtract(yesterdaySales); BigDecimal previousDayComparison = todaySales.subtract(yesterdaySales);
//오늘로부터 1년 전까지의 매출 리스트
return SalesResponse.builder() return SalesResponse.builder()
.todaySales(todaySales) .todaySales(todaySales)
.monthSales(monthSales) .monthSales(monthSales)
.yearSales(getSalesAmountListLast365Days(storeId))
.previousDayComparison(previousDayComparison) .previousDayComparison(previousDayComparison)
.build(); .build();
} }
@ -81,4 +83,18 @@ public class SalesServiceImpl implements SalesService {
.map(Sales::getSalesAmount) .map(Sales::getSalesAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add); .reduce(BigDecimal.ZERO, BigDecimal::add);
} }
/**
* 최근 365일 매출 금액 리스트 조회
*
* @param storeId 매장 ID
* @return 최근 365일 매출 금액 리스트
*/
private List<Sales> getSalesAmountListLast365Days(Long storeId) {
LocalDate endDate = LocalDate.now();
LocalDate startDate = endDate.minusDays(365);
// Sales 엔티티 전체를 조회하는 메서드 사용
return salesRepository.findSalesDataLast365Days(storeId, startDate, endDate);
}
} }

View File

@ -1,6 +1,7 @@
package com.won.smarketing.store.service; package com.won.smarketing.store.service;
import com.won.smarketing.store.dto.StoreCreateRequest; import com.won.smarketing.store.dto.StoreCreateRequest;
import com.won.smarketing.store.dto.StoreCreateResponse;
import com.won.smarketing.store.dto.StoreResponse; import com.won.smarketing.store.dto.StoreResponse;
import com.won.smarketing.store.dto.StoreUpdateRequest; import com.won.smarketing.store.dto.StoreUpdateRequest;
@ -16,7 +17,7 @@ public interface StoreService {
* @param request 매장 등록 요청 정보 * @param request 매장 등록 요청 정보
* @return 등록된 매장 정보 * @return 등록된 매장 정보
*/ */
StoreResponse register(StoreCreateRequest request); StoreCreateResponse register(StoreCreateRequest request);
/** /**
* 매장 정보 조회 (현재 로그인 사용자) * 매장 정보 조회 (현재 로그인 사용자)
@ -36,9 +37,9 @@ public interface StoreService {
/** /**
* 매장 정보 수정 * 매장 정보 수정
* *
* @param storeId 매장 ID * //@param storeId 매장 ID
* @param request 매장 수정 요청 정보 * @param request 매장 수정 요청 정보
* @return 수정된 매장 정보 * @return 수정된 매장 정보
*/ */
StoreResponse updateStore(Long storeId, StoreUpdateRequest request); StoreResponse updateStore(StoreUpdateRequest request);
} }

View File

@ -3,6 +3,7 @@ package com.won.smarketing.store.service;
import com.won.smarketing.common.exception.BusinessException; import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode; import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.store.dto.StoreCreateRequest; import com.won.smarketing.store.dto.StoreCreateRequest;
import com.won.smarketing.store.dto.StoreCreateResponse;
import com.won.smarketing.store.dto.StoreResponse; import com.won.smarketing.store.dto.StoreResponse;
import com.won.smarketing.store.dto.StoreUpdateRequest; import com.won.smarketing.store.dto.StoreUpdateRequest;
import com.won.smarketing.store.entity.Store; import com.won.smarketing.store.entity.Store;
@ -35,7 +36,7 @@ public class StoreServiceImpl implements StoreService {
*/ */
@Override @Override
@Transactional @Transactional
public StoreResponse register(StoreCreateRequest request) { public StoreCreateResponse register(StoreCreateRequest request) {
String memberId = getCurrentUserId(); String memberId = getCurrentUserId();
// Long memberId = Long.valueOf(currentUserId); // 실제로는 Member ID 조회 필요 // Long memberId = Long.valueOf(currentUserId); // 실제로는 Member ID 조회 필요
@ -56,14 +57,15 @@ public class StoreServiceImpl implements StoreService {
.businessHours(request.getBusinessHours()) .businessHours(request.getBusinessHours())
.closedDays(request.getClosedDays()) .closedDays(request.getClosedDays())
.seatCount(request.getSeatCount()) .seatCount(request.getSeatCount())
.snsAccounts(request.getSnsAccounts()) .blogAccounts(request.getBlogAccounts())
.instaAccounts(request.getInstaAccounts())
.description(request.getDescription()) .description(request.getDescription())
.build(); .build();
Store savedStore = storeRepository.save(store); Store savedStore = storeRepository.save(store);
log.info("매장 등록 완료: {} (ID: {})", savedStore.getStoreName(), savedStore.getId()); log.info("매장 등록 완료: {} (ID: {})", savedStore.getStoreName(), savedStore.getId());
return toStoreResponse(savedStore); return toStoreCreateResponse(savedStore);
} }
/** /**
@ -104,14 +106,16 @@ public class StoreServiceImpl implements StoreService {
/** /**
* 매장 정보 수정 * 매장 정보 수정
* *
* @param storeId 매장 ID * //@param storeId 매장 ID
* @param request 매장 수정 요청 정보 * @param request 매장 수정 요청 정보
* @return 수정된 매장 정보 * @return 수정된 매장 정보
*/ */
@Override @Override
@Transactional @Transactional
public StoreResponse updateStore(Long storeId, StoreUpdateRequest request) { public StoreResponse updateStore(StoreUpdateRequest request) {
Store store = storeRepository.findById(storeId) String userId = getCurrentUserId();
Store store = storeRepository.findByUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND));
// 매장 정보 업데이트 // 매장 정보 업데이트
@ -123,7 +127,8 @@ public class StoreServiceImpl implements StoreService {
request.getBusinessHours(), request.getBusinessHours(),
request.getClosedDays(), request.getClosedDays(),
request.getSeatCount(), request.getSeatCount(),
request.getSnsAccounts(), request.getInstaAccounts(),
request.getBlogAccounts(),
request.getDescription() request.getDescription()
); );
@ -149,13 +154,31 @@ public class StoreServiceImpl implements StoreService {
.businessHours(store.getBusinessHours()) .businessHours(store.getBusinessHours())
.closedDays(store.getClosedDays()) .closedDays(store.getClosedDays())
.seatCount(store.getSeatCount()) .seatCount(store.getSeatCount())
.snsAccounts(store.getSnsAccounts()) .blogAccounts(store.getBlogAccounts())
.instaAccounts(store.getInstaAccounts())
.description(store.getDescription()) .description(store.getDescription())
.createdAt(store.getCreatedAt()) .createdAt(store.getCreatedAt())
.updatedAt(store.getUpdatedAt()) .updatedAt(store.getUpdatedAt())
.build(); .build();
} }
private StoreCreateResponse toStoreCreateResponse(Store store) {
return StoreCreateResponse.builder()
.storeId(store.getId())
// .storeName(store.getStoreName())
// .businessType(store.getBusinessType())
// .address(store.getAddress())
// .phoneNumber(store.getPhoneNumber())
// .businessHours(store.getBusinessHours())
// .closedDays(store.getClosedDays())
// .seatCount(store.getSeatCount())
// .snsAccounts(store.getSnsAccounts())
// .description(store.getDescription())
// .createdAt(store.getCreatedAt())
// .updatedAt(store.getUpdatedAt())
.build();
}
/** /**
* 현재 로그인된 사용자 ID 조회 * 현재 로그인된 사용자 ID 조회
* *

View File

@ -2,6 +2,11 @@ server:
port: ${SERVER_PORT:8082} port: ${SERVER_PORT:8082}
spring: spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
enabled: true
application: application:
name: store-service name: store-service
datasource: datasource:
@ -31,3 +36,13 @@ jwt:
secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789} secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789}
access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} access-token-validity: ${JWT_ACCESS_VALIDITY:3600000}
refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000}
# Azure Storage 설정
azure:
storage:
account-name: ${AZURE_STORAGE_ACCOUNT_NAME:stdigitalgarage02}
account-key: ${AZURE_STORAGE_ACCOUNT_KEY:}
endpoint: ${AZURE_STORAGE_ENDPOINT:https://stdigitalgarage02.blob.core.windows.net}
container:
menu-images: ${AZURE_STORAGE_MENU_CONTAINER:smarketing-menu-images}
store-images: ${AZURE_STORAGE_STORE_CONTAINER:smarketing-store-images}
max-file-size: ${AZURE_STORAGE_MAX_FILE_SIZE:10485760} # 10MB