Merge branch 'main' into marketing-contents

This commit is contained in:
박서은
2025-06-17 10:21:53 +09:00
77 changed files with 3466 additions and 1149 deletions
@@ -4,23 +4,26 @@ 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.StoreDataProvider;
import com.won.smarketing.recommend.domain.service.AiTipGenerator;
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
import com.won.smarketing.recommend.domain.service.StoreDataProvider;
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 마케팅 팁 서비스 구현체
*/
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
@@ -32,70 +35,134 @@ public class MarketingTipService implements MarketingTipUseCase {
private final AiTipGenerator aiTipGenerator;
@Override
public MarketingTipResponse generateMarketingTips(MarketingTipRequest request) {
log.info("마케팅 팁 생성 시작: storeId={}", request.getStoreId());
public MarketingTipResponse provideMarketingTip() {
String userId = getCurrentUserId();
log.info("마케팅 팁 제공: userId={}", userId);
try {
// 1. 매장 정보 조회
StoreData storeData = storeDataProvider.getStoreData(request.getStoreId());
log.debug("매장 정보 조회 완료: {}", storeData.getStoreName());
// 2. Python AI 서비스로 팁 생성 (매장 정보 + 추가 요청사항 전달)
String aiGeneratedTip = aiTipGenerator.generateTip(storeData, request.getAdditionalRequirement());
log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length())));
// 3. 도메인 객체 생성 및 저장
MarketingTip marketingTip = MarketingTip.builder()
.storeId(request.getStoreId())
.tipContent(aiGeneratedTip)
.storeData(storeData)
.build();
MarketingTip savedTip = marketingTipRepository.save(marketingTip);
log.info("마케팅 팁 저장 완료: tipId={}", savedTip.getId().getValue());
return convertToResponse(savedTip);
// 1. 사용자의 매장 정보 조회
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
// 2. 1시간 이내에 생성된 마케팅 팁이 있는지 DB에서 확인
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(), storeWithMenuData.getStoreData(), true);
}
// 3. 1시간 이내 팁이 없으면 새로 생성
log.info("1시간 이내 마케팅 팁이 없어 새로 생성합니다: userId={}, storeId={}", userId, storeWithMenuData.getStoreData().getStoreId());
MarketingTip newTip = createNewMarketingTip(storeWithMenuData);
return convertToResponse(newTip, storeWithMenuData.getStoreData(), false);
} catch (Exception e) {
log.error("마케팅 팁 생성 중 오류: storeId={}", request.getStoreId(), e);
log.error("마케팅 팁 조회/생성 중 오류: userId={}", userId, e);
throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR);
}
}
@Override
@Transactional(readOnly = true)
@Cacheable(value = "marketingTipHistory", key = "#storeId + '_' + #pageable.pageNumber + '_' + #pageable.pageSize")
public Page<MarketingTipResponse> getMarketingTipHistory(Long storeId, Pageable pageable) {
log.info("마케팅 팁 이력 조회: storeId={}", storeId);
Page<MarketingTip> tips = marketingTipRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable);
return tips.map(this::convertToResponse);
/**
* DB에서 1시간 이내 생성된 마케팅 팁 조회
*/
private Optional<MarketingTip> findRecentMarketingTip(Long storeId) {
log.debug("DB에서 1시간 이내 마케팅 팁 조회: storeId={}", storeId);
// 최근 생성된 팁 1개 조회
Pageable pageable = PageRequest.of(0, 1);
Page<MarketingTip> recentTips = marketingTipRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable);
if (recentTips.isEmpty()) {
log.debug("매장의 마케팅 팁이 존재하지 않음: storeId={}", storeId);
return Optional.empty();
}
MarketingTip mostRecentTip = recentTips.getContent().get(0);
LocalDateTime oneHourAgo = LocalDateTime.now().minusHours(1);
// 1시간 이내에 생성된 팁인지 확인
if (mostRecentTip.getCreatedAt().isAfter(oneHourAgo)) {
log.debug("1시간 이내 마케팅 팁 발견: tipId={}, 생성시간={}",
mostRecentTip.getId().getValue(), mostRecentTip.getCreatedAt());
return Optional.of(mostRecentTip);
}
log.debug("가장 최근 팁이 1시간 이전에 생성됨: tipId={}, 생성시간={}",
mostRecentTip.getId().getValue(), mostRecentTip.getCreatedAt());
return Optional.empty();
}
@Override
@Transactional(readOnly = true)
public MarketingTipResponse getMarketingTip(Long tipId) {
log.info("마케팅 팁 상세 조회: tipId={}", tipId);
MarketingTip marketingTip = marketingTipRepository.findById(tipId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR));
return convertToResponse(marketingTip);
/**
* 새로운 마케팅 팁 생성
*/
private MarketingTip createNewMarketingTip(StoreWithMenuData storeWithMenuData) {
log.info("새로운 마케팅 팁 생성 시작: storeName={}", storeWithMenuData.getStoreData().getStoreName());
// AI 서비스로 팁 생성
String aiGeneratedTip = aiTipGenerator.generateTip(storeWithMenuData);
log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length())));
// 도메인 객체 생성 및 저장
MarketingTip marketingTip = MarketingTip.builder()
.storeId(storeWithMenuData.getStoreData().getStoreId())
.tipContent(aiGeneratedTip)
.storeWithMenuData(storeWithMenuData)
.createdAt(LocalDateTime.now())
.build();
MarketingTip savedTip = marketingTipRepository.save(marketingTip);
log.info("새로운 마케팅 팁 저장 완료: tipId={}", savedTip.getId().getValue());
log.info("새로운 마케팅 팁 저장 완료: savedTip.getTipContent()={}", savedTip.getTipContent());
return savedTip;
}
private MarketingTipResponse convertToResponse(MarketingTip marketingTip) {
/**
* 마케팅 팁을 응답 DTO로 변환 (전체 내용 포함)
*/
private MarketingTipResponse convertToResponse(MarketingTip marketingTip, StoreData storeData, boolean isRecentlyCreated) {
String tipSummary = generateTipSummary(marketingTip.getTipContent());
return MarketingTipResponse.builder()
.tipId(marketingTip.getId().getValue())
.storeId(marketingTip.getStoreId())
.storeName(marketingTip.getStoreData().getStoreName())
.tipContent(marketingTip.getTipContent())
.tipSummary(tipSummary)
.tipContent(marketingTip.getTipContent()) // 🆕 전체 내용 포함
.storeInfo(MarketingTipResponse.StoreInfo.builder()
.storeName(marketingTip.getStoreData().getStoreName())
.businessType(marketingTip.getStoreData().getBusinessType())
.location(marketingTip.getStoreData().getLocation())
.storeName(storeData.getStoreName())
.businessType(storeData.getBusinessType())
.location(storeData.getLocation())
.build())
.createdAt(marketingTip.getCreatedAt())
.updatedAt(marketingTip.getUpdatedAt())
.isRecentlyCreated(isRecentlyCreated)
.build();
}
}
/**
* 마케팅 팁 요약 생성 (첫 50자 또는 첫 번째 문장)
*/
private String generateTipSummary(String fullContent) {
if (fullContent == null || fullContent.trim().isEmpty()) {
return "마케팅 팁이 생성되었습니다.";
}
// 첫 번째 문장으로 요약 (마침표 기준)
String[] sentences = fullContent.split("[.!?]");
String firstSentence = sentences.length > 0 ? sentences[0].trim() : fullContent;
// 50자 제한
if (firstSentence.length() > 50) {
return firstSentence.substring(0, 47) + "...";
}
return firstSentence;
}
/**
* 현재 로그인된 사용자 ID 조회
*/
private String getCurrentUserId() {
return SecurityContextHolder.getContext().getAuthentication().getName();
}
}
@@ -1,27 +1,12 @@
package com.won.smarketing.recommend.application.usecase;
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
/**
* 마케팅 팁 유즈케이스 인터페이스
*/
public interface MarketingTipUseCase {
/**
* AI 마케팅 팁 생성
* 마케팅 팁 제공
* 1시간 이내 팁이 있으면 기존 것 사용, 없으면 새로 생성
*/
MarketingTipResponse generateMarketingTips(MarketingTipRequest request);
/**
* 마케팅 팁 이력 조회
*/
Page<MarketingTipResponse> getMarketingTipHistory(Long storeId, Pageable pageable);
/**
* 마케팅 팁 상세 조회
*/
MarketingTipResponse getMarketingTip(Long tipId);
}
MarketingTipResponse provideMarketingTip();
}
@@ -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))
@@ -4,6 +4,7 @@ import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.cglib.core.Local;
import java.time.LocalDateTime;
@@ -15,19 +16,21 @@ import java.time.LocalDateTime;
@NoArgsConstructor
@AllArgsConstructor
public class MarketingTip {
private TipId id;
private Long storeId;
private String tipSummary;
private String tipContent;
private StoreData storeData;
private StoreWithMenuData storeWithMenuData;
private LocalDateTime createdAt;
public static MarketingTip create(Long storeId, String tipContent, StoreData storeData) {
private LocalDateTime updatedAt;
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;
}
@@ -13,7 +13,10 @@ import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
public class StoreData {
private Long storeId;
private String storeName;
private String businessType;
private String location;
private String description;
private Integer seatCount;
}
@@ -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,9 +11,8 @@ public interface AiTipGenerator {
/**
* Python AI 서비스를 통한 마케팅 팁 생성
*
* @param storeData 매장 정보
* @param additionalRequirement 추가 요청사항
* @param storeWithMenuData 매장 및 메뉴 정보
* @return AI가 생성한 마케팅 팁
*/
String generateTip(StoreData storeData, String additionalRequirement);
String generateTip(StoreWithMenuData storeWithMenuData);
}
@@ -1,11 +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 {
StoreData getStoreData(Long storeId);
StoreWithMenuData getStoreWithMenuData(String userId);
}
@@ -1,7 +1,10 @@
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;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@@ -9,7 +12,11 @@ import org.springframework.stereotype.Service; // 이 어노테이션이 누락
import org.springframework.web.reactive.function.client.WebClient;
import 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 팁 생성 구현체 (날씨 정보 제거)
@@ -31,37 +38,44 @@ public class PythonAiTipGenerator implements AiTipGenerator {
private int timeout;
@Override
public String generateTip(StoreData storeData, String additionalRequirement) {
public String generateTip(StoreWithMenuData storeWithMenuData) {
try {
log.debug("Python AI 서비스 호출: store={}", storeData.getStoreName());
// Python AI 서비스 사용 가능 여부 확인
if (isPythonServiceAvailable()) {
return callPythonAiService(storeData, additionalRequirement);
} else {
log.warn("Python AI 서비스 사용 불가, Fallback 처리");
return createFallbackTip(storeData, additionalRequirement);
}
log.debug("Python AI 서비스 직접 호출: store={}", storeWithMenuData.getStoreData().getStoreName());
return callPythonAiService(storeWithMenuData);
} catch (Exception e) {
log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage());
return createFallbackTip(storeData, additionalRequirement);
return createFallbackTip(storeWithMenuData);
}
}
private boolean isPythonServiceAvailable() {
return !pythonAiServiceApiKey.equals("dummy-key");
}
private String callPythonAiService(StoreWithMenuData storeWithMenuData) {
private String callPythonAiService(StoreData storeData, String additionalRequirement) {
try {
// Python AI 서비스로 전송할 데이터 (날씨 정보 제거, 매장 정보만 전달)
Map<String, Object> requestData = Map.of(
"store_name", storeData.getStoreName(),
"business_type", storeData.getBusinessType(),
"location", storeData.getLocation(),
"additional_requirement", additionalRequirement != null ? additionalRequirement : ""
);
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);
@@ -84,22 +98,16 @@ public class PythonAiTipGenerator implements AiTipGenerator {
log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage());
}
return createFallbackTip(storeData, additionalRequirement);
return createFallbackTip(storeWithMenuData);
}
/**
* 규칙 기반 Fallback 팁 생성 (날씨 정보 없이 매장 정보만 활용)
*/
private String createFallbackTip(StoreData storeData, String additionalRequirement) {
String businessType = storeData.getBusinessType();
String storeName = storeData.getStoreName();
String location = storeData.getLocation();
// 추가 요청사항이 있는 경우 우선 반영
if (additionalRequirement != null && !additionalRequirement.trim().isEmpty()) {
return String.format("%s에서 %s를 중심으로 한 특별한 서비스로 고객들을 맞이해보세요!",
storeName, additionalRequirement);
}
private String createFallbackTip(StoreWithMenuData storeWithMenuData) {
String businessType = storeWithMenuData.getStoreData().getBusinessType();
String storeName = storeWithMenuData.getStoreData().getStoreName();
String location = storeWithMenuData.getStoreData().getLocation();
// 업종별 기본 팁 생성
if (businessType.contains("카페")) {
@@ -123,16 +131,13 @@ public class PythonAiTipGenerator implements AiTipGenerator {
return String.format("%s만의 특별함을 살린 고객 맞춤 서비스로 단골 고객을 늘려보세요!", storeName);
}
@Getter
private static class PythonAiResponse {
private String tip;
private String status;
private String message;
public String getTip() { return tip; }
public void setTip(String tip) { this.tip = tip; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
private LocalDateTime generatedTip;
private String businessType;
private String aiModel;
}
}
@@ -2,17 +2,29 @@ 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;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service; // 이 어노테이션이 누락되어 있었음
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.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 데이터 제공자 구현체
@@ -30,46 +42,85 @@ public class StoreApiDataProvider implements StoreDataProvider {
@Value("${external.store-service.timeout}")
private int timeout;
@Override
@Cacheable(value = "storeData", key = "#storeId")
public StoreData getStoreData(Long storeId) {
try {
log.debug("매장 정보 조회 시도: storeId={}", storeId);
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
// 외부 서비스 연결 시도, 실패 시 Mock 데이터 반환
if (isStoreServiceAvailable()) {
return callStoreService(storeId);
} else {
log.warn("매장 서비스 연결 불가, Mock 데이터 반환: storeId={}", storeId);
return createMockStoreData(storeId);
}
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={}", storeId, e);
return createMockStoreData(storeId);
log.error("매장 정보와 메뉴 정보 통합 조회 실패, Mock 데이터 반환: storeId={}", userId, e);
// 실패 시 Mock 데이터 반환
return StoreWithMenuData.builder()
.storeData(createMockStoreData(userId))
.menuDataList(createMockMenuData(6L))
.build();
}
}
private boolean isStoreServiceAvailable() {
return !storeServiceBaseUrl.equals("http://localhost:8082");
public StoreData getStoreDataByUserId(String userId) {
try {
log.debug("매장 정보 실시간 조회: userId={}", userId);
return callStoreServiceByUserId(userId);
} catch (Exception e) {
log.error("매장 정보 조회 실패, Mock 데이터 반환: userId={}, error={}", userId, e.getMessage());
return createMockStoreData(userId);
}
}
private StoreData callStoreService(Long storeId) {
public List<MenuData> getMenusByStoreId(Long storeId) {
log.info("매장 메뉴 조회 시작: storeId={}", storeId);
try {
return callMenuService(storeId);
} catch (Exception e) {
log.error("메뉴 조회 실패, Mock 데이터 반환: storeId={}", storeId, e);
return createMockMenuData(storeId);
}
}
private StoreData callStoreServiceByUserId(String userId) {
try {
StoreApiResponse response = webClient
.get()
.uri(storeServiceBaseUrl + "/api/store/" + storeId)
.uri(storeServiceBaseUrl + "/api/store")
.header("Authorization", "Bearer " + getCurrentJwtToken()) // JWT 토큰 추가
.retrieve()
.bodyToMono(StoreApiResponse.class)
.timeout(Duration.ofMillis(timeout))
.block();
log.info("response : {}", response.getData().getStoreName());
log.info("response : {}", response.getData().getStoreId());
if (response != null && response.getData() != null) {
StoreApiResponse.StoreInfo storeInfo = response.getData();
return StoreData.builder()
.storeId(storeInfo.getStoreId())
.storeName(storeInfo.getStoreName())
.businessType(storeInfo.getBusinessType())
.location(storeInfo.getAddress())
.description(storeInfo.getDescription())
.seatCount(storeInfo.getSeatCount())
.build();
}
} catch (WebClientResponseException e) {
@@ -79,17 +130,118 @@ public class StoreApiDataProvider implements StoreDataProvider {
log.error("매장 서비스 호출 실패: {}", e.getMessage());
}
return createMockStoreData(storeId);
return createMockStoreData(userId);
}
private StoreData createMockStoreData(Long storeId) {
private String getCurrentJwtToken() {
try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
log.warn("RequestAttributes를 찾을 수 없음 - HTTP 요청 컨텍스트 없음");
return null;
}
HttpServletRequest request = attributes.getRequest();
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
String token = bearerToken.substring(BEARER_PREFIX.length());
log.debug("JWT 토큰 추출 성공: {}...", token.substring(0, Math.min(10, token.length())));
return token;
} else {
log.warn("Authorization 헤더에서 Bearer 토큰을 찾을 수 없음: {}", bearerToken);
return null;
}
} catch (Exception e) {
log.error("JWT 토큰 추출 중 오류 발생: {}", e.getMessage());
return null;
}
}
private List<MenuData> callMenuService(Long storeId) {
try {
MenuApiResponse response = webClient
.get()
.uri(storeServiceBaseUrl + "/api/menu/store/" + storeId)
.retrieve()
.bodyToMono(MenuApiResponse.class)
.timeout(Duration.ofMillis(timeout))
.block();
if (response != null && response.getData() != null && !response.getData().isEmpty()) {
List<MenuData> menuDataList = response.getData().stream()
.map(this::toMenuData)
.collect(Collectors.toList());
log.info("매장 메뉴 조회 성공: storeId={}, menuCount={}", storeId, menuDataList.size());
return menuDataList;
}
} catch (WebClientResponseException e) {
if (e.getStatusCode().value() == 404) {
log.warn("매장의 메뉴 정보가 없습니다: storeId={}", storeId);
return Collections.emptyList();
}
log.error("메뉴 서비스 호출 실패: storeId={}, error={}", storeId, e.getMessage());
} catch (WebClientException e) {
log.error("메뉴 서비스 연결 실패: storeId={}, error={}", storeId, e.getMessage());
}
return createMockMenuData(storeId);
}
/**
* MenuResponse를 MenuData로 변환
*/
private MenuData toMenuData(MenuApiResponse.MenuInfo menuInfo) {
return MenuData.builder()
.menuId(menuInfo.getMenuId())
.menuName(menuInfo.getMenuName())
.category(menuInfo.getCategory())
.price(menuInfo.getPrice())
.description(menuInfo.getDescription())
.build();
}
private StoreData createMockStoreData(String userId) {
return StoreData.builder()
.storeName("테스트 카페 " + storeId)
.storeName("테스트 카페 " + userId)
.businessType("카페")
.location("서울시 강남구")
.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;
private String message;
@@ -102,23 +254,58 @@ public class StoreApiDataProvider implements StoreDataProvider {
public StoreInfo getData() { return data; }
public void setData(StoreInfo data) { this.data = data; }
@Getter
static class StoreInfo {
private Long storeId;
private String storeName;
private String businessType;
private String address;
private String phoneNumber;
private String description;
private Integer seatCount;
}
}
public Long getStoreId() { return storeId; }
public void setStoreId(Long storeId) { this.storeId = storeId; }
public String getStoreName() { return storeName; }
public void setStoreName(String storeName) { this.storeName = storeName; }
public String getBusinessType() { return businessType; }
public void setBusinessType(String businessType) { this.businessType = businessType; }
public String getAddress() { return address; }
public void setAddress(String address) { this.address = address; }
public String getPhoneNumber() { return phoneNumber; }
public void setPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; }
/**
* 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; }
}
}
}
@@ -8,13 +8,14 @@ import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import jakarta.persistence.*;
import java.time.LocalDateTime;
/**
* 마케팅 팁 JPA 엔티티 (날씨 정보 제거)
* 마케팅 팁 JPA 엔티티
*/
@Entity
@Table(name = "marketing_tips")
@@ -27,53 +28,54 @@ public class MarketingTipEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "tip_id", nullable = false)
private Long id;
@Column(name = "user_id", nullable = false, length = 50)
private String userId;
@Column(name = "store_id", nullable = false)
private Long storeId;
@Column(name = "tip_content", nullable = false, length = 2000)
@Column(name = "tip_summary")
private String tipSummary;
@Lob
@Column(name = "tip_content", nullable = false, columnDefinition = "TEXT")
private String tipContent;
// 매장 정보만 저장
@Column(name = "store_name", length = 200)
private String storeName;
@Column(name = "business_type", length = 100)
private String businessType;
@Column(name = "store_location", length = 500)
private String storeLocation;
@Column(name = "ai_model")
private String aiModel;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
public static MarketingTipEntity fromDomain(MarketingTip marketingTip) {
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
public static MarketingTipEntity fromDomain(MarketingTip marketingTip, String userId) {
return MarketingTipEntity.builder()
.id(marketingTip.getId() != null ? marketingTip.getId().getValue() : null)
.userId(userId)
.storeId(marketingTip.getStoreId())
.tipContent(marketingTip.getTipContent())
.storeName(marketingTip.getStoreData().getStoreName())
.businessType(marketingTip.getStoreData().getBusinessType())
.storeLocation(marketingTip.getStoreData().getLocation())
.tipSummary(marketingTip.getTipSummary())
.createdAt(marketingTip.getCreatedAt())
.updatedAt(marketingTip.getUpdatedAt())
.build();
}
public MarketingTip toDomain() {
StoreData storeData = StoreData.builder()
.storeName(this.storeName)
.businessType(this.businessType)
.location(this.storeLocation)
.build();
public MarketingTip toDomain(StoreData storeData) {
return MarketingTip.builder()
.id(this.id != null ? TipId.of(this.id) : null)
.storeId(this.storeId)
.tipSummary(this.tipSummary)
.tipContent(this.tipContent)
.storeData(storeData)
.createdAt(this.createdAt)
.updatedAt(this.updatedAt)
.build();
}
}
@@ -7,12 +7,34 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* 마케팅 팁 JPA 레포지토리
*/
@Repository
public interface MarketingTipJpaRepository extends JpaRepository<MarketingTipEntity, Long> {
/**
* 매장별 마케팅 팁 조회 (기존 - storeId 기반)
*/
@Query("SELECT m FROM MarketingTipEntity m WHERE m.storeId = :storeId ORDER BY m.createdAt DESC")
Page<MarketingTipEntity> findByStoreIdOrderByCreatedAtDesc(@Param("storeId") Long storeId, Pageable pageable);
}
/**
* 사용자별 마케팅 팁 조회 (새로 추가 - userId 기반)
*/
@Query("SELECT m FROM MarketingTipEntity m WHERE m.userId = :userId ORDER BY m.createdAt DESC")
Page<MarketingTipEntity> findByUserIdOrderByCreatedAtDesc(@Param("userId") String userId, Pageable pageable);
/**
* 사용자의 가장 최근 마케팅 팁 조회
*/
@Query("SELECT m FROM MarketingTipEntity m WHERE m.userId = :userId ORDER BY m.createdAt DESC LIMIT 1")
Optional<MarketingTipEntity> findTopByUserIdOrderByCreatedAtDesc(@Param("userId") String userId);
/**
* 특정 팁이 해당 사용자의 것인지 확인
*/
boolean existsByIdAndUserId(Long id, String userId);
}
@@ -1,39 +1,88 @@
package com.won.smarketing.recommend.infrastructure.persistence;
import com.won.smarketing.recommend.domain.model.MarketingTip;
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
import com.won.smarketing.recommend.domain.repository.MarketingTipRepository;
import com.won.smarketing.recommend.domain.service.StoreDataProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* 마케팅 팁 레포지토리 구현체
*/
@Slf4j
@Repository
@RequiredArgsConstructor
public class MarketingTipRepositoryImpl implements MarketingTipRepository {
private final MarketingTipJpaRepository jpaRepository;
private final StoreDataProvider storeDataProvider;
@Override
public MarketingTip save(MarketingTip marketingTip) {
MarketingTipEntity entity = MarketingTipEntity.fromDomain(marketingTip);
String userId = getCurrentUserId();
MarketingTipEntity entity = MarketingTipEntity.fromDomain(marketingTip, userId);
MarketingTipEntity savedEntity = jpaRepository.save(entity);
return savedEntity.toDomain();
// Store 정보는 다시 조회해서 Domain에 설정
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
return savedEntity.toDomain(storeWithMenuData.getStoreData());
}
@Override
public Optional<MarketingTip> findById(Long tipId) {
return jpaRepository.findById(tipId)
.map(MarketingTipEntity::toDomain);
.map(entity -> {
// Store 정보를 API로 조회
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(entity.getUserId());
return entity.toDomain(storeWithMenuData.getStoreData());
});
}
@Override
public Page<MarketingTip> findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable) {
return jpaRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable)
.map(MarketingTipEntity::toDomain);
// 기존 메서드는 호환성을 위해 유지하지만, 내부적으로는 userId로 조회
String userId = getCurrentUserId();
return findByUserIdOrderByCreatedAtDesc(userId, pageable);
}
/**
* 사용자별 마케팅 팁 조회 (새로 추가)
*/
public Page<MarketingTip> findByUserIdOrderByCreatedAtDesc(String userId, Pageable pageable) {
Page<MarketingTipEntity> entities = jpaRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable);
// Store 정보는 한 번만 조회 (같은 userId이므로)
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
return entities.map(entity -> entity.toDomain(storeWithMenuData.getStoreData()));
}
/**
* 사용자의 가장 최근 마케팅 팁 조회
*/
public Optional<MarketingTip> findMostRecentByUserId(String userId) {
return jpaRepository.findTopByUserIdOrderByCreatedAtDesc(userId)
.map(entity -> {
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
return entity.toDomain(storeWithMenuData.getStoreData());
});
}
/**
* 특정 팁이 해당 사용자의 것인지 확인
*/
public boolean isOwnedByUser(Long tipId, String userId) {
return jpaRepository.existsByIdAndUserId(tipId, userId);
}
/**
* 현재 로그인된 사용자 ID 조회
*/
private String getCurrentUserId() {
return SecurityContextHolder.getContext().getAuthentication().getName();
}
}
@@ -2,22 +2,18 @@ package com.won.smarketing.recommend.presentation.controller;
import com.won.smarketing.common.dto.ApiResponse;
import com.won.smarketing.recommend.application.usecase.MarketingTipUseCase;
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
/**
* AI 마케팅 추천 컨트롤러
* AI 마케팅 추천 컨트롤러 (단일 API)
*/
@Tag(name = "AI 추천", description = "AI 기반 마케팅 팁 추천 API")
@Slf4j
@@ -29,49 +25,17 @@ public class RecommendationController {
private final MarketingTipUseCase marketingTipUseCase;
@Operation(
summary = "AI 마케팅 팁 생성",
description = "매장 정보를 기반으로 Python AI 서비스에서 마케팅 팁을 생성합니다."
summary = "마케팅 팁 조회/생성",
description = "마케팅 팁 전체 내용 조회. 1시간 이내 생성된 팁이 있으면 기존 것 사용, 없으면 새로 생성"
)
@PostMapping("/marketing-tips")
public ResponseEntity<ApiResponse<MarketingTipResponse>> generateMarketingTips(
@Parameter(description = "마케팅 팁 생성 요청") @Valid @RequestBody MarketingTipRequest request) {
log.info("AI 마케팅 팁 생성 요청: storeId={}", request.getStoreId());
MarketingTipResponse response = marketingTipUseCase.generateMarketingTips(request);
log.info("AI 마케팅 팁 생성 완료: tipId={}", response.getTipId());
return ResponseEntity.ok(ApiResponse.success(response));
}
public ResponseEntity<ApiResponse<MarketingTipResponse>> provideMarketingTip() {
@Operation(
summary = "마케팅 팁 이력 조회",
description = "특정 매장의 마케팅 팁 생성 이력을 조회합니다."
)
@GetMapping("/marketing-tips")
public ResponseEntity<ApiResponse<Page<MarketingTipResponse>>> getMarketingTipHistory(
@Parameter(description = "매장 ID") @RequestParam Long storeId,
Pageable pageable) {
log.info("마케팅 팁 이력 조회: storeId={}, page={}", storeId, pageable.getPageNumber());
Page<MarketingTipResponse> response = marketingTipUseCase.getMarketingTipHistory(storeId, pageable);
return ResponseEntity.ok(ApiResponse.success(response));
}
log.info("마케팅 팁 제공 요청");
@Operation(
summary = "마케팅 팁 상세 조회",
description = "특정 마케팅 팁의 상세 정보를 조회합니다."
)
@GetMapping("/marketing-tips/{tipId}")
public ResponseEntity<ApiResponse<MarketingTipResponse>> getMarketingTip(
@Parameter(description = "팁 ID") @PathVariable Long tipId) {
log.info("마케팅 팁 상세 조회: tipId={}", tipId);
MarketingTipResponse response = marketingTipUseCase.getMarketingTip(tipId);
MarketingTipResponse response = marketingTipUseCase.provideMarketingTip();
log.info("마케팅 팁 제공 완료: tipId={}", response.getTipId());
return ResponseEntity.ok(ApiResponse.success(response));
}
}
@@ -1,26 +0,0 @@
package com.won.smarketing.recommend.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
@Schema(description = "마케팅 팁 생성 요청")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MarketingTipRequest {
@Schema(description = "매장 ID", example = "1", required = true)
@NotNull(message = "매장 ID는 필수입니다")
@Positive(message = "매장 ID는 양수여야 합니다")
private Long storeId;
@Schema(description = "추가 요청사항", example = "여름철 음료 프로모션에 집중해주세요")
private String additionalRequirement;
}
@@ -8,43 +8,50 @@ import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Schema(description = "마케팅 팁 응답")
/**
* 마케팅 팁 응답 DTO (요약 + 상세 통합)
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "마케팅 팁 응답")
public class MarketingTipResponse {
@Schema(description = "팁 ID", example = "1")
private Long tipId;
@Schema(description = "매장 ID", example = "1")
private Long storeId;
@Schema(description = "마케팅 팁 요약 (1줄)", example = "가을 시즌 특별 음료로 고객들의 관심을 끌어보세요!")
private String tipSummary;
@Schema(description = "매장명", example = "카페 봄날")
private String storeName;
@Schema(description = "AI 생성 마케팅 팁 내용")
@Schema(description = "마케팅 팁 전체 내용", example = "가을이 다가오면서 고객들은 따뜻하고 계절감 있는 음료를 찾게 됩니다...")
private String tipContent;
@Schema(description = "매장 정보")
private StoreInfo storeInfo;
@Schema(description = "생성 ")
@Schema(description = "생성 시", example = "2025-06-13T14:30:00")
private LocalDateTime createdAt;
@Schema(description = "수정 시간", example = "2025-06-13T14:30:00")
private LocalDateTime updatedAt;
@Schema(description = "1시간 이내 생성 여부", example = "true")
private boolean isRecentlyCreated;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "매장 정보")
public static class StoreInfo {
@Schema(description = "매장명", example = "카페 봄날")
@Schema(description = "매장명", example = "민코의 카페")
private String storeName;
@Schema(description = "업종", example = "카페")
private String businessType;
@Schema(description = "위치", example = "서울시 강남구")
@Schema(description = "위치", example = "서울시 강남구 테헤란로 123")
private String location;
}
}
}
@@ -12,7 +12,7 @@ spring:
password: ${POSTGRES_PASSWORD:postgres}
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:update}
ddl-auto: ${JPA_DDL_AUTO:create-drop}
show-sql: ${JPA_SHOW_SQL:true}
properties:
hibernate:
@@ -29,7 +29,7 @@ external:
base-url: ${STORE_SERVICE_URL:http://localhost:8082}
timeout: ${STORE_SERVICE_TIMEOUT:5000}
python-ai-service:
base-url: ${PYTHON_AI_SERVICE_URL:http://localhost:8090}
base-url: ${PYTHON_AI_SERVICE_URL:http://localhost:5001}
api-key: ${PYTHON_AI_API_KEY:dummy-key}
timeout: ${PYTHON_AI_TIMEOUT:30000}
@@ -42,13 +42,6 @@ management:
health:
show-details: always
springdoc:
swagger-ui:
path: /swagger-ui.html
operations-sorter: method
api-docs:
path: /api-docs
logging:
level:
com.won.smarketing.recommend: ${LOG_LEVEL:DEBUG}
+4
View File
@@ -3,6 +3,10 @@ plugins {
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.4'
}
// 루트 프로젝트에서는 bootJar 태스크 비활성화
bootJar {
enabled = false
}
allprojects {
group = 'com.won.smarketing'
@@ -35,6 +35,15 @@ public enum ErrorCode {
RECOMMENDATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "R001", "추천 생성에 실패했습니다."),
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", "서버 내부 오류가 발생했습니다."),
INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "G002", "잘못된 입력값입니다."),
+217
View File
@@ -0,0 +1,217 @@
def PIPELINE_ID = "${env.BUILD_NUMBER}"
def getImageTag() {
def dateFormat = new java.text.SimpleDateFormat('yyyyMMddHHmmss')
def currentDate = new Date()
return dateFormat.format(currentDate)
}
podTemplate(
label: "${PIPELINE_ID}",
serviceAccount: 'jenkins',
containers: [
containerTemplate(name: 'gradle', image: 'gradle:jdk17', ttyEnabled: true, command: 'cat'),
containerTemplate(name: 'docker', image: 'docker:20.10.16-dind', ttyEnabled: true, privileged: true),
containerTemplate(name: 'azure-cli', image: 'hiondal/azure-kubectl:latest', command: 'cat', ttyEnabled: true),
containerTemplate(name: 'envsubst', image: "hiondal/envsubst", command: 'sleep', args: '1h')
],
volumes: [
emptyDirVolume(mountPath: '/home/gradle/.gradle', memory: false),
emptyDirVolume(mountPath: '/root/.azure', memory: false),
emptyDirVolume(mountPath: '/var/run', memory: false)
]
) {
node(PIPELINE_ID) {
def props
def imageTag = getImageTag()
def manifest = "deploy.yaml"
def namespace
def services = ['member', 'store', 'marketing-content', 'ai-recommend']
stage("Get Source") {
checkout scm
// smarketing-java 하위에 있는 설정 파일 읽기
props = readProperties file: "smarketing-java/deployment/deploy_env_vars"
namespace = "${props.namespace}"
echo "=== Build Information ==="
echo "Services: ${services}"
echo "Namespace: ${namespace}"
echo "Image Tag: ${imageTag}"
}
stage("Setup AKS") {
container('azure-cli') {
withCredentials([azureServicePrincipal('azure-credentials')]) {
sh """
echo "=== Azure 로그인 ==="
az login --service-principal -u \$AZURE_CLIENT_ID -p \$AZURE_CLIENT_SECRET -t \$AZURE_TENANT_ID
az account set --subscription 2513dd36-7978-48e3-9a7c-b221d4874f66
echo "=== AKS 인증정보 가져오기 ==="
az aks get-credentials --resource-group rg-digitalgarage-01 --name aks-digitalgarage-01 --overwrite-existing
echo "=== 네임스페이스 생성 ==="
kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply -f -
echo "=== Image Pull Secret 생성 ==="
kubectl create secret docker-registry acr-secret \\
--docker-server=${props.registry} \\
--docker-username=acrdigitalgarage02 \\
--docker-password=\$(az acr credential show --name acrdigitalgarage02 --query passwords[0].value -o tsv) \\
--namespace=${namespace} \\
--dry-run=client -o yaml | kubectl apply -f -
echo "=== 클러스터 상태 확인 ==="
kubectl get nodes
kubectl get ns ${namespace}
"""
}
}
}
stage('Build Applications') {
container('gradle') {
sh """
echo "=== smarketing-java 디렉토리로 이동 ==="
cd smarketing-java
echo "=== gradlew 권한 설정 ==="
chmod +x gradlew
echo "=== 전체 서비스 빌드 ==="
./gradlew :member:clean :member:build -x test
./gradlew :store:clean :store:build -x test
./gradlew :marketing-content:clean :marketing-content:build -x test
./gradlew :ai-recommend:clean :ai-recommend:build -x test
echo "=== 빌드 결과 확인 ==="
find . -name "*.jar" -path "*/build/libs/*" | grep -v 'plain.jar'
"""
}
}
stage('Build & Push Images') {
container('docker') {
sh """
echo "=== Docker 데몬 시작 대기 ==="
timeout 30 sh -c 'until docker info; do sleep 1; done'
"""
// 🔧 ACR Credential을 Jenkins에서 직접 사용
withCredentials([usernamePassword(
credentialsId: 'acr-credentials',
usernameVariable: 'ACR_USERNAME',
passwordVariable: 'ACR_PASSWORD'
)]) {
sh """
echo "=== Docker로 ACR 로그인 ==="
echo "\$ACR_PASSWORD" | docker login ${props.registry} --username \$ACR_USERNAME --password-stdin
"""
services.each { service ->
script {
def buildDir = "smarketing-java/${service}"
def fullImageName = "${props.registry}/${props.image_org}/${service}:${imageTag}"
echo "Building image for ${service}: ${fullImageName}"
// 실제 JAR 파일명 동적 탐지
def actualJarFile = sh(
script: """
cd ${buildDir}/build/libs
ls *.jar | grep -v 'plain.jar' | head -1
""",
returnStdout: true
).trim()
if (!actualJarFile) {
error "${service} JAR 파일을 찾을 수 없습니다"
}
echo "발견된 JAR 파일: ${actualJarFile}"
sh """
echo "=== ${service} 이미지 빌드 ==="
docker build \\
--build-arg BUILD_LIB_DIR="${buildDir}/build/libs" \\
--build-arg ARTIFACTORY_FILE="${actualJarFile}" \\
-f smarketing-java/deployment/container/Dockerfile \\
-t ${fullImageName} .
echo "=== ${service} 이미지 푸시 ==="
docker push ${fullImageName}
echo "Successfully built and pushed: ${fullImageName}"
"""
}
}
}
}
}
stage('Generate & Apply Manifest') {
container('envsubst') {
sh """
echo "=== 환경변수 설정 ==="
export namespace=${namespace}
export allowed_origins=${props.allowed_origins}
export jwt_secret_key=${props.jwt_secret_key}
export postgres_user=${props.postgres_user}
export postgres_password=${props.postgres_password}
export replicas=${props.replicas}
# 리소스 요구사항 조정 (작게)
export resources_requests_cpu=100m
export resources_requests_memory=128Mi
export resources_limits_cpu=500m
export resources_limits_memory=512Mi
# 이미지 경로 환경변수 설정
export member_image_path=${props.registry}/${props.image_org}/member:${imageTag}
export store_image_path=${props.registry}/${props.image_org}/store:${imageTag}
export marketing_content_image_path=${props.registry}/${props.image_org}/marketing-content:${imageTag}
export ai_recommend_image_path=${props.registry}/${props.image_org}/ai-recommend:${imageTag}
echo "=== Manifest 생성 ==="
envsubst < smarketing-java/deployment/${manifest}.template > smarketing-java/deployment/${manifest}
echo "=== Generated Manifest File ==="
cat smarketing-java/deployment/${manifest}
echo "==============================="
"""
}
container('azure-cli') {
sh """
echo "=== PostgreSQL 서비스 확인 ==="
kubectl get svc -n ${namespace} | grep postgresql || echo "PostgreSQL 서비스가 없습니다. 먼저 설치해주세요."
echo "=== Manifest 적용 ==="
kubectl apply -f smarketing-java/deployment/${manifest}
echo "=== 배포 상태 확인 (60초 대기) ==="
kubectl -n ${namespace} get deployments
kubectl -n ${namespace} get pods
echo "=== 각 서비스 배포 대기 (60초 timeout) ==="
timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/member --timeout=60s || echo "member deployment 대기 타임아웃"
timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/store --timeout=60s || echo "store deployment 대기 타임아웃"
timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/marketing-content --timeout=60s || echo "marketing-content deployment 대기 타임아웃"
timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/ai-recommend --timeout=60s || echo "ai-recommend deployment 대기 타임아웃"
echo "=== 최종 상태 ==="
kubectl -n ${namespace} get all
echo "=== 실패한 Pod 상세 정보 ==="
for pod in \$(kubectl -n ${namespace} get pods --field-selector=status.phase!=Running -o name 2>/dev/null || true); do
if [ ! -z "\$pod" ]; then
echo "=== 실패한 Pod: \$pod ==="
kubectl -n ${namespace} describe \$pod | tail -20
fi
done
"""
}
}
}
}
@@ -0,0 +1,44 @@
# Build stage
FROM eclipse-temurin:17-jre AS builder
ARG BUILD_LIB_DIR
ARG ARTIFACTORY_FILE
WORKDIR /app
COPY ${BUILD_LIB_DIR}/${ARTIFACTORY_FILE} app.jar
# Run stage
FROM eclipse-temurin:17-jre
# Install necessary packages
RUN apt-get update && apt-get install -y \
curl \
netcat-traditional \
&& rm -rf /var/lib/apt/lists/*
ENV USERNAME k8s
ENV ARTIFACTORY_HOME /home/${USERNAME}
ENV JAVA_OPTS=""
# Add a non-root user
RUN groupadd -r ${USERNAME} && useradd -r -g ${USERNAME} ${USERNAME} && \
mkdir -p ${ARTIFACTORY_HOME} && \
chown ${USERNAME}:${USERNAME} ${ARTIFACTORY_HOME}
WORKDIR ${ARTIFACTORY_HOME}
# Copy JAR from builder stage
COPY --from=builder /app/app.jar app.jar
RUN chown ${USERNAME}:${USERNAME} app.jar
# Switch to non-root user
USER ${USERNAME}
# Expose port
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
CMD curl -f http://localhost:8080/actuator/health || exit 1
# Run the application
ENTRYPOINT ["sh", "-c"]
CMD ["java ${JAVA_OPTS} -jar app.jar"]
@@ -0,0 +1,475 @@
# ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: common-config
namespace: ${namespace}
data:
ALLOWED_ORIGINS: ${allowed_origins}
JPA_DDL_AUTO: update
JPA_SHOW_SQL: 'true'
---
apiVersion: v1
kind: ConfigMap
metadata:
name: member-config
namespace: ${namespace}
data:
POSTGRES_DB: member
POSTGRES_HOST: member-postgresql
POSTGRES_PORT: '5432'
SERVER_PORT: '8081'
---
apiVersion: v1
kind: ConfigMap
metadata:
name: store-config
namespace: ${namespace}
data:
POSTGRES_DB: store
POSTGRES_HOST: store-postgresql
POSTGRES_PORT: '5432'
SERVER_PORT: '8082'
---
apiVersion: v1
kind: ConfigMap
metadata:
name: marketing-content-config
namespace: ${namespace}
data:
POSTGRES_DB: marketing_content
POSTGRES_HOST: marketing-content-postgresql
POSTGRES_PORT: '5432'
SERVER_PORT: '8083'
---
apiVersion: v1
kind: ConfigMap
metadata:
name: ai-recommend-config
namespace: ${namespace}
data:
POSTGRES_DB: ai_recommend
POSTGRES_HOST: ai-recommend-postgresql
POSTGRES_PORT: '5432'
SERVER_PORT: '8084'
---
# Secrets
apiVersion: v1
kind: Secret
metadata:
name: common-secret
namespace: ${namespace}
stringData:
JWT_SECRET_KEY: ${jwt_secret_key}
type: Opaque
---
apiVersion: v1
kind: Secret
metadata:
name: member-secret
namespace: ${namespace}
stringData:
JWT_ACCESS_TOKEN_VALIDITY: '3600000'
JWT_REFRESH_TOKEN_VALIDITY: '86400000'
POSTGRES_PASSWORD: ${postgres_password}
POSTGRES_USER: ${postgres_user}
type: Opaque
---
apiVersion: v1
kind: Secret
metadata:
name: store-secret
namespace: ${namespace}
stringData:
POSTGRES_PASSWORD: ${postgres_password}
POSTGRES_USER: ${postgres_user}
type: Opaque
---
apiVersion: v1
kind: Secret
metadata:
name: marketing-content-secret
namespace: ${namespace}
stringData:
POSTGRES_PASSWORD: ${postgres_password}
POSTGRES_USER: ${postgres_user}
type: Opaque
---
apiVersion: v1
kind: Secret
metadata:
name: ai-recommend-secret
namespace: ${namespace}
stringData:
POSTGRES_PASSWORD: ${postgres_password}
POSTGRES_USER: ${postgres_user}
type: Opaque
---
# Deployments
apiVersion: apps/v1
kind: Deployment
metadata:
name: member
namespace: ${namespace}
labels:
app: member
spec:
replicas: ${replicas}
selector:
matchLabels:
app: member
template:
metadata:
labels:
app: member
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: member
image: ${member_image_path}
imagePullPolicy: Always
ports:
- containerPort: 8081
resources:
requests:
cpu: ${resources_requests_cpu}
memory: ${resources_requests_memory}
limits:
cpu: ${resources_limits_cpu}
memory: ${resources_limits_memory}
envFrom:
- configMapRef:
name: common-config
- configMapRef:
name: member-config
- secretRef:
name: common-secret
- secretRef:
name: member-secret
startupProbe:
exec:
command:
- /bin/sh
- -c
- "nc -z member-postgresql 5432"
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 10
livenessProbe:
httpGet:
path: /actuator/health
port: 8081
initialDelaySeconds: 60
periodSeconds: 30
readinessProbe:
httpGet:
path: /actuator/health
port: 8081
initialDelaySeconds: 30
periodSeconds: 5
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: store
namespace: ${namespace}
labels:
app: store
spec:
replicas: ${replicas}
selector:
matchLabels:
app: store
template:
metadata:
labels:
app: store
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: store
image: ${store_image_path}
imagePullPolicy: Always
ports:
- containerPort: 8082
resources:
requests:
cpu: ${resources_requests_cpu}
memory: ${resources_requests_memory}
limits:
cpu: ${resources_limits_cpu}
memory: ${resources_limits_memory}
envFrom:
- configMapRef:
name: common-config
- configMapRef:
name: store-config
- secretRef:
name: common-secret
- secretRef:
name: store-secret
startupProbe:
exec:
command:
- /bin/sh
- -c
- "nc -z store-postgresql 5432"
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 10
livenessProbe:
httpGet:
path: /actuator/health
port: 8082
initialDelaySeconds: 60
periodSeconds: 30
readinessProbe:
httpGet:
path: /actuator/health
port: 8082
initialDelaySeconds: 30
periodSeconds: 5
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: marketing-content
namespace: ${namespace}
labels:
app: marketing-content
spec:
replicas: ${replicas}
selector:
matchLabels:
app: marketing-content
template:
metadata:
labels:
app: marketing-content
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: marketing-content
image: ${marketing_content_image_path}
imagePullPolicy: Always
ports:
- containerPort: 8083
resources:
requests:
cpu: ${resources_requests_cpu}
memory: ${resources_requests_memory}
limits:
cpu: ${resources_limits_cpu}
memory: ${resources_limits_memory}
envFrom:
- configMapRef:
name: common-config
- configMapRef:
name: marketing-content-config
- secretRef:
name: common-secret
- secretRef:
name: marketing-content-secret
startupProbe:
exec:
command:
- /bin/sh
- -c
- "nc -z marketing-content-postgresql 5432"
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 10
livenessProbe:
httpGet:
path: /actuator/health
port: 8083
initialDelaySeconds: 60
periodSeconds: 30
readinessProbe:
httpGet:
path: /actuator/health
port: 8083
initialDelaySeconds: 30
periodSeconds: 5
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ai-recommend
namespace: ${namespace}
labels:
app: ai-recommend
spec:
replicas: ${replicas}
selector:
matchLabels:
app: ai-recommend
template:
metadata:
labels:
app: ai-recommend
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: ai-recommend
image: ${ai_recommend_image_path}
imagePullPolicy: Always
ports:
- containerPort: 8084
resources:
requests:
cpu: ${resources_requests_cpu}
memory: ${resources_requests_memory}
limits:
cpu: ${resources_limits_cpu}
memory: ${resources_limits_memory}
envFrom:
- configMapRef:
name: common-config
- configMapRef:
name: ai-recommend-config
- secretRef:
name: common-secret
- secretRef:
name: ai-recommend-secret
startupProbe:
exec:
command:
- /bin/sh
- -c
- "nc -z ai-recommend-postgresql 5432"
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 10
livenessProbe:
httpGet:
path: /actuator/health
port: 8084
initialDelaySeconds: 60
periodSeconds: 30
readinessProbe:
httpGet:
path: /actuator/health
port: 8084
initialDelaySeconds: 30
periodSeconds: 5
---
# Services
apiVersion: v1
kind: Service
metadata:
name: member
namespace: ${namespace}
spec:
selector:
app: member
ports:
- port: 80
targetPort: 8081
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: store
namespace: ${namespace}
spec:
selector:
app: store
ports:
- port: 80
targetPort: 8082
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: marketing-content
namespace: ${namespace}
spec:
selector:
app: marketing-content
ports:
- port: 80
targetPort: 8083
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: ai-recommend
namespace: ${namespace}
spec:
selector:
app: ai-recommend
ports:
- port: 80
targetPort: 8084
type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: smarketing-backend
namespace: ${namespace}
annotations:
kubernetes.io/ingress.class: nginx
spec:
ingressClassName: nginx
rules:
- http:
paths:
- path: /api/auth
pathType: Prefix
backend:
service:
name: member
port:
number: 80
- path: /api/store
pathType: Prefix
backend:
service:
name: store
port:
number: 80
- path: /api/content
pathType: Prefix
backend:
service:
name: marketing-content
port:
number: 80
- path: /api/recommend
pathType: Prefix
backend:
service:
name: ai-recommend
port:
number: 80
@@ -0,0 +1,23 @@
# Team Settings
teamid=kros235
root_project=smarketing-backend
namespace=smarketing
# Container Registry Settings
registry=acrdigitalgarage02.azurecr.io
image_org=smarketing
# Application Settings
replicas=1
allowed_origins=http://20.249.171.38
# Security Settings
jwt_secret_key=8O2HQ13etL2BWZvYOiWsJ5uWFoLi6NBUG8divYVoCgtHVvlk3dqRksMl16toztDUeBTSIuOOPvHIrYq11G2BwQ
postgres_user=admin
postgres_password=Hi5Jessica!
# Resource Settings (리소스 요구사항 줄임)
resources_requests_cpu=100m
resources_requests_memory=128Mi
resources_limits_cpu=500m
resources_limits_memory=512Mi
+81
View File
@@ -0,0 +1,81 @@
pipeline {
agent any
environment {
ACR_LOGIN_SERVER = 'acrsmarketing17567.azurecr.io'
IMAGE_NAME = 'member'
MANIFEST_REPO = 'https://github.com/won-ktds/smarketing-manifest.git'
MANIFEST_PATH = 'member/deployment.yaml'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Build') {
steps {
dir('member') {
sh './gradlew clean build -x test'
}
}
}
stage('Test') {
steps {
dir('member') {
sh './gradlew test'
}
}
}
stage('Build Docker Image') {
steps {
script {
def imageTag = "${BUILD_NUMBER}-${env.GIT_COMMIT.substring(0,8)}"
def fullImageName = "${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${imageTag}"
dir('member') {
sh "docker build -t ${fullImageName} ."
}
withCredentials([usernamePassword(credentialsId: 'acr-credentials', usernameVariable: 'ACR_USERNAME', passwordVariable: 'ACR_PASSWORD')]) {
sh "docker login ${ACR_LOGIN_SERVER} -u ${ACR_USERNAME} -p ${ACR_PASSWORD}"
sh "docker push ${fullImageName}"
}
env.IMAGE_TAG = imageTag
env.FULL_IMAGE_NAME = fullImageName
}
}
}
stage('Update Manifest') {
steps {
withCredentials([usernamePassword(credentialsId: 'github-credentials', usernameVariable: 'GIT_USERNAME', passwordVariable: 'GIT_TOKEN')]) {
sh '''
git clone https://${GIT_TOKEN}@github.com/won-ktds/smarketing-manifest.git manifest-repo
cd manifest-repo
# Update image tag in deployment.yaml
sed -i "s|image: .*|image: ${FULL_IMAGE_NAME}|g" ${MANIFEST_PATH}
git config user.email "jenkins@smarketing.com"
git config user.name "Jenkins"
git add .
git commit -m "Update ${IMAGE_NAME} image to ${IMAGE_TAG}"
git push origin main
'''
}
}
}
}
post {
always {
cleanWs()
}
}
}
@@ -36,9 +36,9 @@ public class RegisterRequest {
@NotBlank(message = "이름은 필수입니다")
@Size(max = 50, message = "이름은 50자 이하여야 합니다")
private String name;
@Schema(description = "사업자등록번호", example = "123-45-67890")
@Pattern(regexp = "^\\d{3}-\\d{2}-\\d{5}$", message = "사업자등록번호 형식이 올바르지 않습니다 (000-00-00000)")
@Schema(description = "사업자등록번호", example = "1234567890")
@Pattern(regexp = "^\\d{10}$", message = "사업자등록번호는 10자리 숫자여야 합니다")
private String businessNumber;
@Schema(description = "이메일", example = "user@example.com", required = true)
@@ -26,7 +26,7 @@ public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
@Column(name = "member_id")
private Long id;
@Column(name = "user_id", nullable = false, unique = true, length = 50)
@@ -38,7 +38,7 @@ public class Member {
@Column(name = "name", nullable = false, length = 50)
private String name;
@Column(name = "business_number", length = 12)
@Column(name = "business_number", length = 15, unique = true)
private String businessNumber;
@Column(name = "email", nullable = false, unique = true, length = 100)
@@ -52,6 +52,9 @@ public class AuthServiceImpl implements AuthService {
// 패스워드 검증
if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) {
System.out.println(passwordEncoder.encode(request.getPassword()));
System.out.println(passwordEncoder.encode(member.getPassword()));
throw new BusinessException(ErrorCode.INVALID_PASSWORD);
}
@@ -59,6 +62,8 @@ public class AuthServiceImpl implements AuthService {
String accessToken = jwtTokenProvider.generateAccessToken(member.getUserId());
String refreshToken = jwtTokenProvider.generateRefreshToken(member.getUserId());
log.info("{} access token 발급: {}", request.getUserId(), accessToken);
// 리프레시 토큰을 Redis에 저장 (7일)
redisTemplate.opsForValue().set(
REFRESH_TOKEN_PREFIX + member.getUserId(),
@@ -93,17 +98,8 @@ public class AuthServiceImpl implements AuthService {
if (jwtTokenProvider.validateToken(refreshToken)) {
String userId = jwtTokenProvider.getUserIdFromToken(refreshToken);
// Redis에서 리프레시 토큰 삭제
redisTemplate.delete(REFRESH_TOKEN_PREFIX + userId);
// 리프레시 토큰을 블랙리스트에 추가
redisTemplate.opsForValue().set(
BLACKLIST_PREFIX + refreshToken,
"logout",
7,
TimeUnit.DAYS
);
log.info("로그아웃 완료: {}", userId);
}
} catch (Exception ex) {
@@ -156,13 +152,8 @@ public class AuthServiceImpl implements AuthService {
TimeUnit.DAYS
);
// 기존 리프레시 토큰을 블랙리스트에 추가
redisTemplate.opsForValue().set(
BLACKLIST_PREFIX + refreshToken,
"refreshed",
7,
TimeUnit.DAYS
);
// 기존 리프레시 토큰 삭제
redisTemplate.delete(REFRESH_TOKEN_PREFIX + userId);
log.info("토큰 갱신 완료: {}", userId);
@@ -0,0 +1,18 @@
INSERT INTO members (member_id, user_id, password, name, business_number, email, created_at, updated_at)
VALUES
(DEFAULT, 'testuser1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '김소상', '123-45-67890', 'test1@smarketing.com', NOW(), NOW()),
(DEFAULT, 'testuser2', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '이점주', '234-56-78901', 'test2@smarketing.com', NOW(), NOW()),
(DEFAULT, 'testuser3', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '박카페', '345-67-89012', 'test3@smarketing.com', NOW(), NOW()),
(DEFAULT, 'cafeowner1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '최카페', '456-78-90123', 'cafe@smarketing.com', NOW(), NOW()),
(DEFAULT, 'restaurant1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '정식당', '567-89-01234', 'restaurant@smarketing.com', NOW(), NOW())
ON CONFLICT (user_id) DO NOTHING;
-- 이메일 중복 방지를 위한 추가 체크
INSERT INTO members (member_id, user_id, password, name, business_number, email, created_at, updated_at)
VALUES
(DEFAULT, 'bakery1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '김베이커리', '678-90-12345', 'bakery@smarketing.com', NOW(), NOW()),
(DEFAULT, 'chicken1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '한치킨', '789-01-23456', 'chicken@smarketing.com', NOW(), NOW()),
(DEFAULT, 'pizza1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '이피자', '890-12-34567', 'pizza@smarketing.com', NOW(), NOW()),
(DEFAULT, 'dessert1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '달디저트', '901-23-45678', 'dessert@smarketing.com', NOW(), NOW()),
(DEFAULT, 'beauty1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '미뷰티샵', '012-34-56789', 'beauty@smarketing.com', NOW(), NOW())
ON CONFLICT (user_id) DO NOTHING;
+4
View File
@@ -1,4 +1,8 @@
dependencies {
implementation project(':common')
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'
}
@@ -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);
}
}
@@ -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;
}
}
}
@@ -1,18 +1,27 @@
package com.won.smarketing.store.controller;
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.MenuResponse;
import com.won.smarketing.store.dto.MenuUpdateRequest;
import com.won.smarketing.store.service.BlobStorageService;
import com.won.smarketing.store.service.MenuService;
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.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
@@ -43,15 +52,15 @@ public class MenuController {
/**
* 메뉴 목록 조회
*
* @param category 메뉴 카테고리 (선택사항)
* @param storeId 메뉴 카테고리
* @return 메뉴 목록
*/
@Operation(summary = "메뉴 목록 조회", description = "메뉴 목록을 조회합니다. 카테고리별 필터링 가능합니다.")
@GetMapping
public ResponseEntity<ApiResponse<List<MenuResponse>>> getMenus(
@Parameter(description = "메뉴 카테고리")
@RequestParam(required = false) String category) {
List<MenuResponse> response = menuService.getMenus(category);
@Parameter(description = "가게 ID")
@RequestParam(required = true) Long storeId) {
List<MenuResponse> response = menuService.getMenus(storeId);
return ResponseEntity.ok(ApiResponse.success(response));
}
@@ -4,10 +4,12 @@ import com.won.smarketing.common.dto.ApiResponse;
import com.won.smarketing.store.dto.SalesResponse;
import com.won.smarketing.store.service.SalesService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
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.RestController;
@@ -26,12 +28,16 @@ public class SalesController {
/**
* 매출 정보 조회
*
* @param storeId 가게 ID
* @return 매출 정보 (오늘, 월간, 전일 대비)
*/
@Operation(summary = "매출 조회", description = "오늘 매출, 월간 매출, 전일 대비 매출 정보를 조회합니다.")
@GetMapping
public ResponseEntity<ApiResponse<SalesResponse>> getSales() {
SalesResponse response = salesService.getSales();
@GetMapping("/{storeId}")
public ResponseEntity<ApiResponse<SalesResponse>> getSales(
@Parameter(description = "가게 ID", required = true)
@PathVariable Long storeId
) {
SalesResponse response = salesService.getSales(storeId);
return ResponseEntity.ok(ApiResponse.success(response));
}
}
@@ -2,6 +2,7 @@ package com.won.smarketing.store.controller;
import com.won.smarketing.common.dto.ApiResponse;
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.StoreUpdateRequest;
import com.won.smarketing.store.service.StoreService;
@@ -34,8 +35,8 @@ public class StoreController {
*/
@Operation(summary = "매장 등록", description = "새로운 매장 정보를 등록합니다.")
@PostMapping("/register")
public ResponseEntity<ApiResponse<StoreResponse>> register(@Valid @RequestBody StoreCreateRequest request) {
StoreResponse response = storeService.register(request);
public ResponseEntity<ApiResponse<StoreCreateResponse>> register(@Valid @RequestBody StoreCreateRequest request) {
StoreCreateResponse response = storeService.register(request);
return ResponseEntity.ok(ApiResponse.success(response, "매장이 성공적으로 등록되었습니다."));
}
@@ -58,17 +59,17 @@ public class StoreController {
/**
* 매장 정보 수정
*
* @param storeId 수정할 매장 ID
* //@param storeId 수정할 매장 ID
* @param request 매장 수정 요청 정보
* @return 수정된 매장 정보
*/
@Operation(summary = "매장 수정", description = "매장 정보를 수정합니다.")
@PutMapping("/{storeId}")
@PutMapping()
public ResponseEntity<ApiResponse<StoreResponse>> updateStore(
@Parameter(description = "매장 ID", required = true)
@PathVariable Long storeId,
// @PathVariable Long storeId,
@Valid @RequestBody StoreUpdateRequest request) {
StoreResponse response = storeService.updateStore(storeId, request);
StoreResponse response = storeService.updateStore(request);
return ResponseEntity.ok(ApiResponse.success(response, "매장 정보가 성공적으로 수정되었습니다."));
}
}
@@ -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;
}
@@ -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;
}
@@ -39,10 +39,6 @@ public class MenuCreateRequest {
@Schema(description = "메뉴 설명", example = "진한 맛의 아메리카노")
@Size(max = 500, message = "메뉴 설명은 500자 이하여야 합니다")
private String description;
@Schema(description = "이미지 URL", example = "https://example.com/americano.jpg")
@Size(max = 500, message = "이미지 URL은 500자 이하여야 합니다")
private String image;
}
@@ -8,6 +8,7 @@ import lombok.NoArgsConstructor;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
import org.springframework.web.multipart.MultipartFile;
/**
* 메뉴 수정 요청 DTO
@@ -34,7 +35,4 @@ public class MenuUpdateRequest {
@Schema(description = "메뉴 설명", example = "진한 원두의 깊은 맛")
private String description;
@Schema(description = "메뉴 이미지 URL", example = "https://example.com/americano.jpg")
private String image;
}
@@ -1,5 +1,6 @@
package com.won.smarketing.store.dto;
import com.won.smarketing.store.entity.Sales;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
@@ -7,6 +8,7 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.util.List;
/**
* 매출 응답 DTO
@@ -33,4 +35,7 @@ public class SalesResponse {
@Schema(description = "목표 매출 대비 달성율 (%)", example = "85.2")
private BigDecimal goalAchievementRate;
@Schema(description = "일년 동안의 매출액")
private List<Sales> yearSales;
}
@@ -48,7 +48,11 @@ public class StoreCreateRequest {
@Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore")
@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 = "따뜻한 분위기의 동네 카페입니다.")
@Size(max = 1000, message = "매장 설명은 1000자 이하여야 합니다")
@@ -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;
}
@@ -1,6 +1,7 @@
package com.won.smarketing.store.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.Column;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@@ -24,34 +25,40 @@ public class StoreResponse {
@Schema(description = "매장명", example = "맛있는 카페")
private String storeName;
@Schema(description = "업종", example = "카페")
private String businessType;
@Schema(description = "가게 사진")
private String storeImage;
@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 = "블로그: mystore")
private String blogAccounts;
@Schema(description = "인스타 계정 정보", example = "인스타그램: @mystore")
private String instaAccounts;
@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;
}
@@ -43,9 +43,13 @@ public class StoreUpdateRequest {
@Schema(description = "좌석 수", example = "20")
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자 이하여야 합니다")
private String snsAccounts;
private String blogAccounts;
@Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.")
@Size(max = 1000, message = "매장 설명은 1000자 이하여야 합니다")
@@ -27,7 +27,7 @@ public class Menu {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "menu_id")
private Long id;
private Long menuId;
@Column(name = "store_id", nullable = false)
private Long storeId;
@@ -62,10 +62,9 @@ public class Menu {
* @param category 카테고리
* @param price 가격
* @param description 설명
* @param image 이미지 URL
*/
public void updateMenu(String menuName, String category, Integer price,
String description, String image) {
String description) {
if (menuName != null && !menuName.trim().isEmpty()) {
this.menuName = menuName;
}
@@ -76,6 +75,16 @@ public class Menu {
this.price = price;
}
this.description = description;
this.image = image;
}
/**
* 메뉴 이미지 URL 업데이트
*
* @param imageUrl 새로운 이미지 URL
*/
public void updateImage(String imageUrl) {
this.image = imageUrl;
this.updatedAt = LocalDateTime.now();
}
}
@@ -54,12 +54,18 @@ public class Store {
@Column(name = "seat_count")
private Integer seatCount;
@Column(name = "sns_accounts", length = 500)
private String snsAccounts;
@Column(name = "insta_accounts", length = 500)
private String instaAccounts;
@Column(name = "blog_accounts", length = 500)
private String blogAccounts;
@Column(name = "description", length = 1000)
private String description;
@Column(name = "store_image", length = 1000)
private String storeImage;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@@ -78,12 +84,13 @@ public class Store {
* @param businessHours 영업시간
* @param closedDays 휴무일
* @param seatCount 좌석 수
* @param snsAccounts SNS 계정 정보
* @param instaAccounts SNS 계정 정보
* @param blogAccounts SNS 계정 정보
* @param description 설명
*/
public void updateStore(String storeName, String businessType, String address,
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()) {
this.storeName = storeName;
}
@@ -97,7 +104,18 @@ public class Store {
this.businessHours = businessHours;
this.closedDays = closedDays;
this.seatCount = seatCount;
this.snsAccounts = snsAccounts;
this.instaAccounts = instaAccounts;
this.blogAccounts = blogAccounts;
this.description = description;
}
/**
* 메뉴 이미지 URL 업데이트
*
* @param imageUrl 새로운 이미지 URL
*/
public void updateImage(String imageUrl) {
this.storeImage = imageUrl;
this.updatedAt = LocalDateTime.now();
}
}
@@ -5,6 +5,8 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/**
* 메뉴 정보 데이터 접근을 위한 Repository
@@ -12,21 +14,12 @@ import java.util.List;
*/
@Repository
public interface MenuRepository extends JpaRepository<Menu, Long> {
/**
* 카테고리별 메뉴 조회 (메뉴명 오름차순)
*
* @param category 메뉴 카테고리
* @return 메뉴 목록
*/
List<Menu> findByCategoryOrderByMenuNameAsc(String category);
/**
* 전체 메뉴 조회 (메뉴명 오름차순)
*
* @return 메뉴 목록
*/
List<Menu> findAllByOrderByMenuNameAsc();
// /**
// * 전체 메뉴 조회 (메뉴명 오름차순)
// *
// * @return 메뉴 목록
// */
// List<Menu> findAllByOrderByMenuNameAsc(Long );
/**
* 매장별 메뉴 조회
@@ -8,7 +8,9 @@ import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* 매출 정보 데이터 접근을 위한 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(MONTH FROM sales_date) = EXTRACT(MONTH FROM CURRENT_DATE)", nativeQuery = true)
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);
}
@@ -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);
}
@@ -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);
}
}
}
@@ -1,8 +1,10 @@
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.MenuResponse;
import com.won.smarketing.store.dto.MenuUpdateRequest;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@@ -23,10 +25,10 @@ public interface MenuService {
/**
* 메뉴 목록 조회
*
* @param category 메뉴 카테고리 (선택사항)
* @param storeId 가게 ID
* @return 메뉴 목록
*/
List<MenuResponse> getMenus(String category);
List<MenuResponse> getMenus(Long storeId);
/**
* 메뉴 정보 수정
@@ -43,4 +45,13 @@ public interface MenuService {
* @param menuId 메뉴 ID
*/
void deleteMenu(Long menuId);
// /**
// * 메뉴 이미지 업로드
// *
// * @param menuId 메뉴 ID
// * @param file 업로드할 이미지 파일
// * @return 이미지 업로드 결과
// */
// ImageUploadResponse uploadMenuImage(Long menuId, MultipartFile file);
}
@@ -2,6 +2,7 @@ package com.won.smarketing.store.service;
import com.won.smarketing.common.exception.BusinessException;
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.MenuResponse;
import com.won.smarketing.store.dto.MenuUpdateRequest;
@@ -10,6 +11,7 @@ import com.won.smarketing.store.repository.MenuRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.stream.Collectors;
@@ -41,7 +43,6 @@ public class MenuServiceImpl implements MenuService {
.category(request.getCategory())
.price(request.getPrice())
.description(request.getDescription())
.image(request.getImage())
.build();
Menu savedMenu = menuRepository.save(menu);
@@ -51,18 +52,14 @@ public class MenuServiceImpl implements MenuService {
/**
* 메뉴 목록 조회
*
* @param category 메뉴 카테고리 (선택사항)
* @param storeId 가게 ID
* @return 메뉴 목록
*/
@Override
public List<MenuResponse> getMenus(String category) {
public List<MenuResponse> getMenus(Long storeId) {
List<Menu> menus;
if (category != null && !category.trim().isEmpty()) {
menus = menuRepository.findByCategoryOrderByMenuNameAsc(category);
} else {
menus = menuRepository.findAllByOrderByMenuNameAsc();
}
menus = menuRepository.findByStoreId(storeId);
return menus.stream()
.map(this::toMenuResponse)
@@ -79,6 +76,7 @@ public class MenuServiceImpl implements MenuService {
@Override
@Transactional
public MenuResponse updateMenu(Long menuId, MenuUpdateRequest request) {
Menu menu = menuRepository.findById(menuId)
.orElseThrow(() -> new BusinessException(ErrorCode.MENU_NOT_FOUND));
@@ -87,8 +85,7 @@ public class MenuServiceImpl implements MenuService {
request.getMenuName(),
request.getCategory(),
request.getPrice(),
request.getDescription(),
request.getImage()
request.getDescription()
);
Menu updatedMenu = menuRepository.save(menu);
@@ -117,14 +114,53 @@ public class MenuServiceImpl implements MenuService {
*/
private MenuResponse toMenuResponse(Menu menu) {
return MenuResponse.builder()
.menuId(menu.getId())
.menuId(menu.getMenuId())
.menuName(menu.getMenuName())
.category(menu.getCategory())
.price(menu.getPrice())
.description(menu.getDescription())
.image(menu.getImage())
.createdAt(menu.getCreatedAt())
.updatedAt(menu.getUpdatedAt())
.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);
// }
// }
}
@@ -13,5 +13,5 @@ public interface SalesService {
*
* @return 매출 정보
*/
SalesResponse getSales();
SalesResponse getSales(Long storeId);
}
@@ -3,6 +3,7 @@ package com.won.smarketing.store.service;
import com.won.smarketing.store.dto.SalesResponse;
import com.won.smarketing.store.entity.Sales;
import com.won.smarketing.store.repository.SalesRepository;
import com.won.smarketing.store.repository.StoreRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -10,6 +11,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.stream.Collectors;
/**
* 매출 관리 서비스 구현체
@@ -28,10 +30,7 @@ public class SalesServiceImpl implements SalesService {
* @return 매출 정보 (오늘, 월간, 전일 대비)
*/
@Override
public SalesResponse getSales() {
// TODO: 현재는 더미 데이터 반환, 실제로는 현재 로그인한 사용자의 매장 ID를 사용해야 함
Long storeId = 1L; // 임시로 설정
public SalesResponse getSales(Long storeId) {
// 오늘 매출 계산
BigDecimal todaySales = calculateSalesByDate(storeId, LocalDate.now());
@@ -44,9 +43,12 @@ public class SalesServiceImpl implements SalesService {
// 전일 대비 매출 변화량 계산
BigDecimal previousDayComparison = todaySales.subtract(yesterdaySales);
//오늘로부터 1년 전까지의 매출 리스트
return SalesResponse.builder()
.todaySales(todaySales)
.monthSales(monthSales)
.yearSales(getSalesAmountListLast365Days(storeId))
.previousDayComparison(previousDayComparison)
.build();
}
@@ -81,4 +83,18 @@ public class SalesServiceImpl implements SalesService {
.map(Sales::getSalesAmount)
.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);
}
}
@@ -1,6 +1,7 @@
package com.won.smarketing.store.service;
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.StoreUpdateRequest;
@@ -16,7 +17,7 @@ public interface StoreService {
* @param request 매장 등록 요청 정보
* @return 등록된 매장 정보
*/
StoreResponse register(StoreCreateRequest request);
StoreCreateResponse register(StoreCreateRequest request);
/**
* 매장 정보 조회 (현재 로그인 사용자)
@@ -36,9 +37,9 @@ public interface StoreService {
/**
* 매장 정보 수정
*
* @param storeId 매장 ID
* //@param storeId 매장 ID
* @param request 매장 수정 요청 정보
* @return 수정된 매장 정보
*/
StoreResponse updateStore(Long storeId, StoreUpdateRequest request);
StoreResponse updateStore(StoreUpdateRequest request);
}
@@ -3,6 +3,7 @@ package com.won.smarketing.store.service;
import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode;
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.StoreUpdateRequest;
import com.won.smarketing.store.entity.Store;
@@ -35,7 +36,7 @@ public class StoreServiceImpl implements StoreService {
*/
@Override
@Transactional
public StoreResponse register(StoreCreateRequest request) {
public StoreCreateResponse register(StoreCreateRequest request) {
String memberId = getCurrentUserId();
// Long memberId = Long.valueOf(currentUserId); // 실제로는 Member ID 조회 필요
@@ -56,14 +57,15 @@ public class StoreServiceImpl implements StoreService {
.businessHours(request.getBusinessHours())
.closedDays(request.getClosedDays())
.seatCount(request.getSeatCount())
.snsAccounts(request.getSnsAccounts())
.blogAccounts(request.getBlogAccounts())
.instaAccounts(request.getInstaAccounts())
.description(request.getDescription())
.build();
Store savedStore = storeRepository.save(store);
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 매장 수정 요청 정보
* @return 수정된 매장 정보
*/
@Override
@Transactional
public StoreResponse updateStore(Long storeId, StoreUpdateRequest request) {
Store store = storeRepository.findById(storeId)
public StoreResponse updateStore(StoreUpdateRequest request) {
String userId = getCurrentUserId();
Store store = storeRepository.findByUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND));
// 매장 정보 업데이트
@@ -123,7 +127,8 @@ public class StoreServiceImpl implements StoreService {
request.getBusinessHours(),
request.getClosedDays(),
request.getSeatCount(),
request.getSnsAccounts(),
request.getInstaAccounts(),
request.getBlogAccounts(),
request.getDescription()
);
@@ -149,13 +154,31 @@ public class StoreServiceImpl implements StoreService {
.businessHours(store.getBusinessHours())
.closedDays(store.getClosedDays())
.seatCount(store.getSeatCount())
.snsAccounts(store.getSnsAccounts())
.blogAccounts(store.getBlogAccounts())
.instaAccounts(store.getInstaAccounts())
.description(store.getDescription())
.createdAt(store.getCreatedAt())
.updatedAt(store.getUpdatedAt())
.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 조회
*
@@ -2,6 +2,11 @@ server:
port: ${SERVER_PORT:8082}
spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
enabled: true
application:
name: store-service
datasource:
@@ -30,4 +35,14 @@ logging:
jwt:
secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789}
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