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
@@ -4,7 +4,9 @@ import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.recommend.application.usecase.MarketingTipUseCase;
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.StoreWithMenuData;
import com.won.smarketing.recommend.domain.repository.MarketingTipRepository;
import com.won.smarketing.recommend.domain.service.AiTipGenerator;
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 java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Slf4j
@@ -38,21 +41,21 @@ public class MarketingTipService implements MarketingTipUseCase {
try {
// 1. 사용자의 매장 정보 조회
StoreData storeData = storeDataProvider.getStoreDataByUserId(userId);
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
// 2. 1시간 이내에 생성된 마케팅 팁이 있는지 DB에서 확인
Optional<MarketingTip> recentTip = findRecentMarketingTip(storeData.getStoreId());
Optional<MarketingTip> recentTip = findRecentMarketingTip(storeWithMenuData.getStoreData().getStoreId());
if (recentTip.isPresent()) {
log.info("1시간 이내에 생성된 마케팅 팁 발견: tipId={}", recentTip.get().getId().getValue());
log.info("1시간 이내에 생성된 마케팅 팁 발견: getTipContent()={}", recentTip.get().getTipContent());
return convertToResponse(recentTip.get(), storeData, true);
return convertToResponse(recentTip.get(), storeWithMenuData.getStoreData(), true);
}
// 3. 1시간 이내 팁이 없으면 새로 생성
log.info("1시간 이내 마케팅 팁이 없어 새로 생성합니다: userId={}, storeId={}", userId, storeData.getStoreId());
MarketingTip newTip = createNewMarketingTip(storeData);
return convertToResponse(newTip, storeData, false);
log.info("1시간 이내 마케팅 팁이 없어 새로 생성합니다: userId={}, storeId={}", userId, storeWithMenuData.getStoreData().getStoreId());
MarketingTip newTip = createNewMarketingTip(storeWithMenuData);
return convertToResponse(newTip, storeWithMenuData.getStoreData(), false);
} catch (Exception e) {
log.error("마케팅 팁 조회/생성 중 오류: userId={}", userId, e);
@@ -93,18 +96,18 @@ public class MarketingTipService implements MarketingTipUseCase {
/**
* 새로운 마케팅 팁 생성
*/
private MarketingTip createNewMarketingTip(StoreData storeData) {
log.info("새로운 마케팅 팁 생성 시작: storeName={}", storeData.getStoreName());
private MarketingTip createNewMarketingTip(StoreWithMenuData storeWithMenuData) {
log.info("새로운 마케팅 팁 생성 시작: storeName={}", storeWithMenuData.getStoreData().getStoreName());
// AI 서비스로 팁 생성
String aiGeneratedTip = aiTipGenerator.generateTip(storeData);
String aiGeneratedTip = aiTipGenerator.generateTip(storeWithMenuData);
log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length())));
// 도메인 객체 생성 및 저장
MarketingTip marketingTip = MarketingTip.builder()
.storeId(storeData.getStoreId())
.storeId(storeWithMenuData.getStoreData().getStoreId())
.tipContent(aiGeneratedTip)
.storeData(storeData)
.storeWithMenuData(storeWithMenuData)
.createdAt(LocalDateTime.now())
.build();
@@ -18,8 +18,8 @@ public class WebClientConfig {
@Bean
public WebClient webClient() {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
.responseTimeout(Duration.ofMillis(5000));
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000)
.responseTimeout(Duration.ofMillis(30000));
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
@@ -21,15 +21,15 @@ public class MarketingTip {
private Long storeId;
private String tipSummary;
private String tipContent;
private StoreData storeData;
private StoreWithMenuData storeWithMenuData;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public static MarketingTip create(Long storeId, String tipContent, StoreData storeData) {
public static MarketingTip create(Long storeId, String tipContent, StoreWithMenuData storeWithMenuData) {
return MarketingTip.builder()
.storeId(storeId)
.tipContent(tipContent)
.storeData(storeData)
.storeWithMenuData(storeWithMenuData)
.createdAt(LocalDateTime.now())
.build();
}
@@ -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;
}
@@ -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;
}
@@ -1,6 +1,7 @@
package com.won.smarketing.recommend.domain.service;
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
/**
* AI 팁 생성 도메인 서비스 인터페이스 (단순화)
@@ -10,8 +11,8 @@ public interface AiTipGenerator {
/**
* Python AI 서비스를 통한 마케팅 팁 생성
*
* @param storeData 매장 정보
* @param storeWithMenuData 매장 및 메뉴 정보
* @return AI가 생성한 마케팅 팁
*/
String generateTip(StoreData storeData);
String generateTip(StoreWithMenuData storeWithMenuData);
}
@@ -1,17 +1,13 @@
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 {
/**
* 사용자 ID로 매장 정보 조회
*
* @param userId 사용자 ID
* @return 매장 정보
*/
StoreData getStoreDataByUserId(String userId);
StoreWithMenuData getStoreWithMenuData(String userId);
}
@@ -1,6 +1,8 @@
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.StoreWithMenuData;
import com.won.smarketing.recommend.domain.service.AiTipGenerator;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@@ -11,7 +13,10 @@ import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Python AI 팁 생성 구현체 (날씨 정보 제거)
@@ -33,26 +38,44 @@ public class PythonAiTipGenerator implements AiTipGenerator {
private int timeout;
@Override
public String generateTip(StoreData storeData) {
public String generateTip(StoreWithMenuData storeWithMenuData) {
try {
log.debug("Python AI 서비스 직접 호출: store={}", storeData.getStoreName());
return callPythonAiService(storeData);
log.debug("Python AI 서비스 직접 호출: store={}", storeWithMenuData.getStoreData().getStoreName());
return callPythonAiService(storeWithMenuData);
} catch (Exception e) {
log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage());
return createFallbackTip(storeData);
return createFallbackTip(storeWithMenuData);
}
}
private String callPythonAiService(StoreData storeData) {
private String callPythonAiService(StoreWithMenuData storeWithMenuData) {
try {
// Python AI 서비스로 전송할 데이터
Map<String, Object> requestData = Map.of(
"store_name", storeData.getStoreName(),
"business_type", storeData.getBusinessType(),
"location", storeData.getLocation(),
"seat_count", storeData.getSeatCount()
);
StoreData storeData = storeWithMenuData.getStoreData();
List<MenuData> menuDataList = storeWithMenuData.getMenuDataList();
// 메뉴 데이터를 Map 형태로 변환
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);
@@ -75,16 +98,16 @@ public class PythonAiTipGenerator implements AiTipGenerator {
log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage());
}
return createFallbackTip(storeData);
return createFallbackTip(storeWithMenuData);
}
/**
* 규칙 기반 Fallback 팁 생성 (날씨 정보 없이 매장 정보만 활용)
*/
private String createFallbackTip(StoreData storeData) {
String businessType = storeData.getBusinessType();
String storeName = storeData.getStoreName();
String location = storeData.getLocation();
private String createFallbackTip(StoreWithMenuData storeWithMenuData) {
String businessType = storeWithMenuData.getStoreData().getBusinessType();
String storeName = storeWithMenuData.getStoreData().getStoreName();
String location = storeWithMenuData.getStoreData().getLocation();
// 업종별 기본 팁 생성
if (businessType.contains("카페")) {
@@ -2,7 +2,9 @@ package com.won.smarketing.recommend.infrastructure.external;
import com.won.smarketing.common.exception.BusinessException;
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.StoreWithMenuData;
import com.won.smarketing.recommend.domain.service.StoreDataProvider;
import jakarta.servlet.http.HttpServletRequest;
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.ServletRequestAttributes;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientException;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/**
* 매장 API 데이터 제공자 구현체
@@ -38,13 +45,35 @@ public class StoreApiDataProvider implements StoreDataProvider {
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
/**
* 사용자 ID로 매장 정보 조회
*
* @param userId 사용자 ID
* @return 매장 정보
*/
@Override
public StoreWithMenuData getStoreWithMenuData(String userId) {
log.info("매장 정보와 메뉴 정보 통합 조회 시작: userId={}", userId);
try {
// 매장 정보와 메뉴 정보를 병렬로 조회
StoreData storeData = getStoreDataByUserId(userId);
List<MenuData> menuDataList = getMenusByStoreId(storeData.getStoreId());
StoreWithMenuData result = StoreWithMenuData.builder()
.storeData(storeData)
.menuDataList(menuDataList)
.build();
log.info("매장 정보와 메뉴 정보 통합 조회 완료: storeId={}, storeName={}, menuCount={}",
storeData.getStoreId(), storeData.getStoreName(), menuDataList.size());
return result;
} catch (Exception e) {
log.error("매장 정보와 메뉴 정보 통합 조회 실패, Mock 데이터 반환: storeId={}", userId, e);
// 실패 시 Mock 데이터 반환
return StoreWithMenuData.builder()
.storeData(createMockStoreData(userId))
.menuDataList(createMockMenuData(6L))
.build();
}
}
public StoreData getStoreDataByUserId(String userId) {
try {
log.debug("매장 정보 실시간 조회: userId={}", userId);
@@ -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) {
try {
@@ -92,15 +133,6 @@ public class StoreApiDataProvider implements StoreDataProvider {
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() {
try {
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) {
return StoreData.builder()
.storeName("테스트 카페 " + userId)
@@ -136,6 +212,35 @@ public class StoreApiDataProvider implements StoreDataProvider {
.build();
}
private List<MenuData> createMockMenuData(Long storeId) {
log.info("Mock 메뉴 데이터 생성: storeId={}", storeId);
return List.of(
MenuData.builder()
.menuId(1L)
.menuName("아메리카노")
.category("음료")
.price(4000)
.description("깊고 진한 맛의 아메리카노")
.build(),
MenuData.builder()
.menuId(2L)
.menuName("카페라떼")
.category("음료")
.price(4500)
.description("부드러운 우유 거품이 올라간 카페라떼")
.build(),
MenuData.builder()
.menuId(3L)
.menuName("치즈케이크")
.category("디저트")
.price(6000)
.description("진한 치즈 맛의 수제 케이크")
.build()
);
}
@Getter
private static class StoreApiResponse {
private int status;
@@ -159,4 +264,48 @@ public class StoreApiDataProvider implements StoreDataProvider {
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; }
}
}
}
@@ -1,7 +1,7 @@
package com.won.smarketing.recommend.infrastructure.persistence;
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.service.StoreDataProvider;
import lombok.RequiredArgsConstructor;
@@ -28,8 +28,8 @@ public class MarketingTipRepositoryImpl implements MarketingTipRepository {
MarketingTipEntity savedEntity = jpaRepository.save(entity);
// Store 정보는 다시 조회해서 Domain에 설정
StoreData storeData = storeDataProvider.getStoreDataByUserId(userId);
return savedEntity.toDomain(storeData);
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
return savedEntity.toDomain(storeWithMenuData.getStoreData());
}
@Override
@@ -37,8 +37,8 @@ public class MarketingTipRepositoryImpl implements MarketingTipRepository {
return jpaRepository.findById(tipId)
.map(entity -> {
// Store 정보를 API로 조회
StoreData storeData = storeDataProvider.getStoreDataByUserId(entity.getUserId());
return entity.toDomain(storeData);
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(entity.getUserId());
return entity.toDomain(storeWithMenuData.getStoreData());
});
}
@@ -56,9 +56,9 @@ public class MarketingTipRepositoryImpl implements MarketingTipRepository {
Page<MarketingTipEntity> entities = jpaRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable);
// 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) {
return jpaRepository.findTopByUserIdOrderByCreatedAtDesc(userId)
.map(entity -> {
StoreData storeData = storeDataProvider.getStoreDataByUserId(userId);
return entity.toDomain(storeData);
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
return entity.toDomain(storeWithMenuData.getStoreData());
});
}