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