commit b68c7c5fa19b590aa811c91aeb6a04480100b71e Author: yuhalog Date: Wed Jun 11 09:28:32 2025 +0900 add : init project diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/Common module should not have bootJar b/Common module should not have bootJar new file mode 100644 index 0000000..0b49c8c --- /dev/null +++ b/Common module should not have bootJar @@ -0,0 +1,8 @@ +tasks.getByName('bootJar') { + enabled = false +} + +tasks.getByName('jar') { + enabled = true + archiveClassifier = '' +} diff --git a/ai-recommend/build.gradle b/ai-recommend/build.gradle new file mode 100644 index 0000000..6306f15 --- /dev/null +++ b/ai-recommend/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation project(':common') + + // HTTP Client for external API + implementation 'org.springframework.boot:spring-boot-starter-webflux' +} + +bootJar { + archiveFileName = "ai-recommend-service.jar" +} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java new file mode 100644 index 0000000..6ebb3f5 --- /dev/null +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java @@ -0,0 +1,20 @@ +package com.won.smarketing.recommend; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * AI 추천 서비스 메인 애플리케이션 클래스 + * Clean Architecture 패턴을 적용한 AI 마케팅 추천 서비스 + */ +@SpringBootApplication(scanBasePackages = {"com.won.smarketing.recommend", "com.won.smarketing.common"}) +@EntityScan(basePackages = {"com.won.smarketing.recommend.infrastructure.entity"}) +@EnableJpaRepositories(basePackages = {"com.won.smarketing.recommend.infrastructure.repository"}) +public class AIRecommendServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(AIRecommendServiceApplication.class, args); + } +} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java new file mode 100644 index 0000000..7d80205 --- /dev/null +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java @@ -0,0 +1,83 @@ +package com.won.smarketing.recommend.application.service; + +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.StoreData; +import com.won.smarketing.recommend.domain.model.TipId; +import com.won.smarketing.recommend.domain.model.WeatherData; +import com.won.smarketing.recommend.domain.repository.MarketingTipRepository; +import com.won.smarketing.recommend.domain.service.AiTipGenerator; +import com.won.smarketing.recommend.domain.service.StoreDataProvider; +import com.won.smarketing.recommend.domain.service.WeatherDataProvider; +import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest; +import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * 마케팅 팁 서비스 구현체 + * AI 기반 마케팅 팁 생성 및 저장 기능 구현 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MarketingTipService implements MarketingTipUseCase { + + private final MarketingTipRepository marketingTipRepository; + private final StoreDataProvider storeDataProvider; + private final WeatherDataProvider weatherDataProvider; + private final AiTipGenerator aiTipGenerator; + + /** + * AI 마케팅 팁 생성 + * + * @param request 마케팅 팁 생성 요청 + * @return 생성된 마케팅 팁 응답 + */ + @Override + @Transactional + public MarketingTipResponse generateMarketingTips(MarketingTipRequest request) { + try { + // 매장 정보 조회 + StoreData storeData = storeDataProvider.getStoreData(request.getStoreId()); + log.debug("매장 정보 조회 완료: {}", storeData.getStoreName()); + + // 날씨 정보 조회 + WeatherData weatherData = weatherDataProvider.getCurrentWeather(storeData.getLocation()); + log.debug("날씨 정보 조회 완료: {} 도", weatherData.getTemperature()); + + // AI를 사용하여 마케팅 팁 생성 + String tipContent = aiTipGenerator.generateTip(storeData, weatherData); + log.debug("AI 마케팅 팁 생성 완료"); + + // 마케팅 팁 도메인 객체 생성 + MarketingTip marketingTip = MarketingTip.builder() + .storeId(request.getStoreId()) + .tipContent(tipContent) + .weatherData(weatherData) + .storeData(storeData) + .createdAt(LocalDateTime.now()) + .build(); + + // 마케팅 팁 저장 + MarketingTip savedTip = marketingTipRepository.save(marketingTip); + + return MarketingTipResponse.builder() + .tipId(savedTip.getId().getValue()) + .tipContent(savedTip.getTipContent()) + .createdAt(savedTip.getCreatedAt()) + .build(); + + } catch (Exception e) { + log.error("마케팅 팁 생성 중 오류 발생", e); + throw new BusinessException(ErrorCode.RECOMMENDATION_FAILED); + } + } +} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java new file mode 100644 index 0000000..b5e6598 --- /dev/null +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java @@ -0,0 +1,19 @@ +package com.won.smarketing.recommend.application.usecase; + +import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest; +import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse; + +/** + * 마케팅 팁 관련 Use Case 인터페이스 + * AI 기반 마케팅 팁 생성 기능 정의 + */ +public interface MarketingTipUseCase { + + /** + * AI 마케팅 팁 생성 + * + * @param request 마케팅 팁 생성 요청 + * @return 생성된 마케팅 팁 응답 + */ + MarketingTipResponse generateMarketingTips(MarketingTipRequest request); +} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java new file mode 100644 index 0000000..0f38f43 --- /dev/null +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java @@ -0,0 +1,66 @@ +package com.won.smarketing.recommend.domain.model; + +import lombok.*; + +/** + * 매장 데이터 값 객체 + * 마케팅 팁 생성에 사용되는 매장 정보 + */ +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@EqualsAndHashCode +public class StoreData { + + /** + * 매장명 + */ + private String storeName; + + /** + * 업종 + */ + private String businessType; + + /** + * 매장 위치 (주소) + */ + private String location; + + /** + * 매장 데이터 유효성 검증 + * + * @return 유효성 여부 + */ + public boolean isValid() { + return storeName != null && !storeName.trim().isEmpty() && + businessType != null && !businessType.trim().isEmpty() && + location != null && !location.trim().isEmpty(); + } + + /** + * 업종 카테고리 분류 + * + * @return 업종 카테고리 + */ + public String getBusinessCategory() { + if (businessType == null) { + return "기타"; + } + + String lowerCaseType = businessType.toLowerCase(); + + if (lowerCaseType.contains("카페") || lowerCaseType.contains("커피")) { + return "카페"; + } else if (lowerCaseType.contains("식당") || lowerCaseType.contains("레스토랑")) { + return "음식점"; + } else if (lowerCaseType.contains("베이커리") || lowerCaseType.contains("빵")) { + return "베이커리"; + } else if (lowerCaseType.contains("치킨") || lowerCaseType.contains("피자")) { + return "패스트푸드"; + } else { + return "기타"; + } + } +} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java new file mode 100644 index 0000000..ae0b1df --- /dev/null +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java @@ -0,0 +1,29 @@ +package com.won.smarketing.recommend.domain.model; + +import lombok.*; + +/** + * 마케팅 팁 식별자 값 객체 + * 마케팅 팁의 고유 식별자를 나타내는 도메인 객체 + */ +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@EqualsAndHashCode +public class TipId { + + private Long value; + + /** + * TipId 생성 팩토리 메서드 + * + * @param value 식별자 값 + * @return TipId 인스턴스 + */ + public static TipId of(Long value) { + if (value == null || value <= 0) { + throw new IllegalArgumentException("TipId는 양수여야 합니다."); + } + return new TipId(value); + } +} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/WeatherData.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/WeatherData.java new file mode 100644 index 0000000..c1d4f54 --- /dev/null +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/WeatherData.java @@ -0,0 +1,66 @@ +package com.won.smarketing.recommend.domain.model; + +import lombok.*; + +/** + * 날씨 데이터 값 객체 + * 마케팅 팁 생성에 사용되는 날씨 정보 + */ +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@EqualsAndHashCode +public class WeatherData { + + /** + * 온도 (섭씨) + */ + private Double temperature; + + /** + * 날씨 상태 (맑음, 흐림, 비, 눈 등) + */ + private String condition; + + /** + * 습도 (%) + */ + private Double humidity; + + /** + * 날씨 데이터 유효성 검증 + * + * @return 유효성 여부 + */ + public boolean isValid() { + return temperature != null && + condition != null && !condition.trim().isEmpty() && + humidity != null && humidity >= 0 && humidity <= 100; + } + + /** + * 온도 기반 날씨 상태 설명 + * + * @return 날씨 상태 설명 + */ + public String getTemperatureDescription() { + if (temperature == null) { + return "알 수 없음"; + } + + if (temperature >= 30) { + return "매우 더움"; + } else if (temperature >= 25) { + return "더움"; + } else if (temperature >= 20) { + return "따뜻함"; + } else if (temperature >= 10) { + return "선선함"; + } else if (temperature >= 0) { + return "춥다"; + } else { + return "매우 춥다"; + } + } +} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java new file mode 100644 index 0000000..fd5e537 --- /dev/null +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java @@ -0,0 +1,56 @@ +package com.won.smarketing.recommend.domain.repository; + +import com.won.smarketing.recommend.domain.model.MarketingTip; +import com.won.smarketing.recommend.domain.model.TipId; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * 마케팅 팁 저장소 인터페이스 + * 마케팅 팁 도메인의 데이터 접근 추상화 + */ +public interface MarketingTipRepository { + + /** + * 마케팅 팁 저장 + * + * @param marketingTip 저장할 마케팅 팁 + * @return 저장된 마케팅 팁 + */ + MarketingTip save(MarketingTip marketingTip); + + /** + * 마케팅 팁 ID로 조회 + * + * @param id 마케팅 팁 ID + * @return 마케팅 팁 (Optional) + */ + Optional findById(TipId id); + + /** + * 매장별 마케팅 팁 목록 조회 + * + * @param storeId 매장 ID + * @return 마케팅 팁 목록 + */ + List findByStoreId(Long storeId); + + /** + * 특정 기간 내 생성된 마케팅 팁 조회 + * + * @param storeId 매장 ID + * @param startDate 시작 시각 + * @param endDate 종료 시각 + * @return 마케팅 팁 목록 + */ + List findByStoreIdAndCreatedAtBetween(Long storeId, LocalDateTime startDate, LocalDateTime endDate); + + /** + * 마케팅 팁 삭제 + * + * @param id 삭제할 마케팅 팁 ID + */ + void deleteById(TipId id); +} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java new file mode 100644 index 0000000..8680caa --- /dev/null +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java @@ -0,0 +1,20 @@ +package com.won.smarketing.recommend.domain.service; + +import com.won.smarketing.recommend.domain.model.StoreData; +import com.won.smarketing.recommend.domain.model.WeatherData; + +/** + * AI 팁 생성 도메인 서비스 인터페이스 + * AI를 활용한 마케팅 팁 생성 기능 정의 + */ +public interface AiTipGenerator { + + /** + * 매장 정보와 날씨 정보를 바탕으로 마케팅 팁 생성 + * + * @param storeData 매장 데이터 + * @param weatherData 날씨 데이터 + * @return AI가 생성한 마케팅 팁 + */ + String generateTip(StoreData storeData, WeatherData weatherData); +} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java new file mode 100644 index 0000000..bb36bc3 --- /dev/null +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java @@ -0,0 +1,18 @@ +package com.won.smarketing.recommend.domain.service; + +import com.won.smarketing.recommend.domain.model.StoreData; + +/** + * 매장 데이터 제공 도메인 서비스 인터페이스 + * 외부 매장 서비스로부터 매장 정보 조회 기능 정의 + */ +public interface StoreDataProvider { + + /** + * 매장 ID로 매장 데이터 조회 + * + * @param storeId 매장 ID + * @return 매장 데이터 + */ + StoreData getStoreData(Long storeId); +} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/WeatherDataProvider.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/WeatherDataProvider.java new file mode 100644 index 0000000..5129f46 --- /dev/null +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/WeatherDataProvider.java @@ -0,0 +1,18 @@ +package com.won.smarketing.recommend.domain.service; + +import com.won.smarketing.recommend.domain.model.WeatherData; + +/** + * 날씨 데이터 제공 도메인 서비스 인터페이스 + * 외부 날씨 API로부터 날씨 정보 조회 기능 정의 + */ +public interface WeatherDataProvider { + + /** + * 특정 위치의 현재 날씨 정보 조회 + * + * @param location 위치 (주소) + * @return 날씨 데이터 + */ + WeatherData getCurrentWeather(String location); +} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java new file mode 100644 index 0000000..bb93ce3 --- /dev/null +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java @@ -0,0 +1,248 @@ +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.StoreData; +import com.won.smarketing.recommend.domain.model.WeatherData; +import com.won.smarketing.recommend.domain.service.AiTipGenerator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.util.Map; + +/** + * Claude AI 팁 생성기 구현체 + * Claude AI API를 통해 마케팅 팁 생성 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ClaudeAiTipGenerator implements AiTipGenerator { + + private final WebClient webClient; + + @Value("${external.claude-ai.api-key}") + private String claudeApiKey; + + @Value("${external.claude-ai.base-url}") + private String claudeApiBaseUrl; + + @Value("${external.claude-ai.model}") + private String claudeModel; + + @Value("${external.claude-ai.max-tokens}") + private Integer maxTokens; + + /** + * 매장 정보와 날씨 정보를 바탕으로 마케팅 팁 생성 + * + * @param storeData 매장 데이터 + * @param weatherData 날씨 데이터 + * @return AI가 생성한 마케팅 팁 + */ + @Override + public String generateTip(StoreData storeData, WeatherData weatherData) { + try { + log.debug("AI 마케팅 팁 생성 시작: store={}, weather={}도", + storeData.getStoreName(), weatherData.getTemperature()); + + String prompt = buildPrompt(storeData, weatherData); + + Map requestBody = Map.of( + "model", claudeModel, + "max_tokens", maxTokens, + "messages", new Object[]{ + Map.of( + "role", "user", + "content", prompt + ) + } + ); + + ClaudeApiResponse response = webClient + .post() + .uri(claudeApiBaseUrl + "/v1/messages") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + claudeApiKey) + .header("anthropic-version", "2023-06-01") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(requestBody) + .retrieve() + .bodyToMono(ClaudeApiResponse.class) + .timeout(Duration.ofSeconds(30)) + .block(); + + if (response == null || response.getContent() == null || response.getContent().length == 0) { + throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE); + } + + String generatedTip = response.getContent()[0].getText(); + + // 100자 제한 적용 + if (generatedTip.length() > 100) { + generatedTip = generatedTip.substring(0, 97) + "..."; + } + + log.debug("AI 마케팅 팁 생성 완료: length={}", generatedTip.length()); + return generatedTip; + + } catch (WebClientResponseException e) { + log.error("Claude AI API 호출 실패: status={}", e.getStatusCode(), e); + return generateFallbackTip(storeData, weatherData); + } catch (Exception e) { + log.error("AI 마케팅 팁 생성 중 오류 발생", e); + return generateFallbackTip(storeData, weatherData); + } + } + + /** + * AI 프롬프트 구성 + * + * @param storeData 매장 데이터 + * @param weatherData 날씨 데이터 + * @return 프롬프트 문자열 + */ + private String buildPrompt(StoreData storeData, WeatherData weatherData) { + return String.format( + "다음 매장을 위한 오늘의 마케팅 팁을 100자 이내로 작성해주세요.\n\n" + + "매장 정보:\n" + + "- 매장명: %s\n" + + "- 업종: %s\n" + + "- 위치: %s\n\n" + + "오늘 날씨:\n" + + "- 온도: %.1f도\n" + + "- 날씨: %s\n" + + "- 습도: %.1f%%\n\n" + + "날씨와 매장 특성을 고려한 실용적이고 구체적인 마케팅 팁을 제안해주세요. " + + "반드시 100자 이내로 작성하고, 친근하고 실행 가능한 조언을 해주세요.", + storeData.getStoreName(), + storeData.getBusinessType(), + storeData.getLocation(), + weatherData.getTemperature(), + weatherData.getCondition(), + weatherData.getHumidity() + ); + } + + /** + * AI API 실패 시 대체 팁 생성 + * + * @param storeData 매장 데이터 + * @param weatherData 날씨 데이터 + * @return 대체 마케팅 팁 + */ + private String generateFallbackTip(StoreData storeData, WeatherData weatherData) { + StringBuilder tip = new StringBuilder(); + + // 날씨 기반 기본 팁 + if (weatherData.getTemperature() >= 25) { + tip.append("더운 날씨에는 시원한 음료나 디저트를 홍보해보세요! "); + } else if (weatherData.getTemperature() <= 10) { + tip.append("추운 날씨에는 따뜻한 메뉴를 강조해보세요! "); + } else { + tip.append("좋은 날씨를 활용한 야외석 이용을 추천해보세요! "); + } + + // 업종별 기본 팁 + String businessCategory = storeData.getBusinessCategory(); + switch (businessCategory) { + case "카페": + tip.append("인스타그램용 예쁜 음료 사진을 올려보세요."); + break; + case "음식점": + tip.append("시그니처 메뉴의 맛있는 사진을 SNS에 공유해보세요."); + break; + default: + tip.append("오늘의 특별 메뉴를 SNS에 홍보해보세요."); + break; + } + + String fallbackTip = tip.toString(); + return fallbackTip.length() > 100 ? fallbackTip.substring(0, 97) + "..." : fallbackTip; + } + + /** + * Claude API 응답 DTO + */ + private static class ClaudeApiResponse { + private Content[] content; + + public Content[] getContent() { return content; } + public void setContent(Content[] content) { this.content = content; } + + static class Content { + private String text; + private String type; + + public String getText() { return text; } + public void setText(String text) { this.text = text; } + public String getType() { return type; } + public void setType(String type) { this.type = type; } + } + } +}/model/MarketingTip.java +package com.won.smarketing.recommend.domain.model; + +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 마케팅 팁 도메인 모델 + * AI가 생성한 마케팅 팁과 관련 정보를 관리 + */ +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class MarketingTip { + + /** + * 마케팅 팁 고유 식별자 + */ + private TipId id; + + /** + * 매장 ID + */ + private Long storeId; + + /** + * AI가 생성한 마케팅 팁 내용 + */ + private String tipContent; + + /** + * 팁 생성 시 참고한 날씨 데이터 + */ + private WeatherData weatherData; + + /** + * 팁 생성 시 참고한 매장 데이터 + */ + private StoreData storeData; + + /** + * 팁 생성 시각 + */ + private LocalDateTime createdAt; + + /** + * 팁 내용 업데이트 + * + * @param newContent 새로운 팁 내용 + */ + public void updateContent(String newContent) { + if (newContent == null || newContent.trim().isEmpty()) { + throw new IllegalArgumentException("팁 내용은 비어있을 수 없습니다."); + } + this.tipContent = newContent.trim(); + } +} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java new file mode 100644 index 0000000..51efb70 --- /dev/null +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java @@ -0,0 +1,110 @@ +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.StoreData; +import com.won.smarketing.recommend.domain.service.StoreDataProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.core.publisher.Mono; + +import java.time.Duration; + +/** + * 매장 API 데이터 제공자 구현체 + * 외부 매장 서비스 API를 통해 매장 정보 조회 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class StoreApiDataProvider implements StoreDataProvider { + + private final WebClient webClient; + + @Value("${external.store-service.base-url}") + private String storeServiceBaseUrl; + + /** + * 매장 ID로 매장 데이터 조회 + * + * @param storeId 매장 ID + * @return 매장 데이터 + */ + @Override + public StoreData getStoreData(Long storeId) { + try { + log.debug("매장 정보 조회 시작: storeId={}", storeId); + + StoreApiResponse response = webClient + .get() + .uri(storeServiceBaseUrl + "/api/store?storeId=" + storeId) + .retrieve() + .bodyToMono(StoreApiResponse.class) + .timeout(Duration.ofSeconds(10)) + .block(); + + if (response == null || response.getData() == null) { + throw new BusinessException(ErrorCode.STORE_NOT_FOUND); + } + + StoreApiData storeApiData = response.getData(); + + StoreData storeData = StoreData.builder() + .storeName(storeApiData.getStoreName()) + .businessType(storeApiData.getBusinessType()) + .location(storeApiData.getAddress()) + .build(); + + log.debug("매장 정보 조회 완료: {}", storeData.getStoreName()); + return storeData; + + } catch (WebClientResponseException e) { + log.error("매장 서비스 API 호출 실패: storeId={}, status={}", storeId, e.getStatusCode(), e); + throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR); + } catch (Exception e) { + log.error("매장 정보 조회 중 오류 발생: storeId={}", storeId, e); + throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR); + } + } + + /** + * 매장 API 응답 DTO + */ + private static class StoreApiResponse { + private int status; + private String message; + private StoreApiData data; + + // Getters and Setters + public int getStatus() { return status; } + public void setStatus(int status) { this.status = status; } + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + public StoreApiData getData() { return data; } + public void setData(StoreApiData data) { this.data = data; } + } + + /** + * 매장 API 데이터 DTO + */ + private static class StoreApiData { + private Long storeId; + private String storeName; + private String businessType; + private String address; + + // Getters and Setters + 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; } + } +} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java new file mode 100644 index 0000000..3fcf9dd --- /dev/null +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java @@ -0,0 +1,155 @@ +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.WeatherData; +import com.won.smarketing.recommend.domain.service.WeatherDataProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.core.publisher.Mono; + +import java.time.Duration; + +/** + * 날씨 API 데이터 제공자 구현체 + * 외부 날씨 API를 통해 날씨 정보 조회 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WeatherApiDataProvider implements WeatherDataProvider { + + private final WebClient webClient; + + @Value("${external.weather-api.api-key}") + private String weatherApiKey; + + @Value("${external.weather-api.base-url}") + private String weatherApiBaseUrl; + + /** + * 특정 위치의 현재 날씨 정보 조회 + * + * @param location 위치 (주소) + * @return 날씨 데이터 + */ + @Override + public WeatherData getCurrentWeather(String location) { + try { + log.debug("날씨 정보 조회 시작: location={}", location); + + // 한국 주요 도시로 단순화 + String city = extractCity(location); + + WeatherApiResponse response = webClient + .get() + .uri(uriBuilder -> uriBuilder + .scheme("https") + .host("api.openweathermap.org") + .path("/data/2.5/weather") + .queryParam("q", city + ",KR") + .queryParam("appid", weatherApiKey) + .queryParam("units", "metric") + .queryParam("lang", "kr") + .build()) + .retrieve() + .bodyToMono(WeatherApiResponse.class) + .timeout(Duration.ofSeconds(10)) + .onErrorReturn(createDefaultWeatherData()) // 오류 시 기본값 반환 + .block(); + + if (response == null) { + return createDefaultWeatherData(); + } + + WeatherData weatherData = WeatherData.builder() + .temperature(response.getMain().getTemp()) + .condition(response.getWeather()[0].getDescription()) + .humidity(response.getMain().getHumidity()) + .build(); + + log.debug("날씨 정보 조회 완료: {}도, {}", weatherData.getTemperature(), weatherData.getCondition()); + return weatherData; + + } catch (Exception e) { + log.warn("날씨 정보 조회 실패, 기본값 사용: location={}", location, e); + return createDefaultWeatherData(); + } + } + + /** + * 주소에서 도시명 추출 + * + * @param location 전체 주소 + * @return 도시명 + */ + private String extractCity(String location) { + if (location == null || location.trim().isEmpty()) { + return "Seoul"; + } + + // 서울, 부산, 대구, 인천, 광주, 대전, 울산 등 주요 도시 추출 + String[] cities = {"서울", "부산", "대구", "인천", "광주", "대전", "울산", "세종", "수원", "창원"}; + + for (String city : cities) { + if (location.contains(city)) { + return city; + } + } + + return "Seoul"; // 기본값 + } + + /** + * 기본 날씨 데이터 생성 (API 호출 실패 시 사용) + * + * @return 기본 날씨 데이터 + */ + private WeatherApiResponse createDefaultWeatherData() { + WeatherApiResponse response = new WeatherApiResponse(); + response.setMain(new WeatherApiResponse.Main()); + response.getMain().setTemp(20.0); // 기본 온도 20도 + response.getMain().setHumidity(60.0); // 기본 습도 60% + + WeatherApiResponse.Weather[] weather = new WeatherApiResponse.Weather[1]; + weather[0] = new WeatherApiResponse.Weather(); + weather[0].setDescription("맑음"); + response.setWeather(weather); + + return response; + } + + /** + * 날씨 API 응답 DTO + */ + private static class WeatherApiResponse { + private Main main; + private Weather[] weather; + + public Main getMain() { return main; } + public void setMain(Main main) { this.main = main; } + public Weather[] getWeather() { return weather; } + public void setWeather(Weather[] weather) { this.weather = weather; } + + static class Main { + private Double temp; + private Double humidity; + + public Double getTemp() { return temp; } + public void setTemp(Double temp) { this.temp = temp; } + public Double getHumidity() { return humidity; } + public void setHumidity(Double humidity) { this.humidity = humidity; } + } + + static class Weather { + private String description; + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + } + } +} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java new file mode 100644 index 0000000..182ee6e --- /dev/null +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java @@ -0,0 +1,39 @@ +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.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + + +/** + * AI 마케팅 추천을 위한 REST API 컨트롤러 + * AI 기반 마케팅 팁 생성 기능 제공 + */ +@Tag(name = "AI 마케팅 추천", description = "AI 기반 맞춤형 마케팅 추천 API") +@RestController +@RequestMapping("/api/recommendation") +@RequiredArgsConstructor +public class RecommendationController { + + private final MarketingTipUseCase marketingTipUseCase; + + /** + * AI 마케팅 팁 생성 + * + * @param request 마케팅 팁 생성 요청 + * @return 생성된 마케팅 팁 + */ + @Operation(summary = "AI 마케팅 팁 생성", description = "매장 특성과 환경 정보를 바탕으로 AI 마케팅 팁을 생성합니다.") + @PostMapping("/marketing-tips") + public ResponseEntity> generateMarketingTips(@Valid @RequestBody MarketingTipRequest request) { + MarketingTipResponse response = marketingTipUseCase.generateMarketingTips(request); + return ResponseEntity.ok(ApiResponse.success(response, "AI 마케팅 팁이 성공적으로 생성되었습니다.")); + } +} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java new file mode 100644 index 0000000..9e90fc8 --- /dev/null +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java @@ -0,0 +1,34 @@ +package com.won.smarketing.recommend.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 상세 AI 마케팅 팁 응답 DTO + * AI 마케팅 팁과 함께 생성 시 사용된 환경 데이터도 포함합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "상세 AI 마케팅 팁 응답") +public class DetailedMarketingTipResponse { + + @Schema(description = "팁 ID", example = "1") + private Long tipId; + + @Schema(description = "AI 생성 마케팅 팁 내용 (100자 이내)") + private String tipContent; + + @Schema(description = "팁 생성 시간", example = "2024-01-15T10:30:00") + private LocalDateTime createdAt; + + @Schema(description = "팁 생성 시 참고된 날씨 정보") + private WeatherInfoDto weatherInfo; + + @Schema(description = "팁 생성 시 참고된 매장 정보") + private StoreInfoDto storeInfo; +} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java new file mode 100644 index 0000000..335d5ab --- /dev/null +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java @@ -0,0 +1,31 @@ +package com.won.smarketing.recommend.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 에러 응답 DTO + * AI 추천 서비스에서 발생하는 에러 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "에러 응답") +public class ErrorResponseDto { + + @Schema(description = "에러 코드", example = "AI_SERVICE_ERROR") + private String errorCode; + + @Schema(description = "에러 메시지", example = "AI 서비스 연결에 실패했습니다") + private String message; + + @Schema(description = "에러 발생 시간", example = "2024-01-15T10:30:00") + private LocalDateTime timestamp; + + @Schema(description = "요청 경로", example = "/api/recommendation/marketing-tips") + private String path; +} \ No newline at end of file diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java new file mode 100644 index 0000000..70de05e --- /dev/null +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java @@ -0,0 +1,29 @@ +package com.won.smarketing.recommend.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * AI 마케팅 팁 생성을 위한 내부 요청 DTO + * 애플리케이션 계층에서 AI 서비스 호출 시 사용됩니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "AI 마케팅 팁 생성 내부 요청") +public class MarketingTipGenerationRequest { + + @NotNull(message = "매장 정보는 필수입니다") + @Schema(description = "매장 정보", required = true) + private StoreInfoDto storeInfo; + + @Schema(description = "현재 날씨 정보") + private WeatherInfoDto weatherInfo; + + @Schema(description = "팁 생성 옵션", example = "일반") + private String tipType; +} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java new file mode 100644 index 0000000..0bf5ff8 --- /dev/null +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java @@ -0,0 +1,24 @@ +package com.won.smarketing.recommend.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * AI 마케팅 팁 생성 요청 DTO + * 매장 정보를 기반으로 개인화된 마케팅 팁을 요청할 때 사용됩니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "AI 마케팅 팁 생성 요청") +public class MarketingTipRequest { + + @NotNull(message = "매장 ID는 필수입니다") + @Positive(message = "매장 ID는 양수여야 합니다") + @Schema(description = "매장 ID", example = "1", required = true) + private Long storeId; +} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java new file mode 100644 index 0000000..26e2331 --- /dev/null +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java @@ -0,0 +1,29 @@ +package com.won.smarketing.recommend.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * AI 마케팅 팁 생성 응답 DTO + * AI가 생성한 개인화된 마케팅 팁 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "AI 마케팅 팁 생성 응답") +public class MarketingTipResponse { + + @Schema(description = "팁 ID", example = "1") + private Long tipId; + + @Schema(description = "AI 생성 마케팅 팁 내용 (100자 이내)", + example = "오늘 같은 비 오는 날에는 따뜻한 음료와 함께 실내 분위기를 강조한 포스팅을 올려보세요. #비오는날카페 #따뜻한음료 해시태그로 감성을 어필해보세요!") + private String tipContent; + + @Schema(description = "팁 생성 시간", example = "2024-01-15T10:30:00") + private LocalDateTime createdAt; +} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java new file mode 100644 index 0000000..aae7983 --- /dev/null +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java @@ -0,0 +1,26 @@ +package com.won.smarketing.recommend.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 매장 정보 DTO + * AI 마케팅 팁 생성 시 매장 특성을 반영하기 위한 정보입니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "매장 정보") +public class StoreInfoDto { + + @Schema(description = "매장명", example = "카페 원더풀") + private String storeName; + + @Schema(description = "업종", example = "카페") + private String businessType; + + @Schema(description = "매장 위치", example = "서울시 강남구") + private String location; +} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java new file mode 100644 index 0000000..9757f11 --- /dev/null +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java @@ -0,0 +1,26 @@ +package com.won.smarketing.recommend.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 날씨 정보 DTO + * AI 마케팅 팁 생성 시 참고되는 환경 데이터입니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "날씨 정보") +public class WeatherInfoDto { + + @Schema(description = "기온 (섭씨)", example = "23.5") + private Double temperature; + + @Schema(description = "날씨 상태", example = "맑음") + private String condition; + + @Schema(description = "습도 (%)", example = "65.0") + private Double humidity; +} diff --git a/ai-recommend/src/main/resources/application.yml b/ai-recommend/src/main/resources/application.yml new file mode 100644 index 0000000..6604bab --- /dev/null +++ b/ai-recommend/src/main/resources/application.yml @@ -0,0 +1,44 @@ +server: + port: ${SERVER_PORT:8084} + servlet: + context-path: / + +spring: + application: + name: ai-recommend-service + datasource: + url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:recommenddb} + username: ${POSTGRES_USER:postgres} + password: ${POSTGRES_PASSWORD:postgres} + jpa: + hibernate: + ddl-auto: ${JPA_DDL_AUTO:update} + show-sql: ${JPA_SHOW_SQL:true} + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + +external: + claude-ai: + api-key: ${CLAUDE_AI_API_KEY:your-claude-api-key} + base-url: ${CLAUDE_AI_BASE_URL:https://api.anthropic.com} + model: ${CLAUDE_AI_MODEL:claude-3-sonnet-20240229} + max-tokens: ${CLAUDE_AI_MAX_TOKENS:2000} + weather-api: + api-key: ${WEATHER_API_KEY:your-weather-api-key} + base-url: ${WEATHER_API_BASE_URL:https://api.openweathermap.org/data/2.5} + store-service: + base-url: ${STORE_SERVICE_URL:http://localhost:8082} + +springdoc: + swagger-ui: + path: /swagger-ui.html + operations-sorter: method + api-docs: + path: /api-docs + +logging: + level: + com.won.smarketing.recommend: ${LOG_LEVEL:DEBUG} + \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..36a1ec0 --- /dev/null +++ b/build.gradle @@ -0,0 +1,53 @@ +plugins { + id 'org.springframework.boot' version '3.4.0' apply false + id 'io.spring.dependency-management' version '1.1.4' apply false + id 'java' +} + +subprojects { + apply plugin: 'java' + apply plugin: 'org.springframework.boot' + apply plugin: 'io.spring.dependency-management' + + group = 'com.won.smarketing' + version = '0.0.1-SNAPSHOT' + sourceCompatibility = '21' + + repositories { + mavenCentral() + } + + dependencies { + // Spring Boot Starters + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // Database + runtimeOnly 'org.postgresql:postgresql' + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // Test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + } + + test { + useJUnitPlatform() + } +} diff --git a/common/src/main/java/com/won/smarketing/common/config/RedisConfig.java b/common/src/main/java/com/won/smarketing/common/config/RedisConfig.java new file mode 100644 index 0000000..b648673 --- /dev/null +++ b/common/src/main/java/com/won/smarketing/common/config/RedisConfig.java @@ -0,0 +1,47 @@ +package com.won.smarketing.common.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * Redis 설정 클래스 + * Redis 연결 및 템플릿 설정 + */ +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + /** + * Redis 연결 팩토리 설정 + * + * @return Redis 연결 팩토리 + */ + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisHost, redisPort); + } + + /** + * Redis 템플릿 설정 + * + * @return Redis 템플릿 + */ + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + return redisTemplate; + } +} diff --git a/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java b/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java new file mode 100644 index 0000000..834b3bc --- /dev/null +++ b/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java @@ -0,0 +1,93 @@ +package com.won.smarketing.common.config; + +import com.won.smarketing.common.security.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; + +/** + * Spring Security 설정 클래스 + * 인증, 인가, CORS 등 보안 관련 설정 + */ +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + /** + * 패스워드 인코더 Bean 설정 + * + * @return BCrypt 패스워드 인코더 + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + /** + * Security Filter Chain 설정 + * + * @param http HttpSecurity 객체 + * @return SecurityFilterChain + * @throws Exception 예외 + */ + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/api/member/register", + "/api/member/check-duplicate", + "/api/member/validate-password", + "/api/auth/login", + "/swagger-ui/**", + "/swagger-ui.html", + "/api-docs/**", + "/actuator/**" + ).permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + /** + * CORS 설정 + * + * @return CORS 설정 소스 + */ + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(Arrays.asList("*")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("authorization", "content-type", "x-auth-token")); + configuration.setExposedHeaders(Arrays.asList("x-auth-token")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java b/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java new file mode 100644 index 0000000..00fcce0 --- /dev/null +++ b/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java @@ -0,0 +1,41 @@ +package com.won.smarketing.common.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Swagger 설정 클래스 + * API 문서화를 위한 OpenAPI 설정 + */ +@Configuration +public class SwaggerConfig { + + /** + * OpenAPI 설정 + * + * @return OpenAPI 인스턴스 + */ + @Bean + public OpenAPI openAPI() { + String securitySchemeName = "bearerAuth"; + + return new OpenAPI() + .info(new Info() + .title("AI 마케팅 서비스 API") + .description("소상공인을 위한 맞춤형 AI 마케팅 솔루션 API 문서") + .version("v1.0.0")) + .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) + .components(new Components() + .addSecuritySchemes(securitySchemeName, + new SecurityScheme() + .name(securitySchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))); + } +} diff --git a/common/src/main/java/com/won/smarketing/common/dto/ApiResponse.java b/common/src/main/java/com/won/smarketing/common/dto/ApiResponse.java new file mode 100644 index 0000000..dbb123b --- /dev/null +++ b/common/src/main/java/com/won/smarketing/common/dto/ApiResponse.java @@ -0,0 +1,77 @@ +package com.won.smarketing.common.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 표준 API 응답 DTO + * 모든 API 응답에 사용되는 공통 형식 + * + * @param 응답 데이터 타입 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "API 응답") +public class ApiResponse { + + @Schema(description = "응답 상태 코드", example = "200") + private int status; + + @Schema(description = "응답 메시지", example = "요청이 성공적으로 처리되었습니다.") + private String message; + + @Schema(description = "응답 데이터") + private T data; + + /** + * 성공 응답 생성 (데이터 포함) + * + * @param data 응답 데이터 + * @param 데이터 타입 + * @return 성공 응답 + */ + public static ApiResponse success(T data) { + return ApiResponse.builder() + .status(200) + .message("요청이 성공적으로 처리되었습니다.") + .data(data) + .build(); + } + + /** + * 성공 응답 생성 (데이터 및 메시지 포함) + * + * @param data 응답 데이터 + * @param message 응답 메시지 + * @param 데이터 타입 + * @return 성공 응답 + */ + public static ApiResponse success(T data, String message) { + return ApiResponse.builder() + .status(200) + .message(message) + .data(data) + .build(); + } + + /** + * 오류 응답 생성 + * + * @param status 오류 상태 코드 + * @param message 오류 메시지 + * @param 데이터 타입 + * @return 오류 응답 + */ + public static ApiResponse error(int status, String message) { + return ApiResponse.builder() + .status(status) + .message(message) + .data(null) + .build(); + } +} diff --git a/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java b/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java new file mode 100644 index 0000000..af2b74a --- /dev/null +++ b/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java @@ -0,0 +1,44 @@ +package com.won.smarketing.common.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 페이지네이션 응답 DTO + * 페이지 단위 조회 결과를 담는 공통 형식 + * + * @param 페이지 내용의 데이터 타입 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "페이지네이션 응답") +public class PageResponse { + + @Schema(description = "페이지 내용") + private List content; + + @Schema(description = "현재 페이지 번호", example = "0") + private int pageNumber; + + @Schema(description = "페이지 크기", example = "20") + private int pageSize; + + @Schema(description = "전체 요소 수", example = "100") + private long totalElements; + + @Schema(description = "전체 페이지 수", example = "5") + private int totalPages; + + @Schema(description = "첫 번째 페이지 여부", example = "true") + private boolean first; + + @Schema(description = "마지막 페이지 여부", example = "false") + private boolean last; +} diff --git a/common/src/main/java/com/won/smarketing/common/exception/BusinessException.java b/common/src/main/java/com/won/smarketing/common/exception/BusinessException.java new file mode 100644 index 0000000..9a8b7d6 --- /dev/null +++ b/common/src/main/java/com/won/smarketing/common/exception/BusinessException.java @@ -0,0 +1,34 @@ +package com.won.smarketing.common.exception; + +import lombok.Getter; + +/** + * 비즈니스 로직 예외 + * 애플리케이션 내 비즈니스 규칙 위반 시 발생하는 예외 + */ +@Getter +public class BusinessException extends RuntimeException { + + private final ErrorCode errorCode; + + /** + * 비즈니스 예외 생성자 + * + * @param errorCode 오류 코드 + */ + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + /** + * 비즈니스 예외 생성자 (추가 메시지 포함) + * + * @param errorCode 오류 코드 + * @param message 추가 메시지 + */ + public BusinessException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } +} diff --git a/common/src/main/java/com/won/smarketing/common/exception/ErrorCode.java b/common/src/main/java/com/won/smarketing/common/exception/ErrorCode.java new file mode 100644 index 0000000..be6e6ef --- /dev/null +++ b/common/src/main/java/com/won/smarketing/common/exception/ErrorCode.java @@ -0,0 +1,49 @@ +package com.won.smarketing.common.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +/** + * 애플리케이션 오류 코드 정의 + * 각 오류 상황에 대한 코드, HTTP 상태, 메시지 정의 + */ +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + + // 회원 관련 오류 + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "M001", "회원을 찾을 수 없습니다."), + DUPLICATE_MEMBER_ID(HttpStatus.BAD_REQUEST, "M002", "이미 사용 중인 사용자 ID입니다."), + DUPLICATE_EMAIL(HttpStatus.BAD_REQUEST, "M003", "이미 사용 중인 이메일입니다."), + DUPLICATE_BUSINESS_NUMBER(HttpStatus.BAD_REQUEST, "M004", "이미 등록된 사업자 번호입니다."), + INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "M005", "잘못된 패스워드입니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "M006", "유효하지 않은 토큰입니다."), + TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "M007", "만료된 토큰입니다."), + + // 매장 관련 오류 + STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "S001", "매장을 찾을 수 없습니다."), + STORE_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "S002", "이미 등록된 매장이 있습니다."), + MENU_NOT_FOUND(HttpStatus.NOT_FOUND, "S003", "메뉴를 찾을 수 없습니다."), + + // 마케팅 콘텐츠 관련 오류 + CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "C001", "콘텐츠를 찾을 수 없습니다."), + CONTENT_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "C002", "콘텐츠 생성에 실패했습니다."), + AI_SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "C003", "AI 서비스를 사용할 수 없습니다."), + + // AI 추천 관련 오류 + RECOMMENDATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "R001", "추천 생성에 실패했습니다."), + EXTERNAL_API_ERROR(HttpStatus.SERVICE_UNAVAILABLE, "R002", "외부 API 호출에 실패했습니다."), + + // 공통 오류 + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "G001", "서버 내부 오류가 발생했습니다."), + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "G002", "잘못된 입력값입니다."), + INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST, "G003", "잘못된 타입의 값입니다."), + MISSING_REQUEST_PARAMETER(HttpStatus.BAD_REQUEST, "G004", "필수 요청 파라미터가 누락되었습니다."), + ACCESS_DENIED(HttpStatus.FORBIDDEN, "G005", "접근이 거부되었습니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "G006", "허용되지 않은 HTTP 메서드입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java b/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..05ff103 --- /dev/null +++ b/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,151 @@ +package com.won.smarketing.common.exception; + +import com.won.smarketing.common.dto.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.nio.file.AccessDeniedException; + +/** + * 전역 예외 처리 핸들러 + * 애플리케이션에서 발생하는 모든 예외를 처리하여 일관된 응답 형식 제공 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * 비즈니스 예외 처리 + * + * @param ex 비즈니스 예외 + * @return 오류 응답 + */ + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handleBusinessException(BusinessException ex) { + log.warn("Business exception occurred: {}", ex.getMessage()); + ErrorCode errorCode = ex.getErrorCode(); + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(ApiResponse.error(errorCode.getHttpStatus().value(), ex.getMessage())); + } + + /** + * 유효성 검증 예외 처리 (@Valid 애노테이션) + * + * @param ex 유효성 검증 예외 + * @return 오류 응답 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationException(MethodArgumentNotValidException ex) { + log.warn("Validation exception occurred: {}", ex.getMessage()); + String errorMessage = ex.getBindingResult() + .getFieldErrors() + .stream() + .findFirst() + .map(error -> error.getDefaultMessage()) + .orElse("유효성 검증에 실패했습니다."); + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), errorMessage)); + } + + /** + * 바인딩 예외 처리 + * + * @param ex 바인딩 예외 + * @return 오류 응답 + */ + @ExceptionHandler(BindException.class) + public ResponseEntity> handleBindException(BindException ex) { + log.warn("Bind exception occurred: {}", ex.getMessage()); + String errorMessage = ex.getBindingResult() + .getFieldErrors() + .stream() + .findFirst() + .map(error -> error.getDefaultMessage()) + .orElse("요청 데이터 바인딩에 실패했습니다."); + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), errorMessage)); + } + + /** + * 타입 불일치 예외 처리 + * + * @param ex 타입 불일치 예외 + * @return 오류 응답 + */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity> handleTypeMismatchException(MethodArgumentTypeMismatchException ex) { + log.warn("Type mismatch exception occurred: {}", ex.getMessage()); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), "잘못된 타입의 값입니다.")); + } + + /** + * 필수 파라미터 누락 예외 처리 + * + * @param ex 필수 파라미터 누락 예외 + * @return 오류 응답 + */ + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity> handleMissingParameterException(MissingServletRequestParameterException ex) { + log.warn("Missing parameter exception occurred: {}", ex.getMessage()); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), "필수 요청 파라미터가 누락되었습니다: " + ex.getParameterName())); + } + + /** + * HTTP 메서드 불일치 예외 처리 + * + * @param ex HTTP 메서드 불일치 예외 + * @return 오류 응답 + */ + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity> handleMethodNotSupportedException(HttpRequestMethodNotSupportedException ex) { + log.warn("Method not supported exception occurred: {}", ex.getMessage()); + return ResponseEntity + .status(HttpStatus.METHOD_NOT_ALLOWED) + .body(ApiResponse.error(HttpStatus.METHOD_NOT_ALLOWED.value(), "허용되지 않은 HTTP 메서드입니다.")); + } + + /** + * 접근 거부 예외 처리 + * + * @param ex 접근 거부 예외 + * @return 오류 응답 + */ + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDeniedException(AccessDeniedException ex) { + log.warn("Access denied exception occurred: {}", ex.getMessage()); + return ResponseEntity + .status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error(HttpStatus.FORBIDDEN.value(), "접근이 거부되었습니다.")); + } + + /** + * 기타 모든 예외 처리 + * + * @param ex 예외 + * @return 오류 응답 + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGenericException(Exception ex) { + log.error("Unexpected exception occurred", ex); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다.")); + } +} diff --git a/common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java b/common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..26aee46 --- /dev/null +++ b/common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java @@ -0,0 +1,73 @@ +package com.won.smarketing.common.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +/** + * JWT 인증 필터 + * 요청 헤더에서 JWT 토큰을 추출하여 인증 처리 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + /** + * JWT 토큰 인증 처리 + * + * @param request HTTP 요청 + * @param response HTTP 응답 + * @param filterChain 필터 체인 + * @throws ServletException 서블릿 예외 + * @throws IOException I/O 예외 + */ + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + // 요청 헤더에서 JWT 토큰 추출 + String token = resolveToken(request); + + // 토큰이 있고 유효한 경우 인증 정보 설정 + if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { + String userId = jwtTokenProvider.getUserIdFromToken(token); + Authentication authentication = new UsernamePasswordAuthenticationToken( + userId, null, Collections.emptyList()); + SecurityContextHolder.getContext().setAuthentication(authentication); + log.debug("Security context에 '{}' 인증 정보를 저장했습니다.", userId); + } + + filterChain.doFilter(request, response); + } + + /** + * 요청 헤더에서 JWT 토큰 추출 + * + * @param request HTTP 요청 + * @return JWT 토큰 (Bearer 접두사 제거) + */ + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(BEARER_PREFIX.length()); + } + return null; + } +} diff --git a/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java b/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java new file mode 100644 index 0000000..86de936 --- /dev/null +++ b/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java @@ -0,0 +1,150 @@ +package com.won.smarketing.common.security; + +import com.won.smarketing.common.exception.BusinessException; +import com.won.smarketing.common.exception.ErrorCode; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; + +/** + * JWT 토큰 생성 및 검증 유틸리티 + * Access Token과 Refresh Token 관리 + */ +@Slf4j +@Component +public class JwtTokenProvider { + + private final SecretKey key; + private final long accessTokenValidityTime; + private final long refreshTokenValidityTime; + + /** + * JWT 토큰 프로바이더 생성자 + * + * @param secretKey JWT 서명에 사용할 비밀키 + * @param accessTokenValidityTime Access Token 유효 시간 (밀리초) + * @param refreshTokenValidityTime Refresh Token 유효 시간 (밀리초) + */ + public JwtTokenProvider( + @Value("${jwt.secret-key}") String secretKey, + @Value("${jwt.access-token-validity}") long accessTokenValidityTime, + @Value("${jwt.refresh-token-validity}") long refreshTokenValidityTime) { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + this.accessTokenValidityTime = accessTokenValidityTime; + this.refreshTokenValidityTime = refreshTokenValidityTime; + } + + /** + * Access Token 생성 + * + * @param userId 사용자 ID + * @return Access Token + */ + public String generateAccessToken(String userId) { + long now = System.currentTimeMillis(); + Date validity = new Date(now + accessTokenValidityTime); + + return Jwts.builder() + .setSubject(userId) + .setIssuedAt(new Date(now)) + .setExpiration(validity) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + /** + * Refresh Token 생성 + * + * @param userId 사용자 ID + * @return Refresh Token + */ + public String generateRefreshToken(String userId) { + long now = System.currentTimeMillis(); + Date validity = new Date(now + refreshTokenValidityTime); + + return Jwts.builder() + .setSubject(userId) + .setIssuedAt(new Date(now)) + .setExpiration(validity) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + /** + * 토큰에서 사용자 ID 추출 + * + * @param token JWT 토큰 + * @return 사용자 ID + */ + public String getUserIdFromToken(String token) { + Claims claims = parseClaims(token); + return claims.getSubject(); + } + + /** + * 토큰 유효성 검증 + * + * @param token JWT 토큰 + * @return 유효성 여부 + */ + public boolean validateToken(String token) { + try { + parseClaims(token); + return true; + } catch (ExpiredJwtException e) { + log.warn("Expired JWT token: {}", e.getMessage()); + throw new BusinessException(ErrorCode.TOKEN_EXPIRED); + } catch (UnsupportedJwtException e) { + log.warn("Unsupported JWT token: {}", e.getMessage()); + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } catch (MalformedJwtException e) { + log.warn("Malformed JWT token: {}", e.getMessage()); + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } catch (SecurityException e) { + log.warn("Invalid JWT signature: {}", e.getMessage()); + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } catch (IllegalArgumentException e) { + log.warn("JWT token compact of handler are invalid: {}", e.getMessage()); + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + } + + /** + * 토큰에서 Claims 추출 + * + * @param token JWT 토큰 + * @return Claims + */ + private Claims parseClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } + + /** + * Access Token 유효 시간 반환 + * + * @return Access Token 유효 시간 (밀리초) + */ + public long getAccessTokenValidityTime() { + return accessTokenValidityTime; + } + + /** + * Refresh Token 유효 시간 반환 + * + * @return Refresh Token 유효 시간 (밀리초) + */ + public long getRefreshTokenValidityTime() { + return refreshTokenValidityTime; + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9bbc975 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f853b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..faf9300 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/marketing-content/build.gradle b/marketing-content/build.gradle new file mode 100644 index 0000000..bb3b8ea --- /dev/null +++ b/marketing-content/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation project(':common') + + // HTTP Client for external AI API + implementation 'org.springframework.boot:spring-boot-starter-webflux' +} + +bootJar { + archiveFileName = "marketing-content-service.jar" +} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java b/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java new file mode 100644 index 0000000..08115e2 --- /dev/null +++ b/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java @@ -0,0 +1,20 @@ +package com.won.smarketing.content; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * 마케팅 콘텐츠 서비스 메인 애플리케이션 클래스 + * Clean Architecture 패턴을 적용한 마케팅 콘텐츠 관리 서비스 + */ +@SpringBootApplication(scanBasePackages = {"com.won.smarketing.content", "com.won.smarketing.common"}) +@EntityScan(basePackages = {"com.won.smarketing.content.infrastructure.entity"}) +@EnableJpaRepositories(basePackages = {"com.won.smarketing.content.infrastructure.repository"}) +public class MarketingContentServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(MarketingContentServiceApplication.class, args); + } +} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java b/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java new file mode 100644 index 0000000..eb868eb --- /dev/null +++ b/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java @@ -0,0 +1,200 @@ +package com.won.smarketing.content.application.service; + +import com.won.smarketing.common.exception.BusinessException; +import com.won.smarketing.common.exception.ErrorCode; +import com.won.smarketing.content.application.usecase.ContentQueryUseCase; +import com.won.smarketing.content.domain.model.Content; +import com.won.smarketing.content.domain.model.ContentId; +import com.won.smarketing.content.domain.model.ContentStatus; +import com.won.smarketing.content.domain.model.ContentType; +import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.domain.repository.ContentRepository; +import com.won.smarketing.content.presentation.dto.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 콘텐츠 조회 서비스 구현체 + * 콘텐츠 수정, 조회, 삭제 기능 구현 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ContentQueryService implements ContentQueryUseCase { + + private final ContentRepository contentRepository; + + /** + * 콘텐츠 수정 + * + * @param contentId 수정할 콘텐츠 ID + * @param request 콘텐츠 수정 요청 + * @return 수정된 콘텐츠 정보 + */ + @Override + @Transactional + public ContentUpdateResponse updateContent(Long contentId, ContentUpdateRequest request) { + Content content = contentRepository.findById(ContentId.of(contentId)) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + + // 제목과 기간 업데이트 + content.updateTitle(request.getTitle()); + content.updatePeriod(request.getStartDate(), request.getEndDate()); + + Content updatedContent = contentRepository.save(content); + + return ContentUpdateResponse.builder() + .contentId(updatedContent.getId().getValue()) + .contentType(updatedContent.getContentType().name()) + .platform(updatedContent.getPlatform().name()) + .title(updatedContent.getTitle()) + .content(updatedContent.getContent()) + .hashtags(updatedContent.getHashtags()) + .images(updatedContent.getImages()) + .status(updatedContent.getStatus().name()) + .updatedAt(updatedContent.getUpdatedAt()) + .build(); + } + + /** + * 콘텐츠 목록 조회 + * + * @param contentType 콘텐츠 타입 + * @param platform 플랫폼 + * @param period 기간 + * @param sortBy 정렬 기준 + * @return 콘텐츠 목록 + */ + @Override + public List getContents(String contentType, String platform, String period, String sortBy) { + ContentType type = contentType != null ? ContentType.fromString(contentType) : null; + Platform platformEnum = platform != null ? Platform.fromString(platform) : null; + + List contents = contentRepository.findByFilters(type, platformEnum, period, sortBy); + + return contents.stream() + .map(this::toContentResponse) + .collect(Collectors.toList()); + } + + /** + * 진행 중인 콘텐츠 목록 조회 + * + * @param period 기간 + * @return 진행 중인 콘텐츠 목록 + */ + @Override + public List getOngoingContents(String period) { + List contents = contentRepository.findOngoingContents(period); + + return contents.stream() + .map(this::toOngoingContentResponse) + .collect(Collectors.toList()); + } + + /** + * 콘텐츠 상세 조회 + * + * @param contentId 콘텐츠 ID + * @return 콘텐츠 상세 정보 + */ + @Override + public ContentDetailResponse getContentDetail(Long contentId) { + Content content = contentRepository.findById(ContentId.of(contentId)) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + + return ContentDetailResponse.builder() + .contentId(content.getId().getValue()) + .contentType(content.getContentType().name()) + .platform(content.getPlatform().name()) + .title(content.getTitle()) + .content(content.getContent()) + .hashtags(content.getHashtags()) + .images(content.getImages()) + .status(content.getStatus().name()) + .creationConditions(toCreationConditionsDto(content.getCreationConditions())) + .createdAt(content.getCreatedAt()) + .build(); + } + + /** + * 콘텐츠 삭제 + * + * @param contentId 삭제할 콘텐츠 ID + */ + @Override + @Transactional + public void deleteContent(Long contentId) { + Content content = contentRepository.findById(ContentId.of(contentId)) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + + contentRepository.deleteById(ContentId.of(contentId)); + } + + /** + * Content 엔티티를 ContentResponse DTO로 변환 + * + * @param content Content 엔티티 + * @return ContentResponse DTO + */ + private ContentResponse toContentResponse(Content content) { + return ContentResponse.builder() + .contentId(content.getId().getValue()) + .contentType(content.getContentType().name()) + .platform(content.getPlatform().name()) + .title(content.getTitle()) + .content(content.getContent()) + .hashtags(content.getHashtags()) + .images(content.getImages()) + .status(content.getStatus().name()) + .createdAt(content.getCreatedAt()) + .viewCount(0) // TODO: 실제 조회 수 구현 필요 + .build(); + } + + /** + * Content 엔티티를 OngoingContentResponse DTO로 변환 + * + * @param content Content 엔티티 + * @return OngoingContentResponse DTO + */ + private OngoingContentResponse toOngoingContentResponse(Content content) { + return OngoingContentResponse.builder() + .contentId(content.getId().getValue()) + .contentType(content.getContentType().name()) + .platform(content.getPlatform().name()) + .title(content.getTitle()) + .status(content.getStatus().name()) + .createdAt(content.getCreatedAt()) + .viewCount(0) // TODO: 실제 조회 수 구현 필요 + .build(); + } + + /** + * CreationConditions를 DTO로 변환 + * + * @param conditions CreationConditions 도메인 객체 + * @return CreationConditionsDto + */ + private CreationConditionsDto toCreationConditionsDto(CreationConditions conditions) { + if (conditions == null) { + return null; + } + + return CreationConditionsDto.builder() + .category(conditions.getCategory()) + .requirement(conditions.getRequirement()) + .toneAndManner(conditions.getToneAndManner()) + .emotionIntensity(conditions.getEmotionIntensity()) + .eventName(conditions.getEventName()) + .startDate(conditions.getStartDate()) + .endDate(conditions.getEndDate()) + .photoStyle(conditions.getPhotoStyle()) + .build(); + } +} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java b/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java new file mode 100644 index 0000000..c444d75 --- /dev/null +++ b/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java @@ -0,0 +1,108 @@ +package com.won.smarketing.content.application.service; + +import com.won.smarketing.content.application.usecase.PosterContentUseCase; +import com.won.smarketing.content.domain.model.Content; +import com.won.smarketing.content.domain.model.ContentStatus; +import com.won.smarketing.content.domain.model.ContentType; +import com.won.smarketing.content.domain.model.CreationConditions; +import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.domain.repository.ContentRepository; +import com.won.smarketing.content.domain.service.AiPosterGenerator; +import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest; +import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse; +import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 포스터 콘텐츠 서비스 구현체 + * 홍보 포스터 생성 및 저장 기능 구현 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PosterContentService implements PosterContentUseCase { + + private final ContentRepository contentRepository; + private final AiPosterGenerator aiPosterGenerator; + + /** + * 포스터 콘텐츠 생성 + * + * @param request 포스터 콘텐츠 생성 요청 + * @return 생성된 포스터 콘텐츠 정보 + */ + @Override + @Transactional + public PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request) { + // AI를 사용하여 포스터 생성 + String generatedPoster = aiPosterGenerator.generatePoster(request); + + // 다양한 사이즈의 포스터 생성 + Map posterSizes = aiPosterGenerator.generatePosterSizes(generatedPoster); + + // 생성 조건 정보 구성 + CreationConditions conditions = CreationConditions.builder() + .category(request.getCategory()) + .requirement(request.getRequirement()) + .toneAndManner(request.getToneAndManner()) + .emotionIntensity(request.getEmotionIntensity()) + .eventName(request.getEventName()) + .startDate(request.getStartDate()) + .endDate(request.getEndDate()) + .photoStyle(request.getPhotoStyle()) + .build(); + + return PosterContentCreateResponse.builder() + .contentId(null) // 임시 생성이므로 ID 없음 + .contentType(ContentType.POSTER.name()) + .title(request.getTitle()) + .image(generatedPoster) + .posterSizes(posterSizes) + .status(ContentStatus.DRAFT.name()) + .createdAt(LocalDateTime.now()) + .build(); + } + + /** + * 포스터 콘텐츠 저장 + * + * @param request 포스터 콘텐츠 저장 요청 + */ + @Override + @Transactional + public void savePosterContent(PosterContentSaveRequest request) { + // 생성 조건 정보 구성 + CreationConditions conditions = CreationConditions.builder() + .category(request.getCategory()) + .requirement(request.getRequirement()) + .toneAndManner(request.getToneAndManner()) + .emotionIntensity(request.getEmotionIntensity()) + .eventName(request.getEventName()) + .startDate(request.getStartDate()) + .endDate(request.getEndDate()) + .photoStyle(request.getPhotoStyle()) + .build(); + + // 콘텐츠 엔티티 생성 및 저장 + Content content = Content.builder() + .contentType(ContentType.POSTER) + .platform(Platform.GENERAL) // 포스터는 범용 + .title(request.getTitle()) + .content(null) // 포스터는 이미지가 주 콘텐츠 + .hashtags(null) + .images(request.getImages()) + .status(ContentStatus.PUBLISHED) + .creationConditions(conditions) + .storeId(request.getStoreId()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + contentRepository.save(content); + } +} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java b/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java new file mode 100644 index 0000000..508fbe5 --- /dev/null +++ b/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java @@ -0,0 +1,125 @@ +package com.won.smarketing.content.application.service; + +import com.won.smarketing.content.application.usecase.SnsContentUseCase; +import com.won.smarketing.content.domain.model.Content; +import com.won.smarketing.content.domain.model.ContentId; +import com.won.smarketing.content.domain.model.ContentStatus; +import com.won.smarketing.content.domain.model.ContentType; +import com.won.smarketing.content.domain.model.CreationConditions; +import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.domain.repository.ContentRepository; +import com.won.smarketing.content.domain.service.AiContentGenerator; +import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest; +import com.won.smarketing.content.presentation.dto.SnsContentCreateResponse; +import com.won.smarketing.content.presentation.dto.SnsContentSaveRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * SNS 콘텐츠 서비스 구현체 + * SNS 게시물 생성 및 저장 기능 구현 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SnsContentService implements SnsContentUseCase { + + private final ContentRepository contentRepository; + private final AiContentGenerator aiContentGenerator; + + /** + * SNS 콘텐츠 생성 + * + * @param request SNS 콘텐츠 생성 요청 + * @return 생성된 SNS 콘텐츠 정보 + */ + @Override + @Transactional + public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request) { + // AI를 사용하여 SNS 콘텐츠 생성 + String generatedContent = aiContentGenerator.generateSnsContent(request); + + // 플랫폼에 맞는 해시태그 생성 + Platform platform = Platform.fromString(request.getPlatform()); + List hashtags = aiContentGenerator.generateHashtags(generatedContent, platform); + + // 생성 조건 정보 구성 + CreationConditions conditions = CreationConditions.builder() + .category(request.getCategory()) + .requirement(request.getRequirement()) + .toneAndManner(request.getToneAndManner()) + .emotionIntensity(request.getEmotionIntensity()) + .eventName(request.getEventName()) + .startDate(request.getStartDate()) + .endDate(request.getEndDate()) + .build(); + + // 임시 콘텐츠 생성 (저장하지 않음) + Content content = Content.builder() + .contentType(ContentType.SNS_POST) + .platform(platform) + .title(request.getTitle()) + .content(generatedContent) + .hashtags(hashtags) + .images(request.getImages()) + .status(ContentStatus.DRAFT) + .creationConditions(conditions) + .storeId(request.getStoreId()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + return SnsContentCreateResponse.builder() + .contentId(null) // 임시 생성이므로 ID 없음 + .contentType(content.getContentType().name()) + .platform(content.getPlatform().name()) + .title(content.getTitle()) + .content(content.getContent()) + .hashtags(content.getHashtags()) + .images(content.getImages()) + .status(content.getStatus().name()) + .createdAt(content.getCreatedAt()) + .build(); + } + + /** + * SNS 콘텐츠 저장 + * + * @param request SNS 콘텐츠 저장 요청 + */ + @Override + @Transactional + public void saveSnsContent(SnsContentSaveRequest request) { + // 생성 조건 정보 구성 + CreationConditions conditions = CreationConditions.builder() + .category(request.getCategory()) + .requirement(request.getRequirement()) + .toneAndManner(request.getToneAndManner()) + .emotionIntensity(request.getEmotionIntensity()) + .eventName(request.getEventName()) + .startDate(request.getStartDate()) + .endDate(request.getEndDate()) + .build(); + + // 콘텐츠 엔티티 생성 및 저장 + Content content = Content.builder() + .contentType(ContentType.SNS_POST) + .platform(Platform.fromString(request.getPlatform())) + .title(request.getTitle()) + .content(request.getContent()) + .hashtags(request.getHashtags()) + .images(request.getImages()) + .status(ContentStatus.PUBLISHED) + .creationConditions(conditions) + .storeId(request.getStoreId()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + contentRepository.save(content); + } +} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/ContentQueryUseCase.java b/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/ContentQueryUseCase.java new file mode 100644 index 0000000..0712961 --- /dev/null +++ b/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/ContentQueryUseCase.java @@ -0,0 +1,55 @@ +package com.won.smarketing.content.application.usecase; + +import com.won.smarketing.content.presentation.dto.*; + +import java.util.List; + +/** + * 콘텐츠 조회 관련 Use Case 인터페이스 + * 콘텐츠 수정, 조회, 삭제 기능 정의 + */ +public interface ContentQueryUseCase { + + /** + * 콘텐츠 수정 + * + * @param contentId 수정할 콘텐츠 ID + * @param request 콘텐츠 수정 요청 + * @return 수정된 콘텐츠 정보 + */ + ContentUpdateResponse updateContent(Long contentId, ContentUpdateRequest request); + + /** + * 콘텐츠 목록 조회 + * + * @param contentType 콘텐츠 타입 + * @param platform 플랫폼 + * @param period 기간 + * @param sortBy 정렬 기준 + * @return 콘텐츠 목록 + */ + List getContents(String contentType, String platform, String period, String sortBy); + + /** + * 진행 중인 콘텐츠 목록 조회 + * + * @param period 기간 + * @return 진행 중인 콘텐츠 목록 + */ + List getOngoingContents(String period); + + /** + * 콘텐츠 상세 조회 + * + * @param contentId 콘텐츠 ID + * @return 콘텐츠 상세 정보 + */ + ContentDetailResponse getContentDetail(Long contentId); + + /** + * 콘텐츠 삭제 + * + * @param contentId 삭제할 콘텐츠 ID + */ + void deleteContent(Long contentId); +} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java b/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java new file mode 100644 index 0000000..973b234 --- /dev/null +++ b/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java @@ -0,0 +1,27 @@ +package com.won.smarketing.content.application.usecase; + +import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest; +import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse; +import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest; + +/** + * 포스터 콘텐츠 관련 Use Case 인터페이스 + * 홍보 포스터 생성 및 저장 기능 정의 + */ +public interface PosterContentUseCase { + + /** + * 포스터 콘텐츠 생성 + * + * @param request 포스터 콘텐츠 생성 요청 + * @return 생성된 포스터 콘텐츠 정보 + */ + PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request); + + /** + * 포스터 콘텐츠 저장 + * + * @param request 포스터 콘텐츠 저장 요청 + */ + void savePosterContent(PosterContentSaveRequest request); +} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java b/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java new file mode 100644 index 0000000..e62902d --- /dev/null +++ b/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java @@ -0,0 +1,27 @@ +package com.won.smarketing.content.application.usecase; + +import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest; +import com.won.smarketing.content.presentation.dto.SnsContentCreateResponse; +import com.won.smarketing.content.presentation.dto.SnsContentSaveRequest; + +/** + * SNS 콘텐츠 관련 Use Case 인터페이스 + * SNS 게시물 생성 및 저장 기능 정의 + */ +public interface SnsContentUseCase { + + /** + * SNS 콘텐츠 생성 + * + * @param request SNS 콘텐츠 생성 요청 + * @return 생성된 SNS 콘텐츠 정보 + */ + SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request); + + /** + * SNS 콘텐츠 저장 + * + * @param request SNS 콘텐츠 저장 요청 + */ + void saveSnsContent(SnsContentSaveRequest request); +} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java b/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java new file mode 100644 index 0000000..6eee928 --- /dev/null +++ b/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java @@ -0,0 +1,114 @@ +package com.won.smarketing.content.domain.model; + +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 마케팅 콘텐츠 도메인 모델 + * 콘텐츠의 핵심 비즈니스 로직과 상태를 관리 + */ +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Content { + + /** + * 콘텐츠 고유 식별자 + */ + private ContentId id; + + /** + * 콘텐츠 타입 (SNS 게시물, 포스터 등) + */ + private ContentType contentType; + + /** + * 플랫폼 (인스타그램, 네이버 블로그 등) + */ + private Platform platform; + + /** + * 콘텐츠 제목 + */ + private String title; + + /** + * 콘텐츠 내용 + */ + private String content; + + /** + * 해시태그 목록 + */ + private List hashtags; + + /** + * 이미지 URL 목록 + */ + private List images; + + /** + * 콘텐츠 상태 + */ + private ContentStatus status; + + /** + * 콘텐츠 생성 조건 + */ + private CreationConditions creationConditions; + + /** + * 매장 ID + */ + private Long storeId; + + /** + * 생성 시각 + */ + private LocalDateTime createdAt; + + /** + * 수정 시각 + */ + private LocalDateTime updatedAt; + + /** + * 콘텐츠 제목 업데이트 + * + * @param title 새로운 제목 + */ + public void updateTitle(String title) { + this.title = title; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 콘텐츠 기간 업데이트 + * + * @param startDate 시작일 + * @param endDate 종료일 + */ + public void updatePeriod(LocalDate startDate, LocalDate endDate) { + if (this.creationConditions != null) { + this.creationConditions = this.creationConditions.toBuilder() + .startDate(startDate) + .endDate(endDate) + .build(); + } + this.updatedAt = LocalDateTime.now(); + } + + /** + * 콘텐츠 상태 변경 + * + * @param status 새로운 상태 + */ + public void changeStatus(ContentStatus status) { + this.status = status; + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java b/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java new file mode 100644 index 0000000..b2a77bb --- /dev/null +++ b/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java @@ -0,0 +1,33 @@ +package com.won.smarketing.content.domain.model; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 콘텐츠 식별자 값 객체 + * 콘텐츠의 고유 식별자를 나타내는 도메인 객체 + */ +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@EqualsAndHashCode +public class ContentId { + + private Long value; + + /** + * ContentId 생성 팩토리 메서드 + * + * @param value 식별자 값 + * @return ContentId 인스턴스 + */ + public static ContentId of(Long value) { + if (value == null || value <= 0) { + throw new IllegalArgumentException("ContentId는 양수여야 합니다."); + } + return new ContentId(value); + } +} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java b/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java new file mode 100644 index 0000000..c40ec47 --- /dev/null +++ b/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java @@ -0,0 +1,39 @@ +package com.won.smarketing.content.domain.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 콘텐츠 상태 열거형 + * 콘텐츠의 생명주기 상태 정의 + */ +@Getter +@RequiredArgsConstructor +public enum ContentStatus { + + DRAFT("임시저장"), + PUBLISHED("발행됨"), + ARCHIVED("보관됨"); + + private final String displayName; + + /** + * 문자열로부터 ContentStatus 변환 + * + * @param status 상태 문자열 + * @return ContentStatus + */ + public static ContentStatus fromString(String status) { + if (status == null) { + return DRAFT; + } + + for (ContentStatus s : ContentStatus.values()) { + if (s.name().equalsIgnoreCase(status)) { + return s; + } + } + + throw new IllegalArgumentException("알 수 없는 콘텐츠 상태: " + status); + } +} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java b/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java new file mode 100644 index 0000000..dd91b91 --- /dev/null +++ b/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java @@ -0,0 +1,38 @@ +package com.won.smarketing.content.domain.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 콘텐츠 타입 열거형 + * 지원되는 마케팅 콘텐츠 유형 정의 + */ +@Getter +@RequiredArgsConstructor +public enum ContentType { + + SNS_POST("SNS 게시물"), + POSTER("홍보 포스터"); + + private final String displayName; + + /** + * 문자열로부터 ContentType 변환 + * + * @param type 타입 문자열 + * @return ContentType + */ + public static ContentType fromString(String type) { + if (type == null) { + return null; + } + + for (ContentType contentType : ContentType.values()) { + if (contentType.name().equalsIgnoreCase(type)) { + return contentType; + } + } + + throw new IllegalArgumentException("알 수 없는 콘텐츠 타입: " + type); + } +} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java b/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java new file mode 100644 index 0000000..b76a152 --- /dev/null +++ b/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java @@ -0,0 +1,56 @@ +package com.won.smarketing.content.domain.model; + +import lombok.*; + +import java.time.LocalDate; + +/** + * 콘텐츠 생성 조건 도메인 모델 + * AI 콘텐츠 생성 시 사용되는 조건 정보 + */ +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder(toBuilder = true) +public class CreationConditions { + + /** + * 홍보 대상 카테고리 + */ + private String category; + + /** + * 특별 요구사항 + */ + private String requirement; + + /** + * 톤앤매너 + */ + private String toneAndManner; + + /** + * 감정 강도 + */ + private String emotionIntensity; + + /** + * 이벤트명 + */ + private String eventName; + + /** + * 홍보 시작일 + */ + private LocalDate startDate; + + /** + * 홍보 종료일 + */ + private LocalDate endDate; + + /** + * 사진 스타일 (포스터용) + */ + private String photoStyle; +} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java b/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java new file mode 100644 index 0000000..acd6b33 --- /dev/null +++ b/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java @@ -0,0 +1,39 @@ +package com.won.smarketing.content.domain.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 플랫폼 열거형 + * 콘텐츠가 게시될 플랫폼 정의 + */ +@Getter +@RequiredArgsConstructor +public enum Platform { + + INSTAGRAM("인스타그램"), + NAVER_BLOG("네이버 블로그"), + GENERAL("범용"); + + private final String displayName; + + /** + * 문자열로부터 Platform 변환 + * + * @param platform 플랫폼 문자열 + * @return Platform + */ + public static Platform fromString(String platform) { + if (platform == null) { + return GENERAL; + } + + for (Platform p : Platform.values()) { + if (p.name().equalsIgnoreCase(platform)) { + return p; + } + } + + throw new IllegalArgumentException("알 수 없는 플랫폼: " + platform); + } +} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java b/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java new file mode 100644 index 0000000..194c7aa --- /dev/null +++ b/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java @@ -0,0 +1,58 @@ +package com.won.smarketing.content.domain.repository; + +import com.won.smarketing.content.domain.model.Content; +import com.won.smarketing.content.domain.model.ContentId; +import com.won.smarketing.content.domain.model.ContentType; +import com.won.smarketing.content.domain.model.Platform; + +import java.util.List; +import java.util.Optional; + +/** + * 콘텐츠 저장소 인터페이스 + * 콘텐츠 도메인의 데이터 접근 추상화 + */ +public interface ContentRepository { + + /** + * 콘텐츠 저장 + * + * @param content 저장할 콘텐츠 + * @return 저장된 콘텐츠 + */ + Content save(Content content); + + /** + * 콘텐츠 ID로 조회 + * + * @param id 콘텐츠 ID + * @return 콘텐츠 (Optional) + */ + Optional findById(ContentId id); + + /** + * 필터 조건으로 콘텐츠 목록 조회 + * + * @param contentType 콘텐츠 타입 + * @param platform 플랫폼 + * @param period 기간 + * @param sortBy 정렬 기준 + * @return 콘텐츠 목록 + */ + List findByFilters(ContentType contentType, Platform platform, String period, String sortBy); + + /** + * 진행 중인 콘텐츠 목록 조회 + * + * @param period 기간 + * @return 진행 중인 콘텐츠 목록 + */ + List findOngoingContents(String period); + + /** + * 콘텐츠 삭제 + * + * @param id 삭제할 콘텐츠 ID + */ + void deleteById(ContentId id); +} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiContentGenerator.java b/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiContentGenerator.java new file mode 100644 index 0000000..677853a --- /dev/null +++ b/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiContentGenerator.java @@ -0,0 +1,30 @@ +package com.won.smarketing.content.domain.service; + +import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest; + +import java.util.List; + +/** + * AI 콘텐츠 생성 도메인 서비스 인터페이스 + * SNS 콘텐츠 생성 및 해시태그 생성 기능 정의 + */ +public interface AiContentGenerator { + + /** + * SNS 콘텐츠 생성 + * + * @param request SNS 콘텐츠 생성 요청 + * @return 생성된 콘텐츠 + */ + String generateSnsContent(SnsContentCreateRequest request); + + /** + * 플랫폼별 해시태그 생성 + * + * @param content 콘텐츠 내용 + * @param platform 플랫폼 + * @return 해시태그 목록 + */ + List generateHashtags(String content, Platform platform); +} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java b/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java new file mode 100644 index 0000000..6c0f1cb --- /dev/null +++ b/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java @@ -0,0 +1,28 @@ +package com.won.smarketing.content.domain.service; + +import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest; + +import java.util.Map; + +/** + * AI 포스터 생성 도메인 서비스 인터페이스 + * 홍보 포스터 생성 및 다양한 사이즈 생성 기능 정의 + */ +public interface AiPosterGenerator { + + /** + * 포스터 생성 + * + * @param request 포스터 생성 요청 + * @return 생성된 포스터 이미지 URL + */ + String generatePoster(PosterContentCreateRequest request); + + /** + * 다양한 사이즈의 포스터 생성 + * + * @param baseImage 기본 이미지 + * @return 사이즈별 포스터 URL 맵 + */ + Map generatePosterSizes(String baseImage); +} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java b/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java new file mode 100644 index 0000000..e65842a --- /dev/null +++ b/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java @@ -0,0 +1,169 @@ +package com.won.smarketing.content.presentation.controller; + +import com.won.smarketing.common.dto.ApiResponse; +import com.won.smarketing.content.application.usecase.ContentQueryUseCase; +import com.won.smarketing.content.application.usecase.PosterContentUseCase; +import com.won.smarketing.content.application.usecase.SnsContentUseCase; +import com.won.smarketing.content.presentation.dto.*; +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.*; + +import javax.validation.Valid; +import java.util.List; + +/** + * 마케팅 콘텐츠 관리를 위한 REST API 컨트롤러 + * SNS 콘텐츠 생성, 포스터 생성, 콘텐츠 관리 기능 제공 + */ +@Tag(name = "마케팅 콘텐츠 관리", description = "AI 기반 마케팅 콘텐츠 생성 및 관리 API") +@RestController +@RequestMapping("/api/content") +@RequiredArgsConstructor +public class ContentController { + + private final SnsContentUseCase snsContentUseCase; + private final PosterContentUseCase posterContentUseCase; + private final ContentQueryUseCase contentQueryUseCase; + + /** + * SNS 게시물 생성 + * + * @param request SNS 콘텐츠 생성 요청 + * @return 생성된 SNS 콘텐츠 정보 + */ + @Operation(summary = "SNS 게시물 생성", description = "AI를 활용하여 SNS 게시물을 생성합니다.") + @PostMapping("/sns/generate") + public ResponseEntity> generateSnsContent(@Valid @RequestBody SnsContentCreateRequest request) { + SnsContentCreateResponse response = snsContentUseCase.generateSnsContent(request); + return ResponseEntity.ok(ApiResponse.success(response, "SNS 콘텐츠가 성공적으로 생성되었습니다.")); + } + + /** + * SNS 게시물 저장 + * + * @param request SNS 콘텐츠 저장 요청 + * @return 저장 성공 응답 + */ + @Operation(summary = "SNS 게시물 저장", description = "생성된 SNS 게시물을 저장합니다.") + @PostMapping("/sns/save") + public ResponseEntity> saveSnsContent(@Valid @RequestBody SnsContentSaveRequest request) { + snsContentUseCase.saveSnsContent(request); + return ResponseEntity.ok(ApiResponse.success(null, "SNS 콘텐츠가 성공적으로 저장되었습니다.")); + } + + /** + * 홍보 포스터 생성 + * + * @param request 포스터 콘텐츠 생성 요청 + * @return 생성된 포스터 콘텐츠 정보 + */ + @Operation(summary = "홍보 포스터 생성", description = "AI를 활용하여 홍보 포스터를 생성합니다.") + @PostMapping("/poster/generate") + public ResponseEntity> generatePosterContent(@Valid @RequestBody PosterContentCreateRequest request) { + PosterContentCreateResponse response = posterContentUseCase.generatePosterContent(request); + return ResponseEntity.ok(ApiResponse.success(response, "포스터 콘텐츠가 성공적으로 생성되었습니다.")); + } + + /** + * 홍보 포스터 저장 + * + * @param request 포스터 콘텐츠 저장 요청 + * @return 저장 성공 응답 + */ + @Operation(summary = "홍보 포스터 저장", description = "생성된 홍보 포스터를 저장합니다.") + @PostMapping("/poster/save") + public ResponseEntity> savePosterContent(@Valid @RequestBody PosterContentSaveRequest request) { + posterContentUseCase.savePosterContent(request); + return ResponseEntity.ok(ApiResponse.success(null, "포스터 콘텐츠가 성공적으로 저장되었습니다.")); + } + + /** + * 콘텐츠 수정 + * + * @param contentId 수정할 콘텐츠 ID + * @param request 콘텐츠 수정 요청 + * @return 수정된 콘텐츠 정보 + */ + @Operation(summary = "콘텐츠 수정", description = "기존 콘텐츠를 수정합니다.") + @PutMapping("/{contentId}") + public ResponseEntity> updateContent( + @Parameter(description = "콘텐츠 ID", required = true) + @PathVariable Long contentId, + @Valid @RequestBody ContentUpdateRequest request) { + ContentUpdateResponse response = contentQueryUseCase.updateContent(contentId, request); + return ResponseEntity.ok(ApiResponse.success(response, "콘텐츠가 성공적으로 수정되었습니다.")); + } + + /** + * 콘텐츠 목록 조회 + * + * @param contentType 콘텐츠 타입 필터 + * @param platform 플랫폼 필터 + * @param period 기간 필터 + * @param sortBy 정렬 기준 + * @return 콘텐츠 목록 + */ + @Operation(summary = "콘텐츠 목록 조회", description = "다양한 필터와 정렬 옵션으로 콘텐츠 목록을 조회합니다.") + @GetMapping + public ResponseEntity>> getContents( + @Parameter(description = "콘텐츠 타입") + @RequestParam(required = false) String contentType, + @Parameter(description = "플랫폼") + @RequestParam(required = false) String platform, + @Parameter(description = "기간") + @RequestParam(required = false) String period, + @Parameter(description = "정렬 기준") + @RequestParam(required = false) String sortBy) { + List response = contentQueryUseCase.getContents(contentType, platform, period, sortBy); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 진행 중인 콘텐츠 목록 조회 + * + * @param period 기간 필터 + * @return 진행 중인 콘텐츠 목록 + */ + @Operation(summary = "진행 콘텐츠 조회", description = "현재 진행 중인 콘텐츠 목록을 조회합니다.") + @GetMapping("/ongoing") + public ResponseEntity>> getOngoingContents( + @Parameter(description = "기간") + @RequestParam(required = false) String period) { + List response = contentQueryUseCase.getOngoingContents(period); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 콘텐츠 상세 조회 + * + * @param contentId 조회할 콘텐츠 ID + * @return 콘텐츠 상세 정보 + */ + @Operation(summary = "콘텐츠 상세 조회", description = "특정 콘텐츠의 상세 정보를 조회합니다.") + @GetMapping("/{contentId}") + public ResponseEntity> getContentDetail( + @Parameter(description = "콘텐츠 ID", required = true) + @PathVariable Long contentId) { + ContentDetailResponse response = contentQueryUseCase.getContentDetail(contentId); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 콘텐츠 삭제 + * + * @param contentId 삭제할 콘텐츠 ID + * @return 삭제 성공 응답 + */ + @Operation(summary = "콘텐츠 삭제", description = "콘텐츠를 삭제합니다.") + @DeleteMapping("/{contentId}") + public ResponseEntity> deleteContent( + @Parameter(description = "콘텐츠 ID", required = true) + @PathVariable Long contentId) { + contentQueryUseCase.deleteContent(contentId); + return ResponseEntity.ok(ApiResponse.success(null, "콘텐츠가 성공적으로 삭제되었습니다.")); + } +} diff --git a/marketing-content/src/main/resources/application.yml b/marketing-content/src/main/resources/application.yml new file mode 100644 index 0000000..6d8cbfc --- /dev/null +++ b/marketing-content/src/main/resources/application.yml @@ -0,0 +1,38 @@ +server: + port: ${SERVER_PORT:8083} + servlet: + context-path: / + +spring: + application: + name: marketing-content-service + datasource: + url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:contentdb} + username: ${POSTGRES_USER:postgres} + password: ${POSTGRES_PASSWORD:postgres} + jpa: + hibernate: + ddl-auto: ${JPA_DDL_AUTO:update} + show-sql: ${JPA_SHOW_SQL:true} + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + +external: + claude-ai: + api-key: ${CLAUDE_AI_API_KEY:your-claude-api-key} + base-url: ${CLAUDE_AI_BASE_URL:https://api.anthropic.com} + model: ${CLAUDE_AI_MODEL:claude-3-sonnet-20240229} + max-tokens: ${CLAUDE_AI_MAX_TOKENS:4000} + +springdoc: + swagger-ui: + path: /swagger-ui.html + operations-sorter: method + api-docs: + path: /api-docs + +logging: + level: + com.won.smarketing.content: ${LOG_LEVEL:DEBUG} diff --git a/member/build.gradle b/member/build.gradle new file mode 100644 index 0000000..d375e00 --- /dev/null +++ b/member/build.gradle @@ -0,0 +1,7 @@ +dependencies { + implementation project(':common') +} + +bootJar { + archiveFileName = "member-service.jar" +} diff --git a/member/src/main/java/com/won/smarketing/member/MemberServiceApplication.java b/member/src/main/java/com/won/smarketing/member/MemberServiceApplication.java new file mode 100644 index 0000000..d8f2305 --- /dev/null +++ b/member/src/main/java/com/won/smarketing/member/MemberServiceApplication.java @@ -0,0 +1,20 @@ +package com.won.smarketing.member; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * 회원 서비스 메인 애플리케이션 클래스 + * Spring Boot 애플리케이션의 진입점 + */ +@SpringBootApplication(scanBasePackages = {"com.won.smarketing.member", "com.won.smarketing.common"}) +@EntityScan(basePackages = {"com.won.smarketing.member.entity"}) +@EnableJpaRepositories(basePackages = {"com.won.smarketing.member.repository"}) +public class MemberServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(MemberServiceApplication.class, args); + } +} diff --git a/member/src/main/java/com/won/smarketing/member/controller/AuthController.java b/member/src/main/java/com/won/smarketing/member/controller/AuthController.java new file mode 100644 index 0000000..15e3ba7 --- /dev/null +++ b/member/src/main/java/com/won/smarketing/member/controller/AuthController.java @@ -0,0 +1,68 @@ +package com.won.smarketing.member.controller; + +import com.won.smarketing.common.dto.ApiResponse; +import com.won.smarketing.member.dto.LoginRequest; +import com.won.smarketing.member.dto.LoginResponse; +import com.won.smarketing.member.dto.LogoutRequest; +import com.won.smarketing.member.dto.TokenRefreshRequest; +import com.won.smarketing.member.dto.TokenResponse; +import com.won.smarketing.member.service.AuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + + +/** + * 인증/인가를 위한 REST API 컨트롤러 + * 로그인, 로그아웃, 토큰 갱신 기능 제공 + */ +@Tag(name = "인증/인가", description = "로그인, 로그아웃, 토큰 관리 API") +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + /** + * 로그인 인증 + * + * @param request 로그인 요청 정보 + * @return JWT 토큰 정보 + */ + @Operation(summary = "로그인", description = "사용자 인증 후 JWT 토큰을 발급합니다.") + @PostMapping("/login") + public ResponseEntity> login(@Valid @RequestBody LoginRequest request) { + LoginResponse response = authService.login(request); + return ResponseEntity.ok(ApiResponse.success(response, "로그인이 완료되었습니다.")); + } + + /** + * 로그아웃 처리 + * + * @param request 로그아웃 요청 정보 + * @return 로그아웃 성공 응답 + */ + @Operation(summary = "로그아웃", description = "사용자를 로그아웃하고 토큰을 무효화합니다.") + @PostMapping("/logout") + public ResponseEntity> logout(@Valid @RequestBody LogoutRequest request) { + authService.logout(request.getRefreshToken()); + return ResponseEntity.ok(ApiResponse.success(null, "로그아웃이 완료되었습니다.")); + } + + /** + * 토큰 갱신 + * + * @param request 토큰 갱신 요청 정보 + * @return 새로운 JWT 토큰 정보 + */ + @Operation(summary = "토큰 갱신", description = "Refresh Token을 사용하여 새로운 Access Token을 발급합니다.") + @PostMapping("/refresh") + public ResponseEntity> refresh(@Valid @RequestBody TokenRefreshRequest request) { + TokenResponse response = authService.refresh(request.getRefreshToken()); + return ResponseEntity.ok(ApiResponse.success(response, "토큰이 갱신되었습니다.")); + } +} diff --git a/member/src/main/java/com/won/smarketing/member/controller/MemberController.java b/member/src/main/java/com/won/smarketing/member/controller/MemberController.java new file mode 100644 index 0000000..e47078c --- /dev/null +++ b/member/src/main/java/com/won/smarketing/member/controller/MemberController.java @@ -0,0 +1,74 @@ +package com.won.smarketing.member.controller; + +import com.won.smarketing.common.dto.ApiResponse; +import com.won.smarketing.member.dto.DuplicateCheckResponse; +import com.won.smarketing.member.dto.PasswordValidationRequest; +import com.won.smarketing.member.dto.RegisterRequest; +import com.won.smarketing.member.dto.ValidationResponse; +import com.won.smarketing.member.service.MemberService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + + +/** + * 회원 관리를 위한 REST API 컨트롤러 + * 회원가입, ID 중복 확인, 패스워드 유효성 검증 기능 제공 + */ +@Tag(name = "회원 관리", description = "회원가입 및 회원 정보 관리 API") +@RestController +@RequestMapping("/api/member") +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + + /** + * 회원가입 처리 + * + * @param request 회원가입 요청 정보 + * @return 회원가입 성공/실패 응답 + */ + @Operation(summary = "회원가입", description = "새로운 회원을 등록합니다.") + @PostMapping("/register") + public ResponseEntity> register(@Valid @RequestBody RegisterRequest request) { + memberService.register(request); + return ResponseEntity.ok(ApiResponse.success(null, "회원가입이 완료되었습니다.")); + } + + /** + * ID 중복 확인 + * + * @param userId 확인할 사용자 ID + * @return 중복 여부 응답 + */ + @Operation(summary = "ID 중복 확인", description = "사용자 ID의 중복 여부를 확인합니다.") + @GetMapping("/check-duplicate") + public ResponseEntity> checkDuplicate( + @Parameter(description = "확인할 사용자 ID", required = true) + @RequestParam String userId) { + boolean isDuplicate = memberService.checkDuplicate(userId); + DuplicateCheckResponse response = DuplicateCheckResponse.builder() + .isDuplicate(isDuplicate) + .message(isDuplicate ? "이미 사용 중인 ID입니다." : "사용 가능한 ID입니다.") + .build(); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 패스워드 유효성 검증 + * + * @param request 패스워드 유효성 검증 요청 + * @return 유효성 검증 결과 + */ + @Operation(summary = "패스워드 유효성 검증", description = "패스워드가 보안 규칙을 만족하는지 확인합니다.") + @PostMapping("/validate-password") + public ResponseEntity> validatePassword(@Valid @RequestBody PasswordValidationRequest request) { + ValidationResponse response = memberService.validatePassword(request.getPassword()); + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java b/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java new file mode 100644 index 0000000..99d1763 --- /dev/null +++ b/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java @@ -0,0 +1,25 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * ID 중복 확인 응답 DTO + * 사용자 ID 중복 여부 확인 결과 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "ID 중복 확인 응답") +public class DuplicateCheckResponse { + + @Schema(description = "중복 여부", example = "false") + private boolean isDuplicate; + + @Schema(description = "확인 결과 메시지", example = "사용 가능한 ID입니다.") + private String message; +} diff --git a/member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java b/member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java new file mode 100644 index 0000000..7c304d0 --- /dev/null +++ b/member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java @@ -0,0 +1,29 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotBlank; + +/** + * 로그인 요청 DTO + * 로그인 시 필요한 사용자 ID와 패스워드 정보 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "로그인 요청 정보") +public class LoginRequest { + + @Schema(description = "사용자 ID", example = "testuser", required = true) + @NotBlank(message = "사용자 ID는 필수입니다.") + private String userId; + + @Schema(description = "패스워드", example = "password123!", required = true) + @NotBlank(message = "패스워드는 필수입니다.") + private String password; +} diff --git a/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java b/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java new file mode 100644 index 0000000..5769e70 --- /dev/null +++ b/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java @@ -0,0 +1,28 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 로그인 응답 DTO + * 로그인 성공 시 반환되는 JWT 토큰 정보 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "로그인 응답 정보") +public class LoginResponse { + + @Schema(description = "Access Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String accessToken; + + @Schema(description = "Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String refreshToken; + + @Schema(description = "토큰 만료 시간 (밀리초)", example = "900000") + private long expiresIn; +} diff --git a/member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java b/member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java new file mode 100644 index 0000000..d53f388 --- /dev/null +++ b/member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java @@ -0,0 +1,25 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotBlank; + +/** + * 로그아웃 요청 DTO + * 로그아웃 시 무효화할 Refresh Token 정보 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "로그아웃 요청") +public class LogoutRequest { + + @Schema(description = "무효화할 Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", required = true) + @NotBlank(message = "Refresh Token은 필수입니다.") + private String refreshToken; +} diff --git a/member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java b/member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java new file mode 100644 index 0000000..f4a015b --- /dev/null +++ b/member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java @@ -0,0 +1,25 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotBlank; + +/** + * 패스워드 유효성 검증 요청 DTO + * 패스워드 보안 규칙 확인을 위한 요청 정보 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "패스워드 유효성 검증 요청") +public class PasswordValidationRequest { + + @Schema(description = "검증할 패스워드", example = "password123!", required = true) + @NotBlank(message = "패스워드는 필수입니다.") + private String password; +} diff --git a/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java b/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java new file mode 100644 index 0000000..efd99d8 --- /dev/null +++ b/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java @@ -0,0 +1,49 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; + +/** + * 회원가입 요청 DTO + * 회원가입 시 필요한 정보를 담는 데이터 전송 객체 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "회원가입 요청 정보") +public class RegisterRequest { + + @Schema(description = "사용자 ID", example = "testuser", required = true) + @NotBlank(message = "사용자 ID는 필수입니다.") + @Size(min = 4, max = 20, message = "사용자 ID는 4자 이상 20자 이하여야 합니다.") + @Pattern(regexp = "^[a-zA-Z0-9]+$", message = "사용자 ID는 영문과 숫자만 가능합니다.") + private String userId; + + @Schema(description = "패스워드", example = "password123!", required = true) + @NotBlank(message = "패스워드는 필수입니다.") + private String password; + + @Schema(description = "이름", example = "홍길동", required = true) + @NotBlank(message = "이름은 필수입니다.") + @Size(max = 100, message = "이름은 100자 이하여야 합니다.") + private String name; + + @Schema(description = "사업자 번호", example = "123-45-67890", required = true) + @NotBlank(message = "사업자 번호는 필수입니다.") + @Pattern(regexp = "^\\d{3}-\\d{2}-\\d{5}$", message = "사업자 번호 형식이 올바르지 않습니다.") + private String businessNumber; + + @Schema(description = "이메일", example = "test@example.com", required = true) + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + private String email; +} diff --git a/member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java b/member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java new file mode 100644 index 0000000..f62226b --- /dev/null +++ b/member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java @@ -0,0 +1,25 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotBlank; + +/** + * 토큰 갱신 요청 DTO + * Refresh Token을 사용한 토큰 갱신 요청 정보 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "토큰 갱신 요청") +public class TokenRefreshRequest { + + @Schema(description = "Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", required = true) + @NotBlank(message = "Refresh Token은 필수입니다.") + private String refreshToken; +} diff --git a/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java b/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java new file mode 100644 index 0000000..a0cbf85 --- /dev/null +++ b/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java @@ -0,0 +1,28 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 토큰 응답 DTO + * 토큰 갱신 시 반환되는 새로운 JWT 토큰 정보 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "토큰 응답 정보") +public class TokenResponse { + + @Schema(description = "새로운 Access Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String accessToken; + + @Schema(description = "새로운 Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String refreshToken; + + @Schema(description = "토큰 만료 시간 (밀리초)", example = "900000") + private long expiresIn; +} diff --git a/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java b/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java new file mode 100644 index 0000000..5c56b39 --- /dev/null +++ b/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java @@ -0,0 +1,30 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 유효성 검증 응답 DTO + * 패스워드 유효성 검증 결과 정보 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "유효성 검증 응답") +public class ValidationResponse { + + @Schema(description = "유효성 여부", example = "true") + private boolean isValid; + + @Schema(description = "검증 결과 메시지", example = "유효한 패스워드입니다.") + private String message; + + @Schema(description = "오류 목록") + private List errors; +} diff --git a/member/src/main/java/com/won/smarketing/member/entity/Member.java b/member/src/main/java/com/won/smarketing/member/entity/Member.java new file mode 100644 index 0000000..d3adcc1 --- /dev/null +++ b/member/src/main/java/com/won/smarketing/member/entity/Member.java @@ -0,0 +1,87 @@ +package com.won.smarketing.member.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 회원 정보를 나타내는 엔티티 + * 사용자 ID, 패스워드, 이름, 사업자 번호, 이메일 정보 저장 + */ +@Entity +@Table(name = "members") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Member { + + /** + * 회원 고유 식별자 + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 사용자 ID (로그인용) + */ + @Column(name = "user_id", unique = true, nullable = false, length = 50) + private String userId; + + /** + * 암호화된 패스워드 + */ + @Column(name = "password", nullable = false) + private String password; + + /** + * 회원 이름 + */ + @Column(name = "name", nullable = false, length = 100) + private String name; + + /** + * 사업자 번호 + */ + @Column(name = "business_number", unique = true, nullable = false, length = 20) + private String businessNumber; + + /** + * 이메일 주소 + */ + @Column(name = "email", unique = true, nullable = false) + private String email; + + /** + * 회원 생성 시각 + */ + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * 회원 정보 수정 시각 + */ + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * 엔티티 저장 전 실행되는 메서드 + * 생성 시각과 수정 시각을 현재 시각으로 설정 + */ + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + /** + * 엔티티 업데이트 전 실행되는 메서드 + * 수정 시각을 현재 시각으로 갱신 + */ + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java b/member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java new file mode 100644 index 0000000..c7b6d44 --- /dev/null +++ b/member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java @@ -0,0 +1,47 @@ +package com.won.smarketing.member.repository; + +import com.won.smarketing.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * 회원 정보 데이터 접근을 위한 Repository + * JPA를 사용한 회원 CRUD 작업 처리 + */ +@Repository +public interface MemberRepository extends JpaRepository { + + /** + * 사용자 ID로 회원 조회 + * + * @param userId 사용자 ID + * @return 회원 정보 + */ + Optional findByUserId(String userId); + + /** + * 사용자 ID 존재 여부 확인 + * + * @param userId 사용자 ID + * @return 존재 여부 + */ + boolean existsByUserId(String userId); + + /** + * 이메일 존재 여부 확인 + * + * @param email 이메일 + * @return 존재 여부 + */ + boolean existsByEmail(String email); + + /** + * 사업자 번호 존재 여부 확인 + * + * @param businessNumber 사업자 번호 + * @return 존재 여부 + */ + boolean existsByBusinessNumber(String businessNumber); +} diff --git a/member/src/main/java/com/won/smarketing/member/service/AuthService.java b/member/src/main/java/com/won/smarketing/member/service/AuthService.java new file mode 100644 index 0000000..f93b0b7 --- /dev/null +++ b/member/src/main/java/com/won/smarketing/member/service/AuthService.java @@ -0,0 +1,35 @@ +package com.won.smarketing.member.service; + +import com.won.smarketing.member.dto.LoginRequest; +import com.won.smarketing.member.dto.LoginResponse; +import com.won.smarketing.member.dto.TokenResponse; + +/** + * 인증/인가 서비스 인터페이스 + * 로그인, 로그아웃, 토큰 갱신 기능 정의 + */ +public interface AuthService { + + /** + * 로그인 인증 처리 + * + * @param request 로그인 요청 정보 + * @return JWT 토큰 정보 + */ + LoginResponse login(LoginRequest request); + + /** + * 로그아웃 처리 + * + * @param refreshToken 무효화할 Refresh Token + */ + void logout(String refreshToken); + + /** + * 토큰 갱신 처리 + * + * @param refreshToken 갱신에 사용할 Refresh Token + * @return 새로운 JWT 토큰 정보 + */ + TokenResponse refresh(String refreshToken); +} diff --git a/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java b/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java new file mode 100644 index 0000000..8413aed --- /dev/null +++ b/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java @@ -0,0 +1,131 @@ +package com.won.smarketing.member.service; + +import com.won.smarketing.common.exception.BusinessException; +import com.won.smarketing.common.exception.ErrorCode; +import com.won.smarketing.common.security.JwtTokenProvider; +import com.won.smarketing.member.dto.LoginRequest; +import com.won.smarketing.member.dto.LoginResponse; +import com.won.smarketing.member.dto.TokenResponse; +import com.won.smarketing.member.entity.Member; +import com.won.smarketing.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.concurrent.TimeUnit; + +/** + * 인증/인가 서비스 구현체 + * 로그인, 로그아웃, 토큰 갱신 기능 구현 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthServiceImpl implements AuthService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + private final RedisTemplate redisTemplate; + + private static final String REFRESH_TOKEN_PREFIX = "refresh_token:"; + + /** + * 로그인 인증 처리 + * + * @param request 로그인 요청 정보 + * @return JWT 토큰 정보 + */ + @Override + @Transactional + public LoginResponse login(LoginRequest request) { + // 사용자 조회 + Member member = memberRepository.findByUserId(request.getUserId()) + .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + + // 패스워드 검증 + if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) { + throw new BusinessException(ErrorCode.INVALID_PASSWORD); + } + + // JWT 토큰 생성 + String accessToken = jwtTokenProvider.generateAccessToken(member.getUserId()); + String refreshToken = jwtTokenProvider.generateRefreshToken(member.getUserId()); + long expiresIn = jwtTokenProvider.getAccessTokenValidityTime(); + + // Refresh Token을 Redis에 저장 + String refreshTokenKey = REFRESH_TOKEN_PREFIX + member.getUserId(); + redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, + jwtTokenProvider.getRefreshTokenValidityTime(), TimeUnit.MILLISECONDS); + + return LoginResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .expiresIn(expiresIn) + .build(); + } + + /** + * 로그아웃 처리 + * + * @param refreshToken 무효화할 Refresh Token + */ + @Override + @Transactional + public void logout(String refreshToken) { + // 토큰에서 사용자 ID 추출 + String userId = jwtTokenProvider.getUserIdFromToken(refreshToken); + + // Redis에서 Refresh Token 삭제 + String refreshTokenKey = REFRESH_TOKEN_PREFIX + userId; + redisTemplate.delete(refreshTokenKey); + } + + /** + * 토큰 갱신 처리 + * + * @param refreshToken 갱신에 사용할 Refresh Token + * @return 새로운 JWT 토큰 정보 + */ + @Override + @Transactional + public TokenResponse refresh(String refreshToken) { + // Refresh Token 유효성 검증 + if (!jwtTokenProvider.validateToken(refreshToken)) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + // 토큰에서 사용자 ID 추출 + String userId = jwtTokenProvider.getUserIdFromToken(refreshToken); + + // Redis에서 저장된 Refresh Token 확인 + String refreshTokenKey = REFRESH_TOKEN_PREFIX + userId; + String storedRefreshToken = redisTemplate.opsForValue().get(refreshTokenKey); + + if (storedRefreshToken == null || !storedRefreshToken.equals(refreshToken)) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + // 사용자 존재 여부 확인 + Member member = memberRepository.findByUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + + // 새로운 토큰 생성 + String newAccessToken = jwtTokenProvider.generateAccessToken(member.getUserId()); + String newRefreshToken = jwtTokenProvider.generateRefreshToken(member.getUserId()); + long expiresIn = jwtTokenProvider.getAccessTokenValidityTime(); + + // 기존 Refresh Token 삭제 후 새로운 토큰 저장 + redisTemplate.delete(refreshTokenKey); + redisTemplate.opsForValue().set(refreshTokenKey, newRefreshToken, + jwtTokenProvider.getRefreshTokenValidityTime(), TimeUnit.MILLISECONDS); + + return TokenResponse.builder() + .accessToken(newAccessToken) + .refreshToken(newRefreshToken) + .expiresIn(expiresIn) + .build(); + } +} diff --git a/member/src/main/java/com/won/smarketing/member/service/MemberService.java b/member/src/main/java/com/won/smarketing/member/service/MemberService.java new file mode 100644 index 0000000..a2dc6c8 --- /dev/null +++ b/member/src/main/java/com/won/smarketing/member/service/MemberService.java @@ -0,0 +1,34 @@ +package com.won.smarketing.member.service; + +import com.won.smarketing.member.dto.RegisterRequest; +import com.won.smarketing.member.dto.ValidationResponse; + +/** + * 회원 관리 서비스 인터페이스 + * 회원가입, 중복 확인, 패스워드 유효성 검증 기능 정의 + */ +public interface MemberService { + + /** + * 회원가입 처리 + * + * @param request 회원가입 요청 정보 + */ + void register(RegisterRequest request); + + /** + * 사용자 ID 중복 확인 + * + * @param userId 확인할 사용자 ID + * @return 중복 여부 (true: 중복, false: 사용 가능) + */ + boolean checkDuplicate(String userId); + + /** + * 패스워드 유효성 검증 + * + * @param password 검증할 패스워드 + * @return 유효성 검증 결과 + */ + ValidationResponse validatePassword(String password); +} diff --git a/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java b/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java new file mode 100644 index 0000000..c837763 --- /dev/null +++ b/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java @@ -0,0 +1,115 @@ +package com.won.smarketing.member.service; + +import com.won.smarketing.common.exception.BusinessException; +import com.won.smarketing.common.exception.ErrorCode; +import com.won.smarketing.member.dto.RegisterRequest; +import com.won.smarketing.member.dto.ValidationResponse; +import com.won.smarketing.member.entity.Member; +import com.won.smarketing.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +/** + * 회원 관리 서비스 구현체 + * 회원가입, 중복 확인, 패스워드 유효성 검증 기능 구현 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberServiceImpl implements MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + // 패스워드 정규식: 영문, 숫자, 특수문자 각각 최소 1개 포함, 8자 이상 + private static final Pattern PASSWORD_PATTERN = Pattern.compile( + "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$" + ); + + /** + * 회원가입 처리 + * + * @param request 회원가입 요청 정보 + */ + @Override + @Transactional + public void register(RegisterRequest request) { + // 중복 ID 확인 + if (memberRepository.existsByUserId(request.getUserId())) { + throw new BusinessException(ErrorCode.DUPLICATE_MEMBER_ID); + } + + // 이메일 중복 확인 + if (memberRepository.existsByEmail(request.getEmail())) { + throw new BusinessException(ErrorCode.DUPLICATE_EMAIL); + } + + // 사업자 번호 중복 확인 + if (memberRepository.existsByBusinessNumber(request.getBusinessNumber())) { + throw new BusinessException(ErrorCode.DUPLICATE_BUSINESS_NUMBER); + } + + // 패스워드 암호화 + String encodedPassword = passwordEncoder.encode(request.getPassword()); + + // 회원 엔티티 생성 및 저장 + Member member = Member.builder() + .userId(request.getUserId()) + .password(encodedPassword) + .name(request.getName()) + .businessNumber(request.getBusinessNumber()) + .email(request.getEmail()) + .build(); + + memberRepository.save(member); + } + + /** + * 사용자 ID 중복 확인 + * + * @param userId 확인할 사용자 ID + * @return 중복 여부 (true: 중복, false: 사용 가능) + */ + @Override + public boolean checkDuplicate(String userId) { + return memberRepository.existsByUserId(userId); + } + + /** + * 패스워드 유효성 검증 + * + * @param password 검증할 패스워드 + * @return 유효성 검증 결과 + */ + @Override + public ValidationResponse validatePassword(String password) { + List errors = new ArrayList<>(); + boolean isValid = true; + + // 길이 검증 (8자 이상) + if (password.length() < 8) { + errors.add("패스워드는 8자 이상이어야 합니다."); + isValid = false; + } + + // 패턴 검증 (영문, 숫자, 특수문자 포함) + if (!PASSWORD_PATTERN.matcher(password).matches()) { + errors.add("패스워드는 영문, 숫자, 특수문자를 각각 최소 1개씩 포함해야 합니다."); + isValid = false; + } + + String message = isValid ? "유효한 패스워드입니다." : "패스워드가 보안 규칙을 만족하지 않습니다."; + + return ValidationResponse.builder() + .isValid(isValid) + .message(message) + .errors(errors) + .build(); + } +} diff --git a/member/src/main/resources/application.yml b/member/src/main/resources/application.yml new file mode 100644 index 0000000..7fb0c9a --- /dev/null +++ b/member/src/main/resources/application.yml @@ -0,0 +1,42 @@ +server: + port: ${SERVER_PORT:8081} + servlet: + context-path: / + +spring: + application: + name: member-service + datasource: + url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:memberdb} + username: ${POSTGRES_USER:postgres} + password: ${POSTGRES_PASSWORD:postgres} + jpa: + hibernate: + ddl-auto: ${JPA_DDL_AUTO:update} + show-sql: ${JPA_SHOW_SQL:true} + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + timeout: 2000ms + +jwt: + secret-key: ${JWT_SECRET_KEY:mySecretKeyForJWTTokenGenerationThatShouldBeVeryLongAndSecure} + access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:900000} + refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400000} + +springdoc: + swagger-ui: + path: /swagger-ui.html + operations-sorter: method + api-docs: + path: /api-docs + +logging: + level: + com.won.smarketing.member: ${LOG_LEVEL:DEBUG} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..f31190a --- /dev/null +++ b/settings.gradle @@ -0,0 +1,7 @@ +rootProject.name = 'smarketing' + +include 'common' +include 'member' +include 'store' +include 'marketing-content' +include 'ai-recommend' diff --git a/store/build.gradle b/store/build.gradle new file mode 100644 index 0000000..b96273e --- /dev/null +++ b/store/build.gradle @@ -0,0 +1,7 @@ +dependencies { + implementation project(':common') +} + +bootJar { + archiveFileName = "store-service.jar" +} diff --git a/store/src/main/java/com/won/smarketing/store/StoreServiceApplication.java b/store/src/main/java/com/won/smarketing/store/StoreServiceApplication.java new file mode 100644 index 0000000..8d9a7a5 --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/StoreServiceApplication.java @@ -0,0 +1,20 @@ +package com.won.smarketing.store; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * 매장 서비스 메인 애플리케이션 클래스 + * Spring Boot 애플리케이션의 진입점 + */ +@SpringBootApplication(scanBasePackages = {"com.won.smarketing.store", "com.won.smarketing.common"}) +@EntityScan(basePackages = {"com.won.smarketing.store.entity"}) +@EnableJpaRepositories(basePackages = {"com.won.smarketing.store.repository"}) +public class StoreServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(StoreServiceApplication.class, args); + } +} diff --git a/store/src/main/java/com/won/smarketing/store/controller/MenuController.java b/store/src/main/java/com/won/smarketing/store/controller/MenuController.java new file mode 100644 index 0000000..9f87a8b --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/controller/MenuController.java @@ -0,0 +1,89 @@ +package com.won.smarketing.store.controller; + +import com.won.smarketing.common.dto.ApiResponse; +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.MenuService; +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.*; + +import javax.validation.Valid; +import java.util.List; + +/** + * 메뉴 관리를 위한 REST API 컨트롤러 + * 메뉴 등록, 조회, 수정, 삭제 기능 제공 + */ +@Tag(name = "메뉴 관리", description = "메뉴 정보 관리 API") +@RestController +@RequestMapping("/api/menu") +@RequiredArgsConstructor +public class MenuController { + + private final MenuService menuService; + + /** + * 메뉴 정보 등록 + * + * @param request 메뉴 등록 요청 정보 + * @return 등록된 메뉴 정보 + */ + @Operation(summary = "메뉴 등록", description = "새로운 메뉴를 등록합니다.") + @PostMapping("/register") + public ResponseEntity> register(@Valid @RequestBody MenuCreateRequest request) { + MenuResponse response = menuService.register(request); + return ResponseEntity.ok(ApiResponse.success(response, "메뉴가 성공적으로 등록되었습니다.")); + } + + /** + * 메뉴 목록 조회 + * + * @param category 메뉴 카테고리 (선택사항) + * @return 메뉴 목록 + */ + @Operation(summary = "메뉴 목록 조회", description = "메뉴 목록을 조회합니다. 카테고리별 필터링 가능합니다.") + @GetMapping + public ResponseEntity>> getMenus( + @Parameter(description = "메뉴 카테고리") + @RequestParam(required = false) String category) { + List response = menuService.getMenus(category); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 메뉴 정보 수정 + * + * @param menuId 수정할 메뉴 ID + * @param request 메뉴 수정 요청 정보 + * @return 수정된 메뉴 정보 + */ + @Operation(summary = "메뉴 수정", description = "메뉴 정보를 수정합니다.") + @PutMapping("/{menuId}") + public ResponseEntity> updateMenu( + @Parameter(description = "메뉴 ID", required = true) + @PathVariable Long menuId, + @Valid @RequestBody MenuUpdateRequest request) { + MenuResponse response = menuService.updateMenu(menuId, request); + return ResponseEntity.ok(ApiResponse.success(response, "메뉴가 성공적으로 수정되었습니다.")); + } + + /** + * 메뉴 삭제 + * + * @param menuId 삭제할 메뉴 ID + * @return 삭제 성공 응답 + */ + @Operation(summary = "메뉴 삭제", description = "메뉴를 삭제합니다.") + @DeleteMapping("/{menuId}") + public ResponseEntity> deleteMenu( + @Parameter(description = "메뉴 ID", required = true) + @PathVariable Long menuId) { + menuService.deleteMenu(menuId); + return ResponseEntity.ok(ApiResponse.success(null, "메뉴가 성공적으로 삭제되었습니다.")); + } +} diff --git a/store/src/main/java/com/won/smarketing/store/controller/SalesController.java b/store/src/main/java/com/won/smarketing/store/controller/SalesController.java new file mode 100644 index 0000000..d19031b --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/controller/SalesController.java @@ -0,0 +1,37 @@ +package com.won.smarketing.store.controller; + +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.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 매출 정보를 위한 REST API 컨트롤러 + * 매출 조회 기능 제공 + */ +@Tag(name = "매출 관리", description = "매출 정보 조회 API") +@RestController +@RequestMapping("/api/sales") +@RequiredArgsConstructor +public class SalesController { + + private final SalesService salesService; + + /** + * 매출 정보 조회 + * + * @return 매출 정보 (오늘, 월간, 전일 대비) + */ + @Operation(summary = "매출 조회", description = "오늘 매출, 월간 매출, 전일 대비 매출 정보를 조회합니다.") + @GetMapping + public ResponseEntity> getSales() { + SalesResponse response = salesService.getSales(); + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/store/src/main/java/com/won/smarketing/store/controller/StoreController.java b/store/src/main/java/com/won/smarketing/store/controller/StoreController.java new file mode 100644 index 0000000..f4dec35 --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/controller/StoreController.java @@ -0,0 +1,73 @@ +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.StoreResponse; +import com.won.smarketing.store.dto.StoreUpdateRequest; +import com.won.smarketing.store.service.StoreService; +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.*; + +import javax.validation.Valid; + +/** + * 매장 관리를 위한 REST API 컨트롤러 + * 매장 등록, 조회, 수정 기능 제공 + */ +@Tag(name = "매장 관리", description = "매장 정보 관리 API") +@RestController +@RequestMapping("/api/store") +@RequiredArgsConstructor +public class StoreController { + + private final StoreService storeService; + + /** + * 매장 정보 등록 + * + * @param request 매장 등록 요청 정보 + * @return 등록된 매장 정보 + */ + @Operation(summary = "매장 등록", description = "새로운 매장 정보를 등록합니다.") + @PostMapping("/register") + public ResponseEntity> register(@Valid @RequestBody StoreCreateRequest request) { + StoreResponse response = storeService.register(request); + return ResponseEntity.ok(ApiResponse.success(response, "매장이 성공적으로 등록되었습니다.")); + } + + /** + * 매장 정보 조회 + * + * @param storeId 조회할 매장 ID + * @return 매장 정보 + */ + @Operation(summary = "매장 조회", description = "매장 ID로 매장 정보를 조회합니다.") + @GetMapping + public ResponseEntity> getStore( + @Parameter(description = "매장 ID", required = true) + @RequestParam String storeId) { + StoreResponse response = storeService.getStore(storeId); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 매장 정보 수정 + * + * @param storeId 수정할 매장 ID + * @param request 매장 수정 요청 정보 + * @return 수정된 매장 정보 + */ + @Operation(summary = "매장 수정", description = "매장 정보를 수정합니다.") + @PutMapping("/{storeId}") + public ResponseEntity> updateStore( + @Parameter(description = "매장 ID", required = true) + @PathVariable Long storeId, + @Valid @RequestBody StoreUpdateRequest request) { + StoreResponse response = storeService.updateStore(storeId, request); + return ResponseEntity.ok(ApiResponse.success(response, "매장 정보가 성공적으로 수정되었습니다.")); + } +} diff --git a/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java b/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java new file mode 100644 index 0000000..d800e7d --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java @@ -0,0 +1,49 @@ +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; + +import javax.validation.constraints.Min; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; + +/** + * 메뉴 등록 요청 DTO + * 메뉴 등록 시 필요한 정보를 담는 데이터 전송 객체 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "메뉴 등록 요청 정보") +public class MenuCreateRequest { + + @Schema(description = "매장 ID", example = "1", required = true) + @NotNull(message = "매장 ID는 필수입니다.") + private Long storeId; + + @Schema(description = "메뉴명", example = "아메리카노", required = true) + @NotBlank(message = "메뉴명은 필수입니다.") + @Size(max = 200, message = "메뉴명은 200자 이하여야 합니다.") + private String menuName; + + @Schema(description = "메뉴 카테고리", example = "커피", required = true) + @NotBlank(message = "카테고리는 필수입니다.") + @Size(max = 100, message = "카테고리는 100자 이하여야 합니다.") + private String category; + + @Schema(description = "가격", example = "4500", required = true) + @NotNull(message = "가격은 필수입니다.") + @Min(value = 0, message = "가격은 0 이상이어야 합니다.") + private Integer price; + + @Schema(description = "메뉴 설명", example = "진한 원두의 깊은 맛") + private String description; + + @Schema(description = "메뉴 이미지 URL", example = "https://example.com/americano.jpg") + private String image; +} diff --git a/store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java b/store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java new file mode 100644 index 0000000..232556a --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java @@ -0,0 +1,45 @@ +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; + +import java.time.LocalDateTime; + +/** + * 메뉴 정보 응답 DTO + * 메뉴 정보 조회/등록/수정 시 반환되는 데이터 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "메뉴 정보 응답") +public class MenuResponse { + + @Schema(description = "메뉴 ID", example = "1") + private Long menuId; + + @Schema(description = "메뉴명", example = "아메리카노") + private String menuName; + + @Schema(description = "메뉴 카테고리", example = "커피") + private String category; + + @Schema(description = "가격", example = "4500") + private Integer price; + + @Schema(description = "메뉴 설명", example = "진한 원두의 깊은 맛") + private String description; + + @Schema(description = "메뉴 이미지 URL", example = "https://example.com/americano.jpg") + private String image; + + @Schema(description = "등록 시각") + private LocalDateTime createdAt; + + @Schema(description = "수정 시각") + private LocalDateTime updatedAt; +} diff --git a/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java b/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java new file mode 100644 index 0000000..c10ac54 --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java @@ -0,0 +1,40 @@ +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; + +import javax.validation.constraints.Min; +import javax.validation.constraints.Size; + +/** + * 메뉴 수정 요청 DTO + * 메뉴 정보 수정 시 필요한 정보를 담는 데이터 전송 객체 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "메뉴 수정 요청 정보") +public class MenuUpdateRequest { + + @Schema(description = "메뉴명", example = "아메리카노") + @Size(max = 200, message = "메뉴명은 200자 이하여야 합니다.") + private String menuName; + + @Schema(description = "메뉴 카테고리", example = "커피") + @Size(max = 100, message = "카테고리는 100자 이하여야 합니다.") + private String category; + + @Schema(description = "가격", example = "4500") + @Min(value = 0, message = "가격은 0 이상이어야 합니다.") + private Integer price; + + @Schema(description = "메뉴 설명", example = "진한 원두의 깊은 맛") + private String description; + + @Schema(description = "메뉴 이미지 URL", example = "https://example.com/americano.jpg") + private String image; +} diff --git a/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java b/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java new file mode 100644 index 0000000..4fcfa9b --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java @@ -0,0 +1,30 @@ +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; + +import java.math.BigDecimal; + +/** + * 매출 정보 응답 DTO + * 오늘 매출, 월간 매출, 전일 대비 매출 정보 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "매출 정보 응답") +public class SalesResponse { + + @Schema(description = "오늘 매출", example = "150000") + private BigDecimal todaySales; + + @Schema(description = "이번 달 매출", example = "3200000") + private BigDecimal monthSales; + + @Schema(description = "전일 대비 매출 변화량", example = "25000") + private BigDecimal previousDayComparison; +} diff --git a/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java b/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java new file mode 100644 index 0000000..9b56620 --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java @@ -0,0 +1,79 @@ +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; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; + +/** + * 매장 등록 요청 DTO + * 매장 등록 시 필요한 정보를 담는 데이터 전송 객체 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "매장 등록 요청 정보") +public class StoreCreateRequest { + + @Schema(description = "매장 소유자 사용자 ID", example = "testuser", required = true) + @NotBlank(message = "사용자 ID는 필수입니다.") + private String userId; + + @Schema(description = "매장명", example = "맛있는 카페", required = true) + @NotBlank(message = "매장명은 필수입니다.") + @Size(max = 200, message = "매장명은 200자 이하여야 합니다.") + private String storeName; + + @Schema(description = "매장 이미지 URL", example = "https://example.com/store.jpg") + private String storeImage; + + @Schema(description = "업종", example = "카페", required = true) + @NotBlank(message = "업종은 필수입니다.") + @Size(max = 100, message = "업종은 100자 이하여야 합니다.") + private String businessType; + + @Schema(description = "매장 주소", example = "서울시 강남구 테헤란로 123", required = true) + @NotBlank(message = "주소는 필수입니다.") + @Size(max = 500, message = "주소는 500자 이하여야 합니다.") + private String address; + + @Schema(description = "매장 전화번호", example = "02-1234-5678", required = true) + @NotBlank(message = "전화번호는 필수입니다.") + @Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$", message = "올바른 전화번호 형식이 아닙니다.") + private String phoneNumber; + + @Schema(description = "사업자 번호", example = "123-45-67890", required = true) + @NotBlank(message = "사업자 번호는 필수입니다.") + @Pattern(regexp = "^\\d{3}-\\d{2}-\\d{5}$", message = "사업자 번호 형식이 올바르지 않습니다.") + private String businessNumber; + + @Schema(description = "인스타그램 계정", example = "@mycafe") + @Size(max = 100, message = "인스타그램 계정은 100자 이하여야 합니다.") + private String instaAccount; + + @Schema(description = "네이버 블로그 계정", example = "mycafe_blog") + @Size(max = 100, message = "네이버 블로그 계정은 100자 이하여야 합니다.") + private String naverBlogAccount; + + @Schema(description = "오픈 시간", example = "09:00") + @Pattern(regexp = "^\\d{2}:\\d{2}$", message = "올바른 시간 형식이 아닙니다. (HH:MM)") + private String openTime; + + @Schema(description = "마감 시간", example = "22:00") + @Pattern(regexp = "^\\d{2}:\\d{2}$", message = "올바른 시간 형식이 아닙니다. (HH:MM)") + private String closeTime; + + @Schema(description = "휴무일", example = "매주 월요일") + @Size(max = 100, message = "휴무일은 100자 이하여야 합니다.") + private String closedDays; + + @Schema(description = "좌석 수", example = "20") + private Integer seatCount; +} diff --git a/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java b/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java new file mode 100644 index 0000000..72898f3 --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java @@ -0,0 +1,66 @@ +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; + +import java.time.LocalDateTime; + +/** + * 매장 정보 응답 DTO + * 매장 정보 조회/등록/수정 시 반환되는 데이터 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "매장 정보 응답") +public class StoreResponse { + + @Schema(description = "매장 ID", example = "1") + private Long storeId; + + @Schema(description = "매장명", example = "맛있는 카페") + private String storeName; + + @Schema(description = "매장 이미지 URL", example = "https://example.com/store.jpg") + private String storeImage; + + @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 = "123-45-67890") + private String businessNumber; + + @Schema(description = "인스타그램 계정", example = "@mycafe") + private String instaAccount; + + @Schema(description = "네이버 블로그 계정", example = "mycafe_blog") + private String naverBlogAccount; + + @Schema(description = "오픈 시간", example = "09:00") + private String openTime; + + @Schema(description = "마감 시간", example = "22:00") + private String closeTime; + + @Schema(description = "휴무일", example = "매주 월요일") + private String closedDays; + + @Schema(description = "좌석 수", example = "20") + private Integer seatCount; + + @Schema(description = "등록 시각") + private LocalDateTime createdAt; + + @Schema(description = "수정 시각") + private LocalDateTime updatedAt; +} diff --git a/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java b/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java new file mode 100644 index 0000000..7bb9306 --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java @@ -0,0 +1,60 @@ +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; + +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; + +/** + * 매장 수정 요청 DTO + * 매장 정보 수정 시 필요한 정보를 담는 데이터 전송 객체 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "매장 수정 요청 정보") +public class StoreUpdateRequest { + + @Schema(description = "매장명", example = "맛있는 카페") + @Size(max = 200, message = "매장명은 200자 이하여야 합니다.") + private String storeName; + + @Schema(description = "매장 이미지 URL", example = "https://example.com/store.jpg") + private String storeImage; + + @Schema(description = "매장 주소", example = "서울시 강남구 테헤란로 123") + @Size(max = 500, message = "주소는 500자 이하여야 합니다.") + private String address; + + @Schema(description = "매장 전화번호", example = "02-1234-5678") + @Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$", message = "올바른 전화번호 형식이 아닙니다.") + private String phoneNumber; + + @Schema(description = "인스타그램 계정", example = "@mycafe") + @Size(max = 100, message = "인스타그램 계정은 100자 이하여야 합니다.") + private String instaAccount; + + @Schema(description = "네이버 블로그 계정", example = "mycafe_blog") + @Size(max = 100, message = "네이버 블로그 계정은 100자 이하여야 합니다.") + private String naverBlogAccount; + + @Schema(description = "오픈 시간", example = "09:00") + @Pattern(regexp = "^\\d{2}:\\d{2}$", message = "올바른 시간 형식이 아닙니다. (HH:MM)") + private String openTime; + + @Schema(description = "마감 시간", example = "22:00") + @Pattern(regexp = "^\\d{2}:\\d{2}$", message = "올바른 시간 형식이 아닙니다. (HH:MM)") + private String closeTime; + + @Schema(description = "휴무일", example = "매주 월요일") + @Size(max = 100, message = "휴무일은 100자 이하여야 합니다.") + private String closedDays; + + @Schema(description = "좌석 수", example = "20") + private Integer seatCount; +} diff --git a/store/src/main/java/com/won/smarketing/store/entity/Menu.java b/store/src/main/java/com/won/smarketing/store/entity/Menu.java new file mode 100644 index 0000000..7461445 --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/entity/Menu.java @@ -0,0 +1,110 @@ +package com.won.smarketing.store.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 메뉴 정보를 나타내는 엔티티 + * 메뉴명, 카테고리, 가격, 설명, 이미지 정보 저장 + */ +@Entity +@Table(name = "menus") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Menu { + + /** + * 메뉴 고유 식별자 + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 매장 ID + */ + @Column(name = "store_id", nullable = false) + private Long storeId; + + /** + * 메뉴명 + */ + @Column(name = "menu_name", nullable = false, length = 200) + private String menuName; + + /** + * 메뉴 카테고리 + */ + @Column(name = "category", nullable = false, length = 100) + private String category; + + /** + * 가격 + */ + @Column(name = "price", nullable = false) + private Integer price; + + /** + * 메뉴 설명 + */ + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + /** + * 메뉴 이미지 URL + */ + @Column(name = "image", length = 500) + private String image; + + /** + * 메뉴 등록 시각 + */ + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * 메뉴 정보 수정 시각 + */ + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * 엔티티 저장 전 실행되는 메서드 + * 생성 시각과 수정 시각을 현재 시각으로 설정 + */ + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + /** + * 엔티티 업데이트 전 실행되는 메서드 + * 수정 시각을 현재 시각으로 갱신 + */ + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } + + /** + * 메뉴 정보 업데이트 메서드 + * + * @param menuName 메뉴명 + * @param category 카테고리 + * @param price 가격 + * @param description 설명 + * @param image 이미지 URL + */ + public void updateMenuInfo(String menuName, String category, Integer price, String description, String image) { + this.menuName = menuName; + this.category = category; + this.price = price; + this.description = description; + this.image = image; + } +} diff --git a/store/src/main/java/com/won/smarketing/store/entity/Sales.java b/store/src/main/java/com/won/smarketing/store/entity/Sales.java new file mode 100644 index 0000000..5398ae2 --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/entity/Sales.java @@ -0,0 +1,61 @@ +package com.won.smarketing.store.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 매출 정보를 나타내는 엔티티 + * 일별 매출 데이터 저장 + */ +@Entity +@Table(name = "sales") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Sales { + + /** + * 매출 고유 식별자 + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 매장 ID + */ + @Column(name = "store_id", nullable = false) + private Long storeId; + + /** + * 매출 날짜 + */ + @Column(name = "sales_date", nullable = false) + private LocalDate salesDate; + + /** + * 매출 금액 + */ + @Column(name = "sales_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal salesAmount; + + /** + * 매출 등록 시각 + */ + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * 엔티티 저장 전 실행되는 메서드 + * 생성 시각을 현재 시각으로 설정 + */ + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } +} diff --git a/store/src/main/java/com/won/smarketing/store/entity/Store.java b/store/src/main/java/com/won/smarketing/store/entity/Store.java new file mode 100644 index 0000000..e2688d8 --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/entity/Store.java @@ -0,0 +1,164 @@ +package com.won.smarketing.store.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 매장 정보를 나타내는 엔티티 + * 매장의 기본 정보, 운영 정보, SNS 계정 정보 저장 + */ +@Entity +@Table(name = "stores") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Store { + + /** + * 매장 고유 식별자 + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 매장 소유자 사용자 ID + */ + @Column(name = "user_id", unique = true, nullable = false, length = 50) + private String userId; + + /** + * 매장명 + */ + @Column(name = "store_name", nullable = false, length = 200) + private String storeName; + + /** + * 매장 이미지 URL + */ + @Column(name = "store_image", length = 500) + private String storeImage; + + /** + * 업종 + */ + @Column(name = "business_type", nullable = false, length = 100) + private String businessType; + + /** + * 매장 주소 + */ + @Column(name = "address", nullable = false, length = 500) + private String address; + + /** + * 매장 전화번호 + */ + @Column(name = "phone_number", nullable = false, length = 20) + private String phoneNumber; + + /** + * 사업자 번호 + */ + @Column(name = "business_number", nullable = false, length = 20) + private String businessNumber; + + /** + * 인스타그램 계정 + */ + @Column(name = "insta_account", length = 100) + private String instaAccount; + + /** + * 네이버 블로그 계정 + */ + @Column(name = "naver_blog_account", length = 100) + private String naverBlogAccount; + + /** + * 오픈 시간 + */ + @Column(name = "open_time", length = 10) + private String openTime; + + /** + * 마감 시간 + */ + @Column(name = "close_time", length = 10) + private String closeTime; + + /** + * 휴무일 + */ + @Column(name = "closed_days", length = 100) + private String closedDays; + + /** + * 좌석 수 + */ + @Column(name = "seat_count") + private Integer seatCount; + + /** + * 매장 등록 시각 + */ + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * 매장 정보 수정 시각 + */ + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * 엔티티 저장 전 실행되는 메서드 + * 생성 시각과 수정 시각을 현재 시각으로 설정 + */ + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + /** + * 엔티티 업데이트 전 실행되는 메서드 + * 수정 시각을 현재 시각으로 갱신 + */ + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } + + /** + * 매장 정보 업데이트 메서드 + * + * @param storeName 매장명 + * @param storeImage 매장 이미지 + * @param address 주소 + * @param phoneNumber 전화번호 + * @param instaAccount 인스타그램 계정 + * @param naverBlogAccount 네이버 블로그 계정 + * @param openTime 오픈 시간 + * @param closeTime 마감 시간 + * @param closedDays 휴무일 + * @param seatCount 좌석 수 + */ + public void updateStoreInfo(String storeName, String storeImage, String address, String phoneNumber, + String instaAccount, String naverBlogAccount, String openTime, String closeTime, + String closedDays, Integer seatCount) { + this.storeName = storeName; + this.storeImage = storeImage; + this.address = address; + this.phoneNumber = phoneNumber; + this.instaAccount = instaAccount; + this.naverBlogAccount = naverBlogAccount; + this.openTime = openTime; + this.closeTime = closeTime; + this.closedDays = closedDays; + this.seatCount = seatCount; + } +} diff --git a/store/src/main/java/com/won/smarketing/store/repository/MenuRepository.java b/store/src/main/java/com/won/smarketing/store/repository/MenuRepository.java new file mode 100644 index 0000000..67471dd --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/repository/MenuRepository.java @@ -0,0 +1,38 @@ +package com.won.smarketing.store.repository; + +import com.won.smarketing.store.entity.Menu; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * 메뉴 정보 데이터 접근을 위한 Repository + * JPA를 사용한 메뉴 CRUD 작업 처리 + */ +@Repository +public interface MenuRepository extends JpaRepository { + + /** + * 카테고리별 메뉴 조회 (메뉴명 오름차순) + * + * @param category 메뉴 카테고리 + * @return 메뉴 목록 + */ + List findByCategoryOrderByMenuNameAsc(String category); + + /** + * 전체 메뉴 조회 (메뉴명 오름차순) + * + * @return 메뉴 목록 + */ + List findAllByOrderByMenuNameAsc(); + + /** + * 매장별 메뉴 조회 + * + * @param storeId 매장 ID + * @return 메뉴 목록 + */ + List findByStoreId(Long storeId); +} diff --git a/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java b/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java new file mode 100644 index 0000000..c36d1c5 --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java @@ -0,0 +1,44 @@ +package com.won.smarketing.store.repository; + +import com.won.smarketing.store.entity.Sales; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; + +/** + * 매출 정보 데이터 접근을 위한 Repository + * JPA를 사용한 매출 조회 작업 처리 + */ +@Repository +public interface SalesRepository extends JpaRepository { + + /** + * 매장의 오늘 매출 조회 + * + * @param storeId 매장 ID + * @return 오늘 매출 + */ + @Query("SELECT COALESCE(SUM(s.salesAmount), 0) FROM Sales s WHERE s.storeId = :storeId AND s.salesDate = CURRENT_DATE") + BigDecimal findTodaySalesByStoreId(@Param("storeId") Long storeId); + + /** + * 매장의 이번 달 매출 조회 + * + * @param storeId 매장 ID + * @return 이번 달 매출 + */ + @Query("SELECT COALESCE(SUM(s.salesAmount), 0) FROM Sales s WHERE s.storeId = :storeId AND YEAR(s.salesDate) = YEAR(CURRENT_DATE) AND MONTH(s.salesDate) = MONTH(CURRENT_DATE)") + BigDecimal findMonthSalesByStoreId(@Param("storeId") Long storeId); + + /** + * 매장의 전일 대비 매출 변화량 조회 + * + * @param storeId 매장 ID + * @return 전일 대비 매출 변화량 + */ + @Query("SELECT COALESCE((SELECT SUM(s1.salesAmount) FROM Sales s1 WHERE s1.storeId = :storeId AND s1.salesDate = CURRENT_DATE) - (SELECT SUM(s2.salesAmount) FROM Sales s2 WHERE s2.storeId = :storeId AND s2.salesDate = CURRENT_DATE - 1), 0)") + BigDecimal findPreviousDayComparisonByStoreId(@Param("storeId") Long storeId); +} diff --git a/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java b/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java new file mode 100644 index 0000000..97a26db --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java @@ -0,0 +1,23 @@ +package com.won.smarketing.store.repository; + +import com.won.smarketing.store.entity.Store; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * 매장 정보 데이터 접근을 위한 Repository + * JPA를 사용한 매장 CRUD 작업 처리 + */ +@Repository +public interface StoreRepository extends JpaRepository { + + /** + * 사용자 ID로 매장 조회 + * + * @param userId 사용자 ID + * @return 매장 정보 + */ + Optional findByUserId(String userId); +} diff --git a/store/src/main/java/com/won/smarketing/store/service/MenuService.java b/store/src/main/java/com/won/smarketing/store/service/MenuService.java new file mode 100644 index 0000000..e15141b --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/service/MenuService.java @@ -0,0 +1,46 @@ +package com.won.smarketing.store.service; + +import com.won.smarketing.store.dto.MenuCreateRequest; +import com.won.smarketing.store.dto.MenuResponse; +import com.won.smarketing.store.dto.MenuUpdateRequest; + +import java.util.List; + +/** + * 메뉴 관리 서비스 인터페이스 + * 메뉴 등록, 조회, 수정, 삭제 기능 정의 + */ +public interface MenuService { + + /** + * 메뉴 정보 등록 + * + * @param request 메뉴 등록 요청 정보 + * @return 등록된 메뉴 정보 + */ + MenuResponse register(MenuCreateRequest request); + + /** + * 메뉴 목록 조회 + * + * @param category 메뉴 카테고리 (선택사항) + * @return 메뉴 목록 + */ + List getMenus(String category); + + /** + * 메뉴 정보 수정 + * + * @param menuId 수정할 메뉴 ID + * @param request 메뉴 수정 요청 정보 + * @return 수정된 메뉴 정보 + */ + MenuResponse updateMenu(Long menuId, MenuUpdateRequest request); + + /** + * 메뉴 삭제 + * + * @param menuId 삭제할 메뉴 ID + */ + void deleteMenu(Long menuId); +} diff --git a/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java b/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java new file mode 100644 index 0000000..3c66c15 --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java @@ -0,0 +1,130 @@ +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.MenuCreateRequest; +import com.won.smarketing.store.dto.MenuResponse; +import com.won.smarketing.store.dto.MenuUpdateRequest; +import com.won.smarketing.store.entity.Menu; +import com.won.smarketing.store.repository.MenuRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 메뉴 관리 서비스 구현체 + * 메뉴 등록, 조회, 수정, 삭제 기능 구현 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MenuServiceImpl implements MenuService { + + private final MenuRepository menuRepository; + + /** + * 메뉴 정보 등록 + * + * @param request 메뉴 등록 요청 정보 + * @return 등록된 메뉴 정보 + */ + @Override + @Transactional + public MenuResponse register(MenuCreateRequest request) { + // 메뉴 엔티티 생성 및 저장 + Menu menu = Menu.builder() + .storeId(request.getStoreId()) + .menuName(request.getMenuName()) + .category(request.getCategory()) + .price(request.getPrice()) + .description(request.getDescription()) + .image(request.getImage()) + .build(); + + Menu savedMenu = menuRepository.save(menu); + return toMenuResponse(savedMenu); + } + + /** + * 메뉴 목록 조회 + * + * @param category 메뉴 카테고리 (선택사항) + * @return 메뉴 목록 + */ + @Override + public List getMenus(String category) { + List menus; + + if (category != null && !category.trim().isEmpty()) { + menus = menuRepository.findByCategoryOrderByMenuNameAsc(category); + } else { + menus = menuRepository.findAllByOrderByMenuNameAsc(); + } + + return menus.stream() + .map(this::toMenuResponse) + .collect(Collectors.toList()); + } + + /** + * 메뉴 정보 수정 + * + * @param menuId 수정할 메뉴 ID + * @param request 메뉴 수정 요청 정보 + * @return 수정된 메뉴 정보 + */ + @Override + @Transactional + public MenuResponse updateMenu(Long menuId, MenuUpdateRequest request) { + Menu menu = menuRepository.findById(menuId) + .orElseThrow(() -> new BusinessException(ErrorCode.MENU_NOT_FOUND)); + + // 메뉴 정보 업데이트 + menu.updateMenuInfo( + request.getMenuName(), + request.getCategory(), + request.getPrice(), + request.getDescription(), + request.getImage() + ); + + Menu updatedMenu = menuRepository.save(menu); + return toMenuResponse(updatedMenu); + } + + /** + * 메뉴 삭제 + * + * @param menuId 삭제할 메뉴 ID + */ + @Override + @Transactional + public void deleteMenu(Long menuId) { + Menu menu = menuRepository.findById(menuId) + .orElseThrow(() -> new BusinessException(ErrorCode.MENU_NOT_FOUND)); + + menuRepository.delete(menu); + } + + /** + * Menu 엔티티를 MenuResponse DTO로 변환 + * + * @param menu Menu 엔티티 + * @return MenuResponse DTO + */ + private MenuResponse toMenuResponse(Menu menu) { + return MenuResponse.builder() + .menuId(menu.getId()) + .menuName(menu.getMenuName()) + .category(menu.getCategory()) + .price(menu.getPrice()) + .description(menu.getDescription()) + .image(menu.getImage()) + .createdAt(menu.getCreatedAt()) + .updatedAt(menu.getUpdatedAt()) + .build(); + } +} diff --git a/store/src/main/java/com/won/smarketing/store/service/SalesService.java b/store/src/main/java/com/won/smarketing/store/service/SalesService.java new file mode 100644 index 0000000..5d92c5e --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/service/SalesService.java @@ -0,0 +1,17 @@ +package com.won.smarketing.store.service; + +import com.won.smarketing.store.dto.SalesResponse; + +/** + * 매출 관리 서비스 인터페이스 + * 매출 조회 기능 정의 + */ +public interface SalesService { + + /** + * 매출 정보 조회 + * + * @return 매출 정보 (오늘, 월간, 전일 대비) + */ + SalesResponse getSales(); +} diff --git a/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java b/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java new file mode 100644 index 0000000..f4109ab --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java @@ -0,0 +1,42 @@ +package com.won.smarketing.store.service; + +import com.won.smarketing.store.dto.SalesResponse; +import com.won.smarketing.store.repository.SalesRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; + +/** + * 매출 관리 서비스 구현체 + * 매출 조회 기능 구현 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SalesServiceImpl implements SalesService { + + private final SalesRepository salesRepository; + + /** + * 매출 정보 조회 + * + * @return 매출 정보 (오늘, 월간, 전일 대비) + */ + @Override + public SalesResponse getSales() { + // TODO: 현재는 더미 데이터 반환, 실제로는 현재 로그인한 사용자의 매장 ID를 사용해야 함 + Long storeId = 1L; // 임시로 설정 + + BigDecimal todaySales = salesRepository.findTodaySalesByStoreId(storeId); + BigDecimal monthSales = salesRepository.findMonthSalesByStoreId(storeId); + BigDecimal previousDayComparison = salesRepository.findPreviousDayComparisonByStoreId(storeId); + + return SalesResponse.builder() + .todaySales(todaySales != null ? todaySales : BigDecimal.ZERO) + .monthSales(monthSales != null ? monthSales : BigDecimal.ZERO) + .previousDayComparison(previousDayComparison != null ? previousDayComparison : BigDecimal.ZERO) + .build(); + } +} diff --git a/store/src/main/java/com/won/smarketing/store/service/StoreService.java b/store/src/main/java/com/won/smarketing/store/service/StoreService.java new file mode 100644 index 0000000..c00b924 --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/service/StoreService.java @@ -0,0 +1,37 @@ +package com.won.smarketing.store.service; + +import com.won.smarketing.store.dto.StoreCreateRequest; +import com.won.smarketing.store.dto.StoreResponse; +import com.won.smarketing.store.dto.StoreUpdateRequest; + +/** + * 매장 관리 서비스 인터페이스 + * 매장 등록, 조회, 수정 기능 정의 + */ +public interface StoreService { + + /** + * 매장 정보 등록 + * + * @param request 매장 등록 요청 정보 + * @return 등록된 매장 정보 + */ + StoreResponse register(StoreCreateRequest request); + + /** + * 매장 정보 조회 + * + * @param storeId 조회할 매장 ID + * @return 매장 정보 + */ + StoreResponse getStore(String storeId); + + /** + * 매장 정보 수정 + * + * @param storeId 수정할 매장 ID + * @param request 매장 수정 요청 정보 + * @return 수정된 매장 정보 + */ + StoreResponse updateStore(Long storeId, StoreUpdateRequest request); +} diff --git a/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java b/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java new file mode 100644 index 0000000..fe0d505 --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java @@ -0,0 +1,130 @@ +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.StoreResponse; +import com.won.smarketing.store.dto.StoreUpdateRequest; +import com.won.smarketing.store.entity.Store; +import com.won.smarketing.store.repository.StoreRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 매장 관리 서비스 구현체 + * 매장 등록, 조회, 수정 기능 구현 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StoreServiceImpl implements StoreService { + + private final StoreRepository storeRepository; + + /** + * 매장 정보 등록 + * + * @param request 매장 등록 요청 정보 + * @return 등록된 매장 정보 + */ + @Override + @Transactional + public StoreResponse register(StoreCreateRequest request) { + // 사용자별 매장 중복 등록 확인 + if (storeRepository.findByUserId(request.getUserId()).isPresent()) { + throw new BusinessException(ErrorCode.STORE_ALREADY_EXISTS); + } + + // 매장 엔티티 생성 및 저장 + Store store = Store.builder() + .userId(request.getUserId()) + .storeName(request.getStoreName()) + .storeImage(request.getStoreImage()) + .businessType(request.getBusinessType()) + .address(request.getAddress()) + .phoneNumber(request.getPhoneNumber()) + .businessNumber(request.getBusinessNumber()) + .instaAccount(request.getInstaAccount()) + .naverBlogAccount(request.getNaverBlogAccount()) + .openTime(request.getOpenTime()) + .closeTime(request.getCloseTime()) + .closedDays(request.getClosedDays()) + .seatCount(request.getSeatCount()) + .build(); + + Store savedStore = storeRepository.save(store); + return toStoreResponse(savedStore); + } + + /** + * 매장 정보 조회 + * + * @param storeId 조회할 매장 ID + * @return 매장 정보 + */ + @Override + public StoreResponse getStore(String storeId) { + Store store = storeRepository.findByUserId(storeId) + .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); + + return toStoreResponse(store); + } + + /** + * 매장 정보 수정 + * + * @param storeId 수정할 매장 ID + * @param request 매장 수정 요청 정보 + * @return 수정된 매장 정보 + */ + @Override + @Transactional + public StoreResponse updateStore(Long storeId, StoreUpdateRequest request) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); + + // 매장 정보 업데이트 + store.updateStoreInfo( + request.getStoreName(), + request.getStoreImage(), + request.getAddress(), + request.getPhoneNumber(), + request.getInstaAccount(), + request.getNaverBlogAccount(), + request.getOpenTime(), + request.getCloseTime(), + request.getClosedDays(), + request.getSeatCount() + ); + + Store updatedStore = storeRepository.save(store); + return toStoreResponse(updatedStore); + } + + /** + * Store 엔티티를 StoreResponse DTO로 변환 + * + * @param store Store 엔티티 + * @return StoreResponse DTO + */ + private StoreResponse toStoreResponse(Store store) { + return StoreResponse.builder() + .storeId(store.getId()) + .storeName(store.getStoreName()) + .storeImage(store.getStoreImage()) + .businessType(store.getBusinessType()) + .address(store.getAddress()) + .phoneNumber(store.getPhoneNumber()) + .businessNumber(store.getBusinessNumber()) + .instaAccount(store.getInstaAccount()) + .naverBlogAccount(store.getNaverBlogAccount()) + .openTime(store.getOpenTime()) + .closeTime(store.getCloseTime()) + .closedDays(store.getClosedDays()) + .seatCount(store.getSeatCount()) + .createdAt(store.getCreatedAt()) + .updatedAt(store.getUpdatedAt()) + .build(); + } +} diff --git a/store/src/main/resources/application.yml b/store/src/main/resources/application.yml new file mode 100644 index 0000000..800c27d --- /dev/null +++ b/store/src/main/resources/application.yml @@ -0,0 +1,31 @@ +server: + port: ${SERVER_PORT:8082} + servlet: + context-path: / + +spring: + application: + name: store-service + datasource: + url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:storedb} + username: ${POSTGRES_USER:postgres} + password: ${POSTGRES_PASSWORD:postgres} + jpa: + hibernate: + ddl-auto: ${JPA_DDL_AUTO:update} + show-sql: ${JPA_SHOW_SQL:true} + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + +springdoc: + swagger-ui: + path: /swagger-ui.html + operations-sorter: method + api-docs: + path: /api-docs + +logging: + level: + com.won.smarketing.store: ${LOG_LEVEL:DEBUG}