Merge pull request #4 from won-ktds/feature/generate-poster

Feature/generate poster
This commit is contained in:
SeongRak Oh 2025-06-16 14:28:23 +09:00 committed by GitHub
commit 18c274142c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 1248 additions and 195 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

@ -298,4 +298,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

@ -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: dg0408cr.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: ClusterIP
ports:
- port: 80
targetPort: 5001
protocol: TCP
name: http
selector:
app: smarketing

View File

@ -82,7 +82,7 @@ class PosterServiceV3:
prompt = self._create_poster_prompt_v3(request, main_image_analysis) 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,
@ -146,11 +146,6 @@ class PosterServiceV3:
""" """
V3 포스터 생성을 위한 AI 프롬프트 생성 (한글, 글자 완전 제외, 메인 이미지 기반 + 예시 링크 10 포함) 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_description = main_analysis.get('description', '맛있는 음식')
@ -173,32 +168,53 @@ class PosterServiceV3:
example_links = "\n".join([f"- {link}" for link in self.example_images]) example_links = "\n".join([f"- {link}" for link in self.example_images])
prompt = f""" prompt = f"""
메인 이미지 URL을 참조하여, "글이 없는" 심플한 카페 포스터를 디자인해주세요. ## 카페 홍보 포스터 디자인 요청
**핵심 기준 이미지:** ### 📋 기본 정보
메인 이미지 URL: {main_image_url} 카테고리: {request.category}
이미지 URL에 들어가 이미지를 다운로드 , 이미지를 그대로 반영한 홍보 포스터를 디자인해주세요. 콘텐츠 타입: {request.contentType}
심플한 배경이 중요합니다. 메뉴명: {request.menuName or '없음'}
AI가 생성하지 않은 것처럼 현실적인 요소를 반영해주세요. 메뉴 정보: {main_description}
**절대 필수 조건:** ### 📅 이벤트 기간
- 어떤 형태의 텍스트, 글자, 문자, 숫자도 절대 포함하지 !!!! - 가장 중요 시작일: {request.startDate or '지금'}
- 위의 메인 이미지를 임의 변경 없이, 포스터의 중심 요소로 포함할 종료일: {request.endDate or '한정 기간'}
- 하나의 포스터만 생성해주세요 이벤트 시작일과 종료일은 필수로 포스터에 명시해주세요.
- 메인 이미지의 색감과 분위기를 살려서 심플한 포스터 디자인
- 메인 이미지가 돋보이도록 배경과 레이아웃 구성
- 확실하지도 않은 문자 절대 생성 x
**특별 요구사항:** ### 🎨 디자인 요구사항
{request.requirement} 메인 이미지 처리
- 기존 메인 이미지는 변경하지 않고 그대로 유지
- 포스터 전체 크기의 1/3 이하로 배치
- 이미지와 조화로운 작은 장식 이미지 추가
- 크기: {image_orientation}
텍스트 요소
- 메뉴명 (필수)
- 간단한 추가 홍보 문구 (새로 생성, 한글) 혹은 "{request.requirement or '눈길을 끄는 전문적인 디자인'}"라는 요구사항에 맞는 문구
- 메뉴명 추가되는 문구는 1줄만 작성
텍스트 배치 규칙
- 글자가 이미지 경계를 벗어나지 않도록 주의
- 모서리에 너무 가깝게 배치하지
- 적당한 크기로 가독성 확보
- 아기자기한 한글 폰트 사용
**반드시 제외할 요소:** ### 🎨 디자인 스타일
- 모든 형태의 텍스트 (한글, 영어, 숫자, 기호) 참조 이미지
- 메뉴판, 가격표, 간판 {example_links} URL을 참고하여 비슷한 스타일로 제작
- 글자가 적힌 모든 요소
- 브랜드 로고나 문자 색상 가이드
{color_description}
전체적인 디자인 방향
타겟: 한국 카페 고객층
스타일: 화려하고 매력적인 디자인
목적: 소셜미디어 공유용 (적합한 크기)
톤앤매너: 맛있어 보이는 색상, 방문 유도하는 비주얼
### 🎯 최종 목표
고객들이 "이 카페에 가보고 싶다!"라고 생각하게 만드는 시각적으로 매력적인 홍보 포스터 제작
"""
"""
return prompt return prompt

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

@ -78,19 +78,36 @@ class AIClient:
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를 파일로 저장
import base64
from datetime import datetime
b64_data = response.data[0].b64_json
image_data = base64.b64decode(b64_data)
# 로컬 파일 저장
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"poster_{timestamp}.png"
filepath = os.path.join('uploads', 'temp', filename)
os.makedirs(os.path.dirname(filepath), exist_ok=True)
with open(filepath, 'wb') as f:
f.write(image_data)
print(f"✅ 이미지 저장 완료: {filepath}")
# 그냥 파일 경로만 반환
return filepath
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

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