feat: request - add menu

This commit is contained in:
yuhalog 2025-06-16 14:51:19 +09:00
parent 39c1560de8
commit 6003eebb82
40 changed files with 1287 additions and 158 deletions

View File

@ -4,7 +4,9 @@ import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode; import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.recommend.application.usecase.MarketingTipUseCase; import com.won.smarketing.recommend.application.usecase.MarketingTipUseCase;
import com.won.smarketing.recommend.domain.model.MarketingTip; import com.won.smarketing.recommend.domain.model.MarketingTip;
import com.won.smarketing.recommend.domain.model.MenuData;
import com.won.smarketing.recommend.domain.model.StoreData; import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
import com.won.smarketing.recommend.domain.repository.MarketingTipRepository; import com.won.smarketing.recommend.domain.repository.MarketingTipRepository;
import com.won.smarketing.recommend.domain.service.AiTipGenerator; import com.won.smarketing.recommend.domain.service.AiTipGenerator;
import com.won.smarketing.recommend.domain.service.StoreDataProvider; import com.won.smarketing.recommend.domain.service.StoreDataProvider;
@ -19,6 +21,7 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional; import java.util.Optional;
@Slf4j @Slf4j
@ -38,21 +41,21 @@ public class MarketingTipService implements MarketingTipUseCase {
try { try {
// 1. 사용자의 매장 정보 조회 // 1. 사용자의 매장 정보 조회
StoreData storeData = storeDataProvider.getStoreDataByUserId(userId); StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
// 2. 1시간 이내에 생성된 마케팅 팁이 있는지 DB에서 확인 // 2. 1시간 이내에 생성된 마케팅 팁이 있는지 DB에서 확인
Optional<MarketingTip> recentTip = findRecentMarketingTip(storeData.getStoreId()); Optional<MarketingTip> recentTip = findRecentMarketingTip(storeWithMenuData.getStoreData().getStoreId());
if (recentTip.isPresent()) { if (recentTip.isPresent()) {
log.info("1시간 이내에 생성된 마케팅 팁 발견: tipId={}", recentTip.get().getId().getValue()); log.info("1시간 이내에 생성된 마케팅 팁 발견: tipId={}", recentTip.get().getId().getValue());
log.info("1시간 이내에 생성된 마케팅 팁 발견: getTipContent()={}", recentTip.get().getTipContent()); log.info("1시간 이내에 생성된 마케팅 팁 발견: getTipContent()={}", recentTip.get().getTipContent());
return convertToResponse(recentTip.get(), storeData, true); return convertToResponse(recentTip.get(), storeWithMenuData.getStoreData(), true);
} }
// 3. 1시간 이내 팁이 없으면 새로 생성 // 3. 1시간 이내 팁이 없으면 새로 생성
log.info("1시간 이내 마케팅 팁이 없어 새로 생성합니다: userId={}, storeId={}", userId, storeData.getStoreId()); log.info("1시간 이내 마케팅 팁이 없어 새로 생성합니다: userId={}, storeId={}", userId, storeWithMenuData.getStoreData().getStoreId());
MarketingTip newTip = createNewMarketingTip(storeData); MarketingTip newTip = createNewMarketingTip(storeWithMenuData);
return convertToResponse(newTip, storeData, false); return convertToResponse(newTip, storeWithMenuData.getStoreData(), false);
} catch (Exception e) { } catch (Exception e) {
log.error("마케팅 팁 조회/생성 중 오류: userId={}", userId, e); log.error("마케팅 팁 조회/생성 중 오류: userId={}", userId, e);
@ -93,18 +96,18 @@ public class MarketingTipService implements MarketingTipUseCase {
/** /**
* 새로운 마케팅 생성 * 새로운 마케팅 생성
*/ */
private MarketingTip createNewMarketingTip(StoreData storeData) { private MarketingTip createNewMarketingTip(StoreWithMenuData storeWithMenuData) {
log.info("새로운 마케팅 팁 생성 시작: storeName={}", storeData.getStoreName()); log.info("새로운 마케팅 팁 생성 시작: storeName={}", storeWithMenuData.getStoreData().getStoreName());
// AI 서비스로 생성 // AI 서비스로 생성
String aiGeneratedTip = aiTipGenerator.generateTip(storeData); String aiGeneratedTip = aiTipGenerator.generateTip(storeWithMenuData);
log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length()))); log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length())));
// 도메인 객체 생성 저장 // 도메인 객체 생성 저장
MarketingTip marketingTip = MarketingTip.builder() MarketingTip marketingTip = MarketingTip.builder()
.storeId(storeData.getStoreId()) .storeId(storeWithMenuData.getStoreData().getStoreId())
.tipContent(aiGeneratedTip) .tipContent(aiGeneratedTip)
.storeData(storeData) .storeWithMenuData(storeWithMenuData)
.createdAt(LocalDateTime.now()) .createdAt(LocalDateTime.now())
.build(); .build();

View File

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

View File

@ -21,15 +21,15 @@ public class MarketingTip {
private Long storeId; private Long storeId;
private String tipSummary; private String tipSummary;
private String tipContent; private String tipContent;
private StoreData storeData; private StoreWithMenuData storeWithMenuData;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
public static MarketingTip create(Long storeId, String tipContent, StoreData storeData) { public static MarketingTip create(Long storeId, String tipContent, StoreWithMenuData storeWithMenuData) {
return MarketingTip.builder() return MarketingTip.builder()
.storeId(storeId) .storeId(storeId)
.tipContent(tipContent) .tipContent(tipContent)
.storeData(storeData) .storeWithMenuData(storeWithMenuData)
.createdAt(LocalDateTime.now()) .createdAt(LocalDateTime.now())
.build(); .build();
} }

View File

@ -0,0 +1,21 @@
package com.won.smarketing.recommend.domain.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 메뉴 데이터 객체
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MenuData {
private Long menuId;
private String menuName;
private String category;
private Integer price;
private String description;
}

View File

@ -0,0 +1,13 @@
package com.won.smarketing.recommend.domain.model;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class StoreWithMenuData {
private StoreData storeData;
private List<MenuData> menuDataList;
}

View File

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

View File

@ -1,17 +1,13 @@
package com.won.smarketing.recommend.domain.service; package com.won.smarketing.recommend.domain.service;
import com.won.smarketing.recommend.domain.model.StoreData; import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
import java.util.List;
/** /**
* 매장 데이터 제공 도메인 서비스 인터페이스 * 매장 데이터 제공 도메인 서비스 인터페이스
*/ */
public interface StoreDataProvider { public interface StoreDataProvider {
/** StoreWithMenuData getStoreWithMenuData(String userId);
* 사용자 ID로 매장 정보 조회
*
* @param userId 사용자 ID
* @return 매장 정보
*/
StoreData getStoreDataByUserId(String userId);
} }

View File

@ -1,6 +1,8 @@
package com.won.smarketing.recommend.infrastructure.external; package com.won.smarketing.recommend.infrastructure.external;
import com.won.smarketing.recommend.domain.model.MenuData;
import com.won.smarketing.recommend.domain.model.StoreData; import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
import com.won.smarketing.recommend.domain.service.AiTipGenerator; import com.won.smarketing.recommend.domain.service.AiTipGenerator;
import lombok.Getter; import lombok.Getter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -11,7 +13,10 @@ import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors;
/** /**
* Python AI 생성 구현체 (날씨 정보 제거) * Python AI 생성 구현체 (날씨 정보 제거)
@ -33,26 +38,44 @@ public class PythonAiTipGenerator implements AiTipGenerator {
private int timeout; private int timeout;
@Override @Override
public String generateTip(StoreData storeData) { public String generateTip(StoreWithMenuData storeWithMenuData) {
try { try {
log.debug("Python AI 서비스 직접 호출: store={}", storeData.getStoreName()); log.debug("Python AI 서비스 직접 호출: store={}", storeWithMenuData.getStoreData().getStoreName());
return callPythonAiService(storeData); return callPythonAiService(storeWithMenuData);
} catch (Exception e) { } catch (Exception e) {
log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage()); log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage());
return createFallbackTip(storeData); return createFallbackTip(storeWithMenuData);
} }
} }
private String callPythonAiService(StoreData storeData) { private String callPythonAiService(StoreWithMenuData storeWithMenuData) {
try { try {
// Python AI 서비스로 전송할 데이터
Map<String, Object> requestData = Map.of( StoreData storeData = storeWithMenuData.getStoreData();
"store_name", storeData.getStoreName(), List<MenuData> menuDataList = storeWithMenuData.getMenuDataList();
"business_type", storeData.getBusinessType(),
"location", storeData.getLocation(), // 메뉴 데이터를 Map 형태로 변환
"seat_count", storeData.getSeatCount() List<Map<String, Object>> menuList = menuDataList.stream()
); .map(menu -> {
Map<String, Object> menuMap = new HashMap<>();
menuMap.put("menu_id", menu.getMenuId());
menuMap.put("menu_name", menu.getMenuName());
menuMap.put("category", menu.getCategory());
menuMap.put("price", menu.getPrice());
menuMap.put("description", menu.getDescription());
return menuMap;
})
.collect(Collectors.toList());
// Python AI 서비스로 전송할 데이터 (매장 정보 + 메뉴 정보)
Map<String, Object> requestData = new HashMap<>();
requestData.put("store_name", storeData.getStoreName());
requestData.put("business_type", storeData.getBusinessType());
requestData.put("location", storeData.getLocation());
requestData.put("seat_count", storeData.getSeatCount());
requestData.put("menu_list", menuList);
log.debug("Python AI 서비스 요청 데이터: {}", requestData); log.debug("Python AI 서비스 요청 데이터: {}", requestData);
@ -75,16 +98,16 @@ public class PythonAiTipGenerator implements AiTipGenerator {
log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage()); log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage());
} }
return createFallbackTip(storeData); return createFallbackTip(storeWithMenuData);
} }
/** /**
* 규칙 기반 Fallback 생성 (날씨 정보 없이 매장 정보만 활용) * 규칙 기반 Fallback 생성 (날씨 정보 없이 매장 정보만 활용)
*/ */
private String createFallbackTip(StoreData storeData) { private String createFallbackTip(StoreWithMenuData storeWithMenuData) {
String businessType = storeData.getBusinessType(); String businessType = storeWithMenuData.getStoreData().getBusinessType();
String storeName = storeData.getStoreName(); String storeName = storeWithMenuData.getStoreData().getStoreName();
String location = storeData.getLocation(); String location = storeWithMenuData.getStoreData().getLocation();
// 업종별 기본 생성 // 업종별 기본 생성
if (businessType.contains("카페")) { if (businessType.contains("카페")) {

View File

@ -2,7 +2,9 @@ package com.won.smarketing.recommend.infrastructure.external;
import com.won.smarketing.common.exception.BusinessException; import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode; import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.recommend.domain.model.MenuData;
import com.won.smarketing.recommend.domain.model.StoreData; import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
import com.won.smarketing.recommend.domain.service.StoreDataProvider; import com.won.smarketing.recommend.domain.service.StoreDataProvider;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.Getter; import lombok.Getter;
@ -15,9 +17,14 @@ import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientException;
import org.springframework.web.reactive.function.client.WebClientResponseException; import org.springframework.web.reactive.function.client.WebClientResponseException;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/** /**
* 매장 API 데이터 제공자 구현체 * 매장 API 데이터 제공자 구현체
@ -38,13 +45,35 @@ public class StoreApiDataProvider implements StoreDataProvider {
private static final String AUTHORIZATION_HEADER = "Authorization"; private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer "; private static final String BEARER_PREFIX = "Bearer ";
/** public StoreWithMenuData getStoreWithMenuData(String userId) {
* 사용자 ID로 매장 정보 조회 log.info("매장 정보와 메뉴 정보 통합 조회 시작: userId={}", userId);
*
* @param userId 사용자 ID try {
* @return 매장 정보 // 매장 정보와 메뉴 정보를 병렬로 조회
*/ StoreData storeData = getStoreDataByUserId(userId);
@Override List<MenuData> menuDataList = getMenusByStoreId(storeData.getStoreId());
StoreWithMenuData result = StoreWithMenuData.builder()
.storeData(storeData)
.menuDataList(menuDataList)
.build();
log.info("매장 정보와 메뉴 정보 통합 조회 완료: storeId={}, storeName={}, menuCount={}",
storeData.getStoreId(), storeData.getStoreName(), menuDataList.size());
return result;
} catch (Exception e) {
log.error("매장 정보와 메뉴 정보 통합 조회 실패, Mock 데이터 반환: storeId={}", userId, e);
// 실패 Mock 데이터 반환
return StoreWithMenuData.builder()
.storeData(createMockStoreData(userId))
.menuDataList(createMockMenuData(6L))
.build();
}
}
public StoreData getStoreDataByUserId(String userId) { public StoreData getStoreDataByUserId(String userId) {
try { try {
log.debug("매장 정보 실시간 조회: userId={}", userId); log.debug("매장 정보 실시간 조회: userId={}", userId);
@ -56,6 +85,18 @@ public class StoreApiDataProvider implements StoreDataProvider {
} }
} }
public List<MenuData> getMenusByStoreId(Long storeId) {
log.info("매장 메뉴 조회 시작: storeId={}", storeId);
try {
return callMenuService(storeId);
} catch (Exception e) {
log.error("메뉴 조회 실패, Mock 데이터 반환: storeId={}", storeId, e);
return createMockMenuData(storeId);
}
}
private StoreData callStoreServiceByUserId(String userId) { private StoreData callStoreServiceByUserId(String userId) {
try { try {
@ -92,15 +133,6 @@ public class StoreApiDataProvider implements StoreDataProvider {
return createMockStoreData(userId); return createMockStoreData(userId);
} }
private String getCurrentUserId() {
try {
return SecurityContextHolder.getContext().getAuthentication().getName();
} catch (Exception e) {
log.warn("사용자 ID 조회 실패: {}", e.getMessage());
return null;
}
}
private String getCurrentJwtToken() { private String getCurrentJwtToken() {
try { try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
@ -128,6 +160,50 @@ public class StoreApiDataProvider implements StoreDataProvider {
} }
} }
private List<MenuData> callMenuService(Long storeId) {
try {
MenuApiResponse response = webClient
.get()
.uri(storeServiceBaseUrl + "/api/menu/store/" + storeId)
.retrieve()
.bodyToMono(MenuApiResponse.class)
.timeout(Duration.ofMillis(timeout))
.block();
if (response != null && response.getData() != null && !response.getData().isEmpty()) {
List<MenuData> menuDataList = response.getData().stream()
.map(this::toMenuData)
.collect(Collectors.toList());
log.info("매장 메뉴 조회 성공: storeId={}, menuCount={}", storeId, menuDataList.size());
return menuDataList;
}
} catch (WebClientResponseException e) {
if (e.getStatusCode().value() == 404) {
log.warn("매장의 메뉴 정보가 없습니다: storeId={}", storeId);
return Collections.emptyList();
}
log.error("메뉴 서비스 호출 실패: storeId={}, error={}", storeId, e.getMessage());
} catch (WebClientException e) {
log.error("메뉴 서비스 연결 실패: storeId={}, error={}", storeId, e.getMessage());
}
return createMockMenuData(storeId);
}
/**
* MenuResponse를 MenuData로 변환
*/
private MenuData toMenuData(MenuApiResponse.MenuInfo menuInfo) {
return MenuData.builder()
.menuId(menuInfo.getMenuId())
.menuName(menuInfo.getMenuName())
.category(menuInfo.getCategory())
.price(menuInfo.getPrice())
.description(menuInfo.getDescription())
.build();
}
private StoreData createMockStoreData(String userId) { private StoreData createMockStoreData(String userId) {
return StoreData.builder() return StoreData.builder()
.storeName("테스트 카페 " + userId) .storeName("테스트 카페 " + userId)
@ -136,6 +212,35 @@ public class StoreApiDataProvider implements StoreDataProvider {
.build(); .build();
} }
private List<MenuData> createMockMenuData(Long storeId) {
log.info("Mock 메뉴 데이터 생성: storeId={}", storeId);
return List.of(
MenuData.builder()
.menuId(1L)
.menuName("아메리카노")
.category("음료")
.price(4000)
.description("깊고 진한 맛의 아메리카노")
.build(),
MenuData.builder()
.menuId(2L)
.menuName("카페라떼")
.category("음료")
.price(4500)
.description("부드러운 우유 거품이 올라간 카페라떼")
.build(),
MenuData.builder()
.menuId(3L)
.menuName("치즈케이크")
.category("디저트")
.price(6000)
.description("진한 치즈 맛의 수제 케이크")
.build()
);
}
@Getter @Getter
private static class StoreApiResponse { private static class StoreApiResponse {
private int status; private int status;
@ -159,4 +264,48 @@ public class StoreApiDataProvider implements StoreDataProvider {
private Integer seatCount; private Integer seatCount;
} }
} }
/**
* Menu API 응답 DTO (새로 추가)
*/
private static class MenuApiResponse {
private List<MenuInfo> data;
private String message;
private boolean success;
public List<MenuInfo> getData() { return data; }
public void setData(List<MenuInfo> data) { this.data = data; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public boolean isSuccess() { return success; }
public void setSuccess(boolean success) { this.success = success; }
public static class MenuInfo {
private Long menuId;
private String menuName;
private String category;
private Integer price;
private String description;
private String image;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Long getMenuId() { return menuId; }
public void setMenuId(Long menuId) { this.menuId = menuId; }
public String getMenuName() { return menuName; }
public void setMenuName(String menuName) { this.menuName = menuName; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public Integer getPrice() { return price; }
public void setPrice(Integer price) { this.price = price; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getImage() { return image; }
public void setImage(String image) { this.image = image; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
}
} }

View File

@ -1,7 +1,7 @@
package com.won.smarketing.recommend.infrastructure.persistence; package com.won.smarketing.recommend.infrastructure.persistence;
import com.won.smarketing.recommend.domain.model.MarketingTip; import com.won.smarketing.recommend.domain.model.MarketingTip;
import com.won.smarketing.recommend.domain.model.StoreData; import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
import com.won.smarketing.recommend.domain.repository.MarketingTipRepository; import com.won.smarketing.recommend.domain.repository.MarketingTipRepository;
import com.won.smarketing.recommend.domain.service.StoreDataProvider; import com.won.smarketing.recommend.domain.service.StoreDataProvider;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -28,8 +28,8 @@ public class MarketingTipRepositoryImpl implements MarketingTipRepository {
MarketingTipEntity savedEntity = jpaRepository.save(entity); MarketingTipEntity savedEntity = jpaRepository.save(entity);
// Store 정보는 다시 조회해서 Domain에 설정 // Store 정보는 다시 조회해서 Domain에 설정
StoreData storeData = storeDataProvider.getStoreDataByUserId(userId); StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
return savedEntity.toDomain(storeData); return savedEntity.toDomain(storeWithMenuData.getStoreData());
} }
@Override @Override
@ -37,8 +37,8 @@ public class MarketingTipRepositoryImpl implements MarketingTipRepository {
return jpaRepository.findById(tipId) return jpaRepository.findById(tipId)
.map(entity -> { .map(entity -> {
// Store 정보를 API로 조회 // Store 정보를 API로 조회
StoreData storeData = storeDataProvider.getStoreDataByUserId(entity.getUserId()); StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(entity.getUserId());
return entity.toDomain(storeData); return entity.toDomain(storeWithMenuData.getStoreData());
}); });
} }
@ -56,9 +56,9 @@ public class MarketingTipRepositoryImpl implements MarketingTipRepository {
Page<MarketingTipEntity> entities = jpaRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); Page<MarketingTipEntity> entities = jpaRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable);
// Store 정보는 번만 조회 (같은 userId이므로) // Store 정보는 번만 조회 (같은 userId이므로)
StoreData storeData = storeDataProvider.getStoreDataByUserId(userId); StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
return entities.map(entity -> entity.toDomain(storeData)); return entities.map(entity -> entity.toDomain(storeWithMenuData.getStoreData()));
} }
/** /**
@ -67,8 +67,8 @@ public class MarketingTipRepositoryImpl implements MarketingTipRepository {
public Optional<MarketingTip> findMostRecentByUserId(String userId) { public Optional<MarketingTip> findMostRecentByUserId(String userId) {
return jpaRepository.findTopByUserIdOrderByCreatedAtDesc(userId) return jpaRepository.findTopByUserIdOrderByCreatedAtDesc(userId)
.map(entity -> { .map(entity -> {
StoreData storeData = storeDataProvider.getStoreDataByUserId(userId); StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
return entity.toDomain(storeData); return entity.toDomain(storeWithMenuData.getStoreData());
}); });
} }

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

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