Merge remote-tracking branch 'origin/main'

This commit is contained in:
OhSeongRak 2025-06-14 23:00:49 +09:00
commit acc2a3ff90
42 changed files with 1289 additions and 136 deletions

View File

@ -12,7 +12,7 @@ from config.config import Config
from services.poster_service import PosterService from services.poster_service import PosterService
from services.sns_content_service import SnsContentService from services.sns_content_service import SnsContentService
from models.request_models import ContentRequest, PosterRequest, SnsContentGetRequest, PosterContentGetRequest from models.request_models import ContentRequest, PosterRequest, SnsContentGetRequest, PosterContentGetRequest
from services.poster_service_v2 import PosterServiceV2 from services.poster_service_v3 import PosterServiceV3
def create_app(): def create_app():
@ -30,7 +30,7 @@ def create_app():
# 서비스 인스턴스 생성 # 서비스 인스턴스 생성
poster_service = PosterService() poster_service = PosterService()
poster_service_v2 = PosterServiceV2() poster_service_v3 = PosterServiceV3()
sns_content_service = SnsContentService() sns_content_service = SnsContentService()
@app.route('/health', methods=['GET']) @app.route('/health', methods=['GET'])
@ -97,8 +97,8 @@ def create_app():
@app.route('/api/ai/poster', methods=['GET']) @app.route('/api/ai/poster', methods=['GET'])
def generate_poster_content(): def generate_poster_content():
""" """
홍보 포스터 생성 API (개선된 버전) 홍보 포스터 생성 API
원본 이미지 보존 + 한글 텍스트 오버레이 실제 제품 이미지를 포함한 분위기 배경 포스터 생성
""" """
try: try:
# JSON 요청 데이터 검증 # JSON 요청 데이터 검증
@ -115,6 +115,23 @@ def create_app():
if field not in data: if field not in data:
return jsonify({'error': f'필수 필드가 누락되었습니다: {field}'}), 400 return jsonify({'error': f'필수 필드가 누락되었습니다: {field}'}), 400
# 날짜 변환 처리
start_date = None
end_date = None
if data.get('startDate'):
try:
from datetime import datetime
start_date = datetime.strptime(data['startDate'], '%Y-%m-%d').date()
except ValueError:
return jsonify({'error': 'startDate 형식이 올바르지 않습니다. YYYY-MM-DD 형식을 사용하세요.'}), 400
if data.get('endDate'):
try:
from datetime import datetime
end_date = datetime.strptime(data['endDate'], '%Y-%m-%d').date()
except ValueError:
return jsonify({'error': 'endDate 형식이 올바르지 않습니다. YYYY-MM-DD 형식을 사용하세요.'}), 400
# 요청 모델 생성 # 요청 모델 생성
poster_request = PosterContentGetRequest( poster_request = PosterContentGetRequest(
title=data.get('title'), title=data.get('title'),
@ -127,16 +144,18 @@ def create_app():
emotionIntensity=data.get('emotionIntensity'), emotionIntensity=data.get('emotionIntensity'),
menuName=data.get('menuName'), menuName=data.get('menuName'),
eventName=data.get('eventName'), eventName=data.get('eventName'),
startDate=data.get('startDate'), startDate=start_date,
endDate=data.get('endDate') endDate=end_date
) )
# 포스터 생성 # 포스터 생성 (V3 사용)
# result = poster_service.generate_poster(poster_request) result = poster_service_v3.generate_poster(poster_request)
result = poster_service_v2.generate_poster(poster_request)
if result['success']: if result['success']:
return jsonify({'content': result['content']}) return jsonify({
'content': result['content'],
'analysis': result.get('analysis', {})
})
else: else:
return jsonify({'error': result['error']}), 500 return jsonify({'error': result['error']}), 500

View File

@ -4,7 +4,10 @@ Flask 애플리케이션 설정
""" """
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
class Config: class Config:
"""애플리케이션 설정 클래스""" """애플리케이션 설정 클래스"""
# Flask 기본 설정 # Flask 기본 설정
@ -19,8 +22,9 @@ class Config:
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'
@staticmethod @staticmethod
def allowed_file(filename): def allowed_file(filename):
"""업로드 파일 확장자 검증""" """업로드 파일 확장자 검증"""
return '.' in filename and \ return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in Config.ALLOWED_EXTENSIONS filename.rsplit('.', 1)[1].lower() in Config.ALLOWED_EXTENSIONS

View File

@ -4,6 +4,7 @@ API 요청 데이터 구조를 정의
""" """
from dataclasses import dataclass from dataclasses import dataclass
from typing import List, Optional from typing import List, Optional
from datetime import date
@dataclass @dataclass
@ -19,8 +20,8 @@ class SnsContentGetRequest:
emotionIntensity: Optional[str] = None emotionIntensity: Optional[str] = None
menuName: Optional[str] = None menuName: Optional[str] = None
eventName: Optional[str] = None eventName: Optional[str] = None
startDate: Optional[str] = None startDate: Optional[date] = None # LocalDate -> date
endDate: Optional[str] = None endDate: Optional[date] = None # LocalDate -> date
@dataclass @dataclass
@ -36,8 +37,8 @@ class PosterContentGetRequest:
emotionIntensity: Optional[str] = None emotionIntensity: Optional[str] = None
menuName: Optional[str] = None menuName: Optional[str] = None
eventName: Optional[str] = None eventName: Optional[str] = None
startDate: Optional[str] = None startDate: Optional[date] = None # LocalDate -> date
endDate: Optional[str] = None endDate: Optional[date] = None # LocalDate -> date
# 기존 모델들은 유지 # 기존 모델들은 유지

View File

@ -0,0 +1,204 @@
"""
포스터 생성 서비스 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@ -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", "잘못된 입력값입니다."),

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
@ -35,6 +36,7 @@ public class MenuUpdateRequest {
@Schema(description = "메뉴 설명", example = "진한 원두의 깊은 맛") @Schema(description = "메뉴 설명", example = "진한 원두의 깊은 맛")
private String description; private String description;
@Schema(description = "메뉴 이미지 URL", example = "https://example.com/americano.jpg") @Schema(description = "이미지")
private String image; @Size(max = 500, message = "이미지 URL은 500자 이하여야 합니다")
private MultipartFile 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