This commit is contained in:
lsh9672 2025-06-11 16:31:06 +09:00
commit f0fbb47c51
164 changed files with 8667 additions and 0 deletions

58
.gitignore vendored Normal file
View File

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

6
analytics/build.gradle Normal file
View File

@ -0,0 +1,6 @@
dependencies {
implementation project(':common')
// AI APIs
implementation 'org.springframework.boot:spring-boot-starter-webflux'
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<ActionPlan> findActionPlansByStoreId(Long storeId) {
return actionPlanJpaRepository.findByStoreIdOrderByCreatedAtDesc(storeId)
.stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
@Override
public Optional<ActionPlan> 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<String> 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<String> tasks) {
if (tasks == null || tasks.isEmpty()) {
return "[]";
}
return "[\"" + String.join("\",\"", tasks) + "\"]";
}
}

View File

@ -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<String> 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<String> generateActionPlan(AiFeedback feedback) {
log.info("실행 계획 생성 시작: sentiment={}", feedback.getSentiment());
try {
// AI 기반 실행 계획 생성 (Mock)
List<String> 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<String, Object> requestBody = Map.of(
"model", "gpt-3.5-turbo",
"messages", List.of(
Map.of("role", "user", "content",
"다음 리뷰의 감정을 분석해주세요. POSITIVE, NEGATIVE, NEUTRAL 중 하나로 답해주세요: " + content)
),
"max_tokens", 10
);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
ResponseEntity<Map> response = restTemplate.exchange(
"https://api.openai.com/v1/chat/completions",
HttpMethod.POST,
entity,
Map.class
);
// API 응답 파싱
Map<String, Object> responseBody = response.getBody();
if (responseBody != null && responseBody.containsKey("choices")) {
List<Map<String, Object>> choices = (List<Map<String, Object>>) responseBody.get("choices");
if (!choices.isEmpty()) {
Map<String, Object> message = (Map<String, Object>) 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<String> positiveKeywords = List.of("맛있", "", "최고", "추천", "만족", "친절", "깔끔");
List<String> 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<String> reviewData, SentimentType sentiment) {
switch (sentiment) {
case POSITIVE:
return "고객들이 음식의 맛과 서비스에 대해 전반적으로 만족하고 있습니다. 특히 음식의 품질과 직원의 친절함이 높이 평가받고 있습니다.";
case NEGATIVE:
return "일부 고객들이 음식의 맛이나 서비스에 대해 불만을 표현하고 있습니다. 주로 대기시간과 음식의 온도에 대한 개선이 필요해 보입니다.";
default:
return "고객 리뷰가 긍정적인 면과 개선이 필요한 면이 혼재되어 있습니다. 지속적인 품질 관리가 필요합니다.";
}
}
/**
* Mock 긍정 포인트 생성
*/
private List<String> generateMockPositivePoints() {
return List.of(
"음식의 맛이 좋다는 평가",
"직원들이 친절하다는 의견",
"매장이 깔끔하고 청결함",
"가격 대비 만족스러운 품질"
);
}
/**
* Mock 부정 포인트 생성
*/
private List<String> generateMockNegativePoints() {
return List.of(
"주문 후 대기시간이 다소 길음",
"일부 메뉴의 간이 짜다는 의견",
"주차 공간이 부족함"
);
}
/**
* Mock 추천사항 생성
*/
private List<String> generateMockRecommendations() {
return List.of(
"주문 처리 시간 단축을 위한 시스템 개선",
"메뉴별 간 조절에 대한 재검토",
"고객 대기 공간 개선",
"직원 서비스 교육 지속 실시",
"주차 환경 개선 방안 검토"
);
}
/**
* 신뢰도 계산
*/
private Double calculateConfidence(List<String> 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();
}
}

View File

@ -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; // 추가

View File

@ -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<Analytics> 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<AiFeedback> 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<String> 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<String> list) {
if (list == null || list.isEmpty()) {
return "[]";
}
return "[\"" + String.join("\",\"", list) + "\"]";
}
}

View File

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

View File

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

View File

@ -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<String> getReviewData(Long storeId) {
log.info("외부 리뷰 데이터 조회 시작: storeId={}", storeId);
try {
// 실제로는 Review Service와 연동하여 리뷰 데이터를 가져옴
// Mock 데이터 반환
List<String> 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<String> getRecentReviews(Long storeId, Integer days) {
log.info("최근 리뷰 데이터 조회 시작: storeId={}, days={}", storeId, days);
try {
// 실제로는 최근 N일간의 리뷰만 필터링
List<String> allReviews = getReviewData(storeId);
// Mock: 최근 리뷰는 전체 리뷰의 70% 정도로 가정
int recentCount = (int) (allReviews.size() * 0.7);
List<String> 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<String> 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;
}
}
}

View File

@ -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<Long> getFeedbackIdsList() {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(feedbackIdsJson, new TypeReference<List<Long>>() {});
} catch (Exception e) {
return List.of();
}
}
}

View File

@ -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<String, Object> getPositivePointsMap() {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(positivePointsJson, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
return Map.of();
}
}
public Map<String, Object> getNegativePointsMap() {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(negativePointsJson, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
return Map.of();
}
}
public List<String> getRecommendationsList() {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(recommendationsJson, new TypeReference<List<String>>() {});
} catch (Exception e) {
return List.of();
}
}
}

View File

@ -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<String, Object> getAgeStatisticsMap() {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(ageStatisticsJson, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
return Map.of();
}
}
public Map<String, Object> getGenderStatisticsMap() {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(genderStatisticsJson, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
return Map.of();
}
}
public Map<String, Object> getTimeStatisticsMap() {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(timeStatisticsJson, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
return Map.of();
}
}
public Map<String, Object> getMenuPopularityMap() {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(menuPopularityJson, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
return Map.of();
}
}
}

View File

@ -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<ActionPlanEntity, Long> {
/**
* 매장 ID로 실행 계획 목록 조회 (최신순)
*/
List<ActionPlanEntity> findByStoreIdOrderByCreatedAtDesc(Long storeId);
/**
* 매장 ID와 상태로 실행 계획 목록 조회 (최신순)
*/
List<ActionPlanEntity> findByStoreIdAndStatusOrderByCreatedAtDesc(Long storeId, PlanStatus status);
/**
* 사용자 ID로 실행 계획 목록 조회
*/
List<ActionPlanEntity> findByUserIdOrderByCreatedAtDesc(Long userId);
/**
* 매장 ID와 사용자 ID로 실행 계획 목록 조회
*/
List<ActionPlanEntity> findByStoreIdAndUserIdOrderByCreatedAtDesc(Long storeId, Long userId);
}

View File

@ -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<AiFeedbackEntity, Long> {
/**
* 매장 ID와 분석 기간으로 AI 피드백 목록 조회
*/
List<AiFeedbackEntity> findByStoreIdAndAnalysisDateBetweenOrderByAnalysisDateDesc(
Long storeId, LocalDate startDate, LocalDate endDate);
/**
* 매장 ID로 최신 AI 피드백 조회
*/
AiFeedbackEntity findTopByStoreIdOrderByCreatedAtDesc(Long storeId);
/**
* 특정 날짜의 AI 피드백 조회
*/
List<AiFeedbackEntity> findByStoreIdAndAnalysisDate(Long storeId, LocalDate analysisDate);
}

View File

@ -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<StatisticsEntity, Long> {
/**
* 매장 ID와 분석 기간으로 통계 조회
*/
List<StatisticsEntity> findByStoreIdAndAnalysisDateBetween(
Long storeId, LocalDate startDate, LocalDate endDate);
/**
* 매장 ID와 특정 날짜로 통계 조회
*/
Optional<StatisticsEntity> findByStoreIdAndAnalysisDate(Long storeId, LocalDate analysisDate);
/**
* 매장 ID로 최신 통계 조회
*/
Optional<StatisticsEntity> findTopByStoreIdOrderByAnalysisDateDesc(Long storeId);
}

View File

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

View File

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

View File

@ -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()
}
}

View File

@ -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 {
// 설정 클래스는 어노테이션만으로도 충분
}

View File

@ -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";
}
}
}

View File

@ -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<AuditLog, Long> {
/**
* 사용자별 감사 로그 조회
*/
Page<AuditLog> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
/**
* 액션별 감사 로그 조회
*/
Page<AuditLog> findByActionOrderByCreatedAtDesc(AuditAction action, Pageable pageable);
/**
* 엔티티별 감사 로그 조회
*/
Page<AuditLog> 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<AuditLog> 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<Object[]> 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<Object[]> findDailyLogCounts(@Param("startDate") LocalDateTime startDate);
}

View File

@ -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<String> roles;
private final boolean enabled;
private final boolean accountNonExpired;
private final boolean accountNonLocked;
private final boolean credentialsNonExpired;
@Override
public Collection<? extends GrantedAuthority> 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<String> 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<String> roles,
boolean enabled, boolean accountNonExpired,
boolean accountNonLocked, boolean credentialsNonExpired) {
return new CustomUserDetails(
id, username, email, password, roles,
enabled, accountNonExpired, accountNonLocked, credentialsNonExpired
);
}
}

View File

@ -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<SimpleGrantedAuthority> 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<Void> 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("**", ".*")));
}
}

View File

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

View File

@ -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<Long> getCurrentUserId() {
return getCurrentAuthentication()
.map(Authentication::getName)
.map(Long::parseLong);
}
/**
* 현재 인증된 사용자명 조회
*/
public static Optional<String> getCurrentUsername() {
return getCurrentAuthentication()
.map(Authentication::getName);
}
/**
* 현재 인증된 사용자의 권한 조회
*/
public static Set<String> 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<String> userRoles = getCurrentUserRoles();
for (String role : roles) {
if (userRoles.contains(role)) {
return true;
}
}
return false;
}
/**
* 현재 사용자가 모든 권한을 가지는지 확인
*/
public static boolean hasAllRoles(String... roles) {
Set<String> 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<Authentication> 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");
}
}

View File

@ -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);
}
}

View File

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

6
member/build.gradle Normal file
View File

@ -0,0 +1,6 @@
dependencies {
implementation project(':common')
// SMS Service (Optional)
implementation 'net.nurigo:sdk:4.3.0'
}

View File

@ -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);
}
}

View File

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

View File

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

View File

@ -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();
}
}

View File

@ -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"));
}
}

View File

@ -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<TokenResponse> login(@Valid @RequestBody LoginRequest request) {
TokenResponse response = authService.login(request);
return ResponseEntity.ok(response);
}
/**
* 로그아웃 API
*/
@PostMapping("/logout")
@Operation(summary = "로그아웃", description = "현재 로그인된 사용자를 로그아웃 처리합니다.")
public ResponseEntity<SuccessResponse> logout(@Valid @RequestBody LogoutRequest request) {
authService.logout(request);
return ResponseEntity.ok(SuccessResponse.of("로그아웃이 완료되었습니다"));
}
/**
* 토큰 갱신 API
*/
@PostMapping("/refresh")
@Operation(summary = "토큰 갱신", description = "리프레시 토큰을 사용해 새로운 액세스 토큰을 발급받습니다.")
public ResponseEntity<TokenResponse> refreshToken(@RequestParam String refreshToken) {
TokenResponse response = authService.refreshToken(refreshToken);
return ResponseEntity.ok(response);
}
/**
* 아이디 찾기 API
*/
@PostMapping("/find-username")
@Operation(summary = "아이디 찾기", description = "전화번호를 사용해 아이디를 찾습니다.")
public ResponseEntity<SuccessResponse> 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<SuccessResponse> 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<SuccessResponse> sendSmsVerification(@RequestParam String phone) {
authService.sendSmsVerification(phone);
return ResponseEntity.ok(SuccessResponse.of("인증번호가 발송되었습니다"));
}
/**
* SMS 인증번호 확인 API
*/
@PostMapping("/sms/verify")
@Operation(summary = "SMS 인증번호 확인", description = "입력한 인증번호가 올바른지 확인합니다.")
public ResponseEntity<SuccessResponse> 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("인증번호가 올바르지 않습니다"));
}
}
}

View File

@ -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<SuccessResponse> 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<MyPageResponse> 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<SuccessResponse> 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<SuccessResponse> 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<SuccessResponse> 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<SuccessResponse> 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<SuccessResponse> checkNicknameAvailability(@RequestParam String nickname) {
boolean isAvailable = memberService.checkNicknameAvailability(nickname);
String message = isAvailable ? "사용 가능한 닉네임입니다" : "이미 사용 중인 닉네임입니다";
return ResponseEntity.ok(SuccessResponse.of(message));
}
}

View File

@ -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<SuccessResponse> 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<List<TasteTagResponse>> getAvailableTags() {
List<TasteTagResponse> tags = preferenceService.getAvailableTags();
return ResponseEntity.ok(tags);
}
/**
* 태그 유형별 태그 목록 조회 API
*/
@GetMapping("/tags/by-type")
@Operation(summary = "유형별 태그 목록 조회", description = "특정 유형의 취향 태그 목록을 조회합니다.")
public ResponseEntity<List<TasteTagResponse>> getTagsByType(@RequestParam TagType tagType) {
List<TasteTagResponse> tags = preferenceService.getTagsByType(tagType);
return ResponseEntity.ok(tags);
}
}

View File

@ -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();
}
}

View File

@ -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<String> tags;
private String healthInfo;
private String spicyLevel;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* 취향 정보 업데이트
*/
public Preference updatePreference(List<String> 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();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<String> preferences;
@Schema(description = "건강 정보")
private String healthInfo;
@Schema(description = "매운맛 선호도")
private String spicyLevel;
}

View File

@ -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<String> tags;
@Schema(description = "건강 정보", example = "당뇨 있음")
private String healthInfo;
@Schema(description = "매운맛 선호도", example = "보통")
private String spicyLevel;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<String> 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<String> newTags, String newHealthInfo, String newSpicyLevel) {
this.tags = newTags;
this.healthInfo = newHealthInfo;
this.spicyLevel = newSpicyLevel;
}
}

View File

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

View File

@ -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<MemberEntity, Long> {
/**
* 사용자명으로 회원 조회
*/
Optional<MemberEntity> findByUsername(String username);
/**
* 닉네임으로 회원 조회
*/
Optional<MemberEntity> findByNickname(String nickname);
/**
* 전화번호로 회원 조회
*/
Optional<MemberEntity> findByPhone(String phone);
/**
* 사용자명 존재 여부 확인
*/
boolean existsByUsername(String username);
/**
* 닉네임 존재 여부 확인
*/
boolean existsByNickname(String nickname);
}

View File

@ -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<PreferenceEntity, Long> {
/**
* 회원 ID로 취향 정보 조회
*/
Optional<PreferenceEntity> findByMemberId(Long memberId);
/**
* 회원 ID로 취향 정보 존재 여부 확인
*/
boolean existsByMemberId(Long memberId);
/**
* 회원 ID로 취향 정보 삭제
*/
void deleteByMemberId(Long memberId);
}

View File

@ -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<TasteTagEntity, Long> {
/**
* 활성화된 태그 목록 조회
*/
List<TasteTagEntity> findByIsActiveTrue();
/**
* 태그 유형별 태그 목록 조회
*/
List<TasteTagEntity> findByTagTypeAndIsActiveTrue(TagType tagType);
/**
* 태그명으로 태그 조회
*/
List<TasteTagEntity> findByTagNameIn(List<String> tagNames);
}

View File

@ -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);
}

View File

@ -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<String, String> 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);
}
}

View File

@ -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();
}
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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<TasteTagResponse> getAvailableTags();
/**
* 태그 유형별 태그 목록 조회
*/
List<TasteTagResponse> getTagsByType(TagType tagType);
}

View File

@ -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<TasteTagEntity> 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<TasteTagResponse> getAvailableTags() {
List<TasteTagEntity> 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<TasteTagResponse> getTagsByType(TagType tagType) {
List<TasteTagEntity> 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());
}
}

View File

@ -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);
}
}

View File

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

6
recommend/build.gradle Normal file
View File

@ -0,0 +1,6 @@
dependencies {
implementation project(':common')
// AI and Location Services
implementation 'org.springframework.boot:spring-boot-starter-webflux'
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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<Long> 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();
}
}

View File

@ -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<String> 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();
}
}

View File

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

View File

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

View File

@ -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<TasteCategory> preferredCategories;
private Map<String, Double> categoryScores;
private List<String> preferredTags;
private Map<String, Object> behaviorPatterns;
private Double pricePreference;
private Double distancePreference;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* 취향 프로필 업데이트
*/
public TasteProfile updateProfile(List<TasteCategory> categories, Map<String, Double> scores,
List<String> tags, Map<String, Object> 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();
}
}

View File

@ -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<RecommendStoreResponse> recommendStores(Long memberId, RecommendStoreRequest request) {
// 사용자 취향 프로필 조회
TasteProfile tasteProfile = userPreferenceRepository.getMemberPreferences(memberId)
.orElseThrow(() -> new BusinessException("사용자 취향 정보를 찾을 수 없습니다. 취향 등록을 먼저 해주세요."));
// AI 기반 추천
Map<String, Object> preferences = Map.of(
"categories", tasteProfile.getPreferredCategories(),
"tags", tasteProfile.getPreferredTags(),
"pricePreference", tasteProfile.getPricePreference(),
"distancePreference", tasteProfile.getDistancePreference(),
"latitude", request.getLatitude(),
"longitude", request.getLongitude()
);
List<RecommendStore> aiRecommendStores = aiRecommendRepository.recommendStoresByAI(memberId, preferences);
// 위치 기반 추천 결합
List<RecommendStore> locationStores = locationRepository.findStoresWithinRadius(
request.getLatitude(), request.getLongitude(), request.getRadius());
// 추천 결과 통합 점수 계산
List<RecommendStore> 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<RecommendStoreResponse> recommendStoresByLocation(Double latitude, Double longitude, Integer radius) {
List<RecommendStore> 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<RecommendStoreResponse> recommendPopularStores(String category, Integer limit) {
// Mock 구현 - 실제로는 인기도 기반 쿼리 필요
List<RecommendStore> 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<RecommendStore> combineRecommendations(List<RecommendStore> aiStores,
List<RecommendStore> 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();
}
}

View File

@ -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<String> preferredCategories = profile.getPreferredCategories()
.stream()
.map(TasteCategory::getDescription)
.collect(Collectors.toList());
Map<String, Double> 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<String, Object> 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());
}
}
}

View File

@ -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<RecommendStoreResponse> recommendStores(Long memberId, RecommendStoreRequest request);
/**
* 위치 기반 매장 추천
*/
List<RecommendStoreResponse> recommendStoresByLocation(Double latitude, Double longitude, Integer radius);
/**
* 인기 매장 추천
*/
List<RecommendStoreResponse> recommendPopularStores(String category, Integer limit);
}

View File

@ -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);
}

View File

@ -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<RecommendStore> recommendStoresByAI(Long memberId, Map<String, Object> preferences);
/**
* 유사 사용자 기반 추천
*/
List<RecommendStore> recommendStoresBySimilarUsers(Long memberId);
/**
* 협업 필터링 추천
*/
List<RecommendStore> recommendStoresByCollaborativeFiltering(Long memberId);
}

View File

@ -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<RecommendStore> findStoresWithinRadius(Double latitude, Double longitude, Integer radius);
/**
* 거리 계산
*/
Double calculateDistance(Double lat1, Double lon1, Double lat2, Double lon2);
/**
* 주소를 좌표로 변환
*/
Location geocodeAddress(String address);
}

View File

@ -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<RecommendHistory> findRecommendHistoriesByMemberId(Long memberId);
/**
* 취향 프로필 저장
*/
TasteProfile saveTasteProfile(TasteProfile profile);
/**
* 회원 ID로 취향 프로필 조회
*/
Optional<TasteProfile> findTasteProfileByMemberId(Long memberId);
}

View File

@ -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<TasteProfile> getMemberPreferences(Long memberId);
/**
* 회원의 리뷰 기반 취향 분석
*/
Map<String, Object> analyzePreferencesFromReviews(Long memberId);
/**
* 유사한 취향의 사용자 조회
*/
List<Long> findSimilarTasteMembers(Long memberId);
/**
* 취향 프로필 업데이트
*/
TasteProfile updateTasteProfile(Long memberId, Map<String, Object> analysisData);
}

View File

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

View File

@ -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"));
}
}

View File

@ -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<List<RecommendStoreResponse>> recommendStores(Authentication authentication,
@Valid @RequestBody RecommendStoreRequest request) {
Long memberId = Long.valueOf(authentication.getName());
List<RecommendStoreResponse> recommendations = storeRecommendUseCase.recommendStores(memberId, request);
return ResponseEntity.ok(recommendations);
}
/**
* 위치 기반 매장 추천 API
*/
@GetMapping("/stores/nearby")
@Operation(summary = "주변 매장 추천", description = "현재 위치 기반으로 주변 매장을 추천합니다.")
public ResponseEntity<List<RecommendStoreResponse>> recommendNearbyStores(
@RequestParam Double latitude,
@RequestParam Double longitude,
@RequestParam(defaultValue = "5000") Integer radius) {
List<RecommendStoreResponse> recommendations = storeRecommendUseCase
.recommendStoresByLocation(latitude, longitude, radius);
return ResponseEntity.ok(recommendations);
}
/**
* 인기 매장 추천 API
*/
@GetMapping("/stores/popular")
@Operation(summary = "인기 매장 추천", description = "카테고리별 인기 매장을 추천합니다.")
public ResponseEntity<List<RecommendStoreResponse>> recommendPopularStores(
@RequestParam(required = false) String category,
@RequestParam(defaultValue = "10") Integer limit) {
List<RecommendStoreResponse> recommendations = storeRecommendUseCase
.recommendPopularStores(category, limit);
return ResponseEntity.ok(recommendations);
}
}

View File

@ -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<TasteAnalysisResponse> 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<SuccessResponse> updateTasteProfile(Authentication authentication) {
Long memberId = Long.valueOf(authentication.getName());
tasteAnalysisUseCase.updateTasteProfile(memberId);
return ResponseEntity.ok(SuccessResponse.of("취향 프로필이 업데이트되었습니다"));
}
}

View File

@ -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<String> 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보다 커야 합니다");
}
}
}

View File

@ -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<String> 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;
}

View File

@ -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<String> preferredCategories;
@Schema(description = "최고 선호 카테고리")
private String topCategory;
@Schema(description = "카테고리별 점수")
private Map<String, Double> categoryScores;
@Schema(description = "선호 태그")
private List<String> preferredTags;
@Schema(description = "가격 선호도")
private Double pricePreference;
@Schema(description = "거리 선호도")
private Double distancePreference;
@Schema(description = "분석 일시")
private LocalDateTime analysisDate;
}

View File

@ -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<RecommendStore> recommendStoresByAI(Long memberId, Map<String, Object> 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<RecommendStore> 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<RecommendStore> 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()
);
}
}

View File

@ -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<RecommendStore> 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();
}
}

View File

@ -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<RecommendHistory> findRecommendHistoriesByMemberId(Long memberId) {
List<RecommendHistoryEntity> 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<TasteProfile> 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();
}
}

View File

@ -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<TasteProfile> getMemberPreferences(Long memberId) {
return recommendRepositoryAdapter.findTasteProfileByMemberId(memberId);
}
@Override
public Map<String, Object> 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<Long> findSimilarTasteMembers(Long memberId) {
log.info("유사 취향 사용자 조회: memberId={}", memberId);
// Mock 구현 - 실제로는 ML 모델 또는 유사도 계산 알고리즘 사용
return List.of(123L, 456L, 789L);
}
@Override
public TasteProfile updateTasteProfile(Long memberId, Map<String, Object> analysisData) {
log.info("취향 프로필 업데이트: memberId={}", memberId);
// 기존 프로필 조회 또는 새로 생성
Optional<TasteProfile> existingProfile = getMemberPreferences(memberId);
TasteProfile.TasteProfileBuilder builder = TasteProfile.builder()
.memberId(memberId)
.preferredCategories((List<TasteCategory>) analysisData.get("preferredCategories"))
.categoryScores((Map<String, Double>) analysisData.get("categoryScores"))
.preferredTags((List<String>) analysisData.get("preferredTags"))
.behaviorPatterns((Map<String, Object>) 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);
}
}

View File

@ -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<Long> getRecommendedStoreIdsList() {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(recommendedStoreIdsJson, new TypeReference<List<Long>>() {});
} catch (Exception e) {
return List.of();
}
}
}

View File

@ -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<TasteCategory> getPreferredCategoriesList() {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(preferredCategoriesJson, new TypeReference<List<TasteCategory>>() {});
} catch (Exception e) {
return List.of();
}
}
public Map<String, Double> getCategoryScoresMap() {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(categoryScoresJson, new TypeReference<Map<String, Double>>() {});
} catch (Exception e) {
return Map.of();
}
}
public List<String> getPreferredTagsList() {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(preferredTagsJson, new TypeReference<List<String>>() {});
} catch (Exception e) {
return List.of();
}
}
public Map<String, Object> getBehaviorPatternsMap() {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(behaviorPatternsJson, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
return Map.of();
}
}
}

View File

@ -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<RecommendHistoryEntity, Long> {
/**
* 회원 ID로 추천 히스토리 조회 (최신순)
*/
List<RecommendHistoryEntity> findByMemberIdOrderByCreatedAtDesc(Long memberId);
/**
* 회원 ID로 최신 추천 히스토리 조회
*/
RecommendHistoryEntity findTopByMemberIdOrderByCreatedAtDesc(Long memberId);
}

View File

@ -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<TasteProfileEntity, Long> {
/**
* 회원 ID로 취향 프로필 조회
*/
Optional<TasteProfileEntity> findByMemberId(Long memberId);
/**
* 회원 ID로 취향 프로필 존재 여부 확인
*/
boolean existsByMemberId(Long memberId);
/**
* 회원 ID로 취향 프로필 삭제
*/
void deleteByMemberId(Long memberId);
}

View File

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

Some files were not shown because too many files have changed in this diff Show More