commit f0fbb47c5193d8c973ae6ac682c6c2fc9996fefc Author: lsh9672 Date: Wed Jun 11 16:31:06 2025 +0900 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dede7f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Gradle +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +# IntelliJ IDEA +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +# Eclipse +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +# NetBeans +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +# VS Code +.vscode/ + +# Mac +.DS_Store + +# Windows +Thumbs.db + +# Log files +*.log + +# Package Files +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs +hs_err_pid* diff --git a/analytics/build.gradle b/analytics/build.gradle new file mode 100644 index 0000000..fc0ce4a --- /dev/null +++ b/analytics/build.gradle @@ -0,0 +1,6 @@ +dependencies { + implementation project(':common') + + // AI APIs + implementation 'org.springframework.boot:spring-boot-starter-webflux' +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ActionPlanCompleteRequest.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ActionPlanCompleteRequest.java new file mode 100644 index 0000000..e1174e3 --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ActionPlanCompleteRequest.java @@ -0,0 +1,18 @@ +package com.ktds.hi.analytics.infra.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 실행 계획 완료 요청 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ActionPlanCompleteRequest { + + private String note; +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ActionPlanCompleteResponse.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ActionPlanCompleteResponse.java new file mode 100644 index 0000000..70deb9e --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ActionPlanCompleteResponse.java @@ -0,0 +1,22 @@ +package com.ktds.hi.analytics.infra.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 실행 계획 완료 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ActionPlanCompleteResponse { + + private Boolean success; + private String message; + private LocalDateTime completedAt; +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ActionPlanDeleteResponse.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ActionPlanDeleteResponse.java new file mode 100644 index 0000000..aec81f0 --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ActionPlanDeleteResponse.java @@ -0,0 +1,19 @@ +package com.ktds.hi.analytics.infra.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 실행 계획 삭제 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ActionPlanDeleteResponse { + + private Boolean success; + private String message; +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ActionPlanSaveResponse.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ActionPlanSaveResponse.java new file mode 100644 index 0000000..5062cbf --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ActionPlanSaveResponse.java @@ -0,0 +1,20 @@ +package com.ktds.hi.analytics.infra.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 실행 계획 저장 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ActionPlanSaveResponse { + + private Boolean success; + private String message; + private Long planId; +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/ActionPlanRepositoryAdapter.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/ActionPlanRepositoryAdapter.java new file mode 100644 index 0000000..d742052 --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/ActionPlanRepositoryAdapter.java @@ -0,0 +1,109 @@ +package com.ktds.hi.analytics.infra.gateway; + +import com.ktds.hi.analytics.biz.domain.ActionPlan; +import com.ktds.hi.analytics.biz.domain.PlanStatus; +import com.ktds.hi.analytics.biz.usecase.out.ActionPlanPort; +import com.ktds.hi.analytics.infra.gateway.entity.ActionPlanEntity; +import com.ktds.hi.analytics.infra.gateway.repository.ActionPlanJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 실행 계획 리포지토리 어댑터 클래스 + * ActionPlan Port를 구현하여 데이터 영속성 기능을 제공 + */ +@Component +@RequiredArgsConstructor +public class ActionPlanRepositoryAdapter implements ActionPlanPort { + + private final ActionPlanJpaRepository actionPlanJpaRepository; + + @Override + public List findActionPlansByStoreId(Long storeId) { + return actionPlanJpaRepository.findByStoreIdOrderByCreatedAtDesc(storeId) + .stream() + .map(this::toDomain) + .collect(Collectors.toList()); + } + + @Override + public Optional findActionPlanById(Long planId) { + return actionPlanJpaRepository.findById(planId) + .map(this::toDomain); + } + + @Override + public ActionPlan saveActionPlan(ActionPlan actionPlan) { + ActionPlanEntity entity = toEntity(actionPlan); + ActionPlanEntity saved = actionPlanJpaRepository.save(entity); + return toDomain(saved); + } + + @Override + public void deleteActionPlan(Long planId) { + actionPlanJpaRepository.deleteById(planId); + } + + /** + * Entity를 Domain으로 변환 + */ + private ActionPlan toDomain(ActionPlanEntity entity) { + return ActionPlan.builder() + .id(entity.getId()) + .storeId(entity.getStoreId()) + .userId(entity.getUserId()) + .title(entity.getTitle()) + .description(entity.getDescription()) + .period(entity.getPeriod()) + .status(entity.getStatus()) + .tasks(entity.getTasksJson() != null ? parseTasksJson(entity.getTasksJson()) : List.of()) + .note(entity.getNote()) + .createdAt(entity.getCreatedAt()) + .completedAt(entity.getCompletedAt()) + .build(); + } + + /** + * Domain을 Entity로 변환 + */ + private ActionPlanEntity toEntity(ActionPlan domain) { + return ActionPlanEntity.builder() + .id(domain.getId()) + .storeId(domain.getStoreId()) + .userId(domain.getUserId()) + .title(domain.getTitle()) + .description(domain.getDescription()) + .period(domain.getPeriod()) + .status(domain.getStatus()) + .tasksJson(domain.getTasks() != null ? toTasksJsonString(domain.getTasks()) : "[]") + .note(domain.getNote()) + .createdAt(domain.getCreatedAt()) + .completedAt(domain.getCompletedAt()) + .build(); + } + + /** + * JSON 문자열을 Tasks List로 파싱 + */ + private List parseTasksJson(String json) { + if (json == null || json.trim().isEmpty() || "[]".equals(json.trim())) { + return List.of(); + } + return Arrays.asList(json.replace("[", "").replace("]", "").replace("\"", "").split(",")); + } + + /** + * Tasks List를 JSON 문자열로 변환 + */ + private String toTasksJsonString(List tasks) { + if (tasks == null || tasks.isEmpty()) { + return "[]"; + } + return "[\"" + String.join("\",\"", tasks) + "\"]"; + } +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AiServiceAdapter.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AiServiceAdapter.java new file mode 100644 index 0000000..3962a96 --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AiServiceAdapter.java @@ -0,0 +1,266 @@ +package com.ktds.hi.analytics.infra.gateway; + +import com.ktds.hi.analytics.biz.domain.AiFeedback; +import com.ktds.hi.analytics.biz.domain.SentimentType; +import com.ktds.hi.analytics.biz.usecase.out.AIServicePort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +/** + * AI 서비스 어댑터 클래스 + * AI Service Port를 구현하여 외부 AI API 연동 기능을 제공 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class AiServiceAdapter implements AIServicePort { + + private final RestTemplate restTemplate; + + @Value("${external-api.openai.api-key:}") + private String openaiApiKey; + + @Value("${external-api.claude.api-key:}") + private String claudeApiKey; + + @Override + public AiFeedback generateFeedback(List reviewData) { + log.info("AI 피드백 생성 시작: reviewCount={}", reviewData.size()); + + try { + // OpenAI API를 사용한 감정 분석 + String combinedReviews = String.join(" ", reviewData); + SentimentType sentiment = analyzeSentiment(combinedReviews); + + // Mock AI 피드백 생성 (실제로는 OpenAI/Claude API 호출) + AiFeedback feedback = AiFeedback.builder() + .summary(generateMockSummary(reviewData, sentiment)) + .sentiment(sentiment) + .positivePoints(generateMockPositivePoints()) + .negativePoints(generateMockNegativePoints()) + .recommendations(generateMockRecommendations()) + .confidence(calculateConfidence(reviewData)) + .analysisDate(LocalDate.now()) + .build(); + + log.info("AI 피드백 생성 완료: sentiment={}, confidence={}", sentiment, feedback.getConfidence()); + return feedback; + + } catch (Exception e) { + log.error("AI 피드백 생성 실패: error={}", e.getMessage(), e); + return createFallbackFeedback(); + } + } + + @Override + public SentimentType analyzeSentiment(String content) { + log.debug("감정 분석 시작: contentLength={}", content.length()); + + try { + // 실제로는 OpenAI API 호출 + if (openaiApiKey != null && !openaiApiKey.isEmpty()) { + return callOpenAISentimentAPI(content); + } + + // Fallback: 간단한 키워드 기반 감정 분석 + return performKeywordBasedSentiment(content); + + } catch (Exception e) { + log.error("감정 분석 실패: error={}", e.getMessage(), e); + return SentimentType.NEUTRAL; + } + } + + @Override + public List generateActionPlan(AiFeedback feedback) { + log.info("실행 계획 생성 시작: sentiment={}", feedback.getSentiment()); + + try { + // AI 기반 실행 계획 생성 (Mock) + List actionPlan = List.of( + "고객 서비스 개선을 위한 직원 교육 실시", + "주방 청결도 점검 및 개선", + "대기시간 단축을 위한 주문 시스템 개선", + "메뉴 다양성 확대 검토", + "고객 피드백 수집 시스템 구축" + ); + + log.info("실행 계획 생성 완료: planCount={}", actionPlan.size()); + return actionPlan; + + } catch (Exception e) { + log.error("실행 계획 생성 실패: error={}", e.getMessage(), e); + return List.of("AI 분석을 통한 개선사항 검토"); + } + } + + /** + * OpenAI API를 통한 감정 분석 + */ + private SentimentType callOpenAISentimentAPI(String content) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + openaiApiKey); + headers.set("Content-Type", "application/json"); + + Map requestBody = Map.of( + "model", "gpt-3.5-turbo", + "messages", List.of( + Map.of("role", "user", "content", + "다음 리뷰의 감정을 분석해주세요. POSITIVE, NEGATIVE, NEUTRAL 중 하나로 답해주세요: " + content) + ), + "max_tokens", 10 + ); + + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + ResponseEntity response = restTemplate.exchange( + "https://api.openai.com/v1/chat/completions", + HttpMethod.POST, + entity, + Map.class + ); + + // API 응답 파싱 + Map responseBody = response.getBody(); + if (responseBody != null && responseBody.containsKey("choices")) { + List> choices = (List>) responseBody.get("choices"); + if (!choices.isEmpty()) { + Map message = (Map) choices.get(0).get("message"); + String result = (String) message.get("content"); + return SentimentType.valueOf(result.trim().toUpperCase()); + } + } + + return SentimentType.NEUTRAL; + + } catch (Exception e) { + log.warn("OpenAI API 호출 실패, 키워드 분석으로 대체: error={}", e.getMessage()); + return performKeywordBasedSentiment(content); + } + } + + /** + * 키워드 기반 감정 분석 + */ + private SentimentType performKeywordBasedSentiment(String content) { + String lowerContent = content.toLowerCase(); + + List positiveKeywords = List.of("맛있", "좋", "최고", "추천", "만족", "친절", "깔끔"); + List negativeKeywords = List.of("맛없", "나쁘", "별로", "실망", "불친절", "더러", "느리"); + + long positiveCount = positiveKeywords.stream() + .mapToLong(keyword -> countOccurrences(lowerContent, keyword)) + .sum(); + + long negativeCount = negativeKeywords.stream() + .mapToLong(keyword -> countOccurrences(lowerContent, keyword)) + .sum(); + + if (positiveCount > negativeCount) { + return SentimentType.POSITIVE; + } else if (negativeCount > positiveCount) { + return SentimentType.NEGATIVE; + } else { + return SentimentType.NEUTRAL; + } + } + + /** + * 문자열에서 특정 키워드 출현 횟수 계산 + */ + private long countOccurrences(String text, String keyword) { + return (text.length() - text.replace(keyword, "").length()) / keyword.length(); + } + + /** + * Mock 요약 생성 + */ + private String generateMockSummary(List reviewData, SentimentType sentiment) { + switch (sentiment) { + case POSITIVE: + return "고객들이 음식의 맛과 서비스에 대해 전반적으로 만족하고 있습니다. 특히 음식의 품질과 직원의 친절함이 높이 평가받고 있습니다."; + case NEGATIVE: + return "일부 고객들이 음식의 맛이나 서비스에 대해 불만을 표현하고 있습니다. 주로 대기시간과 음식의 온도에 대한 개선이 필요해 보입니다."; + default: + return "고객 리뷰가 긍정적인 면과 개선이 필요한 면이 혼재되어 있습니다. 지속적인 품질 관리가 필요합니다."; + } + } + + /** + * Mock 긍정 포인트 생성 + */ + private List generateMockPositivePoints() { + return List.of( + "음식의 맛이 좋다는 평가", + "직원들이 친절하다는 의견", + "매장이 깔끔하고 청결함", + "가격 대비 만족스러운 품질" + ); + } + + /** + * Mock 부정 포인트 생성 + */ + private List generateMockNegativePoints() { + return List.of( + "주문 후 대기시간이 다소 길음", + "일부 메뉴의 간이 짜다는 의견", + "주차 공간이 부족함" + ); + } + + /** + * Mock 추천사항 생성 + */ + private List generateMockRecommendations() { + return List.of( + "주문 처리 시간 단축을 위한 시스템 개선", + "메뉴별 간 조절에 대한 재검토", + "고객 대기 공간 개선", + "직원 서비스 교육 지속 실시", + "주차 환경 개선 방안 검토" + ); + } + + /** + * 신뢰도 계산 + */ + private Double calculateConfidence(List reviewData) { + // 리뷰 수와 내용 길이를 기반으로 신뢰도 계산 + int reviewCount = reviewData.size(); + double avgLength = reviewData.stream() + .mapToInt(String::length) + .average() + .orElse(0.0); + + // 기본 신뢰도 계산 로직 + double confidence = Math.min(0.95, 0.5 + (reviewCount * 0.05) + (avgLength * 0.001)); + return Math.round(confidence * 100.0) / 100.0; + } + + /** + * Fallback 피드백 생성 + */ + private AiFeedback createFallbackFeedback() { + return AiFeedback.builder() + .summary("AI 분석을 수행할 수 없어 기본 분석 결과를 제공합니다.") + .sentiment(SentimentType.NEUTRAL) + .positivePoints(List.of("분석 데이터 부족")) + .negativePoints(List.of("분석 데이터 부족")) + .recommendations(List.of("더 많은 리뷰 데이터 수집 필요")) + .confidence(0.3) + .analysisDate(LocalDate.now()) + .build(); + } +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AiServiceAdapter.java 수정 (import 추가) b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AiServiceAdapter.java 수정 (import 추가) new file mode 100644 index 0000000..effe40d --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AiServiceAdapter.java 수정 (import 추가) @@ -0,0 +1,19 @@ +package com.ktds.hi.analytics.infra.gateway; + +import com.ktds.hi.analytics.biz.domain.AiFeedback; +import com.ktds.hi.analytics.biz.domain.SentimentType; +import com.ktds.hi.analytics.biz.usecase.out.AIServicePort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.HashMap; // 추가 diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AnalyticsRepositoryAdapter.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AnalyticsRepositoryAdapter.java new file mode 100644 index 0000000..cdf5d22 --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AnalyticsRepositoryAdapter.java @@ -0,0 +1,141 @@ +package com.ktds.hi.analytics.infra.gateway; + +import com.ktds.hi.analytics.biz.domain.Analytics; +import com.ktds.hi.analytics.biz.domain.AiFeedback; +import com.ktds.hi.analytics.biz.usecase.out.AnalyticsPort; +import com.ktds.hi.analytics.infra.gateway.entity.AnalyticsEntity; +import com.ktds.hi.analytics.infra.gateway.entity.AiFeedbackEntity; +import com.ktds.hi.analytics.infra.gateway.repository.AnalyticsJpaRepository; +import com.ktds.hi.analytics.infra.gateway.repository.AiFeedbackJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * 분석 리포지토리 어댑터 클래스 + * Analytics Port를 구현하여 데이터 영속성 기능을 제공 + */ +@Component +@RequiredArgsConstructor +public class AnalyticsRepositoryAdapter implements AnalyticsPort { + + private final AnalyticsJpaRepository analyticsJpaRepository; + private final AiFeedbackJpaRepository aiFeedbackJpaRepository; + + @Override + public Optional findAnalyticsByStoreId(Long storeId) { + return analyticsJpaRepository.findByStoreId(storeId) + .map(this::toDomain); + } + + @Override + public Analytics saveAnalytics(Analytics analytics) { + AnalyticsEntity entity = toEntity(analytics); + AnalyticsEntity saved = analyticsJpaRepository.save(entity); + return toDomain(saved); + } + + @Override + public Optional findAIFeedbackByStoreId(Long storeId) { + return aiFeedbackJpaRepository.findByStoreId(storeId) + .map(this::toAiFeedbackDomain); + } + + @Override + public AiFeedback saveAIFeedback(AiFeedback feedback) { + AiFeedbackEntity entity = toAiFeedbackEntity(feedback); + AiFeedbackEntity saved = aiFeedbackJpaRepository.save(entity); + return toAiFeedbackDomain(saved); + } + + /** + * Entity를 Domain으로 변환 + */ + private Analytics toDomain(AnalyticsEntity entity) { + return Analytics.builder() + .id(entity.getId()) + .storeId(entity.getStoreId()) + .totalReviews(entity.getTotalReviews()) + .averageRating(entity.getAverageRating()) + .sentimentScore(entity.getSentimentScore()) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .build(); + } + + /** + * Domain을 Entity로 변환 + */ + private AnalyticsEntity toEntity(Analytics domain) { + return AnalyticsEntity.builder() + .id(domain.getId()) + .storeId(domain.getStoreId()) + .totalReviews(domain.getTotalReviews()) + .averageRating(domain.getAverageRating()) + .sentimentScore(domain.getSentimentScore()) + .createdAt(domain.getCreatedAt()) + .updatedAt(domain.getUpdatedAt()) + .build(); + } + + /** + * AiFeedback Entity를 Domain으로 변환 + */ + private AiFeedback toAiFeedbackDomain(AiFeedbackEntity entity) { + return AiFeedback.builder() + .id(entity.getId()) + .storeId(entity.getStoreId()) + .summary(entity.getSummary()) + .sentiment(entity.getSentiment()) + .positivePoints(entity.getPositivePointsJson() != null ? + parseJsonList(entity.getPositivePointsJson()) : List.of()) + .negativePoints(entity.getNegativePointsJson() != null ? + parseJsonList(entity.getNegativePointsJson()) : List.of()) + .recommendations(entity.getRecommendationsJson() != null ? + parseJsonList(entity.getRecommendationsJson()) : List.of()) + .confidence(entity.getConfidence()) + .analysisDate(entity.getAnalysisDate()) + .createdAt(entity.getCreatedAt()) + .build(); + } + + /** + * AiFeedback Domain을 Entity로 변환 + */ + private AiFeedbackEntity toAiFeedbackEntity(AiFeedback domain) { + return AiFeedbackEntity.builder() + .id(domain.getId()) + .storeId(domain.getStoreId()) + .summary(domain.getSummary()) + .sentiment(domain.getSentiment()) + .positivePointsJson(toJsonString(domain.getPositivePoints())) + .negativePointsJson(toJsonString(domain.getNegativePoints())) + .recommendationsJson(toJsonString(domain.getRecommendations())) + .confidence(domain.getConfidence()) + .analysisDate(domain.getAnalysisDate()) + .createdAt(domain.getCreatedAt()) + .build(); + } + + /** + * JSON 문자열을 List로 파싱 + */ + private List parseJsonList(String json) { + // 실제로는 Jackson 등을 사용하여 파싱 + if (json == null || json.isEmpty()) { + return List.of(); + } + return Arrays.asList(json.replace("[", "").replace("]", "").replace("\"", "").split(",")); + } + + /** + * List를 JSON 문자열로 변환 + */ + private String toJsonString(List list) { + if (list == null || list.isEmpty()) { + return "[]"; + } + return "[\"" + String.join("\",\"", list) + "\"]"; + } +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AnalyticsRepositoryAdapter.java 수정 (import 추가) b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AnalyticsRepositoryAdapter.java 수정 (import 추가) new file mode 100644 index 0000000..798d019 --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AnalyticsRepositoryAdapter.java 수정 (import 추가) @@ -0,0 +1,15 @@ +package com.ktds.hi.analytics.infra.gateway; + +import com.ktds.hi.analytics.biz.domain.Analytics; +import com.ktds.hi.analytics.biz.domain.AiFeedback; +import com.ktds.hi.analytics.biz.usecase.out.AnalyticsPort; +import com.ktds.hi.analytics.infra.gateway.entity.AnalyticsEntity; +import com.ktds.hi.analytics.infra.gateway.entity.AiFeedbackEntity; +import com.ktds.hi.analytics.infra.gateway.repository.AnalyticsJpaRepository; +import com.ktds.hi.analytics.infra.gateway.repository.AiFeedbackJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Arrays; // 추가 +import java.util.List; // 추가 +import java.util.Optional; diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/EventAdapter.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/EventAdapter.java new file mode 100644 index 0000000..7e25994 --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/EventAdapter.java @@ -0,0 +1,51 @@ +package com.ktds.hi.analytics.infra.gateway; + +import com.ktds.hi.analytics.biz.domain.ActionPlan; +import com.ktds.hi.analytics.biz.usecase.out.EventPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * 이벤트 어댑터 클래스 + * Event Port를 구현하여 이벤트 발행 기능을 제공 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class EventAdapter implements EventPort { + + private final ApplicationEventPublisher eventPublisher; + + @Override + public void publishActionPlanCreatedEvent(ActionPlan actionPlan) { + log.info("실행 계획 생성 이벤트 발행: planId={}, storeId={}", actionPlan.getId(), actionPlan.getStoreId()); + + try { + // 실행 계획 생성 이벤트 객체 생성 및 발행 + ActionPlanCreatedEvent event = new ActionPlanCreatedEvent(actionPlan); + eventPublisher.publishEvent(event); + + log.info("실행 계획 생성 이벤트 발행 완료: planId={}", actionPlan.getId()); + + } catch (Exception e) { + log.error("실행 계획 생성 이벤트 발행 실패: planId={}, error={}", actionPlan.getId(), e.getMessage(), e); + } + } + + /** + * 실행 계획 생성 이벤트 클래스 + */ + public static class ActionPlanCreatedEvent { + private final ActionPlan actionPlan; + + public ActionPlanCreatedEvent(ActionPlan actionPlan) { + this.actionPlan = actionPlan; + } + + public ActionPlan getActionPlan() { + return actionPlan; + } + } +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/ExternalReviewAdapter.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/ExternalReviewAdapter.java new file mode 100644 index 0000000..72c3b2e --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/ExternalReviewAdapter.java @@ -0,0 +1,85 @@ +package com.ktds.hi.analytics.infra.gateway; + +import com.ktds.hi.analytics.biz.usecase.out.ExternalReviewPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * 외부 리뷰 어댑터 클래스 + * External Review Port를 구현하여 외부 리뷰 데이터 연동 기능을 제공 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class ExternalReviewAdapter implements ExternalReviewPort { + + @Override + public List getReviewData(Long storeId) { + log.info("외부 리뷰 데이터 조회 시작: storeId={}", storeId); + + try { + // 실제로는 Review Service와 연동하여 리뷰 데이터를 가져옴 + // Mock 데이터 반환 + List reviews = new ArrayList<>(); + reviews.add("음식이 정말 맛있고 서비스도 친절해요. 다음에 또 올게요!"); + reviews.add("가격 대비 양이 많고 맛도 괜찮습니다. 추천해요."); + reviews.add("분위기가 좋고 음식도 맛있어요. 특히 김치찌개가 일품이네요."); + reviews.add("조금 대기시간이 길었지만 음식은 만족스러웠습니다."); + reviews.add("깔끔하고 맛있어요. 직원분들도 친절하시고 좋았습니다."); + reviews.add("기대보다는 평범했지만 나쁘지 않았어요."); + reviews.add("음식이 너무 짜서 별로였습니다. 개선이 필요할 것 같아요."); + reviews.add("가성비 좋고 맛도 괜찮아요. 재방문 의사 있습니다."); + reviews.add("위생 상태가 좋고 음식도 깔끔해요. 만족합니다."); + reviews.add("주차하기 어려워서 불편했지만 음식은 맛있었어요."); + + log.info("외부 리뷰 데이터 조회 완료: storeId={}, count={}", storeId, reviews.size()); + return reviews; + + } catch (Exception e) { + log.error("외부 리뷰 데이터 조회 실패: storeId={}, error={}", storeId, e.getMessage(), e); + return List.of(); + } + } + + @Override + public List getRecentReviews(Long storeId, Integer days) { + log.info("최근 리뷰 데이터 조회 시작: storeId={}, days={}", storeId, days); + + try { + // 실제로는 최근 N일간의 리뷰만 필터링 + List allReviews = getReviewData(storeId); + + // Mock: 최근 리뷰는 전체 리뷰의 70% 정도로 가정 + int recentCount = (int) (allReviews.size() * 0.7); + List recentReviews = allReviews.subList(0, Math.min(recentCount, allReviews.size())); + + log.info("최근 리뷰 데이터 조회 완료: storeId={}, days={}, count={}", storeId, days, recentReviews.size()); + return recentReviews; + + } catch (Exception e) { + log.error("최근 리뷰 데이터 조회 실패: storeId={}, days={}, error={}", storeId, days, e.getMessage(), e); + return List.of(); + } + } + + @Override + public Integer getReviewCount(Long storeId) { + log.info("리뷰 수 조회 시작: storeId={}", storeId); + + try { + List reviews = getReviewData(storeId); + int count = reviews.size(); + + log.info("리뷰 수 조회 완료: storeId={}, count={}", storeId, count); + return count; + + } catch (Exception e) { + log.error("리뷰 수 조회 실패: storeId={}, error={}", storeId, e.getMessage(), e); + return 0; + } + } +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/entity/ActionPlanEntity.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/entity/ActionPlanEntity.java new file mode 100644 index 0000000..ba258bc --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/entity/ActionPlanEntity.java @@ -0,0 +1,78 @@ +package com.ktds.hi.analytics.infra.gateway.entity; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ktds.hi.analytics.biz.domain.PlanStatus; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 실행 계획 엔티티 클래스 + * 데이터베이스 action_plans 테이블과 매핑되는 JPA 엔티티 + */ +@Entity +@Table(name = "action_plans") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class ActionPlanEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(nullable = false, length = 200) + private String title; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(length = 50) + private String period; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @Builder.Default + private PlanStatus status = PlanStatus.PLANNED; + + @Column(name = "feedback_ids_json", columnDefinition = "TEXT") + private String feedbackIdsJson; + + @Column(columnDefinition = "TEXT") + private String note; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + /** + * JSON 문자열을 List로 변환 + */ + public List getFeedbackIdsList() { + try { + ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(feedbackIdsJson, new TypeReference>() {}); + } catch (Exception e) { + return List.of(); + } + } +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/entity/AiFeedbackEntity.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/entity/AiFeedbackEntity.java new file mode 100644 index 0000000..b318e19 --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/entity/AiFeedbackEntity.java @@ -0,0 +1,93 @@ +package com.ktds.hi.analytics.infra.gateway.entity; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * AI 피드백 엔티티 클래스 + * 데이터베이스 ai_feedback 테이블과 매핑되는 JPA 엔티티 + */ +@Entity +@Table(name = "ai_feedback") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class AiFeedbackEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(columnDefinition = "TEXT") + private String summary; + + @Column(length = 20) + private String sentiment; + + @Column(name = "positive_points_json", columnDefinition = "TEXT") + private String positivePointsJson; + + @Column(name = "negative_points_json", columnDefinition = "TEXT") + private String negativePointsJson; + + @Column(name = "recommendations_json", columnDefinition = "TEXT") + private String recommendationsJson; + + @Column(precision = 3, scale = 2) + private BigDecimal confidence; + + @Column(name = "analysis_date") + private LocalDate analysisDate; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + /** + * JSON 문자열을 객체로 변환하는 메서드들 + */ + public Map getPositivePointsMap() { + try { + ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(positivePointsJson, new TypeReference>() {}); + } catch (Exception e) { + return Map.of(); + } + } + + public Map getNegativePointsMap() { + try { + ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(negativePointsJson, new TypeReference>() {}); + } catch (Exception e) { + return Map.of(); + } + } + + public List getRecommendationsList() { + try { + ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(recommendationsJson, new TypeReference>() {}); + } catch (Exception e) { + return List.of(); + } + } +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/entity/StatisticsEntity.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/entity/StatisticsEntity.java new file mode 100644 index 0000000..9af79c0 --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/entity/StatisticsEntity.java @@ -0,0 +1,107 @@ +package com.ktds.hi.analytics.infra.gateway.entity; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 통계 엔티티 클래스 + * 데이터베이스 order_statistics 테이블과 매핑되는 JPA 엔티티 + */ +@Entity +@Table(name = "order_statistics") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class StatisticsEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(name = "analysis_date") + private LocalDate analysisDate; + + @Column(name = "total_orders") + private Integer totalOrders; + + @Column(name = "total_revenue", precision = 15, scale = 2) + private BigDecimal totalRevenue; + + @Column(name = "avg_order_amount", precision = 10, scale = 2) + private BigDecimal avgOrderAmount; + + @Column(name = "peak_hour") + private Integer peakHour; + + @Column(name = "age_statistics_json", columnDefinition = "TEXT") + private String ageStatisticsJson; + + @Column(name = "gender_statistics_json", columnDefinition = "TEXT") + private String genderStatisticsJson; + + @Column(name = "time_statistics_json", columnDefinition = "TEXT") + private String timeStatisticsJson; + + @Column(name = "menu_popularity_json", columnDefinition = "TEXT") + private String menuPopularityJson; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + /** + * JSON 문자열을 객체로 변환하는 메서드들 + */ + public Map getAgeStatisticsMap() { + try { + ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(ageStatisticsJson, new TypeReference>() {}); + } catch (Exception e) { + return Map.of(); + } + } + + public Map getGenderStatisticsMap() { + try { + ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(genderStatisticsJson, new TypeReference>() {}); + } catch (Exception e) { + return Map.of(); + } + } + + public Map getTimeStatisticsMap() { + try { + ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(timeStatisticsJson, new TypeReference>() {}); + } catch (Exception e) { + return Map.of(); + } + } + + public Map getMenuPopularityMap() { + try { + ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(menuPopularityJson, new TypeReference>() {}); + } catch (Exception e) { + return Map.of(); + } + } +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/repository/ActionPlanJpaRepository.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/repository/ActionPlanJpaRepository.java new file mode 100644 index 0000000..4b0a8d8 --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/repository/ActionPlanJpaRepository.java @@ -0,0 +1,36 @@ +package com.ktds.hi.analytics.infra.gateway.repository; + +import com.ktds.hi.analytics.biz.domain.PlanStatus; +import com.ktds.hi.analytics.infra.gateway.entity.ActionPlanEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * 실행 계획 JPA 리포지토리 인터페이스 + * 실행 계획 데이터의 CRUD 작업을 담당 + */ +@Repository +public interface ActionPlanJpaRepository extends JpaRepository { + + /** + * 매장 ID로 실행 계획 목록 조회 (최신순) + */ + List findByStoreIdOrderByCreatedAtDesc(Long storeId); + + /** + * 매장 ID와 상태로 실행 계획 목록 조회 (최신순) + */ + List findByStoreIdAndStatusOrderByCreatedAtDesc(Long storeId, PlanStatus status); + + /** + * 사용자 ID로 실행 계획 목록 조회 + */ + List findByUserIdOrderByCreatedAtDesc(Long userId); + + /** + * 매장 ID와 사용자 ID로 실행 계획 목록 조회 + */ + List findByStoreIdAndUserIdOrderByCreatedAtDesc(Long storeId, Long userId); +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/repository/AiFeedbackJpaRepository.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/repository/AiFeedbackJpaRepository.java new file mode 100644 index 0000000..f05d10b --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/repository/AiFeedbackJpaRepository.java @@ -0,0 +1,32 @@ +package com.ktds.hi.analytics.infra.gateway.repository; + +import com.ktds.hi.analytics.infra.gateway.entity.AiFeedbackEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +/** + * AI 피드백 JPA 리포지토리 인터페이스 + * AI 피드백 데이터의 CRUD 작업을 담당 + */ +@Repository +public interface AiFeedbackJpaRepository extends JpaRepository { + + /** + * 매장 ID와 분석 기간으로 AI 피드백 목록 조회 + */ + List findByStoreIdAndAnalysisDateBetweenOrderByAnalysisDateDesc( + Long storeId, LocalDate startDate, LocalDate endDate); + + /** + * 매장 ID로 최신 AI 피드백 조회 + */ + AiFeedbackEntity findTopByStoreIdOrderByCreatedAtDesc(Long storeId); + + /** + * 특정 날짜의 AI 피드백 조회 + */ + List findByStoreIdAndAnalysisDate(Long storeId, LocalDate analysisDate); +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/repository/StatisticsJpaRepository.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/repository/StatisticsJpaRepository.java new file mode 100644 index 0000000..4bab058 --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/repository/StatisticsJpaRepository.java @@ -0,0 +1,33 @@ +package com.ktds.hi.analytics.infra.gateway.repository; + +import com.ktds.hi.analytics.infra.gateway.entity.StatisticsEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +/** + * 통계 JPA 리포지토리 인터페이스 + * 통계 데이터의 CRUD 작업을 담당 + */ +@Repository +public interface StatisticsJpaRepository extends JpaRepository { + + /** + * 매장 ID와 분석 기간으로 통계 조회 + */ + List findByStoreIdAndAnalysisDateBetween( + Long storeId, LocalDate startDate, LocalDate endDate); + + /** + * 매장 ID와 특정 날짜로 통계 조회 + */ + Optional findByStoreIdAndAnalysisDate(Long storeId, LocalDate analysisDate); + + /** + * 매장 ID로 최신 통계 조회 + */ + Optional findTopByStoreIdOrderByAnalysisDateDesc(Long storeId); +} diff --git a/analytics/src/main/resources/application.yml b/analytics/src/main/resources/application.yml new file mode 100644 index 0000000..65d9d4c --- /dev/null +++ b/analytics/src/main/resources/application.yml @@ -0,0 +1,42 @@ +server: + port: ${ANALYTICS_SERVICE_PORT:8084} + +spring: + application: + name: analytics-service + + datasource: + url: ${ANALYTICS_DB_URL:jdbc:postgresql://localhost:5432/hiorder_analytics} + username: ${ANALYTICS_DB_USERNAME:hiorder_user} + password: ${ANALYTICS_DB_PASSWORD:hiorder_pass} + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: ${JPA_DDL_AUTO:validate} + show-sql: ${JPA_SHOW_SQL:false} + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.PostgreSQLDialect + + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + +ai-api: + openai: + api-key: ${OPENAI_API_KEY:} + base-url: https://api.openai.com/v1 + model: gpt-4o-mini + claude: + api-key: ${CLAUDE_API_KEY:} + base-url: https://api.anthropic.com + model: claude-3-sonnet-20240229 + +springdoc: + api-docs: + path: /api-docs + swagger-ui: + path: /swagger-ui.html diff --git a/analytics/src/main/resources/application.yml (이미 설정파일.txt에 있음) b/analytics/src/main/resources/application.yml (이미 설정파일.txt에 있음) new file mode 100644 index 0000000..a26bcda --- /dev/null +++ b/analytics/src/main/resources/application.yml (이미 설정파일.txt에 있음) @@ -0,0 +1,46 @@ +server: + port: ${ANALYTICS_SERVICE_PORT:8084} + +spring: + application: + name: analytics-service + + datasource: + url: ${ANALYTICS_DB_URL:jdbc:postgresql://localhost:5432/hiorder_analytics} + username: ${ANALYTICS_DB_USERNAME:hiorder_user} + password: ${ANALYTICS_DB_PASSWORD:hiorder_pass} + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: ${JPA_DDL_AUTO:validate} + show-sql: ${JPA_SHOW_SQL:false} + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.PostgreSQLDialect + + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + +external-api: + openai: + api-key: ${OPENAI_API_KEY:} + base-url: https://api.openai.com + claude: + api-key: ${CLAUDE_API_KEY:} + base-url: https://api.anthropic.com + +springdoc: + api-docs: + path: /api-docs + swagger-ui: + path: /swagger-ui.html + +management: + endpoints: + web: + exposure: + include: health,info,metrics \ No newline at end of file diff --git a/build.gradle (Root Project) b/build.gradle (Root Project) new file mode 100644 index 0000000..852bba6 --- /dev/null +++ b/build.gradle (Root Project) @@ -0,0 +1,65 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.4.0' apply false + id 'io.spring.dependency-management' version '1.1.4' apply false +} + +allprojects { + group = 'com.ktds.hi' + version = '1.0.0' + + repositories { + mavenCentral() + } +} + +subprojects { + apply plugin: 'java' + apply plugin: 'org.springframework.boot' + apply plugin: 'io.spring.dependency-management' + + java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } + } + + dependencies { + // 공통 의존성 + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + + // Database + runtimeOnly 'org.postgresql:postgresql' + + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // MapStruct + implementation 'org.mapstruct:mapstruct:1.5.5.Final' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final' + + // Test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:postgresql' + } + + tasks.named('test') { + useJUnitPlatform() + } +} diff --git a/common/src/main/java/com/ktds/hi/common/CommonModuleConfiguration.java b/common/src/main/java/com/ktds/hi/common/CommonModuleConfiguration.java new file mode 100644 index 0000000..73da2c1 --- /dev/null +++ b/common/src/main/java/com/ktds/hi/common/CommonModuleConfiguration.java @@ -0,0 +1,18 @@ +package com.ktds.hi.common; + +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * Common 모듈 설정 클래스 + * 다른 모듈에서 Common 모듈을 사용할 때 필요한 설정을 자동으로 적용 + */ +@Configuration +@ComponentScan(basePackages = "com.ktds.hi.common") +@EntityScan(basePackages = "com.ktds.hi.common.entity") +@EnableJpaRepositories(basePackages = "com.ktds.hi.common.repository") +public class CommonModuleConfiguration { + // 설정 클래스는 어노테이션만으로도 충분 +} diff --git a/common/src/main/java/com/ktds/hi/common/aspect/AuditAspect.java b/common/src/main/java/com/ktds/hi/common/aspect/AuditAspect.java new file mode 100644 index 0000000..417e07e --- /dev/null +++ b/common/src/main/java/com/ktds/hi/common/aspect/AuditAspect.java @@ -0,0 +1,62 @@ +package com.ktds.hi.common.aspect; + +import com.ktds.hi.common.service.AuditLogService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; + +/** + * 감사 로깅 AOP + * 특정 메서드 실행 시 자동으로 감사 로그를 기록 + */ +@Aspect +@Component +@RequiredArgsConstructor +@Slf4j +public class AuditAspect { + + private final AuditLogService auditLogService; + + /** + * 서비스 메서드 실행 후 감사 로그 기록 + */ + @AfterReturning( + pointcut = "execution(* com.ktds.hi.*.biz.service.*Service.create*(..))", + returning = "result" + ) + public void auditCreate(JoinPoint joinPoint, Object result) { + try { + String methodName = joinPoint.getSignature().getName(); + String className = joinPoint.getTarget().getClass().getSimpleName(); + + auditLogService.logCreate( + className.replace("Service", ""), + extractEntityId(result), + String.format("%s.%s 실행", className, methodName) + ); + } catch (Exception e) { + log.warn("Failed to audit create operation", e); + } + } + + /** + * 결과 객체에서 ID 추출 + */ + private String extractEntityId(Object result) { + if (result == null) { + return "UNKNOWN"; + } + + try { + // 리플렉션을 사용하여 getId() 메서드 호출 + var method = result.getClass().getMethod("getId"); + Object id = method.invoke(result); + return id != null ? id.toString() : "UNKNOWN"; + } catch (Exception e) { + return "UNKNOWN"; + } + } +} \ No newline at end of file diff --git a/common/src/main/java/com/ktds/hi/common/repository/AuditLogRepository.java b/common/src/main/java/com/ktds/hi/common/repository/AuditLogRepository.java new file mode 100644 index 0000000..22ba26d --- /dev/null +++ b/common/src/main/java/com/ktds/hi/common/repository/AuditLogRepository.java @@ -0,0 +1,55 @@ +package com.ktds.hi.common.repository; + +import com.ktds.hi.common.audit.AuditAction; +import com.ktds.hi.common.audit.AuditLog; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +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.time.LocalDateTime; +import java.util.List; + +/** + * 감사 로그 리포지토리 + */ +@Repository +public interface AuditLogRepository extends JpaRepository { + + /** + * 사용자별 감사 로그 조회 + */ + Page findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); + + /** + * 액션별 감사 로그 조회 + */ + Page findByActionOrderByCreatedAtDesc(AuditAction action, Pageable pageable); + + /** + * 엔티티별 감사 로그 조회 + */ + Page findByEntityTypeAndEntityIdOrderByCreatedAtDesc(String entityType, String entityId, Pageable pageable); + + /** + * 기간별 감사 로그 조회 + */ + @Query("SELECT al FROM AuditLog al WHERE al.createdAt BETWEEN :startDate AND :endDate ORDER BY al.createdAt DESC") + Page findByCreatedAtBetween(@Param("startDate") LocalDateTime startDate, + @Param("endDate") LocalDateTime endDate, + Pageable pageable); + + /** + * 사용자 액션 통계 조회 + */ + @Query("SELECT al.action, COUNT(al) FROM AuditLog al WHERE al.userId = :userId GROUP BY al.action") + List findActionStatsByUserId(@Param("userId") Long userId); + + /** + * 일별 로그 수 조회 + */ + @Query("SELECT DATE(al.createdAt), COUNT(al) FROM AuditLog al WHERE al.createdAt >= :startDate GROUP BY DATE(al.createdAt) ORDER BY DATE(al.createdAt)") + List findDailyLogCounts(@Param("startDate") LocalDateTime startDate); +} diff --git a/common/src/main/java/com/ktds/hi/common/security/CustomUserDetails.java b/common/src/main/java/com/ktds/hi/common/security/CustomUserDetails.java new file mode 100644 index 0000000..d6ec5ac --- /dev/null +++ b/common/src/main/java/com/ktds/hi/common/security/CustomUserDetails.java @@ -0,0 +1,89 @@ +package com.ktds.hi.common.security; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 커스텀 사용자 상세 정보 + * Spring Security UserDetails 인터페이스 구현 + */ +@Getter +@AllArgsConstructor +public class CustomUserDetails implements UserDetails { + + private final Long id; + private final String username; + private final String email; + private final String password; + private final List roles; + private final boolean enabled; + private final boolean accountNonExpired; + private final boolean accountNonLocked; + private final boolean credentialsNonExpired; + + @Override + public Collection getAuthorities() { + return roles.stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public boolean isAccountNonExpired() { + return accountNonExpired; + } + + @Override + public boolean isAccountNonLocked() { + return accountNonLocked; + } + + @Override + public boolean isCredentialsNonExpired() { + return credentialsNonExpired; + } + + @Override + public boolean isEnabled() { + return enabled; + } + + /** + * 기본 생성자 (활성화된 사용자) + */ + public static CustomUserDetails of(Long id, String username, String email, String password, List roles) { + return new CustomUserDetails( + id, username, email, password, roles, + true, true, true, true + ); + } + + /** + * 상태를 지정한 생성자 + */ + public static CustomUserDetails of(Long id, String username, String email, String password, List roles, + boolean enabled, boolean accountNonExpired, + boolean accountNonLocked, boolean credentialsNonExpired) { + return new CustomUserDetails( + id, username, email, password, roles, + enabled, accountNonExpired, accountNonLocked, credentialsNonExpired + ); + } +} diff --git a/common/src/main/java/com/ktds/hi/common/security/JwtAuthenticationFilter.java b/common/src/main/java/com/ktds/hi/common/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..a6990e5 --- /dev/null +++ b/common/src/main/java/com/ktds/hi/common/security/JwtAuthenticationFilter.java @@ -0,0 +1,114 @@ +package com.ktds.hi.common.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ktds.hi.common.constants.SecurityConstants; +import com.ktds.hi.common.response.ApiResponse; +import com.ktds.hi.common.response.ResponseCode; +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.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * JWT 인증 필터 + * HTTP 요청에서 JWT 토큰을 추출하고 검증하여 인증 처리 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + try { + String jwt = getJwtFromRequest(request); + + if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) { + // 액세스 토큰인지 확인 + if (!jwtTokenProvider.isAccessToken(jwt)) { + sendErrorResponse(response, ResponseCode.UNAUTHORIZED, "액세스 토큰이 아닙니다."); + return; + } + + String userId = jwtTokenProvider.getUserIdFromToken(jwt); + String roles = jwtTokenProvider.getRolesFromToken(jwt); + + if (StringUtils.hasText(userId)) { + List authorities = Arrays.stream(roles.split(",")) + .map(String::trim) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userId, null, authorities); + + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } + } catch (Exception e) { + log.error("JWT authentication failed", e); + sendErrorResponse(response, ResponseCode.UNAUTHORIZED, "인증에 실패했습니다."); + return; + } + + filterChain.doFilter(request, response); + } + + /** + * 요청에서 JWT 토큰 추출 + */ + private String getJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader(SecurityConstants.JWT_HEADER); + + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(SecurityConstants.JWT_PREFIX)) { + return bearerToken.substring(SecurityConstants.JWT_PREFIX.length()); + } + + return null; + } + + /** + * 에러 응답 전송 + */ + private void sendErrorResponse(HttpServletResponse response, ResponseCode responseCode, String message) + throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + ApiResponse errorResponse = ApiResponse.error(responseCode, message); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } + + /** + * 공개 경로인지 확인 + */ + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + String path = request.getRequestURI(); + + return Arrays.stream(SecurityConstants.PUBLIC_ENDPOINTS) + .anyMatch(pattern -> path.matches(pattern.replace("**", ".*"))); + } +} diff --git a/common/src/main/java/com/ktds/hi/common/security/JwtTokenProvider.java b/common/src/main/java/com/ktds/hi/common/security/JwtTokenProvider.java new file mode 100644 index 0000000..1739aeb --- /dev/null +++ b/common/src/main/java/com/ktds/hi/common/security/JwtTokenProvider.java @@ -0,0 +1,204 @@ +package com.ktds.hi.common.security; + +import com.ktds.hi.common.constants.SecurityConstants; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.stream.Collectors; + +/** + * JWT 토큰 생성 및 검증 제공자 + * JWT 토큰의 생성, 파싱, 검증 기능을 담당 + */ +@Component +@Slf4j +public class JwtTokenProvider { + + private final SecretKey secretKey; + private final long accessTokenValidityInMilliseconds; + private final long refreshTokenValidityInMilliseconds; + + public JwtTokenProvider( + @Value("${app.jwt.secret-key:hiorder-secret-key-for-jwt-token-generation-2024}") String secretKeyString, + @Value("${app.jwt.access-token-validity:3600000}") long accessTokenValidity, + @Value("${app.jwt.refresh-token-validity:604800000}") long refreshTokenValidity) { + + // 비밀키 생성 (256비트 이상이어야 함) + byte[] keyBytes = secretKeyString.getBytes(StandardCharsets.UTF_8); + if (keyBytes.length < 32) { + // 32바이트 미만이면 패딩 + byte[] paddedKey = new byte[32]; + System.arraycopy(keyBytes, 0, paddedKey, 0, Math.min(keyBytes.length, 32)); + keyBytes = paddedKey; + } + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + + this.accessTokenValidityInMilliseconds = accessTokenValidity; + this.refreshTokenValidityInMilliseconds = refreshTokenValidity; + } + + /** + * 액세스 토큰 생성 + */ + public String createAccessToken(Authentication authentication) { + return createToken(authentication, accessTokenValidityInMilliseconds, "access"); + } + + /** + * 리프레시 토큰 생성 + */ + public String createRefreshToken(Authentication authentication) { + return createToken(authentication, refreshTokenValidityInMilliseconds, "refresh"); + } + + /** + * 사용자 정보로 액세스 토큰 생성 + */ + public String createAccessToken(String userId, String username, String roles) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + accessTokenValidityInMilliseconds); + + return Jwts.builder() + .setSubject(userId) + .claim("username", username) + .claim("roles", roles) + .claim("type", "access") + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(secretKey, SignatureAlgorithm.HS512) + .compact(); + } + + /** + * 토큰 생성 공통 메서드 + */ + private String createToken(Authentication authentication, long validityInMilliseconds, String tokenType) { + String authorities = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + validityInMilliseconds); + + return Jwts.builder() + .setSubject(authentication.getName()) + .claim("roles", authorities) + .claim("type", tokenType) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(secretKey, SignatureAlgorithm.HS512) + .compact(); + } + + /** + * 토큰에서 사용자 ID 추출 + */ + public String getUserIdFromToken(String token) { + Claims claims = parseClaimsFromToken(token); + return claims.getSubject(); + } + + /** + * 토큰에서 사용자명 추출 + */ + public String getUsernameFromToken(String token) { + Claims claims = parseClaimsFromToken(token); + return claims.get("username", String.class); + } + + /** + * 토큰에서 권한 추출 + */ + public String getRolesFromToken(String token) { + Claims claims = parseClaimsFromToken(token); + return claims.get("roles", String.class); + } + + /** + * 토큰에서 만료일 추출 + */ + public Date getExpirationDateFromToken(String token) { + Claims claims = parseClaimsFromToken(token); + return claims.getExpiration(); + } + + /** + * 토큰 유효성 검증 + */ + public boolean validateToken(String token) { + try { + parseClaimsFromToken(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + log.debug("Invalid JWT token: {}", e.getMessage()); + return false; + } + } + + /** + * 액세스 토큰인지 확인 + */ + public boolean isAccessToken(String token) { + try { + Claims claims = parseClaimsFromToken(token); + return "access".equals(claims.get("type", String.class)); + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + /** + * 리프레시 토큰인지 확인 + */ + public boolean isRefreshToken(String token) { + try { + Claims claims = parseClaimsFromToken(token); + return "refresh".equals(claims.get("type", String.class)); + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + /** + * 토큰 만료 확인 + */ + public boolean isTokenExpired(String token) { + try { + Date expiration = getExpirationDateFromToken(token); + return expiration.before(new Date()); + } catch (JwtException | IllegalArgumentException e) { + return true; + } + } + + /** + * 토큰에서 Claims 파싱 + */ + private Claims parseClaimsFromToken(String token) { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + } + + /** + * 토큰 만료 시간까지 남은 시간 (밀리초) + */ + public long getTimeUntilExpiration(String token) { + try { + Date expiration = getExpirationDateFromToken(token); + return Math.max(0, expiration.getTime() - System.currentTimeMillis()); + } catch (JwtException | IllegalArgumentException e) { + return 0; + } + } +} diff --git a/common/src/main/java/com/ktds/hi/common/security/SecurityUtil.java b/common/src/main/java/com/ktds/hi/common/security/SecurityUtil.java new file mode 100644 index 0000000..121c6cf --- /dev/null +++ b/common/src/main/java/com/ktds/hi/common/security/SecurityUtil.java @@ -0,0 +1,135 @@ +package com.ktds.hi.common.security; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 보안 관련 유틸리티 클래스 + * 현재 인증된 사용자 정보 조회 등의 기능을 제공 + */ +public final class SecurityUtil { + + private SecurityUtil() { + // 인스턴스 생성 방지 + } + + /** + * 현재 인증된 사용자 ID 조회 + */ + public static Optional getCurrentUserId() { + return getCurrentAuthentication() + .map(Authentication::getName) + .map(Long::parseLong); + } + + /** + * 현재 인증된 사용자명 조회 + */ + public static Optional getCurrentUsername() { + return getCurrentAuthentication() + .map(Authentication::getName); + } + + /** + * 현재 인증된 사용자의 권한 조회 + */ + public static Set getCurrentUserRoles() { + return getCurrentAuthentication() + .map(auth -> auth.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet())) + .orElse(Set.of()); + } + + /** + * 현재 사용자가 특정 권한을 가지는지 확인 + */ + public static boolean hasRole(String role) { + return getCurrentUserRoles().contains(role); + } + + /** + * 현재 사용자가 여러 권한 중 하나라도 가지는지 확인 + */ + public static boolean hasAnyRole(String... roles) { + Set userRoles = getCurrentUserRoles(); + for (String role : roles) { + if (userRoles.contains(role)) { + return true; + } + } + return false; + } + + /** + * 현재 사용자가 모든 권한을 가지는지 확인 + */ + public static boolean hasAllRoles(String... roles) { + Set userRoles = getCurrentUserRoles(); + for (String role : roles) { + if (!userRoles.contains(role)) { + return false; + } + } + return true; + } + + /** + * 현재 사용자가 인증되었는지 확인 + */ + public static boolean isAuthenticated() { + return getCurrentAuthentication() + .map(Authentication::isAuthenticated) + .orElse(false); + } + + /** + * 현재 사용자가 익명 사용자인지 확인 + */ + public static boolean isAnonymous() { + return !isAuthenticated(); + } + + /** + * 현재 Authentication 객체 조회 + */ + public static Optional getCurrentAuthentication() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return Optional.ofNullable(authentication); + } + + /** + * 현재 사용자가 리소스 소유자인지 확인 + */ + public static boolean isOwner(Long resourceOwnerId) { + return getCurrentUserId() + .map(currentUserId -> currentUserId.equals(resourceOwnerId)) + .orElse(false); + } + + /** + * 현재 사용자가 관리자인지 확인 + */ + public static boolean isAdmin() { + return hasRole("ROLE_ADMIN"); + } + + /** + * 현재 사용자가 매장 소유자인지 확인 + */ + public static boolean isStoreOwner() { + return hasRole("ROLE_STORE_OWNER"); + } + + /** + * 현재 사용자가 일반 사용자인지 확인 + */ + public static boolean isUser() { + return hasRole("ROLE_USER"); + } +} diff --git a/common/src/main/java/com/ktds/hi/common/service/AuditLogService.java b/common/src/main/java/com/ktds/hi/common/service/AuditLogService.java new file mode 100644 index 0000000..baa3303 --- /dev/null +++ b/common/src/main/java/com/ktds/hi/common/service/AuditLogService.java @@ -0,0 +1,92 @@ +package com.ktds.hi.common.service; + +import com.ktds.hi.common.audit.AuditAction; +import com.ktds.hi.common.audit.AuditLog; +import com.ktds.hi.common.repository.AuditLogRepository; +import com.ktds.hi.common.security.SecurityUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 감사 로그 서비스 + * 시스템의 중요한 액션들을 비동기적으로 로깅 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class AuditLogService { + + private final AuditLogRepository auditLogRepository; + + /** + * 감사 로그 기록 (비동기) + */ + @Async + @Transactional + public void logAsync(AuditAction action, String entityType, String entityId, String description) { + log(action, entityType, entityId, description); + } + + /** + * 감사 로그 기록 (동기) + */ + @Transactional + public void log(AuditAction action, String entityType, String entityId, String description) { + try { + Long userId = SecurityUtil.getCurrentUserId().orElse(null); + String username = SecurityUtil.getCurrentUsername().orElse("SYSTEM"); + + AuditLog auditLog = AuditLog.create(userId, username, action, entityType, entityId, description); + auditLogRepository.save(auditLog); + + } catch (Exception e) { + log.error("Failed to save audit log: action={}, entityType={}, entityId={}", + action, entityType, entityId, e); + } + } + + /** + * 생성 로그 + */ + public void logCreate(String entityType, String entityId, String description) { + logAsync(AuditAction.CREATE, entityType, entityId, description); + } + + /** + * 수정 로그 + */ + public void logUpdate(String entityType, String entityId, String description) { + logAsync(AuditAction.UPDATE, entityType, entityId, description); + } + + /** + * 삭제 로그 + */ + public void logDelete(String entityType, String entityId, String description) { + logAsync(AuditAction.DELETE, entityType, entityId, description); + } + + /** + * 접근 로그 + */ + public void logAccess(String entityType, String entityId, String description) { + logAsync(AuditAction.ACCESS, entityType, entityId, description); + } + + /** + * 로그인 로그 + */ + public void logLogin(String description) { + logAsync(AuditAction.LOGIN, "USER", SecurityUtil.getCurrentUserId().map(String::valueOf).orElse("UNKNOWN"), description); + } + + /** + * 로그아웃 로그 + */ + public void logLogout(String description) { + logAsync(AuditAction.LOGOUT, "USER", SecurityUtil.getCurrentUserId().map(String::valueOf).orElse("UNKNOWN"), description); + } +} diff --git a/common/src/main/resources/application-common.yml b/common/src/main/resources/application-common.yml new file mode 100644 index 0000000..06c3902 --- /dev/null +++ b/common/src/main/resources/application-common.yml @@ -0,0 +1,94 @@ +spring: + # JPA 설정 + jpa: + hibernate: + ddl-auto: ${JPA_DDL_AUTO:validate} + naming: + physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl + implicit-strategy: org.hibernate.boot.model.naming.ImplicitNamingStrategyLegacyJpaImpl + show-sql: ${JPA_SHOW_SQL:false} + properties: + hibernate: + format_sql: true + use_sql_comments: true + jdbc: + batch_size: 20 + order_inserts: true + order_updates: true + batch_versioned_data: true + + # Redis 설정 + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + timeout: 2000ms + lettuce: + pool: + max-active: 8 + max-wait: -1ms + max-idle: 8 + min-idle: 0 + + # Jackson 설정 + jackson: + time-zone: Asia/Seoul + date-format: yyyy-MM-dd HH:mm:ss + serialization: + write-dates-as-timestamps: false + deserialization: + fail-on-unknown-properties: false + + # 트랜잭션 설정 + transaction: + default-timeout: 30 + +# 애플리케이션 설정 +app: + # JWT 설정 + jwt: + secret-key: ${JWT_SECRET_KEY:hiorder-secret-key-for-jwt-token-generation-2024-very-long-secret-key} + access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000} # 1시간 + refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800000} # 7일 + + # CORS 설정 + cors: + allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:8080} + allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS} + allowed-headers: ${CORS_ALLOWED_HEADERS:*} + exposed-headers: ${CORS_EXPOSED_HEADERS:Authorization} + allow-credentials: ${CORS_ALLOW_CREDENTIALS:true} + max-age: ${CORS_MAX_AGE:3600} + + # 캐시 설정 + cache: + default-ttl: ${CACHE_DEFAULT_TTL:3600} # 1시간 + + # Swagger 설정 + swagger: + title: ${SWAGGER_TITLE:하이오더 API} + description: ${SWAGGER_DESCRIPTION:하이오더 백엔드 API 문서} + version: ${SWAGGER_VERSION:1.0.0} + server-url: ${SWAGGER_SERVER_URL:http://localhost:8080} + +# 로깅 설정 +logging: + level: + com.ktds.hi: ${LOG_LEVEL:INFO} + org.springframework.security: ${SECURITY_LOG_LEVEL:INFO} + org.hibernate.SQL: ${SQL_LOG_LEVEL:INFO} + org.hibernate.type.descriptor.sql.BasicBinder: ${SQL_PARAM_LOG_LEVEL:INFO} + pattern: + console: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" + +# 관리 엔드포인트 설정 +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + endpoint: + health: + show-details: when-authorized diff --git a/member/build.gradle b/member/build.gradle new file mode 100644 index 0000000..8ee387f --- /dev/null +++ b/member/build.gradle @@ -0,0 +1,6 @@ +dependencies { + implementation project(':common') + + // SMS Service (Optional) + implementation 'net.nurigo:sdk:4.3.0' +} diff --git a/member/src/main/java/com/ktds/hi/member/MemberServiceApplication.java b/member/src/main/java/com/ktds/hi/member/MemberServiceApplication.java new file mode 100644 index 0000000..1888f81 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/MemberServiceApplication.java @@ -0,0 +1,19 @@ +package com.ktds.hi.member; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 회원 관리 서비스 메인 애플리케이션 클래스 + * 인증, 회원정보 관리, 취향 관리 기능을 제공 + * + * @author 하이오더 개발팀 + * @version 1.0.0 + */ +@SpringBootApplication(scanBasePackages = {"com.ktds.hi.member", "com.ktds.hi.common"}) +public class MemberServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(MemberServiceApplication.class, args); + } +} diff --git a/member/src/main/java/com/ktds/hi/member/config/JpaConfig.java b/member/src/main/java/com/ktds/hi/member/config/JpaConfig.java new file mode 100644 index 0000000..ff610f1 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/config/JpaConfig.java @@ -0,0 +1,12 @@ +package com.ktds.hi.member.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * JPA 설정 클래스 + */ +@Configuration +@EnableJpaRepositories(basePackages = "com.ktds.hi.member.repository") +public class JpaConfig { +} diff --git a/member/src/main/java/com/ktds/hi/member/config/JwtAuthenticationFilter.java b/member/src/main/java/com/ktds/hi/member/config/JwtAuthenticationFilter.java new file mode 100644 index 0000000..153bc7c --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/config/JwtAuthenticationFilter.java @@ -0,0 +1,51 @@ +package com.ktds.hi.member.config; + +import com.ktds.hi.member.service.JwtTokenProvider; +import com.ktds.hi.member.service.AuthService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * JWT 인증 필터 클래스 + * 요청 헤더의 JWT 토큰을 검증하고 인증 정보를 설정 + */ +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider tokenProvider; + private final AuthService authService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String token = resolveToken(request); + + if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) { + Authentication authentication = tokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + /** + * 요청 헤더에서 JWT 토큰 추출 + */ + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/member/src/main/java/com/ktds/hi/member/config/SecurityConfig.java b/member/src/main/java/com/ktds/hi/member/config/SecurityConfig.java new file mode 100644 index 0000000..e8a099e --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/config/SecurityConfig.java @@ -0,0 +1,66 @@ +package com.ktds.hi.member.config; + +import com.ktds.hi.member.service.JwtTokenProvider; +import com.ktds.hi.member.service.AuthService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +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; + +/** + * Spring Security 설정 클래스 + * JWT 기반 인증 및 권한 관리 설정 + */ +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + private final AuthService authService; + + /** + * 보안 필터 체인 설정 + * JWT 인증 방식을 사용하고 세션은 무상태로 관리 + */ + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(authz -> authz + .requestMatchers("/api/auth/**", "/api/members/register").permitAll() + .requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll() + .requestMatchers("/actuator/**").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, authService), + UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + /** + * 비밀번호 암호화 빈 + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + /** + * 인증 매니저 빈 + */ + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } +} diff --git a/member/src/main/java/com/ktds/hi/member/config/SwaggerConfig.java b/member/src/main/java/com/ktds/hi/member/config/SwaggerConfig.java new file mode 100644 index 0000000..821fa31 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/config/SwaggerConfig.java @@ -0,0 +1,25 @@ +package com.ktds.hi.member.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Swagger 설정 클래스 + * API 문서화를 위한 OpenAPI 설정 + */ +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .addServersItem(new Server().url("/")) + .info(new Info() + .title("하이오더 회원 관리 서비스 API") + .description("회원 가입, 로그인, 취향 관리 등 회원 관련 기능을 제공하는 API") + .version("1.0.0")); + } +} diff --git a/member/src/main/java/com/ktds/hi/member/controller/AuthController.java b/member/src/main/java/com/ktds/hi/member/controller/AuthController.java new file mode 100644 index 0000000..6b3164d --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/controller/AuthController.java @@ -0,0 +1,99 @@ +package com.ktds.hi.member.controller; + +import com.ktds.hi.member.dto.*; +import com.ktds.hi.member.service.AuthService; +import com.ktds.hi.common.dto.SuccessResponse; +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.*; + +/** + * 인증 컨트롤러 클래스 + * 로그인, 로그아웃, 토큰 갱신 등 인증 관련 API를 제공 + */ +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +@Tag(name = "인증 API", description = "로그인, 로그아웃, 토큰 관리 등 인증 관련 API") +public class AuthController { + + private final AuthService authService; + + /** + * 로그인 API + */ + @PostMapping("/login") + @Operation(summary = "로그인", description = "사용자명과 비밀번호로 로그인을 수행합니다.") + public ResponseEntity login(@Valid @RequestBody LoginRequest request) { + TokenResponse response = authService.login(request); + return ResponseEntity.ok(response); + } + + /** + * 로그아웃 API + */ + @PostMapping("/logout") + @Operation(summary = "로그아웃", description = "현재 로그인된 사용자를 로그아웃 처리합니다.") + public ResponseEntity logout(@Valid @RequestBody LogoutRequest request) { + authService.logout(request); + return ResponseEntity.ok(SuccessResponse.of("로그아웃이 완료되었습니다")); + } + + /** + * 토큰 갱신 API + */ + @PostMapping("/refresh") + @Operation(summary = "토큰 갱신", description = "리프레시 토큰을 사용해 새로운 액세스 토큰을 발급받습니다.") + public ResponseEntity refreshToken(@RequestParam String refreshToken) { + TokenResponse response = authService.refreshToken(refreshToken); + return ResponseEntity.ok(response); + } + + /** + * 아이디 찾기 API + */ + @PostMapping("/find-username") + @Operation(summary = "아이디 찾기", description = "전화번호를 사용해 아이디를 찾습니다.") + public ResponseEntity findUsername(@Valid @RequestBody FindUserIdRequest request) { + String username = authService.findUserId(request); + return ResponseEntity.ok(SuccessResponse.of("아이디: " + username)); + } + + /** + * 비밀번호 찾기 API + */ + @PostMapping("/find-password") + @Operation(summary = "비밀번호 찾기", description = "전화번호로 임시 비밀번호를 SMS로 발송합니다.") + public ResponseEntity findPassword(@Valid @RequestBody FindUserIdRequest request) { + authService.findPassword(request); + return ResponseEntity.ok(SuccessResponse.of("임시 비밀번호가 SMS로 발송되었습니다")); + } + + /** + * SMS 인증번호 발송 API + */ + @PostMapping("/sms/send") + @Operation(summary = "SMS 인증번호 발송", description = "입력한 전화번호로 인증번호를 발송합니다.") + public ResponseEntity sendSmsVerification(@RequestParam String phone) { + authService.sendSmsVerification(phone); + return ResponseEntity.ok(SuccessResponse.of("인증번호가 발송되었습니다")); + } + + /** + * SMS 인증번호 확인 API + */ + @PostMapping("/sms/verify") + @Operation(summary = "SMS 인증번호 확인", description = "입력한 인증번호가 올바른지 확인합니다.") + public ResponseEntity verifySmsCode(@RequestParam String phone, @RequestParam String code) { + boolean isValid = authService.verifySmsCode(phone, code); + + if (isValid) { + return ResponseEntity.ok(SuccessResponse.of("인증이 완료되었습니다")); + } else { + return ResponseEntity.badRequest().body(SuccessResponse.of("인증번호가 올바르지 않습니다")); + } + } +} diff --git a/member/src/main/java/com/ktds/hi/member/controller/MemberController.java b/member/src/main/java/com/ktds/hi/member/controller/MemberController.java new file mode 100644 index 0000000..402aadd --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/controller/MemberController.java @@ -0,0 +1,104 @@ +package com.ktds.hi.member.controller; + +import com.ktds.hi.member.dto.*; +import com.ktds.hi.member.service.MemberService; +import com.ktds.hi.common.dto.SuccessResponse; +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.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +/** + * 회원 컨트롤러 클래스 + * 회원 가입, 정보 조회/수정 등 회원 관리 API를 제공 + */ +@RestController +@RequestMapping("/api/members") +@RequiredArgsConstructor +@Tag(name = "회원 관리 API", description = "회원 가입, 정보 조회/수정 등 회원 관리 관련 API") +public class MemberController { + + private final MemberService memberService; + + /** + * 회원 가입 API + */ + @PostMapping("/register") + @Operation(summary = "회원 가입", description = "새로운 회원을 등록합니다.") + public ResponseEntity registerMember(@Valid @RequestBody SignupRequest request) { + Long memberId = memberService.registerMember(request); + return ResponseEntity.ok(SuccessResponse.of("회원 가입이 완료되었습니다. 회원ID: " + memberId)); + } + + /** + * 마이페이지 정보 조회 API + */ + @GetMapping("/profile") + @Operation(summary = "마이페이지 조회", description = "현재 로그인한 회원의 정보를 조회합니다.") + public ResponseEntity getMyPageInfo(Authentication authentication) { + Long memberId = Long.valueOf(authentication.getName()); + MyPageResponse response = memberService.getMyPageInfo(memberId); + return ResponseEntity.ok(response); + } + + /** + * 닉네임 변경 API + */ + @PutMapping("/nickname") + @Operation(summary = "닉네임 변경", description = "현재 로그인한 회원의 닉네임을 변경합니다.") + public ResponseEntity updateNickname(Authentication authentication, + @Valid @RequestBody UpdateNicknameRequest request) { + Long memberId = Long.valueOf(authentication.getName()); + memberService.updateNickname(memberId, request); + return ResponseEntity.ok(SuccessResponse.of("닉네임이 변경되었습니다")); + } + + /** + * 아이디 변경 API + */ + @PutMapping("/username") + @Operation(summary = "아이디 변경", description = "현재 로그인한 회원의 아이디를 변경합니다.") + public ResponseEntity updateUsername(Authentication authentication, + @RequestParam String username) { + Long memberId = Long.valueOf(authentication.getName()); + memberService.updateUsername(memberId, username); + return ResponseEntity.ok(SuccessResponse.of("아이디가 변경되었습니다")); + } + + /** + * 비밀번호 변경 API + */ + @PutMapping("/password") + @Operation(summary = "비밀번호 변경", description = "현재 로그인한 회원의 비밀번호를 변경합니다.") + public ResponseEntity updatePassword(Authentication authentication, + @RequestParam String password) { + Long memberId = Long.valueOf(authentication.getName()); + memberService.updatePassword(memberId, password); + return ResponseEntity.ok(SuccessResponse.of("비밀번호가 변경되었습니다")); + } + + /** + * 아이디 중복 확인 API + */ + @GetMapping("/check-username") + @Operation(summary = "아이디 중복 확인", description = "사용 가능한 아이디인지 확인합니다.") + public ResponseEntity checkUsernameAvailability(@RequestParam String username) { + boolean isAvailable = memberService.checkUsernameAvailability(username); + String message = isAvailable ? "사용 가능한 아이디입니다" : "이미 사용 중인 아이디입니다"; + return ResponseEntity.ok(SuccessResponse.of(message)); + } + + /** + * 닉네임 중복 확인 API + */ + @GetMapping("/check-nickname") + @Operation(summary = "닉네임 중복 확인", description = "사용 가능한 닉네임인지 확인합니다.") + public ResponseEntity checkNicknameAvailability(@RequestParam String nickname) { + boolean isAvailable = memberService.checkNicknameAvailability(nickname); + String message = isAvailable ? "사용 가능한 닉네임입니다" : "이미 사용 중인 닉네임입니다"; + return ResponseEntity.ok(SuccessResponse.of(message)); + } +} diff --git a/member/src/main/java/com/ktds/hi/member/controller/PreferenceController.java b/member/src/main/java/com/ktds/hi/member/controller/PreferenceController.java new file mode 100644 index 0000000..67e2f72 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/controller/PreferenceController.java @@ -0,0 +1,62 @@ +package com.ktds.hi.member.controller; + +import com.ktds.hi.member.dto.PreferenceRequest; +import com.ktds.hi.member.dto.TasteTagResponse; +import com.ktds.hi.member.domain.TagType; +import com.ktds.hi.member.service.PreferenceService; +import com.ktds.hi.common.dto.SuccessResponse; +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.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 취향 관리 컨트롤러 클래스 + * 취향 정보 등록/수정 및 태그 관리 API를 제공 + */ +@RestController +@RequestMapping("/api/members/preferences") +@RequiredArgsConstructor +@Tag(name = "취향 관리 API", description = "회원 취향 정보 등록/수정 및 태그 관리 관련 API") +public class PreferenceController { + + private final PreferenceService preferenceService; + + /** + * 취향 정보 등록/수정 API + */ + @PostMapping + @Operation(summary = "취향 정보 등록", description = "회원의 취향 정보를 등록하거나 수정합니다.") + public ResponseEntity savePreference(Authentication authentication, + @Valid @RequestBody PreferenceRequest request) { + Long memberId = Long.valueOf(authentication.getName()); + preferenceService.savePreference(memberId, request); + return ResponseEntity.ok(SuccessResponse.of("취향 정보가 저장되었습니다")); + } + + /** + * 사용 가능한 취향 태그 목록 조회 API + */ + @GetMapping("/tags") + @Operation(summary = "취향 태그 목록 조회", description = "사용 가능한 모든 취향 태그 목록을 조회합니다.") + public ResponseEntity> getAvailableTags() { + List tags = preferenceService.getAvailableTags(); + return ResponseEntity.ok(tags); + } + + /** + * 태그 유형별 태그 목록 조회 API + */ + @GetMapping("/tags/by-type") + @Operation(summary = "유형별 태그 목록 조회", description = "특정 유형의 취향 태그 목록을 조회합니다.") + public ResponseEntity> getTagsByType(@RequestParam TagType tagType) { + List tags = preferenceService.getTagsByType(tagType); + return ResponseEntity.ok(tags); + } +} + diff --git a/member/src/main/java/com/ktds/hi/member/domain/Member.java b/member/src/main/java/com/ktds/hi/member/domain/Member.java new file mode 100644 index 0000000..f02206f --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/domain/Member.java @@ -0,0 +1,76 @@ +package com.ktds.hi.member.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 회원 도메인 클래스 + * 회원의 기본 정보를 담는 도메인 객체 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Member { + + private Long id; + private String username; + private String password; + private String nickname; + private String phone; + private String role; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + /** + * 닉네임 변경 + */ + public Member updateNickname(String newNickname) { + return Member.builder() + .id(this.id) + .username(this.username) + .password(this.password) + .nickname(newNickname) + .phone(this.phone) + .role(this.role) + .createdAt(this.createdAt) + .updatedAt(LocalDateTime.now()) + .build(); + } + + /** + * 아이디 변경 + */ + public Member updateUsername(String newUsername) { + return Member.builder() + .id(this.id) + .username(newUsername) + .password(this.password) + .nickname(this.nickname) + .phone(this.phone) + .role(this.role) + .createdAt(this.createdAt) + .updatedAt(LocalDateTime.now()) + .build(); + } + + /** + * 비밀번호 변경 + */ + public Member updatePassword(String newPassword) { + return Member.builder() + .id(this.id) + .username(this.username) + .password(newPassword) + .nickname(this.nickname) + .phone(this.phone) + .role(this.role) + .createdAt(this.createdAt) + .updatedAt(LocalDateTime.now()) + .build(); + } +} diff --git a/member/src/main/java/com/ktds/hi/member/domain/Preference.java b/member/src/main/java/com/ktds/hi/member/domain/Preference.java new file mode 100644 index 0000000..30f7fdf --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/domain/Preference.java @@ -0,0 +1,43 @@ +package com.ktds.hi.member.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 취향 정보 도메인 클래스 + * 회원의 음식 취향 및 건강 정보를 담는 도메인 객체 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Preference { + + private Long id; + private Long memberId; + private List tags; + private String healthInfo; + private String spicyLevel; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + /** + * 취향 정보 업데이트 + */ + public Preference updatePreference(List newTags, String newHealthInfo, String newSpicyLevel) { + return Preference.builder() + .id(this.id) + .memberId(this.memberId) + .tags(newTags) + .healthInfo(newHealthInfo) + .spicyLevel(newSpicyLevel) + .createdAt(this.createdAt) + .updatedAt(LocalDateTime.now()) + .build(); + } +} diff --git a/member/src/main/java/com/ktds/hi/member/domain/TagType.java b/member/src/main/java/com/ktds/hi/member/domain/TagType.java new file mode 100644 index 0000000..ca3a99d --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/domain/TagType.java @@ -0,0 +1,23 @@ +package com.ktds.hi.member.domain; + +/** + * 태그 유형 열거형 + * 취향 태그의 카테고리를 정의 + */ +public enum TagType { + CUISINE("음식 종류"), + FLAVOR("맛"), + DIETARY("식이 제한"), + ATMOSPHERE("분위기"), + PRICE("가격대"); + + private final String description; + + TagType(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/member/src/main/java/com/ktds/hi/member/domain/TasteTag.java b/member/src/main/java/com/ktds/hi/member/domain/TasteTag.java new file mode 100644 index 0000000..faacc90 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/domain/TasteTag.java @@ -0,0 +1,23 @@ +package com.ktds.hi.member.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 취향 태그 도메인 클래스 + * 사용 가능한 취향 태그 정보를 담는 도메인 객체 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TasteTag { + + private Long id; + private String tagName; + private TagType tagType; + private String description; + private Boolean isActive; +} diff --git a/member/src/main/java/com/ktds/hi/member/dto/FindUserIdRequest.java b/member/src/main/java/com/ktds/hi/member/dto/FindUserIdRequest.java new file mode 100644 index 0000000..bd4cc0c --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/dto/FindUserIdRequest.java @@ -0,0 +1,23 @@ +package com.ktds.hi.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 아이디 찾기 요청 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "아이디 찾기 요청") +public class FindUserIdRequest { + + @NotBlank(message = "전화번호는 필수입니다") + @Pattern(regexp = "^010-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다") + @Schema(description = "전화번호", example = "010-1234-5678") + private String phone; +} diff --git a/member/src/main/java/com/ktds/hi/member/dto/LoginRequest.java b/member/src/main/java/com/ktds/hi/member/dto/LoginRequest.java new file mode 100644 index 0000000..a281468 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/dto/LoginRequest.java @@ -0,0 +1,26 @@ +package com.ktds.hi.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 로그인 요청 DTO + * 사용자 로그인 시 필요한 정보를 담는 데이터 전송 객체 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "로그인 요청") +public class LoginRequest { + + @NotBlank(message = "사용자명은 필수입니다") + @Schema(description = "사용자명", example = "test@example.com") + private String username; + + @NotBlank(message = "비밀번호는 필수입니다") + @Schema(description = "비밀번호", example = "password123") + private String password; +} diff --git a/member/src/main/java/com/ktds/hi/member/dto/LogoutRequest.java b/member/src/main/java/com/ktds/hi/member/dto/LogoutRequest.java new file mode 100644 index 0000000..454ef6e --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/dto/LogoutRequest.java @@ -0,0 +1,21 @@ +package com.ktds.hi.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 로그아웃 요청 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "로그아웃 요청") +public class LogoutRequest { + + @NotBlank(message = "리프레시 토큰은 필수입니다") + @Schema(description = "리프레시 토큰") + private String refreshToken; +} diff --git a/member/src/main/java/com/ktds/hi/member/dto/MyPageResponse.java b/member/src/main/java/com/ktds/hi/member/dto/MyPageResponse.java new file mode 100644 index 0000000..5864953 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/dto/MyPageResponse.java @@ -0,0 +1,38 @@ +package com.ktds.hi.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 마이페이지 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "마이페이지 정보") +public class MyPageResponse { + + @Schema(description = "사용자명") + private String username; + + @Schema(description = "닉네임") + private String nickname; + + @Schema(description = "전화번호") + private String phone; + + @Schema(description = "취향 태그 목록") + private List preferences; + + @Schema(description = "건강 정보") + private String healthInfo; + + @Schema(description = "매운맛 선호도") + private String spicyLevel; +} diff --git a/member/src/main/java/com/ktds/hi/member/dto/PreferenceRequest.java b/member/src/main/java/com/ktds/hi/member/dto/PreferenceRequest.java new file mode 100644 index 0000000..50587fa --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/dto/PreferenceRequest.java @@ -0,0 +1,29 @@ +package com.ktds.hi.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 취향 정보 등록 요청 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "취향 정보 등록 요청") +public class PreferenceRequest { + + @NotEmpty(message = "취향 태그는 최소 1개 이상 선택해야 합니다") + @Schema(description = "취향 태그 목록", example = "[\"한식\", \"매운맛\", \"저칼로리\"]") + private List tags; + + @Schema(description = "건강 정보", example = "당뇨 있음") + private String healthInfo; + + @Schema(description = "매운맛 선호도", example = "보통") + private String spicyLevel; +} diff --git a/member/src/main/java/com/ktds/hi/member/dto/SignupRequest.java b/member/src/main/java/com/ktds/hi/member/dto/SignupRequest.java new file mode 100644 index 0000000..f4640d1 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/dto/SignupRequest.java @@ -0,0 +1,37 @@ +package com.ktds.hi.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 회원가입 요청 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "회원가입 요청") +public class SignupRequest { + + @NotBlank(message = "사용자명은 필수입니다") + @Schema(description = "사용자명", example = "test@example.com") + private String username; + + @NotBlank(message = "비밀번호는 필수입니다") + @Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다") + @Schema(description = "비밀번호", example = "password123") + private String password; + + @NotBlank(message = "닉네임은 필수입니다") + @Size(min = 2, max = 20, message = "닉네임은 2-20자 사이여야 합니다") + @Schema(description = "닉네임", example = "홍길동") + private String nickname; + + @Pattern(regexp = "^010-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다") + @Schema(description = "전화번호", example = "010-1234-5678") + private String phone; +} diff --git a/member/src/main/java/com/ktds/hi/member/dto/TasteTagResponse.java b/member/src/main/java/com/ktds/hi/member/dto/TasteTagResponse.java new file mode 100644 index 0000000..2c83ef0 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/dto/TasteTagResponse.java @@ -0,0 +1,31 @@ +package com.ktds.hi.member.dto; + +import com.ktds.hi.member.domain.TagType; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 취향 태그 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "취향 태그 정보") +public class TasteTagResponse { + + @Schema(description = "태그 ID") + private Long id; + + @Schema(description = "태그명") + private String tagName; + + @Schema(description = "태그 유형") + private TagType tagType; + + @Schema(description = "태그 설명") + private String description; +} diff --git a/member/src/main/java/com/ktds/hi/member/dto/TokenResponse.java b/member/src/main/java/com/ktds/hi/member/dto/TokenResponse.java new file mode 100644 index 0000000..5d4fbf9 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/dto/TokenResponse.java @@ -0,0 +1,31 @@ +package com.ktds.hi.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 토큰 응답 DTO + * 로그인 성공 시 반환되는 JWT 토큰 정보 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "토큰 응답") +public class TokenResponse { + + @Schema(description = "액세스 토큰") + private String accessToken; + + @Schema(description = "리프레시 토큰") + private String refreshToken; + + @Schema(description = "회원 ID") + private Long memberId; + + @Schema(description = "사용자 역할") + private String role; +} diff --git a/member/src/main/java/com/ktds/hi/member/dto/UpdateNicknameRequest.java b/member/src/main/java/com/ktds/hi/member/dto/UpdateNicknameRequest.java new file mode 100644 index 0000000..d5de332 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/dto/UpdateNicknameRequest.java @@ -0,0 +1,23 @@ +package com.ktds.hi.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 닉네임 변경 요청 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "닉네임 변경 요청") +public class UpdateNicknameRequest { + + @NotBlank(message = "닉네임은 필수입니다") + @Size(min = 2, max = 20, message = "닉네임은 2-20자 사이여야 합니다") + @Schema(description = "새 닉네임", example = "새닉네임") + private String nickname; +} diff --git a/member/src/main/java/com/ktds/hi/member/repository/entity/MemberEntity.java b/member/src/main/java/com/ktds/hi/member/repository/entity/MemberEntity.java new file mode 100644 index 0000000..e2f02b0 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/repository/entity/MemberEntity.java @@ -0,0 +1,74 @@ +package com.ktds.hi.member.repository.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 회원 엔티티 클래스 + * 데이터베이스 회원 테이블과 매핑되는 JPA 엔티티 + */ +@Entity +@Table(name = "members") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class MemberEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false, length = 100) + private String username; + + @Column(nullable = false) + private String password; + + @Column(unique = true, nullable = false, length = 50) + private String nickname; + + @Column(length = 20) + private String phone; + + @Column(length = 20) + @Builder.Default + private String role = "USER"; + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; + + /** + * 닉네임 변경 + */ + public void updateNickname(String newNickname) { + this.nickname = newNickname; + } + + /** + * 아이디 변경 + */ + public void updateUsername(String newUsername) { + this.username = newUsername; + } + + /** + * 비밀번호 변경 + */ + public void updatePassword(String newPassword) { + this.password = newPassword; + } +} diff --git a/member/src/main/java/com/ktds/hi/member/repository/entity/PreferenceEntity.java b/member/src/main/java/com/ktds/hi/member/repository/entity/PreferenceEntity.java new file mode 100644 index 0000000..9ca6e3d --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/repository/entity/PreferenceEntity.java @@ -0,0 +1,63 @@ +package com.ktds.hi.member.repository.entity; + +import com.ktds.hi.member.domain.TagType; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 취향 정보 엔티티 클래스 + * 데이터베이스 preferences 테이블과 매핑되는 JPA 엔티티 + */ +@Entity +@Table(name = "preferences") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class PreferenceEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @ElementCollection + @CollectionTable(name = "preference_tags", + joinColumns = @JoinColumn(name = "preference_id")) + @Column(name = "tag") + private List tags; + + @Column(name = "health_info", length = 500) + private String healthInfo; + + @Column(name = "spicy_level", length = 20) + private String spicyLevel; + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; + + /** + * 취향 정보 업데이트 + */ + public void updatePreference(List newTags, String newHealthInfo, String newSpicyLevel) { + this.tags = newTags; + this.healthInfo = newHealthInfo; + this.spicyLevel = newSpicyLevel; + } +} diff --git a/member/src/main/java/com/ktds/hi/member/repository/entity/TasteTagEntity.java b/member/src/main/java/com/ktds/hi/member/repository/entity/TasteTagEntity.java new file mode 100644 index 0000000..23729e1 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/repository/entity/TasteTagEntity.java @@ -0,0 +1,39 @@ +package com.ktds.hi.member.repository.entity; + +import com.ktds.hi.member.domain.TagType; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 취향 태그 엔티티 클래스 + * 데이터베이스 taste_tags 테이블과 매핑되는 JPA 엔티티 + */ +@Entity +@Table(name = "taste_tags") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TasteTagEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "tag_name", unique = true, nullable = false, length = 50) + private String tagName; + + @Enumerated(EnumType.STRING) + @Column(name = "tag_type", nullable = false) + private TagType tagType; + + @Column(length = 200) + private String description; + + @Column(name = "is_active") + @Builder.Default + private Boolean isActive = true; +} diff --git a/member/src/main/java/com/ktds/hi/member/repository/jpa/MemberRepository.java b/member/src/main/java/com/ktds/hi/member/repository/jpa/MemberRepository.java new file mode 100644 index 0000000..d5a2b8f --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/repository/jpa/MemberRepository.java @@ -0,0 +1,40 @@ +package com.ktds.hi.member.repository.jpa; + +import com.ktds.hi.member.repository.entity.MemberEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * 회원 JPA 리포지토리 인터페이스 + * 회원 데이터의 CRUD 작업을 담당 + */ +@Repository +public interface MemberRepository extends JpaRepository { + + /** + * 사용자명으로 회원 조회 + */ + Optional findByUsername(String username); + + /** + * 닉네임으로 회원 조회 + */ + Optional findByNickname(String nickname); + + /** + * 전화번호로 회원 조회 + */ + Optional findByPhone(String phone); + + /** + * 사용자명 존재 여부 확인 + */ + boolean existsByUsername(String username); + + /** + * 닉네임 존재 여부 확인 + */ + boolean existsByNickname(String nickname); +} diff --git a/member/src/main/java/com/ktds/hi/member/repository/jpa/PreferenceRepository.java b/member/src/main/java/com/ktds/hi/member/repository/jpa/PreferenceRepository.java new file mode 100644 index 0000000..a830720 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/repository/jpa/PreferenceRepository.java @@ -0,0 +1,30 @@ +package com.ktds.hi.member.repository.jpa; + +import com.ktds.hi.member.repository.entity.PreferenceEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * 취향 정보 JPA 리포지토리 인터페이스 + * 취향 정보 데이터의 CRUD 작업을 담당 + */ +@Repository +public interface PreferenceRepository extends JpaRepository { + + /** + * 회원 ID로 취향 정보 조회 + */ + Optional findByMemberId(Long memberId); + + /** + * 회원 ID로 취향 정보 존재 여부 확인 + */ + boolean existsByMemberId(Long memberId); + + /** + * 회원 ID로 취향 정보 삭제 + */ + void deleteByMemberId(Long memberId); +} diff --git a/member/src/main/java/com/ktds/hi/member/repository/jpa/TasteTagRepository.java b/member/src/main/java/com/ktds/hi/member/repository/jpa/TasteTagRepository.java new file mode 100644 index 0000000..e44c6c5 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/repository/jpa/TasteTagRepository.java @@ -0,0 +1,31 @@ +package com.ktds.hi.member.repository.jpa; + +import com.ktds.hi.member.domain.TagType; +import com.ktds.hi.member.repository.entity.TasteTagEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * 취향 태그 JPA 리포지토리 인터페이스 + * 취향 태그 데이터의 CRUD 작업을 담당 + */ +@Repository +public interface TasteTagRepository extends JpaRepository { + + /** + * 활성화된 태그 목록 조회 + */ + List findByIsActiveTrue(); + + /** + * 태그 유형별 태그 목록 조회 + */ + List findByTagTypeAndIsActiveTrue(TagType tagType); + + /** + * 태그명으로 태그 조회 + */ + List findByTagNameIn(List tagNames); +} diff --git a/member/src/main/java/com/ktds/hi/member/service/AuthService.java b/member/src/main/java/com/ktds/hi/member/service/AuthService.java new file mode 100644 index 0000000..5e06469 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/service/AuthService.java @@ -0,0 +1,45 @@ +package com.ktds.hi.member.service; + +import com.ktds.hi.member.dto.*; + +/** + * 인증 서비스 인터페이스 + * 로그인, 로그아웃, 토큰 관리 등 인증 관련 기능을 정의 + */ +public interface AuthService { + + /** + * 로그인 처리 + */ + TokenResponse login(LoginRequest request); + + /** + * 로그아웃 처리 + */ + void logout(LogoutRequest request); + + /** + * 토큰 갱신 + */ + TokenResponse refreshToken(String refreshToken); + + /** + * 아이디 찾기 + */ + String findUserId(FindUserIdRequest request); + + /** + * 비밀번호 찾기 (SMS 발송) + */ + void findPassword(FindUserIdRequest request); + + /** + * SMS 인증번호 발송 + */ + void sendSmsVerification(String phone); + + /** + * SMS 인증번호 확인 + */ + boolean verifySmsCode(String phone, String code); +} diff --git a/member/src/main/java/com/ktds/hi/member/service/AuthServiceImpl.java b/member/src/main/java/com/ktds/hi/member/service/AuthServiceImpl.java new file mode 100644 index 0000000..88b3633 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/service/AuthServiceImpl.java @@ -0,0 +1,170 @@ +package com.ktds.hi.member.service; + +import com.ktds.hi.member.dto.*; +import com.ktds.hi.member.repository.entity.MemberEntity; +import com.ktds.hi.member.repository.jpa.MemberRepository; +import com.ktds.hi.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +/** + * 인증 서비스 구현체 + * 로그인, 로그아웃, 토큰 관리 등 인증 관련 기능을 구현 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class AuthServiceImpl implements AuthService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + private final SmsService smsService; + private final RedisTemplate redisTemplate; + + @Override + public TokenResponse login(LoginRequest request) { + // 회원 조회 + MemberEntity member = memberRepository.findByUsername(request.getUsername()) + .orElseThrow(() -> new BusinessException("존재하지 않는 사용자입니다")); + + // 비밀번호 검증 + if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) { + throw new BusinessException("비밀번호가 일치하지 않습니다"); + } + + // JWT 토큰 생성 + String accessToken = jwtTokenProvider.generateAccessToken(member.getId(), member.getRole()); + String refreshToken = jwtTokenProvider.generateRefreshToken(member.getId()); + + // 리프레시 토큰 Redis 저장 + redisTemplate.opsForValue().set( + "refresh_token:" + member.getId(), + refreshToken, + 7, TimeUnit.DAYS + ); + + return TokenResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .memberId(member.getId()) + .role(member.getRole()) + .build(); + } + + @Override + public void logout(LogoutRequest request) { + // 리프레시 토큰에서 사용자 ID 추출 + Long memberId = jwtTokenProvider.getMemberIdFromToken(request.getRefreshToken()); + + // Redis에서 리프레시 토큰 삭제 + redisTemplate.delete("refresh_token:" + memberId); + } + + @Override + public TokenResponse refreshToken(String refreshToken) { + // 토큰 유효성 검증 + if (!jwtTokenProvider.validateToken(refreshToken)) { + throw new BusinessException("유효하지 않은 리프레시 토큰입니다"); + } + + Long memberId = jwtTokenProvider.getMemberIdFromToken(refreshToken); + + // Redis에서 리프레시 토큰 확인 + String storedToken = redisTemplate.opsForValue().get("refresh_token:" + memberId); + if (!refreshToken.equals(storedToken)) { + throw new BusinessException("유효하지 않은 리프레시 토큰입니다"); + } + + // 회원 정보 조회 + MemberEntity member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException("존재하지 않는 사용자입니다")); + + // 새 토큰 생성 + String newAccessToken = jwtTokenProvider.generateAccessToken(member.getId(), member.getRole()); + String newRefreshToken = jwtTokenProvider.generateRefreshToken(member.getId()); + + // 새 리프레시 토큰 Redis 저장 + redisTemplate.opsForValue().set( + "refresh_token:" + member.getId(), + newRefreshToken, + 7, TimeUnit.DAYS + ); + + return TokenResponse.builder() + .accessToken(newAccessToken) + .refreshToken(newRefreshToken) + .memberId(member.getId()) + .role(member.getRole()) + .build(); + } + + @Override + public String findUserId(FindUserIdRequest request) { + MemberEntity member = memberRepository.findByPhone(request.getPhone()) + .orElseThrow(() -> new BusinessException("해당 전화번호로 가입된 계정이 없습니다")); + + return member.getUsername(); + } + + @Override + public void findPassword(FindUserIdRequest request) { + MemberEntity member = memberRepository.findByPhone(request.getPhone()) + .orElseThrow(() -> new BusinessException("해당 전화번호로 가입된 계정이 없습니다")); + + // 임시 비밀번호 생성 및 SMS 발송 + String tempPassword = generateTempPassword(); + smsService.sendTempPassword(request.getPhone(), tempPassword); + + // 임시 비밀번호로 업데이트 + member.updatePassword(passwordEncoder.encode(tempPassword)); + memberRepository.save(member); + } + + @Override + public void sendSmsVerification(String phone) { + String verificationCode = generateVerificationCode(); + + // SMS 발송 + smsService.sendVerificationCode(phone, verificationCode); + + // Redis에 인증코드 저장 (5분 만료) + redisTemplate.opsForValue().set( + "sms_code:" + phone, + verificationCode, + 5, TimeUnit.MINUTES + ); + } + + @Override + public boolean verifySmsCode(String phone, String code) { + String storedCode = redisTemplate.opsForValue().get("sms_code:" + phone); + + if (storedCode != null && storedCode.equals(code)) { + // 인증 성공 시 코드 삭제 + redisTemplate.delete("sms_code:" + phone); + return true; + } + + return false; + } + + /** + * 임시 비밀번호 생성 + */ + private String generateTempPassword() { + return "temp" + System.currentTimeMillis(); + } + + /** + * SMS 인증코드 생성 + */ + private String generateVerificationCode() { + return String.valueOf((int)(Math.random() * 900000) + 100000); + } +} diff --git a/member/src/main/java/com/ktds/hi/member/service/JwtTokenProvider.java b/member/src/main/java/com/ktds/hi/member/service/JwtTokenProvider.java new file mode 100644 index 0000000..19ea701 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/service/JwtTokenProvider.java @@ -0,0 +1,114 @@ +package com.ktds.hi.member.service; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Collections; +import java.util.Date; + +/** + * JWT 토큰 프로바이더 클래스 + * JWT 토큰 생성, 검증, 파싱 기능을 제공 + */ +@Component +@Slf4j +public class JwtTokenProvider { + + private final SecretKey secretKey; + private final long accessTokenExpiration; + private final long refreshTokenExpiration; + + public JwtTokenProvider(@Value("${jwt.secret}") String secret, + @Value("${jwt.access-token-expiration}") long accessTokenExpiration, + @Value("${jwt.refresh-token-expiration}") long refreshTokenExpiration) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes()); + this.accessTokenExpiration = accessTokenExpiration; + this.refreshTokenExpiration = refreshTokenExpiration; + } + + /** + * 액세스 토큰 생성 + */ + public String generateAccessToken(Long memberId, String role) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + accessTokenExpiration); + + return Jwts.builder() + .setSubject(memberId.toString()) + .claim("role", role) + .setIssuedAt(now) + .setExpiration(expiration) + .signWith(secretKey) + .compact(); + } + + /** + * 리프레시 토큰 생성 + */ + public String generateRefreshToken(Long memberId) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + refreshTokenExpiration); + + return Jwts.builder() + .setSubject(memberId.toString()) + .setIssuedAt(now) + .setExpiration(expiration) + .signWith(secretKey) + .compact(); + } + + /** + * 토큰에서 인증 정보 추출 + */ + public Authentication getAuthentication(String token) { + Claims claims = parseClaims(token); + + String memberId = claims.getSubject(); + String role = claims.get("role", String.class); + + return new UsernamePasswordAuthenticationToken( + memberId, + null, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role)) + ); + } + + /** + * 토큰에서 회원 ID 추출 + */ + public Long getMemberIdFromToken(String token) { + Claims claims = parseClaims(token); + return Long.valueOf(claims.getSubject()); + } + + /** + * 토큰 유효성 검증 + */ + public boolean validateToken(String token) { + try { + parseClaims(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + log.warn("Invalid JWT token: {}", e.getMessage()); + return false; + } + } + + /** + * 토큰 파싱 + */ + private Claims parseClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + } +} diff --git a/member/src/main/java/com/ktds/hi/member/service/MemberService.java b/member/src/main/java/com/ktds/hi/member/service/MemberService.java new file mode 100644 index 0000000..467bf1e --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/service/MemberService.java @@ -0,0 +1,45 @@ +package com.ktds.hi.member.service; + +import com.ktds.hi.member.dto.*; + +/** + * 회원 서비스 인터페이스 + * 회원 가입, 정보 조회/수정 등 회원 관리 기능을 정의 + */ +public interface MemberService { + + /** + * 회원 가입 + */ + Long registerMember(SignupRequest request); + + /** + * 마이페이지 정보 조회 + */ + MyPageResponse getMyPageInfo(Long memberId); + + /** + * 닉네임 변경 + */ + void updateNickname(Long memberId, UpdateNicknameRequest request); + + /** + * 아이디 변경 + */ + void updateUsername(Long memberId, String newUsername); + + /** + * 비밀번호 변경 + */ + void updatePassword(Long memberId, String newPassword); + + /** + * 아이디 중복 확인 + */ + boolean checkUsernameAvailability(String username); + + /** + * 닉네임 중복 확인 + */ + boolean checkNicknameAvailability(String nickname); +} diff --git a/member/src/main/java/com/ktds/hi/member/service/MemberServiceImpl.java b/member/src/main/java/com/ktds/hi/member/service/MemberServiceImpl.java new file mode 100644 index 0000000..4390b23 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/service/MemberServiceImpl.java @@ -0,0 +1,131 @@ +package com.ktds.hi.member.service; + +import com.ktds.hi.member.dto.*; +import com.ktds.hi.member.repository.entity.MemberEntity; +import com.ktds.hi.member.repository.entity.PreferenceEntity; +import com.ktds.hi.member.repository.jpa.MemberRepository; +import com.ktds.hi.member.repository.jpa.PreferenceRepository; +import com.ktds.hi.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; + +/** + * 회원 서비스 구현체 + * 회원 가입, 정보 조회/수정 등 회원 관리 기능을 구현 + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class MemberServiceImpl implements MemberService { + + private final MemberRepository memberRepository; + private final PreferenceRepository preferenceRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public Long registerMember(SignupRequest request) { + // 중복 검사 + if (memberRepository.existsByUsername(request.getUsername())) { + throw new BusinessException("이미 사용 중인 사용자명입니다"); + } + + if (memberRepository.existsByNickname(request.getNickname())) { + throw new BusinessException("이미 사용 중인 닉네임입니다"); + } + + // 회원 생성 + MemberEntity member = MemberEntity.builder() + .username(request.getUsername()) + .password(passwordEncoder.encode(request.getPassword())) + .nickname(request.getNickname()) + .phone(request.getPhone()) + .role("USER") + .build(); + + MemberEntity savedMember = memberRepository.save(member); + + log.info("회원 가입 완료: memberId={}, username={}", savedMember.getId(), savedMember.getUsername()); + + return savedMember.getId(); + } + + @Override + @Transactional(readOnly = true) + public MyPageResponse getMyPageInfo(Long memberId) { + MemberEntity member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException("존재하지 않는 회원입니다")); + + PreferenceEntity preference = preferenceRepository.findByMemberId(memberId) + .orElse(null); + + return MyPageResponse.builder() + .username(member.getUsername()) + .nickname(member.getNickname()) + .phone(member.getPhone()) + .preferences(preference != null ? preference.getTags() : Collections.emptyList()) + .healthInfo(preference != null ? preference.getHealthInfo() : null) + .spicyLevel(preference != null ? preference.getSpicyLevel() : null) + .build(); + } + + @Override + public void updateNickname(Long memberId, UpdateNicknameRequest request) { + MemberEntity member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException("존재하지 않는 회원입니다")); + + // 닉네임 중복 검사 + if (memberRepository.existsByNickname(request.getNickname())) { + throw new BusinessException("이미 사용 중인 닉네임입니다"); + } + + member.updateNickname(request.getNickname()); + memberRepository.save(member); + + log.info("닉네임 변경 완료: memberId={}, newNickname={}", memberId, request.getNickname()); + } + + @Override + public void updateUsername(Long memberId, String newUsername) { + MemberEntity member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException("존재하지 않는 회원입니다")); + + // 아이디 중복 검사 + if (memberRepository.existsByUsername(newUsername)) { + throw new BusinessException("이미 사용 중인 사용자명입니다"); + } + + member.updateUsername(newUsername); + memberRepository.save(member); + + log.info("아이디 변경 완료: memberId={}, newUsername={}", memberId, newUsername); + } + + @Override + public void updatePassword(Long memberId, String newPassword) { + MemberEntity member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException("존재하지 않는 회원입니다")); + + member.updatePassword(passwordEncoder.encode(newPassword)); + memberRepository.save(member); + + log.info("비밀번호 변경 완료: memberId={}", memberId); + } + + @Override + @Transactional(readOnly = true) + public boolean checkUsernameAvailability(String username) { + return !memberRepository.existsByUsername(username); + } + + @Override + @Transactional(readOnly = true) + public boolean checkNicknameAvailability(String nickname) { + return !memberRepository.existsByNickname(nickname); + } +} diff --git a/member/src/main/java/com/ktds/hi/member/service/PreferenceService.java b/member/src/main/java/com/ktds/hi/member/service/PreferenceService.java new file mode 100644 index 0000000..b7c2a67 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/service/PreferenceService.java @@ -0,0 +1,29 @@ +package com.ktds.hi.member.service; + +import com.ktds.hi.member.dto.PreferenceRequest; +import com.ktds.hi.member.dto.TasteTagResponse; +import com.ktds.hi.member.domain.TagType; + +import java.util.List; + +/** + * 취향 관리 서비스 인터페이스 + * 취향 정보 등록/수정 및 태그 관리 기능을 정의 + */ +public interface PreferenceService { + + /** + * 취향 정보 등록/수정 + */ + void savePreference(Long memberId, PreferenceRequest request); + + /** + * 사용 가능한 취향 태그 목록 조회 + */ + List getAvailableTags(); + + /** + * 태그 유형별 태그 목록 조회 + */ + List getTagsByType(TagType tagType); +} diff --git a/member/src/main/java/com/ktds/hi/member/service/PreferenceServiceImpl.java b/member/src/main/java/com/ktds/hi/member/service/PreferenceServiceImpl.java new file mode 100644 index 0000000..ba93eea --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/service/PreferenceServiceImpl.java @@ -0,0 +1,91 @@ +package com.ktds.hi.member.service; + +import com.ktds.hi.member.dto.PreferenceRequest; +import com.ktds.hi.member.dto.TasteTagResponse; +import com.ktds.hi.member.domain.TagType; +import com.ktds.hi.member.repository.entity.PreferenceEntity; +import com.ktds.hi.member.repository.entity.TasteTagEntity; +import com.ktds.hi.member.repository.jpa.PreferenceRepository; +import com.ktds.hi.member.repository.jpa.TasteTagRepository; +import com.ktds.hi.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 취향 관리 서비스 구현체 + * 취향 정보 등록/수정 및 태그 관리 기능을 구현 + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class PreferenceServiceImpl implements PreferenceService { + + private final PreferenceRepository preferenceRepository; + private final TasteTagRepository tasteTagRepository; + + @Override + public void savePreference(Long memberId, PreferenceRequest request) { + // 태그 유효성 검증 + List existingTags = tasteTagRepository.findByTagNameIn(request.getTags()); + if (existingTags.size() != request.getTags().size()) { + throw new BusinessException("유효하지 않은 태그가 포함되어 있습니다"); + } + + // 기존 취향 정보 조회 + PreferenceEntity preference = preferenceRepository.findByMemberId(memberId) + .orElse(null); + + if (preference != null) { + // 기존 정보 업데이트 + preference.updatePreference(request.getTags(), request.getHealthInfo(), request.getSpicyLevel()); + } else { + // 새로운 취향 정보 생성 + preference = PreferenceEntity.builder() + .memberId(memberId) + .tags(request.getTags()) + .healthInfo(request.getHealthInfo()) + .spicyLevel(request.getSpicyLevel()) + .build(); + } + + preferenceRepository.save(preference); + + log.info("취향 정보 저장 완료: memberId={}, tags={}", memberId, request.getTags()); + } + + @Override + @Transactional(readOnly = true) + public List getAvailableTags() { + List tags = tasteTagRepository.findByIsActiveTrue(); + + return tags.stream() + .map(tag -> TasteTagResponse.builder() + .id(tag.getId()) + .tagName(tag.getTagName()) + .tagType(tag.getTagType()) + .description(tag.getDescription()) + .build()) + .collect(Collectors.toList()); + } + + @Override + @Transactional(readOnly = true) + public List getTagsByType(TagType tagType) { + List tags = tasteTagRepository.findByTagTypeAndIsActiveTrue(tagType); + + return tags.stream() + .map(tag -> TasteTagResponse.builder() + .id(tag.getId()) + .tagName(tag.getTagName()) + .tagType(tag.getTagType()) + .description(tag.getDescription()) + .build()) + .collect(Collectors.toList()); + } +} diff --git a/member/src/main/java/com/ktds/hi/member/service/SmsService.java b/member/src/main/java/com/ktds/hi/member/service/SmsService.java new file mode 100644 index 0000000..9de1c53 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/service/SmsService.java @@ -0,0 +1,43 @@ +package com.ktds.hi.member.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** + * SMS 서비스 클래스 + * SMS 발송 기능을 담당 (실제 구현은 외부 SMS API 연동 필요) + */ +@Service +@Slf4j +public class SmsService { + + @Value("${sms.api-key:}") + private String apiKey; + + @Value("${sms.api-secret:}") + private String apiSecret; + + @Value("${sms.from-number:}") + private String fromNumber; + + /** + * SMS 인증번호 발송 + */ + public void sendVerificationCode(String phone, String code) { + String message = String.format("[하이오더] 인증번호는 %s입니다.", code); + + // TODO: 실제 SMS API 연동 구현 + log.info("SMS 발송: phone={}, message={}", phone, message); + } + + /** + * 임시 비밀번호 SMS 발송 + */ + public void sendTempPassword(String phone, String tempPassword) { + String message = String.format("[하이오더] 임시 비밀번호는 %s입니다. 로그인 후 비밀번호를 변경해주세요.", tempPassword); + + // TODO: 실제 SMS API 연동 구현 + log.info("임시 비밀번호 SMS 발송: phone={}, tempPassword={}", phone, tempPassword); + } +} diff --git a/member/src/main/resources/application.yml b/member/src/main/resources/application.yml new file mode 100644 index 0000000..ebc4e5d --- /dev/null +++ b/member/src/main/resources/application.yml @@ -0,0 +1,55 @@ +server: + port: ${MEMBER_SERVICE_PORT:8081} + +spring: + application: + name: member-service + + datasource: + url: ${MEMBER_DB_URL:jdbc:postgresql://localhost:5432/hiorder_member} + username: ${MEMBER_DB_USERNAME:hiorder_user} + password: ${MEMBER_DB_PASSWORD:hiorder_pass} + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: ${JPA_DDL_AUTO:validate} + show-sql: ${JPA_SHOW_SQL:false} + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.PostgreSQLDialect + + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + timeout: 2000ms + lettuce: + pool: + max-active: 8 + max-wait: -1ms + max-idle: 8 + min-idle: 0 + +jwt: + secret: ${JWT_SECRET:hiorder-secret-key-for-jwt-token-generation-must-be-long-enough} + access-token-expiration: ${JWT_ACCESS_EXPIRATION:3600000} # 1시간 + refresh-token-expiration: ${JWT_REFRESH_EXPIRATION:604800000} # 7일 + +sms: + api-key: ${SMS_API_KEY:} + api-secret: ${SMS_API_SECRET:} + from-number: ${SMS_FROM_NUMBER:} + +springdoc: + api-docs: + path: /api-docs + swagger-ui: + path: /swagger-ui.html + +management: + endpoints: + web: + exposure: + include: health,info,metrics diff --git a/recommend/build.gradle b/recommend/build.gradle new file mode 100644 index 0000000..1fc1174 --- /dev/null +++ b/recommend/build.gradle @@ -0,0 +1,6 @@ +dependencies { + implementation project(':common') + + // AI and Location Services + implementation 'org.springframework.boot:spring-boot-starter-webflux' +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/RecommendServiceApplication.java b/recommend/src/main/java/com/ktds/hi/recommend/RecommendServiceApplication.java new file mode 100644 index 0000000..e6dfce8 --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/RecommendServiceApplication.java @@ -0,0 +1,21 @@ +package com.ktds.hi.recommend; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * 추천 서비스 메인 애플리케이션 클래스 + * 가게 추천, 취향 분석 기능을 제공 + * + * @author 하이오더 개발팀 + * @version 1.0.0 + */ +@SpringBootApplication(scanBasePackages = {"com.ktds.hi.recommend", "com.ktds.hi.common"}) +@EnableJpaAuditing +public class RecommendServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(RecommendServiceApplication.class, args); + } +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/biz/domain/Location.java b/recommend/src/main/java/com/ktds/hi/recommend/biz/domain/Location.java new file mode 100644 index 0000000..5f9335b --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/biz/domain/Location.java @@ -0,0 +1,40 @@ +package com.ktds.hi.recommend.biz.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 위치 도메인 클래스 + * 위치 정보를 담는 도메인 객체 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Location { + + private Long id; + private String address; + private Double latitude; + private Double longitude; + private String city; + private String district; + private String country; + + /** + * 좌표 업데이트 + */ + public Location updateCoordinates(Double newLatitude, Double newLongitude) { + return Location.builder() + .id(this.id) + .address(this.address) + .latitude(newLatitude) + .longitude(newLongitude) + .city(this.city) + .district(this.district) + .country(this.country) + .build(); + } +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/biz/domain/RecommendHistory.java b/recommend/src/main/java/com/ktds/hi/recommend/biz/domain/RecommendHistory.java new file mode 100644 index 0000000..f021b17 --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/biz/domain/RecommendHistory.java @@ -0,0 +1,41 @@ +package com.ktds.hi.recommend.biz.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 추천 히스토리 도메인 클래스 + * 사용자의 추천 기록을 담는 도메인 객체 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RecommendHistory { + + private Long id; + private Long memberId; + private List recommendedStoreIds; + private RecommendType recommendType; + private String criteria; + private LocalDateTime createdAt; + + /** + * 추천 기준 업데이트 + */ + public RecommendHistory updateCriteria(String newCriteria) { + return RecommendHistory.builder() + .id(this.id) + .memberId(this.memberId) + .recommendedStoreIds(this.recommendedStoreIds) + .recommendType(this.recommendType) + .criteria(newCriteria) + .createdAt(this.createdAt) + .build(); + } +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/biz/domain/RecommendStore.java b/recommend/src/main/java/com/ktds/hi/recommend/biz/domain/RecommendStore.java new file mode 100644 index 0000000..5f67fd5 --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/biz/domain/RecommendStore.java @@ -0,0 +1,69 @@ +package com.ktds.hi.recommend.biz.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 추천 매장 도메인 클래스 + * 추천된 매장 정보를 담는 도메인 객체 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RecommendStore { + + private Long storeId; + private String storeName; + private String address; + private String category; + private List tags; + private Double rating; + private Integer reviewCount; + private Double distance; + private Double recommendScore; + private RecommendType recommendType; + private String recommendReason; + + /** + * 추천 점수 업데이트 + */ + public RecommendStore updateRecommendScore(Double newScore) { + return RecommendStore.builder() + .storeId(this.storeId) + .storeName(this.storeName) + .address(this.address) + .category(this.category) + .tags(this.tags) + .rating(this.rating) + .reviewCount(this.reviewCount) + .distance(this.distance) + .recommendScore(newScore) + .recommendType(this.recommendType) + .recommendReason(this.recommendReason) + .build(); + } + + /** + * 추천 이유 업데이트 + */ + public RecommendStore updateRecommendReason(String newReason) { + return RecommendStore.builder() + .storeId(this.storeId) + .storeName(this.storeName) + .address(this.address) + .category(this.category) + .tags(this.tags) + .rating(this.rating) + .reviewCount(this.reviewCount) + .distance(this.distance) + .recommendScore(this.recommendScore) + .recommendType(this.recommendType) + .recommendReason(newReason) + .build(); + } +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/biz/domain/RecommendType.java b/recommend/src/main/java/com/ktds/hi/recommend/biz/domain/RecommendType.java new file mode 100644 index 0000000..0ba2192 --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/biz/domain/RecommendType.java @@ -0,0 +1,24 @@ +package com.ktds.hi.recommend.biz.domain; + +/** + * 추천 유형 열거형 + * 추천의 종류를 정의 + */ +public enum RecommendType { + TASTE_BASED("취향 기반"), + LOCATION_BASED("위치 기반"), + POPULARITY_BASED("인기 기반"), + COLLABORATIVE_FILTERING("협업 필터링"), + AI_RECOMMENDATION("AI 추천"), + SIMILAR_USER("유사 사용자 기반"); + + private final String description; + + RecommendType(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/biz/domain/TasteCategory.java b/recommend/src/main/java/com/ktds/hi/recommend/biz/domain/TasteCategory.java new file mode 100644 index 0000000..d62487b --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/biz/domain/TasteCategory.java @@ -0,0 +1,30 @@ +package com.ktds.hi.recommend.biz.domain; + +/** + * 취향 카테고리 열거형 + * 음식 카테고리를 정의 + */ +public enum TasteCategory { + KOREAN("한식"), + CHINESE("중식"), + JAPANESE("일식"), + WESTERN("양식"), + FAST_FOOD("패스트푸드"), + CAFE("카페"), + DESSERT("디저트"), + CHICKEN("치킨"), + PIZZA("피자"), + ASIAN("아시안"), + VEGETARIAN("채식"), + SEAFOOD("해산물"); + + private final String description; + + TasteCategory(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/biz/domain/TasteProfile.java b/recommend/src/main/java/com/ktds/hi/recommend/biz/domain/TasteProfile.java new file mode 100644 index 0000000..7b3f82c --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/biz/domain/TasteProfile.java @@ -0,0 +1,52 @@ +package com.ktds.hi.recommend.biz.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * 취향 프로필 도메인 클래스 + * 사용자의 취향 분석 결과를 담는 도메인 객체 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TasteProfile { + + private Long id; + private Long memberId; + private List preferredCategories; + private Map categoryScores; + private List preferredTags; + private Map behaviorPatterns; + private Double pricePreference; + private Double distancePreference; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + /** + * 취향 프로필 업데이트 + */ + public TasteProfile updateProfile(List categories, Map scores, + List tags, Map patterns, + Double pricePreference, Double distancePreference) { + return TasteProfile.builder() + .id(this.id) + .memberId(this.memberId) + .preferredCategories(categories) + .categoryScores(scores) + .preferredTags(tags) + .behaviorPatterns(patterns) + .pricePreference(pricePreference) + .distancePreference(distancePreference) + .createdAt(this.createdAt) + .updatedAt(LocalDateTime.now()) + .build(); + } +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/biz/service/StoreRecommendInteractor.java b/recommend/src/main/java/com/ktds/hi/recommend/biz/service/StoreRecommendInteractor.java new file mode 100644 index 0000000..5935d4b --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/biz/service/StoreRecommendInteractor.java @@ -0,0 +1,159 @@ +package com.ktds.hi.recommend.biz.service; + +import com.ktds.hi.recommend.biz.usecase.in.StoreRecommendUseCase; +import com.ktds.hi.recommend.biz.usecase.out.*; +import com.ktds.hi.recommend.biz.domain.*; +import com.ktds.hi.recommend.infra.dto.request.RecommendStoreRequest; +import com.ktds.hi.recommend.infra.dto.response.RecommendStoreResponse; +import com.ktds.hi.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 매장 추천 인터랙터 클래스 + * 사용자 취향 기반 매장 추천 기능을 구현 + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class StoreRecommendInteractor implements StoreRecommendUseCase { + + private final RecommendRepository recommendRepository; + private final AiRecommendRepository aiRecommendRepository; + private final LocationRepository locationRepository; + private final UserPreferenceRepository userPreferenceRepository; + + @Override + public List recommendStores(Long memberId, RecommendStoreRequest request) { + // 사용자 취향 프로필 조회 + TasteProfile tasteProfile = userPreferenceRepository.getMemberPreferences(memberId) + .orElseThrow(() -> new BusinessException("사용자 취향 정보를 찾을 수 없습니다. 취향 등록을 먼저 해주세요.")); + + // AI 기반 추천 + Map preferences = Map.of( + "categories", tasteProfile.getPreferredCategories(), + "tags", tasteProfile.getPreferredTags(), + "pricePreference", tasteProfile.getPricePreference(), + "distancePreference", tasteProfile.getDistancePreference(), + "latitude", request.getLatitude(), + "longitude", request.getLongitude() + ); + + List aiRecommendStores = aiRecommendRepository.recommendStoresByAI(memberId, preferences); + + // 위치 기반 추천 결합 + List locationStores = locationRepository.findStoresWithinRadius( + request.getLatitude(), request.getLongitude(), request.getRadius()); + + // 추천 결과 통합 및 점수 계산 + List combinedStores = combineRecommendations(aiRecommendStores, locationStores, tasteProfile); + + // 추천 히스토리 저장 + RecommendHistory history = RecommendHistory.builder() + .memberId(memberId) + .recommendedStoreIds(combinedStores.stream().map(RecommendStore::getStoreId).collect(Collectors.toList())) + .recommendType(RecommendType.TASTE_BASED) + .criteria("취향 + AI + 위치 기반 통합 추천") + .createdAt(LocalDateTime.now()) + .build(); + + recommendRepository.saveRecommendHistory(history); + + log.info("매장 추천 완료: memberId={}, 추천 매장 수={}", memberId, combinedStores.size()); + + return combinedStores.stream() + .map(this::toRecommendStoreResponse) + .collect(Collectors.toList()); + } + + @Override + @Transactional(readOnly = true) + public List recommendStoresByLocation(Double latitude, Double longitude, Integer radius) { + List stores = locationRepository.findStoresWithinRadius(latitude, longitude, radius); + + return stores.stream() + .map(store -> store.updateRecommendReason("위치 기반 추천")) + .map(this::toRecommendStoreResponse) + .collect(Collectors.toList()); + } + + @Override + @Transactional(readOnly = true) + public List recommendPopularStores(String category, Integer limit) { + // Mock 구현 - 실제로는 인기도 기반 쿼리 필요 + List popularStores = List.of( + RecommendStore.builder() + .storeId(1L) + .storeName("인기 매장 1") + .address("서울시 강남구") + .category(category) + .rating(4.5) + .reviewCount(100) + .recommendScore(95.0) + .recommendType(RecommendType.POPULARITY_BASED) + .recommendReason("높은 평점과 많은 리뷰") + .build() + ); + + return popularStores.stream() + .limit(limit != null ? limit : 10) + .map(this::toRecommendStoreResponse) + .collect(Collectors.toList()); + } + + /** + * 추천 결과 통합 및 점수 계산 + */ + private List combineRecommendations(List aiStores, + List locationStores, + TasteProfile profile) { + // AI 추천과 위치 기반 추천을 통합하여 최종 점수 계산 + // 실제로는 더 복잡한 로직이 필요 + + return aiStores.stream() + .map(store -> store.updateRecommendScore( + calculateFinalScore(store, profile) + )) + .sorted((s1, s2) -> Double.compare(s2.getRecommendScore(), s1.getRecommendScore())) + .limit(20) + .collect(Collectors.toList()); + } + + /** + * 최종 추천 점수 계산 + */ + private Double calculateFinalScore(RecommendStore store, TasteProfile profile) { + double baseScore = store.getRecommendScore() != null ? store.getRecommendScore() : 0.0; + double ratingScore = store.getRating() != null ? store.getRating() * 10 : 0.0; + double reviewScore = store.getReviewCount() != null ? Math.min(store.getReviewCount() * 0.1, 10) : 0.0; + double distanceScore = store.getDistance() != null ? Math.max(0, 10 - store.getDistance() / 1000) : 0.0; + + return (baseScore * 0.4) + (ratingScore * 0.3) + (reviewScore * 0.2) + (distanceScore * 0.1); + } + + /** + * 도메인을 응답 DTO로 변환 + */ + private RecommendStoreResponse toRecommendStoreResponse(RecommendStore store) { + return RecommendStoreResponse.builder() + .storeId(store.getStoreId()) + .storeName(store.getStoreName()) + .address(store.getAddress()) + .category(store.getCategory()) + .tags(store.getTags()) + .rating(store.getRating()) + .reviewCount(store.getReviewCount()) + .distance(store.getDistance()) + .recommendScore(store.getRecommendScore()) + .recommendReason(store.getRecommendReason()) + .build(); + } +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/biz/service/TasteAnalysisInteractor.java b/recommend/src/main/java/com/ktds/hi/recommend/biz/service/TasteAnalysisInteractor.java new file mode 100644 index 0000000..24e094b --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/biz/service/TasteAnalysisInteractor.java @@ -0,0 +1,79 @@ +package com.ktds.hi.recommend.biz.service; + +import com.ktds.hi.recommend.biz.usecase.in.TasteAnalysisUseCase; +import com.ktds.hi.recommend.biz.usecase.out.UserPreferenceRepository; +import com.ktds.hi.recommend.biz.domain.TasteProfile; +import com.ktds.hi.recommend.biz.domain.TasteCategory; +import com.ktds.hi.recommend.infra.dto.response.TasteAnalysisResponse; +import com.ktds.hi.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 취향 분석 인터랙터 클래스 + * 사용자 취향 분석 기능을 구현 + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class TasteAnalysisInteractor implements TasteAnalysisUseCase { + + private final UserPreferenceRepository userPreferenceRepository; + + @Override + @Transactional(readOnly = true) + public TasteAnalysisResponse analyzeMemberTaste(Long memberId) { + TasteProfile profile = userPreferenceRepository.getMemberPreferences(memberId) + .orElseThrow(() -> new BusinessException("사용자 취향 정보를 찾을 수 없습니다")); + + // 취향 분석 결과 생성 + List preferredCategories = profile.getPreferredCategories() + .stream() + .map(TasteCategory::getDescription) + .collect(Collectors.toList()); + + Map categoryScores = profile.getCategoryScores(); + String topCategory = categoryScores.entrySet() + .stream() + .max(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey) + .orElse("정보 없음"); + + return TasteAnalysisResponse.builder() + .memberId(memberId) + .preferredCategories(preferredCategories) + .topCategory(topCategory) + .categoryScores(categoryScores) + .preferredTags(profile.getPreferredTags()) + .pricePreference(profile.getPricePreference()) + .distancePreference(profile.getDistancePreference()) + .analysisDate(profile.getUpdatedAt()) + .build(); + } + + @Override + public void updateTasteProfile(Long memberId) { + log.info("취향 프로필 업데이트 시작: memberId={}", memberId); + + try { + // 리뷰 기반 취향 분석 + Map analysisData = userPreferenceRepository.analyzePreferencesFromReviews(memberId); + + // 취향 프로필 업데이트 + TasteProfile updatedProfile = userPreferenceRepository.updateTasteProfile(memberId, analysisData); + + log.info("취향 프로필 업데이트 완료: memberId={}, profileId={}", memberId, updatedProfile.getId()); + + } catch (Exception e) { + log.error("취향 프로필 업데이트 실패: memberId={}, error={}", memberId, e.getMessage(), e); + throw new BusinessException("취향 프로필 업데이트 중 오류가 발생했습니다: " + e.getMessage()); + } + } +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/biz/usecase/in/StoreRecommendUseCase.java b/recommend/src/main/java/com/ktds/hi/recommend/biz/usecase/in/StoreRecommendUseCase.java new file mode 100644 index 0000000..5450fb5 --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/biz/usecase/in/StoreRecommendUseCase.java @@ -0,0 +1,28 @@ +package com.ktds.hi.recommend.biz.usecase.in; + +import com.ktds.hi.recommend.infra.dto.request.RecommendStoreRequest; +import com.ktds.hi.recommend.infra.dto.response.RecommendStoreResponse; + +import java.util.List; + +/** + * 매장 추천 유스케이스 인터페이스 + * 사용자 취향 기반 매장 추천 기능을 정의 + */ +public interface StoreRecommendUseCase { + + /** + * 사용자 취향 기반 매장 추천 + */ + List recommendStores(Long memberId, RecommendStoreRequest request); + + /** + * 위치 기반 매장 추천 + */ + List recommendStoresByLocation(Double latitude, Double longitude, Integer radius); + + /** + * 인기 매장 추천 + */ + List recommendPopularStores(String category, Integer limit); +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/biz/usecase/in/TasteAnalysisUseCase.java b/recommend/src/main/java/com/ktds/hi/recommend/biz/usecase/in/TasteAnalysisUseCase.java new file mode 100644 index 0000000..ce5f58c --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/biz/usecase/in/TasteAnalysisUseCase.java @@ -0,0 +1,20 @@ +package com.ktds.hi.recommend.biz.usecase.in; + +import com.ktds.hi.recommend.infra.dto.response.TasteAnalysisResponse; + +/** + * 취향 분석 유스케이스 인터페이스 + * 사용자 취향 분석 기능을 정의 + */ +public interface TasteAnalysisUseCase { + + /** + * 사용자 취향 분석 + */ + TasteAnalysisResponse analyzeMemberTaste(Long memberId); + + /** + * 취향 프로필 업데이트 + */ + void updateTasteProfile(Long memberId); +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/biz/usecase/out/AiRecommendRepository.java b/recommend/src/main/java/com/ktds/hi/recommend/biz/usecase/out/AiRecommendRepository.java new file mode 100644 index 0000000..7d80dd7 --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/biz/usecase/out/AiRecommendRepository.java @@ -0,0 +1,28 @@ +package com.ktds.hi.recommend.biz.usecase.out; + +import com.ktds.hi.recommend.biz.domain.RecommendStore; + +import java.util.List; +import java.util.Map; + +/** + * AI 추천 리포지토리 인터페이스 + * AI 기반 추천 기능을 정의 + */ +public interface AiRecommendRepository { + + /** + * AI 기반 매장 추천 + */ + List recommendStoresByAI(Long memberId, Map preferences); + + /** + * 유사 사용자 기반 추천 + */ + List recommendStoresBySimilarUsers(Long memberId); + + /** + * 협업 필터링 추천 + */ + List recommendStoresByCollaborativeFiltering(Long memberId); +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/biz/usecase/out/LocationRepository.java b/recommend/src/main/java/com/ktds/hi/recommend/biz/usecase/out/LocationRepository.java new file mode 100644 index 0000000..03a2510 --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/biz/usecase/out/LocationRepository.java @@ -0,0 +1,33 @@ +package com.ktds.hi.recommend.biz.usecase.out; + +import com.ktds.hi.recommend.biz.domain.Location; +import com.ktds.hi.recommend.biz.domain.RecommendStore; + +import java.util.List; + +/** + * 위치 기반 서비스 리포지토리 인터페이스 + * 위치 정보 처리 기능을 정의 + */ +public interface LocationRepository { + + /** + * 위치 정보 저장 + */ + Location saveLocation(Location location); + + /** + * 반경 내 매장 조회 + */ + List findStoresWithinRadius(Double latitude, Double longitude, Integer radius); + + /** + * 거리 계산 + */ + Double calculateDistance(Double lat1, Double lon1, Double lat2, Double lon2); + + /** + * 주소를 좌표로 변환 + */ + Location geocodeAddress(String address); +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/biz/usecase/out/RecommendRepository.java b/recommend/src/main/java/com/ktds/hi/recommend/biz/usecase/out/RecommendRepository.java new file mode 100644 index 0000000..f5b7e46 --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/biz/usecase/out/RecommendRepository.java @@ -0,0 +1,34 @@ +package com.ktds.hi.recommend.biz.usecase.out; + +import com.ktds.hi.recommend.biz.domain.RecommendHistory; +import com.ktds.hi.recommend.biz.domain.TasteProfile; + +import java.util.List; +import java.util.Optional; + +/** + * 추천 리포지토리 인터페이스 + * 추천 관련 데이터 영속성 기능을 정의 + */ +public interface RecommendRepository { + + /** + * 추천 히스토리 저장 + */ + RecommendHistory saveRecommendHistory(RecommendHistory history); + + /** + * 회원 ID로 추천 히스토리 조회 + */ + List findRecommendHistoriesByMemberId(Long memberId); + + /** + * 취향 프로필 저장 + */ + TasteProfile saveTasteProfile(TasteProfile profile); + + /** + * 회원 ID로 취향 프로필 조회 + */ + Optional findTasteProfileByMemberId(Long memberId); +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/biz/usecase/out/UserPreferenceRepository.java b/recommend/src/main/java/com/ktds/hi/recommend/biz/usecase/out/UserPreferenceRepository.java new file mode 100644 index 0000000..8bc8948 --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/biz/usecase/out/UserPreferenceRepository.java @@ -0,0 +1,34 @@ +package com.ktds.hi.recommend.biz.usecase.out; + +import com.ktds.hi.recommend.biz.domain.TasteProfile; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 사용자 선호도 리포지토리 인터페이스 + * 사용자 취향 데이터 처리 기능을 정의 + */ +public interface UserPreferenceRepository { + + /** + * 회원 취향 정보 조회 + */ + Optional getMemberPreferences(Long memberId); + + /** + * 회원의 리뷰 기반 취향 분석 + */ + Map analyzePreferencesFromReviews(Long memberId); + + /** + * 유사한 취향의 사용자 조회 + */ + List findSimilarTasteMembers(Long memberId); + + /** + * 취향 프로필 업데이트 + */ + TasteProfile updateTasteProfile(Long memberId, Map analysisData); +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/infra/config/RecommendConfig.java b/recommend/src/main/java/com/ktds/hi/recommend/infra/config/RecommendConfig.java new file mode 100644 index 0000000..1506075 --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/infra/config/RecommendConfig.java @@ -0,0 +1,12 @@ +package com.ktds.hi.recommend.infra.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * 추천 서비스 설정 클래스 + */ +@Configuration +@EnableJpaRepositories(basePackages = "com.ktds.hi.recommend.infra.gateway.repository") +public class RecommendConfig { +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/infra/config/SwaggerConfig.java b/recommend/src/main/java/com/ktds/hi/recommend/infra/config/SwaggerConfig.java new file mode 100644 index 0000000..9ee017c --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/infra/config/SwaggerConfig.java @@ -0,0 +1,25 @@ +package com.ktds.hi.recommend.infra.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Swagger 설정 클래스 + * API 문서화를 위한 OpenAPI 설정 + */ +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .addServersItem(new Server().url("/")) + .info(new Info() + .title("하이오더 추천 서비스 API") + .description("사용자 취향 기반 매장 추천 및 취향 분석 관련 기능을 제공하는 API") + .version("1.0.0")); + } +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/infra/controller/StoreRecommendController.java b/recommend/src/main/java/com/ktds/hi/recommend/infra/controller/StoreRecommendController.java new file mode 100644 index 0000000..5d51609 --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/infra/controller/StoreRecommendController.java @@ -0,0 +1,68 @@ +package com.ktds.hi.recommend.infra.controller; + +import com.ktds.hi.recommend.biz.usecase.in.StoreRecommendUseCase; +import com.ktds.hi.recommend.infra.dto.request.RecommendStoreRequest; +import com.ktds.hi.recommend.infra.dto.response.RecommendStoreResponse; +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.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 매장 추천 컨트롤러 클래스 + * 매장 추천 관련 API를 제공 + */ +@RestController +@RequestMapping("/api/recommend") +@RequiredArgsConstructor +@Tag(name = "매장 추천 API", description = "사용자 취향 기반 매장 추천 관련 API") +public class StoreRecommendController { + + private final StoreRecommendUseCase storeRecommendUseCase; + + /** + * 사용자 취향 기반 매장 추천 API + */ + @PostMapping("/stores") + @Operation(summary = "매장 추천", description = "사용자 취향과 위치를 기반으로 매장을 추천합니다.") + public ResponseEntity> recommendStores(Authentication authentication, + @Valid @RequestBody RecommendStoreRequest request) { + Long memberId = Long.valueOf(authentication.getName()); + List recommendations = storeRecommendUseCase.recommendStores(memberId, request); + return ResponseEntity.ok(recommendations); + } + + /** + * 위치 기반 매장 추천 API + */ + @GetMapping("/stores/nearby") + @Operation(summary = "주변 매장 추천", description = "현재 위치 기반으로 주변 매장을 추천합니다.") + public ResponseEntity> recommendNearbyStores( + @RequestParam Double latitude, + @RequestParam Double longitude, + @RequestParam(defaultValue = "5000") Integer radius) { + + List recommendations = storeRecommendUseCase + .recommendStoresByLocation(latitude, longitude, radius); + return ResponseEntity.ok(recommendations); + } + + /** + * 인기 매장 추천 API + */ + @GetMapping("/stores/popular") + @Operation(summary = "인기 매장 추천", description = "카테고리별 인기 매장을 추천합니다.") + public ResponseEntity> recommendPopularStores( + @RequestParam(required = false) String category, + @RequestParam(defaultValue = "10") Integer limit) { + + List recommendations = storeRecommendUseCase + .recommendPopularStores(category, limit); + return ResponseEntity.ok(recommendations); + } +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/infra/controller/TasteAnalysisController.java b/recommend/src/main/java/com/ktds/hi/recommend/infra/controller/TasteAnalysisController.java new file mode 100644 index 0000000..6c8a388 --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/infra/controller/TasteAnalysisController.java @@ -0,0 +1,46 @@ +package com.ktds.hi.recommend.infra.controller; + +import com.ktds.hi.recommend.biz.usecase.in.TasteAnalysisUseCase; +import com.ktds.hi.recommend.infra.dto.response.TasteAnalysisResponse; +import com.ktds.hi.common.dto.SuccessResponse; +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.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +/** + * 취향 분석 컨트롤러 클래스 + * 사용자 취향 분석 관련 API를 제공 + */ +@RestController +@RequestMapping("/api/recommend/taste") +@RequiredArgsConstructor +@Tag(name = "취향 분석 API", description = "사용자 취향 분석 관련 API") +public class TasteAnalysisController { + + private final TasteAnalysisUseCase tasteAnalysisUseCase; + + /** + * 사용자 취향 분석 조회 API + */ + @GetMapping("/analysis") + @Operation(summary = "취향 분석 조회", description = "현재 로그인한 사용자의 취향 분석 결과를 조회합니다.") + public ResponseEntity getMemberTasteAnalysis(Authentication authentication) { + Long memberId = Long.valueOf(authentication.getName()); + TasteAnalysisResponse analysis = tasteAnalysisUseCase.analyzeMemberTaste(memberId); + return ResponseEntity.ok(analysis); + } + + /** + * 취향 프로필 업데이트 API + */ + @PostMapping("/update") + @Operation(summary = "취향 프로필 업데이트", description = "사용자의 리뷰 데이터를 기반으로 취향 프로필을 업데이트합니다.") + public ResponseEntity updateTasteProfile(Authentication authentication) { + Long memberId = Long.valueOf(authentication.getName()); + tasteAnalysisUseCase.updateTasteProfile(memberId); + return ResponseEntity.ok(SuccessResponse.of("취향 프로필이 업데이트되었습니다")); + } +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/infra/dto/request/RecommendStoreRequest.java b/recommend/src/main/java/com/ktds/hi/recommend/infra/dto/request/RecommendStoreRequest.java new file mode 100644 index 0000000..281e88c --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/infra/dto/request/RecommendStoreRequest.java @@ -0,0 +1,57 @@ +package com.ktds.hi.recommend.infra.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 매장 추천 요청 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "매장 추천 요청") +public class RecommendStoreRequest { + + @NotNull(message = "위도는 필수입니다") + @Schema(description = "위도", example = "37.5665") + private Double latitude; + + @NotNull(message = "경도는 필수입니다") + @Schema(description = "경도", example = "126.9780") + private Double longitude; + + @Schema(description = "검색 반경(미터)", example = "5000", defaultValue = "5000") + private Integer radius = 5000; + + @Schema(description = "선호 카테고리", example = "[\"한식\", \"일식\"]") + private List preferredCategories; + + @Schema(description = "가격 범위", example = "MEDIUM") + private String priceRange; + + @Schema(description = "추천 개수", example = "10", defaultValue = "10") + private Integer limit = 10; + + /** + * 유효성 검증 + */ + public void validate() { + if (latitude == null || longitude == null) { + throw new IllegalArgumentException("위도와 경도는 필수입니다"); + } + if (latitude < -90 || latitude > 90) { + throw new IllegalArgumentException("위도는 -90과 90 사이여야 합니다"); + } + if (longitude < -180 || longitude > 180) { + throw new IllegalArgumentException("경도는 -180과 180 사이여야 합니다"); + } + if (radius != null && radius <= 0) { + throw new IllegalArgumentException("검색 반경은 0보다 커야 합니다"); + } + } +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/infra/dto/response/RecommendStoreResponse.java b/recommend/src/main/java/com/ktds/hi/recommend/infra/dto/response/RecommendStoreResponse.java new file mode 100644 index 0000000..bbd78b5 --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/infra/dto/response/RecommendStoreResponse.java @@ -0,0 +1,50 @@ +package com.ktds.hi.recommend.infra.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 매장 추천 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "매장 추천 응답") +public class RecommendStoreResponse { + + @Schema(description = "매장 ID") + private Long storeId; + + @Schema(description = "매장명") + private String storeName; + + @Schema(description = "주소") + private String address; + + @Schema(description = "카테고리") + private String category; + + @Schema(description = "태그 목록") + private List tags; + + @Schema(description = "평점") + private Double rating; + + @Schema(description = "리뷰 수") + private Integer reviewCount; + + @Schema(description = "거리(미터)") + private Double distance; + + @Schema(description = "추천 점수") + private Double recommendScore; + + @Schema(description = "추천 이유") + private String recommendReason; +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/infra/dto/response/TasteAnalysisResponse.java b/recommend/src/main/java/com/ktds/hi/recommend/infra/dto/response/TasteAnalysisResponse.java new file mode 100644 index 0000000..5c57c57 --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/infra/dto/response/TasteAnalysisResponse.java @@ -0,0 +1,46 @@ +package com.ktds.hi.recommend.infra.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * 취향 분석 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "취향 분석 응답") +public class TasteAnalysisResponse { + + @Schema(description = "회원 ID") + private Long memberId; + + @Schema(description = "선호 카테고리") + private List preferredCategories; + + @Schema(description = "최고 선호 카테고리") + private String topCategory; + + @Schema(description = "카테고리별 점수") + private Map categoryScores; + + @Schema(description = "선호 태그") + private List preferredTags; + + @Schema(description = "가격 선호도") + private Double pricePreference; + + @Schema(description = "거리 선호도") + private Double distancePreference; + + @Schema(description = "분석 일시") + private LocalDateTime analysisDate; +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/AiRecommendAdapter.java b/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/AiRecommendAdapter.java new file mode 100644 index 0000000..f0a1fdc --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/AiRecommendAdapter.java @@ -0,0 +1,101 @@ +package com.ktds.hi.recommend.infra.gateway; + +import com.ktds.hi.recommend.biz.usecase.out.AiRecommendRepository; +import com.ktds.hi.recommend.biz.domain.RecommendStore; +import com.ktds.hi.recommend.biz.domain.RecommendType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * AI 추천 어댑터 클래스 + * AI 기반 추천 기능을 구현 (현재는 Mock 구현) + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class AiRecommendAdapter implements AiRecommendRepository { + + @Override + public List recommendStoresByAI(Long memberId, Map preferences) { + log.info("AI 기반 매장 추천 요청: memberId={}, preferences={}", memberId, preferences); + + // Mock 구현 - 실제로는 AI 모델 API 호출 + return List.of( + RecommendStore.builder() + .storeId(1L) + .storeName("AI 추천 매장 1") + .address("서울시 강남구 역삼동") + .category("한식") + .tags(List.of("맛집", "깔끔", "한식")) + .rating(4.5) + .reviewCount(150) + .distance(500.0) + .recommendScore(92.0) + .recommendType(RecommendType.AI_RECOMMENDATION) + .recommendReason("사용자 취향과 92% 일치") + .build(), + + RecommendStore.builder() + .storeId(2L) + .storeName("AI 추천 매장 2") + .address("서울시 강남구 논현동") + .category("일식") + .tags(List.of("초밥", "신선", "일식")) + .rating(4.3) + .reviewCount(89) + .distance(800.0) + .recommendScore(87.0) + .recommendType(RecommendType.AI_RECOMMENDATION) + .recommendReason("사용자가 선호하는 일식 카테고리") + .build() + ); + } + + @Override + public List recommendStoresBySimilarUsers(Long memberId) { + log.info("유사 사용자 기반 추천 요청: memberId={}", memberId); + + // Mock 구현 + return List.of( + RecommendStore.builder() + .storeId(3L) + .storeName("유사 취향 추천 매장") + .address("서울시 서초구 서초동") + .category("양식") + .tags(List.of("파스타", "분위기", "양식")) + .rating(4.4) + .reviewCount(203) + .distance(1200.0) + .recommendScore(85.0) + .recommendType(RecommendType.SIMILAR_USER) + .recommendReason("비슷한 취향의 사용자들이 좋아하는 매장") + .build() + ); + } + + @Override + public List recommendStoresByCollaborativeFiltering(Long memberId) { + log.info("협업 필터링 추천 요청: memberId={}", memberId); + + // Mock 구현 + return List.of( + RecommendStore.builder() + .storeId(4L) + .storeName("협업 필터링 추천 매장") + .address("서울시 마포구 홍대입구") + .category("카페") + .tags(List.of("커피", "디저트", "분위기")) + .rating(4.2) + .reviewCount(127) + .distance(2500.0) + .recommendScore(82.0) + .recommendType(RecommendType.COLLABORATIVE_FILTERING) + .recommendReason("사용자 행동 패턴 기반 추천") + .build() + ); + } +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/LocationServiceAdapter.java b/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/LocationServiceAdapter.java new file mode 100644 index 0000000..d3e430a --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/LocationServiceAdapter.java @@ -0,0 +1,106 @@ +package com.ktds.hi.recommend.infra.gateway; + +import com.ktds.hi.recommend.biz.usecase.out.LocationRepository; +import com.ktds.hi.recommend.biz.domain.Location; +import com.ktds.hi.recommend.biz.domain.RecommendStore; +import com.ktds.hi.recommend.biz.domain.RecommendType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 위치 서비스 어댑터 클래스 + * 위치 기반 서비스 기능을 구현 (현재는 Mock 구현) + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class LocationServiceAdapter implements LocationRepository { + + @Override + public Location saveLocation(Location location) { + log.info("위치 정보 저장: {}", location.getAddress()); + + // Mock 구현 + return Location.builder() + .id(1L) + .address(location.getAddress()) + .latitude(location.getLatitude()) + .longitude(location.getLongitude()) + .city("서울시") + .district("강남구") + .country("대한민국") + .build(); + } + + @Override + public List findStoresWithinRadius(Double latitude, Double longitude, Integer radius) { + log.info("반경 내 매장 조회: lat={}, lon={}, radius={}", latitude, longitude, radius); + + // Mock 구현 + return List.of( + RecommendStore.builder() + .storeId(5L) + .storeName("근처 매장 1") + .address("서울시 강남구 역삼동 123-45") + .category("한식") + .tags(List.of("근처", "맛집")) + .rating(4.1) + .reviewCount(95) + .distance(300.0) + .recommendScore(78.0) + .recommendType(RecommendType.LOCATION_BASED) + .recommendReason("현재 위치에서 300m 거리") + .build(), + + RecommendStore.builder() + .storeId(6L) + .storeName("근처 매장 2") + .address("서울시 강남구 역삼동 678-90") + .category("카페") + .tags(List.of("커피", "디저트")) + .rating(4.0) + .reviewCount(67) + .distance(450.0) + .recommendScore(75.0) + .recommendType(RecommendType.LOCATION_BASED) + .recommendReason("현재 위치에서 450m 거리") + .build() + ); + } + + @Override + public Double calculateDistance(Double lat1, Double lon1, Double lat2, Double lon2) { + // Haversine 공식을 사용한 거리 계산 + final int R = 6371; // 지구의 반지름 (km) + + double latDistance = Math.toRadians(lat2 - lat1); + double lonDistance = Math.toRadians(lon2 - lon1); + + double a = Math.sin(latDistance / 2) * Math.sin(latDistance / 2) + + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) + * Math.sin(lonDistance / 2) * Math.sin(lonDistance / 2); + + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + double distance = R * c * 1000; // 미터로 변환 + + return distance; + } + + @Override + public Location geocodeAddress(String address) { + log.info("주소 좌표 변환 요청: {}", address); + + // Mock 구현 - 실제로는 Google Maps API 등 사용 + return Location.builder() + .address(address) + .latitude(37.5665) + .longitude(126.9780) + .city("서울시") + .district("중구") + .country("대한민국") + .build(); + } +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/RecommendRepositoryAdapter.java b/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/RecommendRepositoryAdapter.java new file mode 100644 index 0000000..e5288a6 --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/RecommendRepositoryAdapter.java @@ -0,0 +1,110 @@ +package com.ktds.hi.recommend.infra.gateway; + +import com.ktds.hi.recommend.biz.usecase.out.RecommendRepository; +import com.ktds.hi.recommend.biz.domain.RecommendHistory; +import com.ktds.hi.recommend.biz.domain.TasteProfile; +import com.ktds.hi.recommend.infra.gateway.repository.RecommendHistoryJpaRepository; +import com.ktds.hi.recommend.infra.gateway.repository.TasteProfileJpaRepository; +import com.ktds.hi.recommend.infra.gateway.entity.RecommendHistoryEntity; +import com.ktds.hi.recommend.infra.gateway.entity.TasteProfileEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 추천 리포지토리 어댑터 클래스 + * 도메인 리포지토리 인터페이스를 JPA 리포지토리에 연결 + */ +@Component +@RequiredArgsConstructor +public class RecommendRepositoryAdapter implements RecommendRepository { + + private final RecommendHistoryJpaRepository recommendHistoryJpaRepository; + private final TasteProfileJpaRepository tasteProfileJpaRepository; + + @Override + public RecommendHistory saveRecommendHistory(RecommendHistory history) { + RecommendHistoryEntity entity = toRecommendHistoryEntity(history); + RecommendHistoryEntity savedEntity = recommendHistoryJpaRepository.save(entity); + return toRecommendHistory(savedEntity); + } + + @Override + public List findRecommendHistoriesByMemberId(Long memberId) { + List entities = recommendHistoryJpaRepository.findByMemberIdOrderByCreatedAtDesc(memberId); + return entities.stream() + .map(this::toRecommendHistory) + .collect(Collectors.toList()); + } + + @Override + public TasteProfile saveTasteProfile(TasteProfile profile) { + TasteProfileEntity entity = toTasteProfileEntity(profile); + TasteProfileEntity savedEntity = tasteProfileJpaRepository.save(entity); + return toTasteProfile(savedEntity); + } + + @Override + public Optional findTasteProfileByMemberId(Long memberId) { + return tasteProfileJpaRepository.findByMemberId(memberId) + .map(this::toTasteProfile); + } + + /** + * 엔티티를 도메인으로 변환 + */ + private RecommendHistory toRecommendHistory(RecommendHistoryEntity entity) { + return RecommendHistory.builder() + .id(entity.getId()) + .memberId(entity.getMemberId()) + .recommendedStoreIds(entity.getRecommendedStoreIdsList()) + .recommendType(entity.getRecommendType()) + .criteria(entity.getCriteria()) + .createdAt(entity.getCreatedAt()) + .build(); + } + + private TasteProfile toTasteProfile(TasteProfileEntity entity) { + return TasteProfile.builder() + .id(entity.getId()) + .memberId(entity.getMemberId()) + .preferredCategories(entity.getPreferredCategoriesList()) + .categoryScores(entity.getCategoryScoresMap()) + .preferredTags(entity.getPreferredTagsList()) + .behaviorPatterns(entity.getBehaviorPatternsMap()) + .pricePreference(entity.getPricePreference()) + .distancePreference(entity.getDistancePreference()) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .build(); + } + + /** + * 도메인을 엔티티로 변환 + */ + private RecommendHistoryEntity toRecommendHistoryEntity(RecommendHistory domain) { + return RecommendHistoryEntity.builder() + .id(domain.getId()) + .memberId(domain.getMemberId()) + .recommendedStoreIdsJson(domain.getRecommendedStoreIds().toString()) // JSON 변환 필요 + .recommendType(domain.getRecommendType()) + .criteria(domain.getCriteria()) + .build(); + } + + private TasteProfileEntity toTasteProfileEntity(TasteProfile domain) { + return TasteProfileEntity.builder() + .id(domain.getId()) + .memberId(domain.getMemberId()) + .preferredCategoriesJson(domain.getPreferredCategories().toString()) // JSON 변환 필요 + .categoryScoresJson(domain.getCategoryScores().toString()) // JSON 변환 필요 + .preferredTagsJson(domain.getPreferredTags().toString()) // JSON 변환 필요 + .behaviorPatternsJson(domain.getBehaviorPatterns().toString()) // JSON 변환 필요 + .pricePreference(domain.getPricePreference()) + .distancePreference(domain.getDistancePreference()) + .build(); + } +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/UserPreferenceAdapter.java b/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/UserPreferenceAdapter.java new file mode 100644 index 0000000..a6ab80b --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/UserPreferenceAdapter.java @@ -0,0 +1,91 @@ +package com.ktds.hi.recommend.infra.gateway; + +import com.ktds.hi.recommend.biz.usecase.out.UserPreferenceRepository; +import com.ktds.hi.recommend.biz.domain.TasteProfile; +import com.ktds.hi.recommend.biz.domain.TasteCategory; +import com.ktds.hi.recommend.infra.gateway.repository.TasteProfileJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 사용자 선호도 어댑터 클래스 + * 사용자 취향 데이터 처리 기능을 구현 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class UserPreferenceAdapter implements UserPreferenceRepository { + + private final TasteProfileJpaRepository tasteProfileJpaRepository; + private final RecommendRepositoryAdapter recommendRepositoryAdapter; + + @Override + public Optional getMemberPreferences(Long memberId) { + return recommendRepositoryAdapter.findTasteProfileByMemberId(memberId); + } + + @Override + public Map analyzePreferencesFromReviews(Long memberId) { + log.info("리뷰 기반 취향 분석 시작: memberId={}", memberId); + + // Mock 구현 - 실제로는 리뷰 서비스 API 호출하여 분석 + return Map.of( + "preferredCategories", List.of(TasteCategory.KOREAN, TasteCategory.JAPANESE), + "categoryScores", Map.of( + "한식", 85.0, + "일식", 78.0, + "양식", 65.0 + ), + "preferredTags", List.of("맛집", "깔끔", "친절"), + "pricePreference", 60.0, // 0-100 점수 + "distancePreference", 70.0, + "behaviorPatterns", Map.of( + "weekendDining", true, + "avgRating", 4.2, + "reviewFrequency", "medium" + ) + ); + } + + @Override + public List findSimilarTasteMembers(Long memberId) { + log.info("유사 취향 사용자 조회: memberId={}", memberId); + + // Mock 구현 - 실제로는 ML 모델 또는 유사도 계산 알고리즘 사용 + return List.of(123L, 456L, 789L); + } + + @Override + public TasteProfile updateTasteProfile(Long memberId, Map analysisData) { + log.info("취향 프로필 업데이트: memberId={}", memberId); + + // 기존 프로필 조회 또는 새로 생성 + Optional existingProfile = getMemberPreferences(memberId); + + TasteProfile.TasteProfileBuilder builder = TasteProfile.builder() + .memberId(memberId) + .preferredCategories((List) analysisData.get("preferredCategories")) + .categoryScores((Map) analysisData.get("categoryScores")) + .preferredTags((List) analysisData.get("preferredTags")) + .behaviorPatterns((Map) analysisData.get("behaviorPatterns")) + .pricePreference((Double) analysisData.get("pricePreference")) + .distancePreference((Double) analysisData.get("distancePreference")) + .updatedAt(LocalDateTime.now()); + + if (existingProfile.isPresent()) { + builder.id(existingProfile.get().getId()) + .createdAt(existingProfile.get().getCreatedAt()); + } else { + builder.createdAt(LocalDateTime.now()); + } + + TasteProfile updatedProfile = builder.build(); + return recommendRepositoryAdapter.saveTasteProfile(updatedProfile); + } +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/entity/RecommendHistoryEntity.java b/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/entity/RecommendHistoryEntity.java new file mode 100644 index 0000000..b09e8ad --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/entity/RecommendHistoryEntity.java @@ -0,0 +1,62 @@ +package com.ktds.hi.recommend.infra.gateway.entity; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ktds.hi.recommend.biz.domain.RecommendType; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 추천 히스토리 엔티티 클래스 + * 데이터베이스 recommend_history 테이블과 매핑되는 JPA 엔티티 + */ +@Entity +@Table(name = "recommend_history") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class RecommendHistoryEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "recommended_store_ids_json", columnDefinition = "TEXT") + private String recommendedStoreIdsJson; + + @Enumerated(EnumType.STRING) + @Column(name = "recommend_type", nullable = false) + private RecommendType recommendType; + + @Column(length = 500) + private String criteria; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + /** + * JSON 문자열을 List로 변환 + */ + public List getRecommendedStoreIdsList() { + try { + ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(recommendedStoreIdsJson, new TypeReference>() {}); + } catch (Exception e) { + return List.of(); + } + } +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/entity/TasteProfileEntity.java b/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/entity/TasteProfileEntity.java new file mode 100644 index 0000000..d25c9a4 --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/entity/TasteProfileEntity.java @@ -0,0 +1,103 @@ +package com.ktds.hi.recommend.infra.gateway.entity; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ktds.hi.recommend.biz.domain.TasteCategory; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * 취향 프로필 엔티티 클래스 + * 데이터베이스 taste_profiles 테이블과 매핑되는 JPA 엔티티 + */ +@Entity +@Table(name = "taste_profiles") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class TasteProfileEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false, unique = true) + private Long memberId; + + @Column(name = "preferred_categories_json", columnDefinition = "TEXT") + private String preferredCategoriesJson; + + @Column(name = "category_scores_json", columnDefinition = "TEXT") + private String categoryScoresJson; + + @Column(name = "preferred_tags_json", columnDefinition = "TEXT") + private String preferredTagsJson; + + @Column(name = "behavior_patterns_json", columnDefinition = "TEXT") + private String behaviorPatternsJson; + + @Column(name = "price_preference") + private Double pricePreference; + + @Column(name = "distance_preference") + private Double distancePreference; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + /** + * JSON 문자열을 객체로 변환하는 메서드들 + */ + public List getPreferredCategoriesList() { + try { + ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(preferredCategoriesJson, new TypeReference>() {}); + } catch (Exception e) { + return List.of(); + } + } + + public Map getCategoryScoresMap() { + try { + ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(categoryScoresJson, new TypeReference>() {}); + } catch (Exception e) { + return Map.of(); + } + } + + public List getPreferredTagsList() { + try { + ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(preferredTagsJson, new TypeReference>() {}); + } catch (Exception e) { + return List.of(); + } + } + + public Map getBehaviorPatternsMap() { + try { + ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(behaviorPatternsJson, new TypeReference>() {}); + } catch (Exception e) { + return Map.of(); + } + } +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/repository/RecommendHistoryJpaRepository.java b/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/repository/RecommendHistoryJpaRepository.java new file mode 100644 index 0000000..7dc6d72 --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/repository/RecommendHistoryJpaRepository.java @@ -0,0 +1,25 @@ +package com.ktds.hi.recommend.infra.gateway.repository; + +import com.ktds.hi.recommend.infra.gateway.entity.RecommendHistoryEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * 추천 히스토리 JPA 리포지토리 인터페이스 + * 추천 히스토리 데이터의 CRUD 작업을 담당 + */ +@Repository +public interface RecommendHistoryJpaRepository extends JpaRepository { + + /** + * 회원 ID로 추천 히스토리 조회 (최신순) + */ + List findByMemberIdOrderByCreatedAtDesc(Long memberId); + + /** + * 회원 ID로 최신 추천 히스토리 조회 + */ + RecommendHistoryEntity findTopByMemberIdOrderByCreatedAtDesc(Long memberId); +} diff --git a/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/repository/TasteProfileJpaRepository.java b/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/repository/TasteProfileJpaRepository.java new file mode 100644 index 0000000..4eb42fc --- /dev/null +++ b/recommend/src/main/java/com/ktds/hi/recommend/infra/gateway/repository/TasteProfileJpaRepository.java @@ -0,0 +1,30 @@ +package com.ktds.hi.recommend.infra.gateway.repository; + +import com.ktds.hi.recommend.infra.gateway.entity.TasteProfileEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * 취향 프로필 JPA 리포지토리 인터페이스 + * 취향 프로필 데이터의 CRUD 작업을 담당 + */ +@Repository +public interface TasteProfileJpaRepository extends JpaRepository { + + /** + * 회원 ID로 취향 프로필 조회 + */ + Optional findByMemberId(Long memberId); + + /** + * 회원 ID로 취향 프로필 존재 여부 확인 + */ + boolean existsByMemberId(Long memberId); + + /** + * 회원 ID로 취향 프로필 삭제 + */ + void deleteByMemberId(Long memberId); +} diff --git a/recommend/src/main/resources/application.yml b/recommend/src/main/resources/application.yml new file mode 100644 index 0000000..40c79a3 --- /dev/null +++ b/recommend/src/main/resources/application.yml @@ -0,0 +1,44 @@ +server: + port: ${RECOMMEND_SERVICE_PORT:8085} + +spring: + application: + name: recommend-service + + datasource: + url: ${RECOMMEND_DB_URL:jdbc:postgresql://localhost:5432/hiorder_recommend} + username: ${RECOMMEND_DB_USERNAME:hiorder_user} + password: ${RECOMMEND_DB_PASSWORD:hiorder_pass} + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: ${JPA_DDL_AUTO:validate} + show-sql: ${JPA_SHOW_SQL:false} + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.PostgreSQLDialect + + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + +recommendation: + cache-ttl: 3600 # 1시간 + max-recommendations: 20 + default-radius: 5000 # 5km + +location: + google-maps-api-key: ${GOOGLE_MAPS_API_KEY:} + +hiorder-api: + base-url: ${HIORDER_API_BASE_URL:https://api.hiorder.com} + api-key: ${HIORDER_API_KEY:} + +springdoc: + api-docs: + path: /api-docs + swagger-ui: + path: /swagger-ui.html diff --git a/review/build.gradle b/review/build.gradle new file mode 100644 index 0000000..c712cf8 --- /dev/null +++ b/review/build.gradle @@ -0,0 +1,6 @@ +dependencies { + implementation project(':common') + + // File Storage + implementation 'org.springframework.boot:spring-boot-starter-webflux' +} diff --git a/review/src/main/java/com/ktds/hi/review/ReviewServiceApplication.java b/review/src/main/java/com/ktds/hi/review/ReviewServiceApplication.java new file mode 100644 index 0000000..019abb3 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/ReviewServiceApplication.java @@ -0,0 +1,21 @@ +package com.ktds.hi.review; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * 리뷰 관리 서비스 메인 애플리케이션 클래스 + * 리뷰 작성, 조회, 삭제, 반응, 댓글 기능을 제공 + * + * @author 하이오더 개발팀 + * @version 1.0.0 + */ +@SpringBootApplication(scanBasePackages = {"com.ktds.hi.review", "com.ktds.hi.common"}) +@EnableJpaAuditing +public class ReviewServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(ReviewServiceApplication.class, args); + } +} diff --git a/review/src/main/java/com/ktds/hi/review/biz/domain/ReactionType.java b/review/src/main/java/com/ktds/hi/review/biz/domain/ReactionType.java new file mode 100644 index 0000000..43a889f --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/biz/domain/ReactionType.java @@ -0,0 +1,20 @@ +package com.ktds.hi.review.biz.domain; + +/** + * 반응 유형 열거형 + * 리뷰 반응의 종류를 정의 + */ +public enum ReactionType { + LIKE("좋아요"), + DISLIKE("싫어요"); + + private final String description; + + ReactionType(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/review/src/main/java/com/ktds/hi/review/biz/domain/Review.java b/review/src/main/java/com/ktds/hi/review/biz/domain/Review.java new file mode 100644 index 0000000..73a83b3 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/biz/domain/Review.java @@ -0,0 +1,93 @@ +package com.ktds.hi.review.biz.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 리뷰 도메인 클래스 + * 리뷰의 기본 정보를 담는 도메인 객체 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Review { + + private Long id; + private Long storeId; + private Long memberId; + private String memberNickname; + private Integer rating; + private String content; + private List imageUrls; + private ReviewStatus status; + private Integer likeCount; + private Integer dislikeCount; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + /** + * 리뷰 내용 수정 + */ + public Review updateContent(String newContent, Integer newRating) { + return Review.builder() + .id(this.id) + .storeId(this.storeId) + .memberId(this.memberId) + .memberNickname(this.memberNickname) + .rating(newRating) + .content(newContent) + .imageUrls(this.imageUrls) + .status(this.status) + .likeCount(this.likeCount) + .dislikeCount(this.dislikeCount) + .createdAt(this.createdAt) + .updatedAt(LocalDateTime.now()) + .build(); + } + + /** + * 리뷰 상태 변경 + */ + public Review updateStatus(ReviewStatus newStatus) { + return Review.builder() + .id(this.id) + .storeId(this.storeId) + .memberId(this.memberId) + .memberNickname(this.memberNickname) + .rating(this.rating) + .content(this.content) + .imageUrls(this.imageUrls) + .status(newStatus) + .likeCount(this.likeCount) + .dislikeCount(this.dislikeCount) + .createdAt(this.createdAt) + .updatedAt(LocalDateTime.now()) + .build(); + } + + /** + * 좋아요 수 업데이트 + */ + public Review updateLikeCount(Integer likeCount, Integer dislikeCount) { + return Review.builder() + .id(this.id) + .storeId(this.storeId) + .memberId(this.memberId) + .memberNickname(this.memberNickname) + .rating(this.rating) + .content(this.content) + .imageUrls(this.imageUrls) + .status(this.status) + .likeCount(likeCount) + .dislikeCount(dislikeCount) + .createdAt(this.createdAt) + .updatedAt(this.updatedAt) + .build(); + } +} diff --git a/review/src/main/java/com/ktds/hi/review/biz/domain/ReviewComment.java b/review/src/main/java/com/ktds/hi/review/biz/domain/ReviewComment.java new file mode 100644 index 0000000..5571601 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/biz/domain/ReviewComment.java @@ -0,0 +1,42 @@ +package com.ktds.hi.review.biz.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 리뷰 댓글 도메인 클래스 + * 리뷰에 대한 점주 댓글 정보를 담는 도메인 객체 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ReviewComment { + + private Long id; + private Long reviewId; + private Long ownerId; + private String ownerNickname; + private String content; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + /** + * 댓글 내용 수정 + */ + public ReviewComment updateContent(String newContent) { + return ReviewComment.builder() + .id(this.id) + .reviewId(this.reviewId) + .ownerId(this.ownerId) + .ownerNickname(this.ownerNickname) + .content(newContent) + .createdAt(this.createdAt) + .updatedAt(LocalDateTime.now()) + .build(); + } +} diff --git a/review/src/main/java/com/ktds/hi/review/biz/domain/ReviewReaction.java b/review/src/main/java/com/ktds/hi/review/biz/domain/ReviewReaction.java new file mode 100644 index 0000000..3ac606a --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/biz/domain/ReviewReaction.java @@ -0,0 +1,38 @@ +package com.ktds.hi.review.biz.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 리뷰 반응 도메인 클래스 + * 리뷰에 대한 좋아요/싫어요 반응 정보를 담는 도메인 객체 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ReviewReaction { + + private Long id; + private Long reviewId; + private Long memberId; + private ReactionType reactionType; + private LocalDateTime createdAt; + + /** + * 반응 유형 변경 + */ + public ReviewReaction updateReactionType(ReactionType newType) { + return ReviewReaction.builder() + .id(this.id) + .reviewId(this.reviewId) + .memberId(this.memberId) + .reactionType(newType) + .createdAt(this.createdAt) + .build(); + } +} diff --git a/review/src/main/java/com/ktds/hi/review/biz/domain/ReviewStatus.java b/review/src/main/java/com/ktds/hi/review/biz/domain/ReviewStatus.java new file mode 100644 index 0000000..94050c3 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/biz/domain/ReviewStatus.java @@ -0,0 +1,22 @@ +package com.ktds.hi.review.biz.domain; + +/** + * 리뷰 상태 열거형 + * 리뷰의 현재 상태를 정의 + */ +public enum ReviewStatus { + ACTIVE("활성"), + DELETED("삭제됨"), + HIDDEN("숨김"), + REPORTED("신고됨"); + + private final String description; + + ReviewStatus(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/review/src/main/java/com/ktds/hi/review/biz/service/ReviewCommentInteractor.java b/review/src/main/java/com/ktds/hi/review/biz/service/ReviewCommentInteractor.java new file mode 100644 index 0000000..90f8202 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/biz/service/ReviewCommentInteractor.java @@ -0,0 +1,86 @@ +package com.ktds.hi.review.biz.service; + +import com.ktds.hi.review.biz.usecase.in.ManageReviewCommentUseCase; +import com.ktds.hi.review.biz.usecase.out.ReviewCommentRepository; +import com.ktds.hi.review.biz.usecase.out.ReviewRepository; +import com.ktds.hi.review.biz.domain.ReviewComment; +import com.ktds.hi.review.biz.domain.Review; +import com.ktds.hi.review.infra.dto.request.ReviewCommentRequest; +import com.ktds.hi.review.infra.dto.response.ReviewCommentResponse; +import com.ktds.hi.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 리뷰 댓글 인터랙터 클래스 + * 리뷰 댓글 작성, 조회, 삭제 기능을 구현 + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class ReviewCommentInteractor implements ManageReviewCommentUseCase { + + private final ReviewCommentRepository commentRepository; + private final ReviewRepository reviewRepository; + + @Override + @Transactional(readOnly = true) + public List getReviewComments(Long reviewId) { + List comments = commentRepository.findCommentsByReviewId(reviewId); + + return comments.stream() + .map(comment -> ReviewCommentResponse.builder() + .commentId(comment.getId()) + .ownerNickname(comment.getOwnerNickname()) + .content(comment.getContent()) + .createdAt(comment.getCreatedAt()) + .build()) + .collect(Collectors.toList()); + } + + @Override + public ReviewCommentResponse createComment(Long reviewId, Long ownerId, ReviewCommentRequest request) { + // 리뷰 존재 확인 + Review review = reviewRepository.findReviewById(reviewId) + .orElseThrow(() -> new BusinessException("존재하지 않는 리뷰입니다")); + + // 댓글 생성 + ReviewComment comment = ReviewComment.builder() + .reviewId(reviewId) + .ownerId(ownerId) + .ownerNickname("점주" + ownerId) // TODO: 점주 서비스에서 닉네임 조회 + .content(request.getContent()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + ReviewComment savedComment = commentRepository.saveComment(comment); + + log.info("리뷰 댓글 생성 완료: commentId={}, reviewId={}, ownerId={}", + savedComment.getId(), reviewId, ownerId); + + return ReviewCommentResponse.builder() + .commentId(savedComment.getId()) + .ownerNickname(savedComment.getOwnerNickname()) + .content(savedComment.getContent()) + .createdAt(savedComment.getCreatedAt()) + .build(); + } + + @Override + public void deleteComment(Long commentId, Long ownerId) { + ReviewComment comment = commentRepository.findCommentByIdAndOwnerId(commentId, ownerId) + .orElseThrow(() -> new BusinessException("댓글을 찾을 수 없거나 권한이 없습니다")); + + commentRepository.deleteComment(commentId); + + log.info("리뷰 댓글 삭제 완료: commentId={}, ownerId={}", commentId, ownerId); + } +} diff --git a/review/src/main/java/com/ktds/hi/review/biz/service/ReviewInteractor.java b/review/src/main/java/com/ktds/hi/review/biz/service/ReviewInteractor.java new file mode 100644 index 0000000..d0ca3b3 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/biz/service/ReviewInteractor.java @@ -0,0 +1,144 @@ +package com.ktds.hi.review.biz.service; + +import com.ktds.hi.review.biz.usecase.in.CreateReviewUseCase; +import com.ktds.hi.review.biz.usecase.in.DeleteReviewUseCase; +import com.ktds.hi.review.biz.usecase.in.GetReviewUseCase; +import com.ktds.hi.review.biz.usecase.out.ReviewRepository; +import com.ktds.hi.review.biz.domain.Review; +import com.ktds.hi.review.biz.domain.ReviewStatus; +import com.ktds.hi.review.infra.dto.request.ReviewCreateRequest; +import com.ktds.hi.review.infra.dto.response.*; +import com.ktds.hi.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 리뷰 인터랙터 클래스 + * 리뷰 생성, 조회, 삭제 기능을 구현 + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class ReviewInteractor implements CreateReviewUseCase, DeleteReviewUseCase, GetReviewUseCase { + + private final ReviewRepository reviewRepository; + + @Override + public ReviewCreateResponse createReview(Long memberId, ReviewCreateRequest request) { + // 리뷰 생성 + Review review = Review.builder() + .storeId(request.getStoreId()) + .memberId(memberId) + .memberNickname("회원" + memberId) // TODO: 회원 서비스에서 닉네임 조회 + .rating(request.getRating()) + .content(request.getContent()) + .imageUrls(request.getImageUrls()) + .status(ReviewStatus.ACTIVE) + .likeCount(0) + .dislikeCount(0) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + Review savedReview = reviewRepository.saveReview(review); + + log.info("리뷰 생성 완료: reviewId={}, storeId={}, memberId={}", + savedReview.getId(), savedReview.getStoreId(), savedReview.getMemberId()); + + return ReviewCreateResponse.builder() + .reviewId(savedReview.getId()) + .message("리뷰가 성공적으로 등록되었습니다") + .build(); + } + + @Override + public ReviewDeleteResponse deleteReview(Long reviewId, Long memberId) { + Review review = reviewRepository.findReviewByIdAndMemberId(reviewId, memberId) + .orElseThrow(() -> new BusinessException("리뷰를 찾을 수 없거나 권한이 없습니다")); + + Review deletedReview = review.updateStatus(ReviewStatus.DELETED); + reviewRepository.saveReview(deletedReview); + + log.info("리뷰 삭제 완료: reviewId={}, memberId={}", reviewId, memberId); + + return ReviewDeleteResponse.builder() + .success(true) + .message("리뷰가 삭제되었습니다") + .build(); + } + + @Override + @Transactional(readOnly = true) + public List getStoreReviews(Long storeId, Integer page, Integer size) { + Pageable pageable = PageRequest.of(page != null ? page : 0, size != null ? size : 20); + Page reviews = reviewRepository.findReviewsByStoreId(storeId, pageable); + + return reviews.stream() + .filter(review -> review.getStatus() == ReviewStatus.ACTIVE) + .map(review -> ReviewListResponse.builder() + .reviewId(review.getId()) + .memberNickname(review.getMemberNickname()) + .rating(review.getRating()) + .content(review.getContent()) + .imageUrls(review.getImageUrls()) + .likeCount(review.getLikeCount()) + .dislikeCount(review.getDislikeCount()) + .createdAt(review.getCreatedAt()) + .build()) + .collect(Collectors.toList()); + } + + @Override + @Transactional(readOnly = true) + public ReviewDetailResponse getReviewDetail(Long reviewId) { + Review review = reviewRepository.findReviewById(reviewId) + .orElseThrow(() -> new BusinessException("존재하지 않는 리뷰입니다")); + + if (review.getStatus() != ReviewStatus.ACTIVE) { + throw new BusinessException("삭제되었거나 숨겨진 리뷰입니다"); + } + + return ReviewDetailResponse.builder() + .reviewId(review.getId()) + .storeId(review.getStoreId()) + .memberNickname(review.getMemberNickname()) + .rating(review.getRating()) + .content(review.getContent()) + .imageUrls(review.getImageUrls()) + .likeCount(review.getLikeCount()) + .dislikeCount(review.getDislikeCount()) + .createdAt(review.getCreatedAt()) + .build(); + } + + @Override + @Transactional(readOnly = true) + public List getMyReviews(Long memberId, Integer page, Integer size) { + Pageable pageable = PageRequest.of(page != null ? page : 0, size != null ? size : 20); + Page reviews = reviewRepository.findReviewsByMemberId(memberId, pageable); + + return reviews.stream() + .filter(review -> review.getStatus() == ReviewStatus.ACTIVE) + .map(review -> ReviewListResponse.builder() + .reviewId(review.getId()) + .memberNickname(review.getMemberNickname()) + .rating(review.getRating()) + .content(review.getContent()) + .imageUrls(review.getImageUrls()) + .likeCount(review.getLikeCount()) + .dislikeCount(review.getDislikeCount()) + .createdAt(review.getCreatedAt()) + .build()) + .collect(Collectors.toList()); + } +} diff --git a/review/src/main/java/com/ktds/hi/review/biz/service/ReviewReactionInteractor.java b/review/src/main/java/com/ktds/hi/review/biz/service/ReviewReactionInteractor.java new file mode 100644 index 0000000..14a71d2 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/biz/service/ReviewReactionInteractor.java @@ -0,0 +1,105 @@ +package com.ktds.hi.review.biz.service; + +import com.ktds.hi.review.biz.usecase.in.ManageReviewReactionUseCase; +import com.ktds.hi.review.biz.usecase.out.ReviewReactionRepository; +import com.ktds.hi.review.biz.usecase.out.ReviewRepository; +import com.ktds.hi.review.biz.domain.ReviewReaction; +import com.ktds.hi.review.biz.domain.ReactionType; +import com.ktds.hi.review.biz.domain.Review; +import com.ktds.hi.review.infra.dto.response.ReviewReactionResponse; +import com.ktds.hi.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Optional; + +/** + * 리뷰 반응 인터랙터 클래스 + * 리뷰 좋아요/싫어요 기능을 구현 + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class ReviewReactionInteractor implements ManageReviewReactionUseCase { + + private final ReviewReactionRepository reactionRepository; + private final ReviewRepository reviewRepository; + + @Override + public ReviewReactionResponse addReaction(Long reviewId, Long memberId, ReactionType reactionType) { + // 리뷰 존재 확인 + Review review = reviewRepository.findReviewById(reviewId) + .orElseThrow(() -> new BusinessException("존재하지 않는 리뷰입니다")); + + // 기존 반응 확인 + Optional existingReaction = reactionRepository + .findReactionByReviewIdAndMemberId(reviewId, memberId); + + if (existingReaction.isPresent()) { + ReviewReaction reaction = existingReaction.get(); + if (reaction.getReactionType() == reactionType) { + throw new BusinessException("이미 같은 반응을 등록했습니다"); + } + + // 반응 유형 변경 + ReviewReaction updatedReaction = reaction.updateReactionType(reactionType); + reactionRepository.saveReaction(updatedReaction); + } else { + // 새로운 반응 생성 + ReviewReaction newReaction = ReviewReaction.builder() + .reviewId(reviewId) + .memberId(memberId) + .reactionType(reactionType) + .createdAt(LocalDateTime.now()) + .build(); + + reactionRepository.saveReaction(newReaction); + } + + // 반응 개수 업데이트 + updateReactionCounts(reviewId); + + log.info("리뷰 반응 추가: reviewId={}, memberId={}, type={}", reviewId, memberId, reactionType); + + return ReviewReactionResponse.builder() + .success(true) + .message("반응이 등록되었습니다") + .build(); + } + + @Override + public ReviewReactionResponse removeReaction(Long reviewId, Long memberId) { + ReviewReaction reaction = reactionRepository.findReactionByReviewIdAndMemberId(reviewId, memberId) + .orElseThrow(() -> new BusinessException("등록된 반응이 없습니다")); + + reactionRepository.deleteReaction(reaction.getId()); + + // 반응 개수 업데이트 + updateReactionCounts(reviewId); + + log.info("리뷰 반응 제거: reviewId={}, memberId={}", reviewId, memberId); + + return ReviewReactionResponse.builder() + .success(true) + .message("반응이 제거되었습니다") + .build(); + } + + /** + * 리뷰의 반응 개수 업데이트 + */ + private void updateReactionCounts(Long reviewId) { + Long likeCount = reactionRepository.countReactionsByReviewIdAndType(reviewId, ReactionType.LIKE); + Long dislikeCount = reactionRepository.countReactionsByReviewIdAndType(reviewId, ReactionType.DISLIKE); + + Review review = reviewRepository.findReviewById(reviewId) + .orElseThrow(() -> new BusinessException("존재하지 않는 리뷰입니다")); + + Review updatedReview = review.updateLikeCount(likeCount.intValue(), dislikeCount.intValue()); + reviewRepository.saveReview(updatedReview); + } +} diff --git a/review/src/main/java/com/ktds/hi/review/biz/usecase/in/CreateReviewUseCase.java b/review/src/main/java/com/ktds/hi/review/biz/usecase/in/CreateReviewUseCase.java new file mode 100644 index 0000000..49f4187 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/biz/usecase/in/CreateReviewUseCase.java @@ -0,0 +1,16 @@ +package com.ktds.hi.review.biz.usecase.in; + +import com.ktds.hi.review.infra.dto.request.ReviewCreateRequest; +import com.ktds.hi.review.infra.dto.response.ReviewCreateResponse; + +/** + * 리뷰 생성 유스케이스 인터페이스 + * 새로운 리뷰 작성 기능을 정의 + */ +public interface CreateReviewUseCase { + + /** + * 리뷰 생성 + */ + ReviewCreateResponse createReview(Long memberId, ReviewCreateRequest request); +} diff --git a/review/src/main/java/com/ktds/hi/review/biz/usecase/in/DeleteReviewUseCase.java b/review/src/main/java/com/ktds/hi/review/biz/usecase/in/DeleteReviewUseCase.java new file mode 100644 index 0000000..4a323bd --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/biz/usecase/in/DeleteReviewUseCase.java @@ -0,0 +1,15 @@ +package com.ktds.hi.review.biz.usecase.in; + +import com.ktds.hi.review.infra.dto.response.ReviewDeleteResponse; + +/** + * 리뷰 삭제 유스케이스 인터페이스 + * 리뷰 삭제 기능을 정의 + */ +public interface DeleteReviewUseCase { + + /** + * 리뷰 삭제 + */ + ReviewDeleteResponse deleteReview(Long reviewId, Long memberId); +} diff --git a/review/src/main/java/com/ktds/hi/review/biz/usecase/in/GetReviewUseCase.java b/review/src/main/java/com/ktds/hi/review/biz/usecase/in/GetReviewUseCase.java new file mode 100644 index 0000000..750972b --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/biz/usecase/in/GetReviewUseCase.java @@ -0,0 +1,28 @@ +package com.ktds.hi.review.biz.usecase.in; + +import com.ktds.hi.review.infra.dto.response.ReviewDetailResponse; +import com.ktds.hi.review.infra.dto.response.ReviewListResponse; + +import java.util.List; + +/** + * 리뷰 조회 유스케이스 인터페이스 + * 리뷰 목록 및 상세 조회 기능을 정의 + */ +public interface GetReviewUseCase { + + /** + * 매장 리뷰 목록 조회 + */ + List getStoreReviews(Long storeId, Integer page, Integer size); + + /** + * 리뷰 상세 조회 + */ + ReviewDetailResponse getReviewDetail(Long reviewId); + + /** + * 내가 작성한 리뷰 목록 조회 + */ + List getMyReviews(Long memberId, Integer page, Integer size); +} diff --git a/review/src/main/java/com/ktds/hi/review/biz/usecase/in/ManageReviewCommentUseCase.java b/review/src/main/java/com/ktds/hi/review/biz/usecase/in/ManageReviewCommentUseCase.java new file mode 100644 index 0000000..07dcb99 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/biz/usecase/in/ManageReviewCommentUseCase.java @@ -0,0 +1,28 @@ +package com.ktds.hi.review.biz.usecase.in; + +import com.ktds.hi.review.infra.dto.request.ReviewCommentRequest; +import com.ktds.hi.review.infra.dto.response.ReviewCommentResponse; + +import java.util.List; + +/** + * 리뷰 댓글 관리 유스케이스 인터페이스 + * 리뷰 댓글 작성, 조회, 삭제 기능을 정의 + */ +public interface ManageReviewCommentUseCase { + + /** + * 리뷰 댓글 목록 조회 + */ + List getReviewComments(Long reviewId); + + /** + * 리뷰 댓글 작성 (점주용) + */ + ReviewCommentResponse createComment(Long reviewId, Long ownerId, ReviewCommentRequest request); + + /** + * 리뷰 댓글 삭제 + */ + void deleteComment(Long commentId, Long ownerId); +} diff --git a/review/src/main/java/com/ktds/hi/review/biz/usecase/in/ManageReviewReactionUseCase.java b/review/src/main/java/com/ktds/hi/review/biz/usecase/in/ManageReviewReactionUseCase.java new file mode 100644 index 0000000..4e68418 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/biz/usecase/in/ManageReviewReactionUseCase.java @@ -0,0 +1,21 @@ +package com.ktds.hi.review.biz.usecase.in; + +import com.ktds.hi.review.biz.domain.ReactionType; +import com.ktds.hi.review.infra.dto.response.ReviewReactionResponse; + +/** + * 리뷰 반응 관리 유스케이스 인터페이스 + * 리뷰 좋아요/싫어요 기능을 정의 + */ +public interface ManageReviewReactionUseCase { + + /** + * 리뷰 반응 추가 + */ + ReviewReactionResponse addReaction(Long reviewId, Long memberId, ReactionType reactionType); + + /** + * 리뷰 반응 제거 + */ + ReviewReactionResponse removeReaction(Long reviewId, Long memberId); +} diff --git a/review/src/main/java/com/ktds/hi/review/biz/usecase/out/ReviewCommentRepository.java b/review/src/main/java/com/ktds/hi/review/biz/usecase/out/ReviewCommentRepository.java new file mode 100644 index 0000000..dedc504 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/biz/usecase/out/ReviewCommentRepository.java @@ -0,0 +1,38 @@ +package com.ktds.hi.review.biz.usecase.out; + +import com.ktds.hi.review.biz.domain.ReviewComment; + +import java.util.List; +import java.util.Optional; + +/** + * 리뷰 댓글 리포지토리 인터페이스 + * 리뷰 댓글 데이터 영속성 기능을 정의 + */ +public interface ReviewCommentRepository { + + /** + * 댓글 저장 + */ + ReviewComment saveComment(ReviewComment comment); + + /** + * 리뷰 ID로 댓글 목록 조회 + */ + List findCommentsByReviewId(Long reviewId); + + /** + * 댓글 ID로 조회 + */ + Optional findCommentById(Long commentId); + + /** + * 댓글 삭제 + */ + void deleteComment(Long commentId); + + /** + * 댓글 ID와 소유자 ID로 댓글 조회 + */ + Optional findCommentByIdAndOwnerId(Long commentId, Long ownerId); +} diff --git a/review/src/main/java/com/ktds/hi/review/biz/usecase/out/ReviewReactionRepository.java b/review/src/main/java/com/ktds/hi/review/biz/usecase/out/ReviewReactionRepository.java new file mode 100644 index 0000000..d920197 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/biz/usecase/out/ReviewReactionRepository.java @@ -0,0 +1,33 @@ +package com.ktds.hi.review.biz.usecase.out; + +import com.ktds.hi.review.biz.domain.ReviewReaction; +import com.ktds.hi.review.biz.domain.ReactionType; + +import java.util.Optional; + +/** + * 리뷰 반응 리포지토리 인터페이스 + * 리뷰 반응 데이터 영속성 기능을 정의 + */ +public interface ReviewReactionRepository { + + /** + * 반응 저장 + */ + ReviewReaction saveReaction(ReviewReaction reaction); + + /** + * 리뷰 ID와 회원 ID로 반응 조회 + */ + Optional findReactionByReviewIdAndMemberId(Long reviewId, Long memberId); + + /** + * 반응 삭제 + */ + void deleteReaction(Long reactionId); + + /** + * 리뷰 ID와 반응 유형별 개수 조회 + */ + Long countReactionsByReviewIdAndType(Long reviewId, ReactionType reactionType); +} diff --git a/review/src/main/java/com/ktds/hi/review/biz/usecase/out/ReviewRepository.java b/review/src/main/java/com/ktds/hi/review/biz/usecase/out/ReviewRepository.java new file mode 100644 index 0000000..3412431 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/biz/usecase/out/ReviewRepository.java @@ -0,0 +1,45 @@ +package com.ktds.hi.review.biz.usecase.out; + +import com.ktds.hi.review.biz.domain.Review; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +/** + * 리뷰 리포지토리 인터페이스 + * 리뷰 데이터 영속성 기능을 정의 + */ +public interface ReviewRepository { + + /** + * 리뷰 저장 + */ + Review saveReview(Review review); + + /** + * 리뷰 ID로 조회 + */ + Optional findReviewById(Long reviewId); + + /** + * 매장 ID로 리뷰 목록 조회 + */ + Page findReviewsByStoreId(Long storeId, Pageable pageable); + + /** + * 회원 ID로 리뷰 목록 조회 + */ + Page findReviewsByMemberId(Long memberId, Pageable pageable); + + /** + * 리뷰 삭제 + */ + void deleteReview(Long reviewId); + + /** + * 리뷰 ID와 회원 ID로 리뷰 조회 + */ + Optional findReviewByIdAndMemberId(Long reviewId, Long memberId); +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/config/ReviewConfig.java b/review/src/main/java/com/ktds/hi/review/infra/config/ReviewConfig.java new file mode 100644 index 0000000..42ed9fc --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/config/ReviewConfig.java @@ -0,0 +1,12 @@ +package com.ktds.hi.review.infra.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * 리뷰 서비스 설정 클래스 + */ +@Configuration +@EnableJpaRepositories(basePackages = "com.ktds.hi.review.infra.gateway.repository") +public class ReviewConfig { +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/config/SwaggerConfig.java b/review/src/main/java/com/ktds/hi/review/infra/config/SwaggerConfig.java new file mode 100644 index 0000000..051b36d --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/config/SwaggerConfig.java @@ -0,0 +1,26 @@ +package com.ktds.hi.review.infra.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Swagger 설정 클래스 + * API 문서화를 위한 OpenAPI 설정 + */ +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .addServersItem(new Server().url("/")) + .info(new Info() + .title("하이오더 리뷰 관리 서비스 API") + .description("리뷰 작성, 조회, 삭제, 반응, 댓글 등 리뷰 관련 기능을 제공하는 API") + .version("1.0.0")); + } +} + diff --git a/review/src/main/java/com/ktds/hi/review/infra/controller/ReviewCommentController.java b/review/src/main/java/com/ktds/hi/review/infra/controller/ReviewCommentController.java new file mode 100644 index 0000000..812ba5b --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/controller/ReviewCommentController.java @@ -0,0 +1,64 @@ +package com.ktds.hi.review.infra.controller; + +import com.ktds.hi.review.biz.usecase.in.ManageReviewCommentUseCase; +import com.ktds.hi.review.infra.dto.request.ReviewCommentRequest; +import com.ktds.hi.review.infra.dto.response.ReviewCommentResponse; +import com.ktds.hi.common.dto.SuccessResponse; +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.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 리뷰 댓글 관리 컨트롤러 클래스 + * 리뷰 댓글 작성, 조회, 삭제 API를 제공 + */ +@RestController +@RequestMapping("/api/reviews") +@RequiredArgsConstructor +@Tag(name = "리뷰 댓글 API", description = "리뷰 댓글 작성, 조회, 삭제 관련 API") +public class ReviewCommentController { + + private final ManageReviewCommentUseCase manageReviewCommentUseCase; + + /** + * 리뷰 댓글 목록 조회 API + */ + @GetMapping("/{reviewId}/comments") + @Operation(summary = "리뷰 댓글 목록 조회", description = "특정 리뷰의 댓글 목록을 조회합니다.") + public ResponseEntity> getReviewComments(@PathVariable Long reviewId) { + List comments = manageReviewCommentUseCase.getReviewComments(reviewId); + return ResponseEntity.ok(comments); + } + + /** + * 리뷰 댓글 작성 API (점주용) + */ + @PostMapping("/{reviewId}/comments") + @Operation(summary = "리뷰 댓글 작성", description = "점주가 리뷰에 댓글을 작성합니다.") + public ResponseEntity createComment(Authentication authentication, + @PathVariable Long reviewId, + @Valid @RequestBody ReviewCommentRequest request) { + Long ownerId = Long.valueOf(authentication.getName()); + ReviewCommentResponse response = manageReviewCommentUseCase.createComment(reviewId, ownerId, request); + return ResponseEntity.ok(response); + } + + /** + * 리뷰 댓글 삭제 API + */ + @DeleteMapping("/{reviewId}/comments/{commentId}") + @Operation(summary = "리뷰 댓글 삭제", description = "작성한 리뷰 댓글을 삭제합니다.") + public ResponseEntity deleteComment(Authentication authentication, + @PathVariable Long reviewId, + @PathVariable Long commentId) { + Long ownerId = Long.valueOf(authentication.getName()); + manageReviewCommentUseCase.deleteComment(commentId, ownerId); + return ResponseEntity.ok(SuccessResponse.of("댓글이 삭제되었습니다")); + } +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/controller/ReviewController.java b/review/src/main/java/com/ktds/hi/review/infra/controller/ReviewController.java new file mode 100644 index 0000000..c65da71 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/controller/ReviewController.java @@ -0,0 +1,126 @@ +package com.ktds.hi.review.infra.controller; + +import com.ktds.hi.review.biz.usecase.in.*; +import com.ktds.hi.review.biz.domain.ReactionType; +import com.ktds.hi.review.infra.dto.request.ReviewCreateRequest; +import com.ktds.hi.review.infra.dto.response.*; +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.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 리뷰 관리 컨트롤러 클래스 + * 리뷰 생성, 조회, 삭제 및 반응 관리 API를 제공 + */ +@RestController +@RequestMapping("/api/reviews") +@RequiredArgsConstructor +@Tag(name = "리뷰 관리 API", description = "리뷰 작성, 조회, 삭제 및 반응 관리 관련 API") +public class ReviewController { + + private final CreateReviewUseCase createReviewUseCase; + private final DeleteReviewUseCase deleteReviewUseCase; + private final GetReviewUseCase getReviewUseCase; + private final ManageReviewReactionUseCase manageReviewReactionUseCase; + + /** + * 리뷰 작성 API + */ + @PostMapping + @Operation(summary = "리뷰 작성", description = "새로운 리뷰를 작성합니다.") + public ResponseEntity createReview(Authentication authentication, + @Valid @RequestBody ReviewCreateRequest request) { + Long memberId = Long.valueOf(authentication.getName()); + ReviewCreateResponse response = createReviewUseCase.createReview(memberId, request); + return ResponseEntity.ok(response); + } + + /** + * 매장 리뷰 목록 조회 API + */ + @GetMapping("/stores/{storeId}") + @Operation(summary = "매장 리뷰 목록 조회", description = "특정 매장의 리뷰 목록을 조회합니다.") + public ResponseEntity> getStoreReviews(@PathVariable Long storeId, + @RequestParam(defaultValue = "0") Integer page, + @RequestParam(defaultValue = "20") Integer size) { + List reviews = getReviewUseCase.getStoreReviews(storeId, page, size); + return ResponseEntity.ok(reviews); + } + + /** + * 리뷰 상세 조회 API + */ + @GetMapping("/{reviewId}") + @Operation(summary = "리뷰 상세 조회", description = "특정 리뷰의 상세 정보를 조회합니다.") + public ResponseEntity getReviewDetail(@PathVariable Long reviewId) { + ReviewDetailResponse review = getReviewUseCase.getReviewDetail(reviewId); + return ResponseEntity.ok(review); + } + + /** + * 내가 작성한 리뷰 목록 조회 API + */ + @GetMapping("/my") + @Operation(summary = "내 리뷰 목록 조회", description = "현재 로그인한 회원이 작성한 리뷰 목록을 조회합니다.") + public ResponseEntity> getMyReviews(Authentication authentication, + @RequestParam(defaultValue = "0") Integer page, + @RequestParam(defaultValue = "20") Integer size) { + Long memberId = Long.valueOf(authentication.getName()); + List reviews = getReviewUseCase.getMyReviews(memberId, page, size); + return ResponseEntity.ok(reviews); + } + + /** + * 리뷰 삭제 API + */ + @DeleteMapping("/{reviewId}") + @Operation(summary = "리뷰 삭제", description = "작성한 리뷰를 삭제합니다.") + public ResponseEntity deleteReview(Authentication authentication, + @PathVariable Long reviewId) { + Long memberId = Long.valueOf(authentication.getName()); + ReviewDeleteResponse response = deleteReviewUseCase.deleteReview(reviewId, memberId); + return ResponseEntity.ok(response); + } + + /** + * 리뷰 좋아요 API + */ + @PostMapping("/{reviewId}/like") + @Operation(summary = "리뷰 좋아요", description = "리뷰에 좋아요를 등록합니다.") + public ResponseEntity likeReview(Authentication authentication, + @PathVariable Long reviewId) { + Long memberId = Long.valueOf(authentication.getName()); + ReviewReactionResponse response = manageReviewReactionUseCase.addReaction(reviewId, memberId, ReactionType.LIKE); + return ResponseEntity.ok(response); + } + + /** + * 리뷰 싫어요 API + */ + @PostMapping("/{reviewId}/dislike") + @Operation(summary = "리뷰 싫어요", description = "리뷰에 싫어요를 등록합니다.") + public ResponseEntity dislikeReview(Authentication authentication, + @PathVariable Long reviewId) { + Long memberId = Long.valueOf(authentication.getName()); + ReviewReactionResponse response = manageReviewReactionUseCase.addReaction(reviewId, memberId, ReactionType.DISLIKE); + return ResponseEntity.ok(response); + } + + /** + * 리뷰 반응 제거 API + */ + @DeleteMapping("/{reviewId}/reaction") + @Operation(summary = "리뷰 반응 제거", description = "리뷰에 등록한 반응을 제거합니다.") + public ResponseEntity removeReaction(Authentication authentication, + @PathVariable Long reviewId) { + Long memberId = Long.valueOf(authentication.getName()); + ReviewReactionResponse response = manageReviewReactionUseCase.removeReaction(reviewId, memberId); + return ResponseEntity.ok(response); + } +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/dto/request/ReviewCommentRequest.java b/review/src/main/java/com/ktds/hi/review/infra/dto/request/ReviewCommentRequest.java new file mode 100644 index 0000000..9a17457 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/dto/request/ReviewCommentRequest.java @@ -0,0 +1,32 @@ +package com.ktds.hi.review.infra.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 리뷰 댓글 요청 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "리뷰 댓글 요청") +public class ReviewCommentRequest { + + @NotBlank(message = "댓글 내용은 필수입니다") + @Size(min = 5, max = 500, message = "댓글 내용은 5자 이상 500자 이하여야 합니다") + @Schema(description = "댓글 내용", example = "소중한 리뷰 감사합니다. 더 좋은 서비스로 보답하겠습니다.") + private String content; + + /** + * 유효성 검증 + */ + public void validate() { + if (content == null || content.trim().length() < 5) { + throw new IllegalArgumentException("댓글 내용은 5자 이상이어야 합니다"); + } + } +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/dto/request/ReviewCreateRequest.java b/review/src/main/java/com/ktds/hi/review/infra/dto/request/ReviewCreateRequest.java new file mode 100644 index 0000000..afd16be --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/dto/request/ReviewCreateRequest.java @@ -0,0 +1,52 @@ +package com.ktds.hi.review.infra.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 리뷰 생성 요청 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "리뷰 생성 요청") +public class ReviewCreateRequest { + + @NotNull(message = "매장 ID는 필수입니다") + @Schema(description = "매장 ID", example = "1") + private Long storeId; + + @NotNull(message = "평점은 필수입니다") + @Min(value = 1, message = "평점은 1점 이상이어야 합니다") + @Max(value = 5, message = "평점은 5점 이하여야 합니다") + @Schema(description = "평점 (1-5)", example = "4") + private Integer rating; + + @NotBlank(message = "리뷰 내용은 필수입니다") + @Size(min = 10, max = 1000, message = "리뷰 내용은 10자 이상 1000자 이하여야 합니다") + @Schema(description = "리뷰 내용", example = "음식이 정말 맛있었습니다. 서비스도 친절하고 재방문 의사 있습니다.") + private String content; + + @Schema(description = "이미지 URL 목록", example = "[\"https://example.com/image1.jpg\", \"https://example.com/image2.jpg\"]") + private List imageUrls; + + /** + * 유효성 검증 + */ + public void validate() { + if (storeId == null || storeId <= 0) { + throw new IllegalArgumentException("유효한 매장 ID가 필요합니다"); + } + if (rating == null || rating < 1 || rating > 5) { + throw new IllegalArgumentException("평점은 1점에서 5점 사이여야 합니다"); + } + if (content == null || content.trim().length() < 10) { + throw new IllegalArgumentException("리뷰 내용은 10자 이상이어야 합니다"); + } + } +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/dto/response/ReviewCommentResponse.java b/review/src/main/java/com/ktds/hi/review/infra/dto/response/ReviewCommentResponse.java new file mode 100644 index 0000000..af7550a --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/dto/response/ReviewCommentResponse.java @@ -0,0 +1,32 @@ +package com.ktds.hi.review.infra.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 리뷰 댓글 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "리뷰 댓글 응답") +public class ReviewCommentResponse { + + @Schema(description = "댓글 ID") + private Long commentId; + + @Schema(description = "점주 닉네임") + private String ownerNickname; + + @Schema(description = "댓글 내용") + private String content; + + @Schema(description = "작성일시") + private LocalDateTime createdAt; +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/dto/response/ReviewCreateResponse.java b/review/src/main/java/com/ktds/hi/review/infra/dto/response/ReviewCreateResponse.java new file mode 100644 index 0000000..6ccc07b --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/dto/response/ReviewCreateResponse.java @@ -0,0 +1,24 @@ +package com.ktds.hi.review.infra.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 리뷰 생성 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "리뷰 생성 응답") +public class ReviewCreateResponse { + + @Schema(description = "생성된 리뷰 ID") + private Long reviewId; + + @Schema(description = "응답 메시지") + private String message; +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/dto/response/ReviewDeleteResponse.java b/review/src/main/java/com/ktds/hi/review/infra/dto/response/ReviewDeleteResponse.java new file mode 100644 index 0000000..d4a009d --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/dto/response/ReviewDeleteResponse.java @@ -0,0 +1,24 @@ +package com.ktds.hi.review.infra.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 리뷰 삭제 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "리뷰 삭제 응답") +public class ReviewDeleteResponse { + + @Schema(description = "성공 여부") + private Boolean success; + + @Schema(description = "응답 메시지") + private String message; +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/dto/response/ReviewDetailResponse.java b/review/src/main/java/com/ktds/hi/review/infra/dto/response/ReviewDetailResponse.java new file mode 100644 index 0000000..f5e09db --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/dto/response/ReviewDetailResponse.java @@ -0,0 +1,48 @@ +package com.ktds.hi.review.infra.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 리뷰 상세 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "리뷰 상세 응답") +public class ReviewDetailResponse { + + @Schema(description = "리뷰 ID") + private Long reviewId; + + @Schema(description = "매장 ID") + private Long storeId; + + @Schema(description = "작성자 닉네임") + private String memberNickname; + + @Schema(description = "평점") + private Integer rating; + + @Schema(description = "리뷰 내용") + private String content; + + @Schema(description = "이미지 URL 목록") + private List imageUrls; + + @Schema(description = "좋아요 수") + private Integer likeCount; + + @Schema(description = "싫어요 수") + private Integer dislikeCount; + + @Schema(description = "작성일시") + private LocalDateTime createdAt; +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/dto/response/ReviewListResponse.java b/review/src/main/java/com/ktds/hi/review/infra/dto/response/ReviewListResponse.java new file mode 100644 index 0000000..5c5869c --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/dto/response/ReviewListResponse.java @@ -0,0 +1,45 @@ +package com.ktds.hi.review.infra.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 리뷰 목록 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "리뷰 목록 응답") +public class ReviewListResponse { + + @Schema(description = "리뷰 ID") + private Long reviewId; + + @Schema(description = "작성자 닉네임") + private String memberNickname; + + @Schema(description = "평점") + private Integer rating; + + @Schema(description = "리뷰 내용") + private String content; + + @Schema(description = "이미지 URL 목록") + private List imageUrls; + + @Schema(description = "좋아요 수") + private Integer likeCount; + + @Schema(description = "싫어요 수") + private Integer dislikeCount; + + @Schema(description = "작성일시") + private LocalDateTime createdAt; +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/dto/response/ReviewReactionResponse.java b/review/src/main/java/com/ktds/hi/review/infra/dto/response/ReviewReactionResponse.java new file mode 100644 index 0000000..d37f018 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/dto/response/ReviewReactionResponse.java @@ -0,0 +1,24 @@ +package com.ktds.hi.review.infra.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 리뷰 반응 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "리뷰 반응 응답") +public class ReviewReactionResponse { + + @Schema(description = "성공 여부") + private Boolean success; + + @Schema(description = "응답 메시지") + private String message; +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/gateway/ReviewCommentRepositoryAdapter.java b/review/src/main/java/com/ktds/hi/review/infra/gateway/ReviewCommentRepositoryAdapter.java new file mode 100644 index 0000000..0ff8e43 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/gateway/ReviewCommentRepositoryAdapter.java @@ -0,0 +1,83 @@ +package com.ktds.hi.review.infra.gateway; + +import com.ktds.hi.review.biz.usecase.out.ReviewCommentRepository; +import com.ktds.hi.review.biz.domain.ReviewComment; +import com.ktds.hi.review.infra.gateway.repository.ReviewCommentJpaRepository; +import com.ktds.hi.review.infra.gateway.entity.ReviewCommentEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 리뷰 댓글 리포지토리 어댑터 클래스 + * 도메인 리포지토리 인터페이스를 JPA 리포지토리에 연결 + */ +@Component +@RequiredArgsConstructor +public class ReviewCommentRepositoryAdapter implements ReviewCommentRepository { + + private final ReviewCommentJpaRepository reviewCommentJpaRepository; + + @Override + public ReviewComment saveComment(ReviewComment comment) { + ReviewCommentEntity entity = toEntity(comment); + ReviewCommentEntity savedEntity = reviewCommentJpaRepository.save(entity); + return toDomain(savedEntity); + } + + @Override + public List findCommentsByReviewId(Long reviewId) { + List entities = reviewCommentJpaRepository.findByReviewIdOrderByCreatedAtDesc(reviewId); + return entities.stream() + .map(this::toDomain) + .collect(Collectors.toList()); + } + + @Override + public Optional findCommentById(Long commentId) { + return reviewCommentJpaRepository.findById(commentId) + .map(this::toDomain); + } + + @Override + public void deleteComment(Long commentId) { + reviewCommentJpaRepository.deleteById(commentId); + } + + @Override + public Optional findCommentByIdAndOwnerId(Long commentId, Long ownerId) { + return reviewCommentJpaRepository.findByIdAndOwnerId(commentId, ownerId) + .map(this::toDomain); + } + + /** + * 엔티티를 도메인으로 변환 + */ + private ReviewComment toDomain(ReviewCommentEntity entity) { + return ReviewComment.builder() + .id(entity.getId()) + .reviewId(entity.getReviewId()) + .ownerId(entity.getOwnerId()) + .ownerNickname(entity.getOwnerNickname()) + .content(entity.getContent()) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .build(); + } + + /** + * 도메인을 엔티티로 변환 + */ + private ReviewCommentEntity toEntity(ReviewComment domain) { + return ReviewCommentEntity.builder() + .id(domain.getId()) + .reviewId(domain.getReviewId()) + .ownerId(domain.getOwnerId()) + .ownerNickname(domain.getOwnerNickname()) + .content(domain.getContent()) + .build(); + } +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/gateway/ReviewReactionRepositoryAdapter.java b/review/src/main/java/com/ktds/hi/review/infra/gateway/ReviewReactionRepositoryAdapter.java new file mode 100644 index 0000000..1c84078 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/gateway/ReviewReactionRepositoryAdapter.java @@ -0,0 +1,70 @@ +package com.ktds.hi.review.infra.gateway; + +import com.ktds.hi.review.biz.usecase.out.ReviewReactionRepository; +import com.ktds.hi.review.biz.domain.ReviewReaction; +import com.ktds.hi.review.biz.domain.ReactionType; +import com.ktds.hi.review.infra.gateway.repository.ReviewReactionJpaRepository; +import com.ktds.hi.review.infra.gateway.entity.ReviewReactionEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * 리뷰 반응 리포지토리 어댑터 클래스 + * 도메인 리포지토리 인터페이스를 JPA 리포지토리에 연결 + */ +@Component +@RequiredArgsConstructor +public class ReviewReactionRepositoryAdapter implements ReviewReactionRepository { + + private final ReviewReactionJpaRepository reviewReactionJpaRepository; + + @Override + public ReviewReaction saveReaction(ReviewReaction reaction) { + ReviewReactionEntity entity = toEntity(reaction); + ReviewReactionEntity savedEntity = reviewReactionJpaRepository.save(entity); + return toDomain(savedEntity); + } + + @Override + public Optional findReactionByReviewIdAndMemberId(Long reviewId, Long memberId) { + return reviewReactionJpaRepository.findByReviewIdAndMemberId(reviewId, memberId) + .map(this::toDomain); + } + + @Override + public void deleteReaction(Long reactionId) { + reviewReactionJpaRepository.deleteById(reactionId); + } + + @Override + public Long countReactionsByReviewIdAndType(Long reviewId, ReactionType reactionType) { + return reviewReactionJpaRepository.countByReviewIdAndReactionType(reviewId, reactionType); + } + + /** + * 엔티티를 도메인으로 변환 + */ + private ReviewReaction toDomain(ReviewReactionEntity entity) { + return ReviewReaction.builder() + .id(entity.getId()) + .reviewId(entity.getReviewId()) + .memberId(entity.getMemberId()) + .reactionType(entity.getReactionType()) + .createdAt(entity.getCreatedAt()) + .build(); + } + + /** + * 도메인을 엔티티로 변환 + */ + private ReviewReactionEntity toEntity(ReviewReaction domain) { + return ReviewReactionEntity.builder() + .id(domain.getId()) + .reviewId(domain.getReviewId()) + .memberId(domain.getMemberId()) + .reactionType(domain.getReactionType()) + .build(); + } +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/gateway/ReviewRepositoryAdapter.java b/review/src/main/java/com/ktds/hi/review/infra/gateway/ReviewRepositoryAdapter.java new file mode 100644 index 0000000..a2427eb --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/gateway/ReviewRepositoryAdapter.java @@ -0,0 +1,98 @@ +package com.ktds.hi.review.infra.gateway; + +import com.ktds.hi.review.biz.usecase.out.ReviewRepository; +import com.ktds.hi.review.biz.domain.Review; +import com.ktds.hi.review.biz.domain.ReviewStatus; +import com.ktds.hi.review.infra.gateway.repository.ReviewJpaRepository; +import com.ktds.hi.review.infra.gateway.entity.ReviewEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * 리뷰 리포지토리 어댑터 클래스 + * 도메인 리포지토리 인터페이스를 JPA 리포지토리에 연결 + */ +@Component +@RequiredArgsConstructor +public class ReviewRepositoryAdapter implements ReviewRepository { + + private final ReviewJpaRepository reviewJpaRepository; + + @Override + public Review saveReview(Review review) { + ReviewEntity entity = toEntity(review); + ReviewEntity savedEntity = reviewJpaRepository.save(entity); + return toDomain(savedEntity); + } + + @Override + public Optional findReviewById(Long reviewId) { + return reviewJpaRepository.findById(reviewId) + .map(this::toDomain); + } + + @Override + public Page findReviewsByStoreId(Long storeId, Pageable pageable) { + Page entities = reviewJpaRepository.findByStoreIdAndStatus(storeId, ReviewStatus.ACTIVE, pageable); + return entities.map(this::toDomain); + } + + @Override + public Page findReviewsByMemberId(Long memberId, Pageable pageable) { + Page entities = reviewJpaRepository.findByMemberIdAndStatus(memberId, ReviewStatus.ACTIVE, pageable); + return entities.map(this::toDomain); + } + + @Override + public void deleteReview(Long reviewId) { + reviewJpaRepository.deleteById(reviewId); + } + + @Override + public Optional findReviewByIdAndMemberId(Long reviewId, Long memberId) { + return reviewJpaRepository.findByIdAndMemberId(reviewId, memberId) + .map(this::toDomain); + } + + /** + * 엔티티를 도메인으로 변환 + */ + private Review toDomain(ReviewEntity entity) { + return Review.builder() + .id(entity.getId()) + .storeId(entity.getStoreId()) + .memberId(entity.getMemberId()) + .memberNickname(entity.getMemberNickname()) + .rating(entity.getRating()) + .content(entity.getContent()) + .imageUrls(entity.getImageUrls()) + .status(entity.getStatus()) + .likeCount(entity.getLikeCount()) + .dislikeCount(entity.getDislikeCount()) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .build(); + } + + /** + * 도메인을 엔티티로 변환 + */ + private ReviewEntity toEntity(Review domain) { + return ReviewEntity.builder() + .id(domain.getId()) + .storeId(domain.getStoreId()) + .memberId(domain.getMemberId()) + .memberNickname(domain.getMemberNickname()) + .rating(domain.getRating()) + .content(domain.getContent()) + .imageUrls(domain.getImageUrls()) + .status(domain.getStatus()) + .likeCount(domain.getLikeCount()) + .dislikeCount(domain.getDislikeCount()) + .build(); + } +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/gateway/entity/ReviewCommentEntity.java b/review/src/main/java/com/ktds/hi/review/infra/gateway/entity/ReviewCommentEntity.java new file mode 100644 index 0000000..ce596b2 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/gateway/entity/ReviewCommentEntity.java @@ -0,0 +1,50 @@ +package com.ktds.hi.review.infra.gateway.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 리뷰 댓글 엔티티 클래스 + * 데이터베이스 review_comments 테이블과 매핑되는 JPA 엔티티 + */ +@Entity +@Table(name = "review_comments") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class ReviewCommentEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "review_id", nullable = false) + private Long reviewId; + + @Column(name = "owner_id", nullable = false) + private Long ownerId; + + @Column(name = "owner_nickname", nullable = false, length = 50) + private String ownerNickname; + + @Column(nullable = false, length = 500) + private String content; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/gateway/entity/ReviewEntity.java b/review/src/main/java/com/ktds/hi/review/infra/gateway/entity/ReviewEntity.java new file mode 100644 index 0000000..d966a5c --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/gateway/entity/ReviewEntity.java @@ -0,0 +1,74 @@ +package com.ktds.hi.review.infra.gateway.entity; + +import com.ktds.hi.review.biz.domain.ReviewStatus; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 리뷰 엔티티 클래스 + * 데이터베이스 reviews 테이블과 매핑되는 JPA 엔티티 + */ +@Entity +@Table(name = "reviews") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class ReviewEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "member_nickname", nullable = false, length = 50) + private String memberNickname; + + @Column(nullable = false) + private Integer rating; + + @Column(nullable = false, length = 1000) + private String content; + + @ElementCollection + @CollectionTable(name = "review_images", + joinColumns = @JoinColumn(name = "review_id")) + @Column(name = "image_url") + private List imageUrls; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @Builder.Default + private ReviewStatus status = ReviewStatus.ACTIVE; + + @Column(name = "like_count") + @Builder.Default + private Integer likeCount = 0; + + @Column(name = "dislike_count") + @Builder.Default + private Integer dislikeCount = 0; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/gateway/entity/ReviewReactionEntity.java b/review/src/main/java/com/ktds/hi/review/infra/gateway/entity/ReviewReactionEntity.java new file mode 100644 index 0000000..ffc4e46 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/gateway/entity/ReviewReactionEntity.java @@ -0,0 +1,45 @@ +package com.ktds.hi.review.infra.gateway.entity; + +import com.ktds.hi.review.biz.domain.ReactionType; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 리뷰 반응 엔티티 클래스 + * 데이터베이스 review_reactions 테이블과 매핑되는 JPA 엔티티 + */ +@Entity +@Table(name = "review_reactions", + uniqueConstraints = @UniqueConstraint(columnNames = {"review_id", "member_id"})) +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class ReviewReactionEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "review_id", nullable = false) + private Long reviewId; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(name = "reaction_type", nullable = false) + private ReactionType reactionType; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/gateway/repository/ReviewCommentJpaRepository.java b/review/src/main/java/com/ktds/hi/review/infra/gateway/repository/ReviewCommentJpaRepository.java new file mode 100644 index 0000000..c7dc5c1 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/gateway/repository/ReviewCommentJpaRepository.java @@ -0,0 +1,31 @@ +package com.ktds.hi.review.infra.gateway.repository; + +import com.ktds.hi.review.infra.gateway.entity.ReviewCommentEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * 리뷰 댓글 JPA 리포지토리 인터페이스 + * 리뷰 댓글 데이터의 CRUD 작업을 담당 + */ +@Repository +public interface ReviewCommentJpaRepository extends JpaRepository { + + /** + * 리뷰 ID로 댓글 목록 조회 (최신순) + */ + List findByReviewIdOrderByCreatedAtDesc(Long reviewId); + + /** + * 댓글 ID와 소유자 ID로 댓글 조회 + */ + Optional findByIdAndOwnerId(Long id, Long ownerId); + + /** + * 소유자 ID로 댓글 목록 조회 + */ + List findByOwnerIdOrderByCreatedAtDesc(Long ownerId); +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/gateway/repository/ReviewJpaRepository.java b/review/src/main/java/com/ktds/hi/review/infra/gateway/repository/ReviewJpaRepository.java new file mode 100644 index 0000000..7cf0d70 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/gateway/repository/ReviewJpaRepository.java @@ -0,0 +1,38 @@ +package com.ktds.hi.review.infra.gateway.repository; + +import com.ktds.hi.review.biz.domain.ReviewStatus; +import com.ktds.hi.review.infra.gateway.entity.ReviewEntity; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * 리뷰 JPA 리포지토리 인터페이스 + * 리뷰 데이터의 CRUD 작업을 담당 + */ +@Repository +public interface ReviewJpaRepository extends JpaRepository { + + /** + * 매장 ID와 상태로 리뷰 목록 조회 + */ + Page findByStoreIdAndStatus(Long storeId, ReviewStatus status, Pageable pageable); + + /** + * 회원 ID와 상태로 리뷰 목록 조회 + */ + Page findByMemberIdAndStatus(Long memberId, ReviewStatus status, Pageable pageable); + + /** + * 리뷰 ID와 회원 ID로 리뷰 조회 + */ + Optional findByIdAndMemberId(Long id, Long memberId); + + /** + * 매장 ID와 회원 ID로 리뷰 존재 여부 확인 + */ + boolean existsByStoreIdAndMemberId(Long storeId, Long memberId); +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/gateway/repository/ReviewReactionJpaRepository.java b/review/src/main/java/com/ktds/hi/review/infra/gateway/repository/ReviewReactionJpaRepository.java new file mode 100644 index 0000000..3be08db --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/gateway/repository/ReviewReactionJpaRepository.java @@ -0,0 +1,31 @@ +package com.ktds.hi.review.infra.gateway.repository; + +import com.ktds.hi.review.biz.domain.ReactionType; +import com.ktds.hi.review.infra.gateway.entity.ReviewReactionEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * 리뷰 반응 JPA 리포지토리 인터페이스 + * 리뷰 반응 데이터의 CRUD 작업을 담당 + */ +@Repository +public interface ReviewReactionJpaRepository extends JpaRepository { + + /** + * 리뷰 ID와 회원 ID로 반응 조회 + */ + Optional findByReviewIdAndMemberId(Long reviewId, Long memberId); + + /** + * 리뷰 ID와 반응 유형별 개수 조회 + */ + Long countByReviewIdAndReactionType(Long reviewId, ReactionType reactionType); + + /** + * 회원 ID로 반응 목록 조회 + */ + Long countByMemberId(Long memberId); +} diff --git a/review/src/main/resources/application.yml b/review/src/main/resources/application.yml new file mode 100644 index 0000000..855d35b --- /dev/null +++ b/review/src/main/resources/application.yml @@ -0,0 +1,42 @@ +server: + port: ${REVIEW_SERVICE_PORT:8083} + +spring: + application: + name: review-service + + datasource: + url: ${REVIEW_DB_URL:jdbc:postgresql://localhost:5432/hiorder_review} + username: ${REVIEW_DB_USERNAME:hiorder_user} + password: ${REVIEW_DB_PASSWORD:hiorder_pass} + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: ${JPA_DDL_AUTO:validate} + show-sql: ${JPA_SHOW_SQL:false} + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.PostgreSQLDialect + + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + + servlet: + multipart: + max-file-size: ${MAX_FILE_SIZE:10MB} + max-request-size: ${MAX_REQUEST_SIZE:50MB} + +file-storage: + base-path: ${FILE_STORAGE_PATH:/var/hiorder/uploads} + allowed-extensions: jpg,jpeg,png,gif,webp + max-file-size: 10485760 # 10MB + +springdoc: + api-docs: + path: /api-docs + swagger-ui: + path: /swagger-ui.html diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..7b005d7 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,8 @@ +rootProject.name = 'hiorder-backend' + +include 'common' +include 'member' +include 'store' +include 'review' +include 'analytics' +include 'recommend' diff --git a/store/build.gradle b/store/build.gradle new file mode 100644 index 0000000..b59d106 --- /dev/null +++ b/store/build.gradle @@ -0,0 +1,6 @@ +dependencies { + implementation project(':common') + + // External API Integration + implementation 'org.springframework.boot:spring-boot-starter-webflux' +} diff --git a/store/src/main/java/com/ktds/hi/store/biz/usecase/out/CachePort.java b/store/src/main/java/com/ktds/hi/store/biz/usecase/out/CachePort.java new file mode 100644 index 0000000..dd78c30 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/biz/usecase/out/CachePort.java @@ -0,0 +1,26 @@ +package com.ktds.hi.store.biz.usecase.out; + +import java.time.Duration; +import java.util.Optional; + +/** + * 캐시 포트 인터페이스 + * 캐시 기능을 정의 + */ +public interface CachePort { + + /** + * 캐시에서 매장 데이터 조회 + */ + Optional getStoreCache(String key); + + /** + * 캐시에 매장 데이터 저장 + */ + void putStoreCache(String key, Object value, Duration ttl); + + /** + * 캐시 무효화 + */ + void invalidateStoreCache(Long storeId); +} diff --git a/store/src/main/java/com/ktds/hi/store/biz/usecase/out/EventPort.java b/store/src/main/java/com/ktds/hi/store/biz/usecase/out/EventPort.java new file mode 100644 index 0000000..c947e6e --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/biz/usecase/out/EventPort.java @@ -0,0 +1,25 @@ +package com.ktds.hi.store.biz.usecase.out; + +import com.ktds.hi.store.biz.domain.Store; + +/** + * 이벤트 포트 인터페이스 + * 이벤트 발행 기능을 정의 + */ +public interface EventPort { + + /** + * 매장 생성 이벤트 발행 + */ + void publishStoreCreatedEvent(Store store); + + /** + * 매장 수정 이벤트 발행 + */ + void publishStoreUpdatedEvent(Store store); + + /** + * 매장 삭제 이벤트 발행 + */ + void publishStoreDeletedEvent(Long storeId); +} diff --git a/store/src/main/java/com/ktds/hi/store/infra/controller/ExternalIntegrationController.java b/store/src/main/java/com/ktds/hi/store/infra/controller/ExternalIntegrationController.java new file mode 100644 index 0000000..4d0b5b2 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/controller/ExternalIntegrationController.java @@ -0,0 +1,50 @@ +package com.ktds.hi.store.infra.controller; + +import com.ktds.hi.store.biz.usecase.in.ExternalIntegrationUseCase; +import com.ktds.hi.store.infra.dto.ExternalSyncRequest; +import com.ktds.hi.store.infra.dto.ExternalSyncResponse; +import com.ktds.hi.store.infra.dto.ExternalConnectRequest; +import com.ktds.hi.store.infra.dto.ExternalConnectResponse; +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.*; + +/** + * 외부 연동 컨트롤러 클래스 + * 외부 플랫폼 연동 관련 API를 제공 + */ +@RestController +@RequestMapping("/api/external") +@RequiredArgsConstructor +@Tag(name = "외부연동 API", description = "외부 플랫폼 연동 및 동기화 관련 API") +public class ExternalIntegrationController { + + private final ExternalIntegrationUseCase externalIntegrationUseCase; + + /** + * 외부 플랫폼 리뷰 동기화 API + */ + @PostMapping("/stores/{storeId}/sync-reviews") + @Operation(summary = "외부 플랫폼 리뷰 동기화", description = "외부 플랫폼의 리뷰를 동기화합니다.") + public ResponseEntity syncReviews( + @PathVariable Long storeId, + @Valid @RequestBody ExternalSyncRequest request) { + ExternalSyncResponse response = externalIntegrationUseCase.syncReviews(storeId, request); + return ResponseEntity.ok(response); + } + + /** + * 외부 플랫폼 계정 연동 API + */ + @PostMapping("/stores/{storeId}/connect") + @Operation(summary = "외부 플랫폼 계정 연동", description = "외부 플랫폼 계정을 연동합니다.") + public ResponseEntity connectPlatform( + @PathVariable Long storeId, + @Valid @RequestBody ExternalConnectRequest request) { + ExternalConnectResponse response = externalIntegrationUseCase.connectPlatform(storeId, request); + return ResponseEntity.ok(response); + } +} diff --git a/store/src/main/java/com/ktds/hi/store/infra/dto/ExternalConnectResponse.java b/store/src/main/java/com/ktds/hi/store/infra/dto/ExternalConnectResponse.java new file mode 100644 index 0000000..4b6143a --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/dto/ExternalConnectResponse.java @@ -0,0 +1,20 @@ +package com.ktds.hi.store.infra.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 외부 플랫폼 계정 연동 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ExternalConnectResponse { + + private Boolean success; + private String message; +} + diff --git a/store/src/main/java/com/ktds/hi/store/infra/dto/ExternalSyncResponse.java b/store/src/main/java/com/ktds/hi/store/infra/dto/ExternalSyncResponse.java new file mode 100644 index 0000000..343b164 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/dto/ExternalSyncResponse.java @@ -0,0 +1,20 @@ +package com.ktds.hi.store.infra.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 외부 플랫폼 동기화 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ExternalSyncResponse { + + private Boolean success; + private String message; + private Integer syncedCount; +} diff --git a/store/src/main/java/com/ktds/hi/store/infra/dto/MenuDeleteResponse.java b/store/src/main/java/com/ktds/hi/store/infra/dto/MenuDeleteResponse.java new file mode 100644 index 0000000..5de612e --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/dto/MenuDeleteResponse.java @@ -0,0 +1,19 @@ +package com.ktds.hi.store.infra.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 메뉴 삭제 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MenuDeleteResponse { + + private Boolean success; + private String message; +} diff --git a/store/src/main/java/com/ktds/hi/store/infra/dto/MenuSaveResponse.java b/store/src/main/java/com/ktds/hi/store/infra/dto/MenuSaveResponse.java new file mode 100644 index 0000000..7c1a6b2 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/dto/MenuSaveResponse.java @@ -0,0 +1,20 @@ +package com.ktds.hi.store.infra.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 메뉴 저장 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MenuSaveResponse { + + private Boolean success; + private String message; + private Long menuId; +} diff --git a/store/src/main/java/com/ktds/hi/store/infra/dto/MenuUpdateRequest.java b/store/src/main/java/com/ktds/hi/store/infra/dto/MenuUpdateRequest.java new file mode 100644 index 0000000..cfbd023 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/dto/MenuUpdateRequest.java @@ -0,0 +1,35 @@ +package com.ktds.hi.store.infra.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 메뉴 수정 요청 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MenuUpdateRequest { + + @NotBlank(message = "메뉴명은 필수입니다.") + @Size(max = 100, message = "메뉴명은 100자를 초과할 수 없습니다.") + private String menuName; + + @Size(max = 500, message = "설명은 500자를 초과할 수 없습니다.") + private String description; + + @NotNull(message = "가격은 필수입니다.") + @Min(value = 1, message = "가격은 0보다 큰 값이어야 합니다.") + private Integer price; + + private String category; + private String imageUrl; + private Boolean isAvailable; +} diff --git a/store/src/main/java/com/ktds/hi/store/infra/dto/MenuUpdateResponse.java b/store/src/main/java/com/ktds/hi/store/infra/dto/MenuUpdateResponse.java new file mode 100644 index 0000000..c345771 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/dto/MenuUpdateResponse.java @@ -0,0 +1,19 @@ +package com.ktds.hi.store.infra.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 메뉴 수정 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MenuUpdateResponse { + + private Boolean success; + private String message; +} diff --git a/store/src/main/java/com/ktds/hi/store/infra/dto/StoreDeleteResponse.java b/store/src/main/java/com/ktds/hi/store/infra/dto/StoreDeleteResponse.java new file mode 100644 index 0000000..77204a4 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/dto/StoreDeleteResponse.java @@ -0,0 +1,19 @@ +package com.ktds.hi.store.infra.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 매장 삭제 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StoreDeleteResponse { + + private Boolean success; + private String message; +} diff --git a/store/src/main/java/com/ktds/hi/store/infra/dto/StoreUpdateResponse.java b/store/src/main/java/com/ktds/hi/store/infra/dto/StoreUpdateResponse.java new file mode 100644 index 0000000..d42a481 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/dto/StoreUpdateResponse.java @@ -0,0 +1,19 @@ +package com.ktds.hi.store.infra.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 매장 수정 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StoreUpdateResponse { + + private Boolean success; + private String message; +} diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/CacheAdapter.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/CacheAdapter.java new file mode 100644 index 0000000..487e172 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/CacheAdapter.java @@ -0,0 +1,58 @@ +package com.ktds.hi.store.infra.gateway; + +import com.ktds.hi.store.biz.usecase.out.CachePort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Optional; + +/** + * 캐시 어댑터 클래스 + * Cache Port를 구현하여 Redis 캐시 기능을 제공 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class CacheAdapter implements CachePort { + + private final RedisTemplate redisTemplate; + + @Override + public Optional getStoreCache(String key) { + try { + Object value = redisTemplate.opsForValue().get(key); + return Optional.ofNullable(value); + } catch (Exception e) { + log.error("매장 캐시 조회 실패: key={}, error={}", key, e.getMessage()); + return Optional.empty(); + } + } + + @Override + public void putStoreCache(String key, Object value, Duration ttl) { + try { + redisTemplate.opsForValue().set(key, value, ttl); + log.debug("매장 캐시 저장 완료: key={}, ttl={}분", key, ttl.toMinutes()); + } catch (Exception e) { + log.error("매장 캐시 저장 실패: key={}, error={}", key, e.getMessage()); + } + } + + @Override + public void invalidateStoreCache(Long storeId) { + try { + // 매장 관련 모든 캐시 키 패턴 삭제 + String storeDetailKey = "store_detail:" + storeId; + String myStoresKey = "my_stores:*"; + + redisTemplate.delete(storeDetailKey); + + log.debug("매장 캐시 무효화 완료: storeId={}", storeId); + } catch (Exception e) { + log.error("매장 캐시 무효화 실패: storeId={}, error={}", storeId, e.getMessage()); + } + } +} diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/EventAdapter.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/EventAdapter.java new file mode 100644 index 0000000..e8c69ea --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/EventAdapter.java @@ -0,0 +1,104 @@ +package com.ktds.hi.store.infra.gateway; + +import com.ktds.hi.store.biz.domain.Store; +import com.ktds.hi.store.biz.usecase.out.EventPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * 이벤트 어댑터 클래스 + * Event Port를 구현하여 이벤트 발행 기능을 제공 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class EventAdapter implements EventPort { + + private final ApplicationEventPublisher eventPublisher; + + @Override + public void publishStoreCreatedEvent(Store store) { + log.info("매장 생성 이벤트 발행: storeId={}", store.getId()); + + try { + StoreCreatedEvent event = new StoreCreatedEvent(store); + eventPublisher.publishEvent(event); + + log.info("매장 생성 이벤트 발행 완료: storeId={}", store.getId()); + + } catch (Exception e) { + log.error("매장 생성 이벤트 발행 실패: storeId={}, error={}", store.getId(), e.getMessage(), e); + } + } + + @Override + public void publishStoreUpdatedEvent(Store store) { + log.info("매장 수정 이벤트 발행: storeId={}", store.getId()); + + try { + StoreUpdatedEvent event = new StoreUpdatedEvent(store); + eventPublisher.publishEvent(event); + + log.info("매장 수정 이벤트 발행 완료: storeId={}", store.getId()); + + } catch (Exception e) { + log.error("매장 수정 이벤트 발행 실패: storeId={}, error={}", store.getId(), e.getMessage(), e); + } + } + + @Override + public void publishStoreDeletedEvent(Long storeId) { + log.info("매장 삭제 이벤트 발행: storeId={}", storeId); + + try { + StoreDeletedEvent event = new StoreDeletedEvent(storeId); + eventPublisher.publishEvent(event); + + log.info("매장 삭제 이벤트 발행 완료: storeId={}", storeId); + + } catch (Exception e) { + log.error("매장 삭제 이벤트 발행 실패: storeId={}, error={}", storeId, e.getMessage(), e); + } + } + + /** + * 매장 이벤트 클래스들 + */ + public static class StoreCreatedEvent { + private final Store store; + + public StoreCreatedEvent(Store store) { + this.store = store; + } + + public Store getStore() { + return store; + } + } + + public static class StoreUpdatedEvent { + private final Store store; + + public StoreUpdatedEvent(Store store) { + this.store = store; + } + + public Store getStore() { + return store; + } + } + + public static class StoreDeletedEvent { + private final Long storeId; + + public StoreDeletedEvent(Long storeId) { + this.storeId = storeId; + } + + public Long getStoreId() { + return storeId; + } + } +} diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/ExternalPlatformAdapter.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/ExternalPlatformAdapter.java new file mode 100644 index 0000000..adef64a --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/ExternalPlatformAdapter.java @@ -0,0 +1,226 @@ +package com.ktds.hi.store.infra.gateway; + +import com.ktds.hi.store.biz.usecase.out.ExternalPlatformPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +/** + * 외부 플랫폼 어댑터 클래스 + * External Platform Port를 구현하여 외부 API 연동 기능을 제공 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class ExternalPlatformAdapter implements ExternalPlatformPort { + + private final RestTemplate restTemplate; + + @Value("${external-api.naver.client-id:}") + private String naverClientId; + + @Value("${external-api.naver.client-secret:}") + private String naverClientSecret; + + @Value("${external-api.kakao.api-key:}") + private String kakaoApiKey; + + @Value("${external-api.google.api-key:}") + private String googleApiKey; + + @Value("${external-api.hiorder.api-key:}") + private String hiorderApiKey; + + @Override + public int syncNaverReviews(Long storeId, String externalStoreId) { + log.info("네이버 리뷰 동기화 시작: storeId={}, externalStoreId={}", storeId, externalStoreId); + + try { + // 네이버 API 호출 (Mock) + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Naver-Client-Id", naverClientId); + headers.set("X-Naver-Client-Secret", naverClientSecret); + + // 실제 API 호출 로직 + // ResponseEntity response = restTemplate.exchange(...); + + // Mock 응답 + int syncedCount = 15; // Mock 데이터 + + log.info("네이버 리뷰 동기화 완료: storeId={}, syncedCount={}", storeId, syncedCount); + return syncedCount; + + } catch (Exception e) { + log.error("네이버 리뷰 동기화 실패: storeId={}, error={}", storeId, e.getMessage(), e); + return 0; + } + } + + @Override + public int syncKakaoReviews(Long storeId, String externalStoreId) { + log.info("카카오 리뷰 동기화 시작: storeId={}, externalStoreId={}", storeId, externalStoreId); + + try { + // 카카오 API 호출 (Mock) + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "KakaoAK " + kakaoApiKey); + + // Mock 응답 + int syncedCount = 12; + + log.info("카카오 리뷰 동기화 완료: storeId={}, syncedCount={}", storeId, syncedCount); + return syncedCount; + + } catch (Exception e) { + log.error("카카오 리뷰 동기화 실패: storeId={}, error={}", storeId, e.getMessage(), e); + return 0; + } + } + + @Override + public int syncGoogleReviews(Long storeId, String externalStoreId) { + log.info("구글 리뷰 동기화 시작: storeId={}, externalStoreId={}", storeId, externalStoreId); + + try { + // 구글 Places API 호출 (Mock) + String url = "https://maps.googleapis.com/maps/api/place/details/json?place_id=" + + externalStoreId + "&fields=reviews&key=" + googleApiKey; + + // Mock 응답 + int syncedCount = 20; + + log.info("구글 리뷰 동기화 완료: storeId={}, syncedCount={}", storeId, syncedCount); + return syncedCount; + + } catch (Exception e) { + log.error("구글 리뷰 동기화 실패: storeId={}, error={}", storeId, e.getMessage(), e); + return 0; + } + } + + @Override + public int syncHiorderReviews(Long storeId, String externalStoreId) { + log.info("하이오더 리뷰 동기화 시작: storeId={}, externalStoreId={}", storeId, externalStoreId); + + try { + // 하이오더 API 호출 (Mock) + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + hiorderApiKey); + + // Mock 응답 + int syncedCount = 8; + + log.info("하이오더 리뷰 동기화 완료: storeId={}, syncedCount={}", storeId, syncedCount); + return syncedCount; + + } catch (Exception e) { + log.error("하이오더 리뷰 동기화 실패: storeId={}, error={}", storeId, e.getMessage(), e); + return 0; + } + } + + @Override + public boolean connectNaverAccount(Long storeId, String username, String password) { + log.info("네이버 계정 연동 시작: storeId={}, username={}", storeId, username); + + try { + // 네이버 계정 인증 로직 (Mock) + // 실제로는 OAuth2 플로우나 ID/PW 인증 + + // Mock 성공 + boolean connected = true; + + if (connected) { + // 연동 정보 저장 + saveExternalConnection(storeId, "NAVER", username); + } + + log.info("네이버 계정 연동 완료: storeId={}, connected={}", storeId, connected); + return connected; + + } catch (Exception e) { + log.error("네이버 계정 연동 실패: storeId={}, error={}", storeId, e.getMessage(), e); + return false; + } + } + + @Override + public boolean connectKakaoAccount(Long storeId, String username, String password) { + log.info("카카오 계정 연동 시작: storeId={}, username={}", storeId, username); + + try { + // 카카오 계정 인증 로직 (Mock) + boolean connected = true; + + if (connected) { + saveExternalConnection(storeId, "KAKAO", username); + } + + log.info("카카오 계정 연동 완료: storeId={}, connected={}", storeId, connected); + return connected; + + } catch (Exception e) { + log.error("카카오 계정 연동 실패: storeId={}, error={}", storeId, e.getMessage(), e); + return false; + } + } + + @Override + public boolean connectGoogleAccount(Long storeId, String username, String password) { + log.info("구글 계정 연동 시작: storeId={}, username={}", storeId, username); + + try { + // 구글 계정 인증 로직 (Mock) + boolean connected = true; + + if (connected) { + saveExternalConnection(storeId, "GOOGLE", username); + } + + log.info("구글 계정 연동 완료: storeId={}, connected={}", storeId, connected); + return connected; + + } catch (Exception e) { + log.error("구글 계정 연동 실패: storeId={}, error={}", storeId, e.getMessage(), e); + return false; + } + } + + @Override + public boolean connectHiorderAccount(Long storeId, String username, String password) { + log.info("하이오더 계정 연동 시작: storeId={}, username={}", storeId, username); + + try { + // 하이오더 계정 인증 로직 (Mock) + boolean connected = true; + + if (connected) { + saveExternalConnection(storeId, "HIORDER", username); + } + + log.info("하이오더 계정 연동 완료: storeId={}, connected={}", storeId, connected); + return connected; + + } catch (Exception e) { + log.error("하이오더 계정 연동 실패: storeId={}, error={}", storeId, e.getMessage(), e); + return false; + } + } + + /** + * 외부 연동 정보 저장 + */ + private void saveExternalConnection(Long storeId, String platform, String username) { + // 실제로는 ExternalPlatformEntity에 연동 정보 저장 + log.info("외부 연동 정보 저장: storeId={}, platform={}, username={}", storeId, platform, username); + } +} + diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/MenuRepositoryAdapter.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/MenuRepositoryAdapter.java new file mode 100644 index 0000000..cfc829a --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/MenuRepositoryAdapter.java @@ -0,0 +1,85 @@ +package com.ktds.hi.store.infra.gateway; + +import com.ktds.hi.store.biz.domain.Menu; +import com.ktds.hi.store.biz.usecase.out.MenuRepositoryPort; +import com.ktds.hi.store.infra.gateway.entity.MenuEntity; +import com.ktds.hi.store.infra.gateway.repository.MenuJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 메뉴 리포지토리 어댑터 클래스 + * Menu Repository Port를 구현하여 데이터 영속성 기능을 제공 + */ +@Component +@RequiredArgsConstructor +public class MenuRepositoryAdapter implements MenuRepositoryPort { + + private final MenuJpaRepository menuJpaRepository; + + @Override + public List findMenusByStoreId(Long storeId) { + return menuJpaRepository.findByStoreId(storeId) + .stream() + .map(this::toDomain) + .collect(Collectors.toList()); + } + + @Override + public Optional findMenuById(Long menuId) { + return menuJpaRepository.findById(menuId) + .map(this::toDomain); + } + + @Override + public Menu saveMenu(Menu menu) { + MenuEntity entity = toEntity(menu); + MenuEntity saved = menuJpaRepository.save(entity); + return toDomain(saved); + } + + @Override + public void deleteMenu(Long menuId) { + menuJpaRepository.deleteById(menuId); + } + + /** + * Entity를 Domain으로 변환 + */ + private Menu toDomain(MenuEntity entity) { + return Menu.builder() + .id(entity.getId()) + .storeId(entity.getStoreId()) + .menuName(entity.getMenuName()) + .description(entity.getDescription()) + .price(entity.getPrice()) + .category(entity.getCategory()) + .imageUrl(entity.getImageUrl()) + .isAvailable(entity.getIsAvailable()) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .build(); + } + + /** + * Domain을 Entity로 변환 + */ + private MenuEntity toEntity(Menu domain) { + return MenuEntity.builder() + .id(domain.getId()) + .storeId(domain.getStoreId()) + .menuName(domain.getMenuName()) + .description(domain.getDescription()) + .price(domain.getPrice()) + .category(domain.getCategory()) + .imageUrl(domain.getImageUrl()) + .isAvailable(domain.getIsAvailable()) + .createdAt(domain.getCreatedAt()) + .updatedAt(domain.getUpdatedAt()) + .build(); + } +} diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/StoreRepositoryAdapter.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/StoreRepositoryAdapter.java new file mode 100644 index 0000000..18bb140 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/StoreRepositoryAdapter.java @@ -0,0 +1,115 @@ +package com.ktds.hi.store.infra.gateway; + +import com.ktds.hi.store.biz.domain.Store; +import com.ktds.hi.store.biz.usecase.out.StoreRepositoryPort; +import com.ktds.hi.store.infra.gateway.entity.StoreEntity; +import com.ktds.hi.store.infra.gateway.repository.StoreJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 매장 리포지토리 어댑터 클래스 + * Store Repository Port를 구현하여 데이터 영속성 기능을 제공 + */ +@Component +@RequiredArgsConstructor +public class StoreRepositoryAdapter implements StoreRepositoryPort { + + private final StoreJpaRepository storeJpaRepository; + + @Override + public List findStoresByOwnerId(Long ownerId) { + return storeJpaRepository.findByOwnerId(ownerId) + .stream() + .map(this::toDomain) + .collect(Collectors.toList()); + } + + @Override + public Optional findStoreById(Long storeId) { + return storeJpaRepository.findById(storeId) + .map(this::toDomain); + } + + @Override + public Optional findStoreByIdAndOwnerId(Long storeId, Long ownerId) { + return storeJpaRepository.findByIdAndOwnerId(storeId, ownerId) + .map(this::toDomain); + } + + @Override + public Store saveStore(Store store) { + StoreEntity entity = toEntity(store); + StoreEntity saved = storeJpaRepository.save(entity); + return toDomain(saved); + } + + @Override + public void deleteStore(Long storeId) { + storeJpaRepository.deleteById(storeId); + } + + /** + * Entity를 Domain으로 변환 + */ + private Store toDomain(StoreEntity entity) { + return Store.builder() + .id(entity.getId()) + .ownerId(entity.getOwnerId()) + .storeName(entity.getStoreName()) + .address(entity.getAddress()) + .latitude(entity.getLatitude()) + .longitude(entity.getLongitude()) + .category(entity.getCategory()) + .description(entity.getDescription()) + .phone(entity.getPhone()) + .operatingHours(entity.getOperatingHours()) + .tags(entity.getTagsJson() != null ? parseTagsJson(entity.getTagsJson()) : List.of()) + .status(entity.getStatus()) + .rating(entity.getRating()) + .reviewCount(entity.getReviewCount()) + .imageUrl(entity.getImageUrl()) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .build(); + } + + /** + * Domain을 Entity로 변환 + */ + private StoreEntity toEntity(Store domain) { + return StoreEntity.builder() + .id(domain.getId()) + .ownerId(domain.getOwnerId()) + .storeName(domain.getStoreName()) + .address(domain.getAddress()) + .latitude(domain.getLatitude()) + .longitude(domain.getLongitude()) + .category(domain.getCategory()) + .description(domain.getDescription()) + .phone(domain.getPhone()) + .operatingHours(domain.getOperatingHours()) + .tagsJson(domain.getTags() != null ? String.join(",", domain.getTags()) : "") + .status(domain.getStatus()) + .rating(domain.getRating()) + .reviewCount(domain.getReviewCount()) + .imageUrl(domain.getImageUrl()) + .createdAt(domain.getCreatedAt()) + .updatedAt(domain.getUpdatedAt()) + .build(); + } + + /** + * JSON 태그를 List로 파싱 + */ + private List parseTagsJson(String tagsJson) { + if (tagsJson == null || tagsJson.trim().isEmpty()) { + return List.of(); + } + return Arrays.asList(tagsJson.split(",")); + } +} diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/StoreRepositoryAdapter.java 수정 (import 추가) b/store/src/main/java/com/ktds/hi/store/infra/gateway/StoreRepositoryAdapter.java 수정 (import 추가) new file mode 100644 index 0000000..a9eea0d --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/StoreRepositoryAdapter.java 수정 (import 추가) @@ -0,0 +1,13 @@ +package com.ktds.hi.store.infra.gateway; + +import com.ktds.hi.store.biz.domain.Store; +import com.ktds.hi.store.biz.usecase.out.StoreRepositoryPort; +import com.ktds.hi.store.infra.gateway.entity.StoreEntity; +import com.ktds.hi.store.infra.gateway.repository.StoreJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Arrays; // 추가 +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/MenuEntity.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/MenuEntity.java new file mode 100644 index 0000000..9c3c65b --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/MenuEntity.java @@ -0,0 +1,60 @@ +package com.ktds.hi.store.infra.gateway.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 메뉴 엔티티 클래스 + * 데이터베이스 menus 테이블과 매핑되는 JPA 엔티티 + */ +@Entity +@Table(name = "menus") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class MenuEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(name = "menu_name", nullable = false, length = 100) + private String menuName; + + @Column(length = 500) + private String description; + + @Column(nullable = false) + private Integer price; + + @Column(length = 50) + private String category; + + @Column(name = "image_url", length = 500) + private String imageUrl; + + @Column(name = "is_available") + @Builder.Default + private Boolean isAvailable = true; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/repository/MenuJpaRepository.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/repository/MenuJpaRepository.java new file mode 100644 index 0000000..58bdefd --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/repository/MenuJpaRepository.java @@ -0,0 +1,35 @@ +package com.ktds.hi.store.infra.gateway.repository; + +import com.ktds.hi.store.infra.gateway.entity.MenuEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * 메뉴 JPA 리포지토리 인터페이스 + * 메뉴 데이터의 CRUD 작업을 담당 + */ +@Repository +public interface MenuJpaRepository extends JpaRepository { + + /** + * 매장 ID로 이용 가능한 메뉴 목록 조회 + */ + List findByStoreIdAndIsAvailableTrue(Long storeId); + + /** + * 매장 ID로 모든 메뉴 목록 조회 + */ + List findByStoreId(Long storeId); + + /** + * 매장 ID로 메뉴 삭제 + */ + void deleteByStoreId(Long storeId); + + /** + * 카테고리별 메뉴 조회 + */ + List findByStoreIdAndCategoryAndIsAvailableTrue(Long storeId, String category); +} diff --git a/store/src/main/resources/application.yml b/store/src/main/resources/application.yml new file mode 100644 index 0000000..0651ce6 --- /dev/null +++ b/store/src/main/resources/application.yml @@ -0,0 +1,47 @@ +server: + port: ${STORE_SERVICE_PORT:8082} + +spring: + application: + name: store-service + + datasource: + url: ${STORE_DB_URL:jdbc:postgresql://localhost:5432/hiorder_store} + username: ${STORE_DB_USERNAME:hiorder_user} + password: ${STORE_DB_PASSWORD:hiorder_pass} + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: ${JPA_DDL_AUTO:validate} + show-sql: ${JPA_SHOW_SQL:false} + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.PostgreSQLDialect + + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + +external-api: + naver: + client-id: ${NAVER_CLIENT_ID:} + client-secret: ${NAVER_CLIENT_SECRET:} + base-url: https://openapi.naver.com + kakao: + api-key: ${KAKAO_API_KEY:} + base-url: https://dapi.kakao.com + google: + api-key: ${GOOGLE_API_KEY:} + base-url: https://maps.googleapis.com + hiorder: + api-key: ${HIORDER_API_KEY:} + base-url: ${HIORDER_BASE_URL:https://api.hiorder.com} + +springdoc: + api-docs: + path: /api-docs + swagger-ui: + path: /swagger-ui.html diff --git a/누락된 import 수정 및 추가 클래스들 b/누락된 import 수정 및 추가 클래스들 new file mode 100644 index 0000000..e69de29 diff --git a/추가 설정 파일 b/추가 설정 파일 new file mode 100644 index 0000000..e69de29