modify: folder - java/python

This commit is contained in:
unknown
2025-06-11 14:24:26 +09:00
parent a86d2e47ce
commit 7813f934b9
126 changed files with 1856 additions and 129 deletions
+37
View 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/
@@ -0,0 +1,4 @@
dependencies {
implementation project(':common')
runtimeOnly 'com.mysql:mysql-connector-j'
}
@@ -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,58 @@
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,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,190 @@
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; }
}
}
}
@@ -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 WeatherApiResponse 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 lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
/**
* 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,32 @@
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,31 @@
package com.won.smarketing.recommend.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* AI 마케팅 팁 생성 응답 DTO
* AI가 생성한 개인화된 마케팅 팁 정보를 전달합니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@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;
}
@@ -0,0 +1,50 @@
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
ai:
service:
url: ${AI_SERVICE_URL:http://localhost:8080/ai}
timeout: ${AI_SERVICE_TIMEOUT:30000}
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}
+51
View File
@@ -0,0 +1,51 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.4'
}
allprojects {
group = 'com.won.smarketing'
version = '1.0.0'
repositories {
mavenCentral()
}
}
subprojects {
apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// PostgreSQL (운영용)
runtimeOnly 'org.postgresql:postgresql:42.7.1'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
tasks.named('test') {
useJUnitPlatform()
}
}
+23
View File
@@ -0,0 +1,23 @@
bootJar {
enabled = false
}
jar {
enabled = true
archiveClassifier = ''
}
// 공통 의존성 재정의 (API 노출용)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
implementation 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
}
@@ -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,83 @@
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 설정 클래스
* JWT 기반 인증 및 CORS 설정
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
/**
* Spring Security 필터 체인 설정
*
* @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/auth/**", "/api/member/register", "/api/member/check-duplicate/**",
"/api/member/validate-password", "/swagger-ui/**", "/v3/api-docs/**",
"/swagger-resources/**", "/webjars/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/**
* 패스워드 인코더 빈 등록
*
* @return BCryptPasswordEncoder
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* CORS 설정
*
* @return CorsConfigurationSource
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
@@ -0,0 +1,43 @@
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 OpenAPI 설정 클래스
* API 문서화 및 JWT 인증 설정
*/
@Configuration
public class SwaggerConfig {
/**
* OpenAPI 설정
*
* @return OpenAPI 객체
*/
@Bean
public OpenAPI openAPI() {
String jwtSchemeName = "jwtAuth";
SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName);
Components components = new Components()
.addSecuritySchemes(jwtSchemeName, new SecurityScheme()
.name(jwtSchemeName)
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT"));
return new OpenAPI()
.info(new Info()
.title("스마케팅 API")
.description("소상공인을 위한 AI 마케팅 서비스 API")
.version("1.0.0"))
.addSecurityItem(securityRequirement)
.components(components);
}
}
@@ -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,68 @@
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 = "페이지 컨텐츠", example = "[...]")
private List<T> content;
@Schema(description = "페이지 번호 (0부터 시작)", 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;
/**
* 성공적인 페이징 응답 생성
*
* @param content 페이지 컨텐츠
* @param pageNumber 페이지 번호
* @param pageSize 페이지 크기
* @param totalElements 전체 요소 수
* @param <T> 데이터 타입
* @return 페이징 응답
*/
public static <T> PageResponse<T> of(List<T> content, int pageNumber, int pageSize, long totalElements) {
int totalPages = (int) Math.ceil((double) totalElements / pageSize);
return PageResponse.<T>builder()
.content(content)
.pageNumber(pageNumber)
.pageSize(pageSize)
.totalElements(totalElements)
.totalPages(totalPages)
.first(pageNumber == 0)
.last(pageNumber >= totalPages - 1)
.build();
}
}
@@ -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,79 @@
package com.won.smarketing.common.exception;
import com.won.smarketing.common.dto.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
/**
* 전역 예외 처리기
* 애플리케이션 전반의 예외를 통일된 형식으로 처리
*/
@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());
return ResponseEntity
.status(ex.getErrorCode().getHttpStatus())
.body(ApiResponse.error(
ex.getErrorCode().getHttpStatus().value(),
ex.getMessage()
));
}
/**
* 입력값 검증 예외 처리
*
* @param ex 입력값 검증 예외
* @return 오류 응답
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleValidationException(
MethodArgumentNotValidException ex) {
log.warn("Validation exception occurred: {}", ex.getMessage());
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach(error -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
return ResponseEntity.badRequest()
.body(ApiResponse.<Map<String, String>>builder()
.status(400)
.message("입력값 검증에 실패했습니다.")
.data(errors)
.build());
}
/**
* 일반적인 예외 처리
*
* @param ex 예외
* @return 오류 응답
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception ex) {
log.error("Unexpected exception occurred", ex);
return ResponseEntity.internalServerError()
.body(ApiResponse.error(500, "서버 내부 오류가 발생했습니다."));
}
}
@@ -0,0 +1,82 @@
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.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
/**
* JWT 인증 필터
* HTTP 요청에서 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 IO 예외
*/
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String jwt = getJwtFromRequest(request);
if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {
String userId = jwtTokenProvider.getUserIdFromToken(jwt);
// 사용자 인증 정보 설정
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("User '{}' authenticated successfully", userId);
}
} catch (Exception ex) {
log.error("Could not set user authentication in security context", ex);
}
filterChain.doFilter(request, response);
}
/**
* HTTP 요청에서 JWT 토큰 추출
*
* @param request HTTP 요청
* @return JWT 토큰 (Bearer 접두사 제거된)
*/
private String getJwtFromRequest(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,126 @@
package com.won.smarketing.common.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.Getter;
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 토큰 생성 및 검증을 담당하는 클래스
* 액세스 토큰과 리프레시 토큰의 생성, 검증, 파싱 기능 제공
*/
@Slf4j
@Component
public class JwtTokenProvider {
private final SecretKey secretKey;
/**
* -- GETTER --
* 액세스 토큰 유효시간 반환
*
* @return 액세스 토큰 유효시간 (밀리초)
*/
@Getter
private final long accessTokenValidityTime;
private final long refreshTokenValidityTime;
/**
* JWT 토큰 프로바이더 생성자
*
* @param secret JWT 서명에 사용할 비밀키
* @param accessTokenValidityTime 액세스 토큰 유효시간 (밀리초)
* @param refreshTokenValidityTime 리프레시 토큰 유효시간 (밀리초)
*/
public JwtTokenProvider(@Value("${jwt.secret}") String secret,
@Value("${jwt.access-token-validity}") long accessTokenValidityTime,
@Value("${jwt.refresh-token-validity}") long refreshTokenValidityTime) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes());
this.accessTokenValidityTime = accessTokenValidityTime;
this.refreshTokenValidityTime = refreshTokenValidityTime;
}
/**
* 액세스 토큰 생성
*
* @param userId 사용자 ID
* @return 생성된 액세스 토큰
*/
public String generateAccessToken(String userId) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + accessTokenValidityTime);
return Jwts.builder()
.subject(userId)
.issuedAt(now)
.expiration(expiryDate)
.signWith(secretKey)
.compact();
}
/**
* 리프레시 토큰 생성
*
* @param userId 사용자 ID
* @return 생성된 리프레시 토큰
*/
public String generateRefreshToken(String userId) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + refreshTokenValidityTime);
return Jwts.builder()
.subject(userId)
.issuedAt(now)
.expiration(expiryDate)
.signWith(secretKey)
.compact();
}
/**
* 토큰에서 사용자 ID 추출
*
* @param token JWT 토큰
* @return 사용자 ID
*/
public String getUserIdFromToken(String token) {
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
return claims.getSubject();
}
/**
* 토큰 유효성 검증
*
* @param token 검증할 토큰
* @return 유효성 여부
*/
public boolean validateToken(String token) {
try {
Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token);
return true;
} catch (SecurityException ex) {
log.error("Invalid JWT signature: {}", ex.getMessage());
} catch (MalformedJwtException ex) {
log.error("Invalid JWT token: {}", ex.getMessage());
} catch (ExpiredJwtException ex) {
log.error("Expired JWT token: {}", ex.getMessage());
} catch (UnsupportedJwtException ex) {
log.error("Unsupported JWT token: {}", ex.getMessage());
} catch (IllegalArgumentException ex) {
log.error("JWT claims string is empty: {}", ex.getMessage());
}
return false;
}
}
Binary file not shown.
@@ -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
View 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
View 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
@@ -0,0 +1,4 @@
dependencies {
implementation project(':common')
runtimeOnly 'com.mysql:mysql-connector-j'
}
@@ -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,191 @@
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.*;
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 ContentDetailResponse.CreationConditionsDto toCreationConditionsDto(CreationConditions conditions) {
if (conditions == null) {
return null;
}
return ContentDetailResponse.CreationConditionsDto.builder()
.toneAndManner(conditions.getToneAndManner())
.emotionIntensity(conditions.getEmotionIntensity())
.eventName(conditions.getEventName())
.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,611 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java
package com.won.smarketing.content.domain.model;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 콘텐츠 도메인 모델
*
* 이 클래스는 마케팅 콘텐츠의 핵심 정보와 비즈니스 로직을 포함하는
* DDD(Domain-Driven Design) 엔티티입니다.
*
* Clean Architecture의 Domain Layer에 위치하며,
* 비즈니스 규칙과 도메인 로직을 캡슐화합니다.
*/
@Entity
@Table(
name = "contents",
indexes = {
@Index(name = "idx_store_id", columnList = "store_id"),
@Index(name = "idx_content_type", columnList = "content_type"),
@Index(name = "idx_platform", columnList = "platform"),
@Index(name = "idx_status", columnList = "status"),
@Index(name = "idx_promotion_dates", columnList = "promotion_start_date, promotion_end_date"),
@Index(name = "idx_created_at", columnList = "created_at")
}
)
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EntityListeners(AuditingEntityListener.class)
public class Content {
// ==================== 기본키 및 식별자 ====================
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "content_id")
private Long id;
// ==================== 콘텐츠 분류 ====================
@Enumerated(EnumType.STRING)
@Column(name = "content_type", nullable = false, length = 20)
private ContentType contentType;
@Enumerated(EnumType.STRING)
@Column(name = "platform", nullable = false, length = 20)
private Platform platform;
// ==================== 콘텐츠 내용 ====================
@Column(name = "title", nullable = false, length = 200)
private String title;
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
private String content;
// ==================== 멀티미디어 및 메타데이터 ====================
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(
name = "content_hashtags",
joinColumns = @JoinColumn(name = "content_id"),
indexes = @Index(name = "idx_content_hashtags", columnList = "content_id")
)
@Column(name = "hashtag", length = 100)
@Builder.Default
private List<String> hashtags = new ArrayList<>();
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(
name = "content_images",
joinColumns = @JoinColumn(name = "content_id"),
indexes = @Index(name = "idx_content_images", columnList = "content_id")
)
@Column(name = "image_url", length = 500)
@Builder.Default
private List<String> images = new ArrayList<>();
// ==================== 상태 관리 ====================
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
@Builder.Default
private ContentStatus status = ContentStatus.DRAFT;
// ==================== AI 생성 조건 (Embedded) ====================
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "toneAndManner", column = @Column(name = "tone_and_manner", length = 50)),
@AttributeOverride(name = "promotionType", column = @Column(name = "promotion_type", length = 50)),
@AttributeOverride(name = "emotionIntensity", column = @Column(name = "emotion_intensity", length = 50)),
@AttributeOverride(name = "targetAudience", column = @Column(name = "target_audience", length = 50)),
@AttributeOverride(name = "eventName", column = @Column(name = "event_name", length = 100))
})
private CreationConditions creationConditions;
// ==================== 비즈니스 관계 ====================
@Column(name = "store_id", nullable = false)
private Long storeId;
// ==================== 홍보 기간 ====================
@Column(name = "promotion_start_date")
private LocalDateTime promotionStartDate;
@Column(name = "promotion_end_date")
private LocalDateTime promotionEndDate;
// ==================== 감사(Audit) 정보 ====================
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
// ==================== 비즈니스 로직 메서드 ====================
/**
* 콘텐츠 제목 수정
*
* 비즈니스 규칙:
* - 제목은 null이거나 빈 값일 수 없음
* - 200자를 초과할 수 없음
* - 발행된 콘텐츠는 제목 변경 시 상태가 DRAFT로 변경됨
*
* @param title 새로운 제목
* @throws IllegalArgumentException 제목이 유효하지 않은 경우
*/
public void updateTitle(String title) {
validateTitle(title);
boolean wasPublished = isPublished();
this.title = title.trim();
// 발행된 콘텐츠의 제목이 변경되면 재검토 필요
if (wasPublished) {
this.status = ContentStatus.DRAFT;
}
}
/**
* 콘텐츠 내용 수정
*
* 비즈니스 규칙:
* - 내용은 null이거나 빈 값일 수 없음
* - 발행된 콘텐츠는 내용 변경 시 상태가 DRAFT로 변경됨
*
* @param content 새로운 콘텐츠 내용
* @throws IllegalArgumentException 내용이 유효하지 않은 경우
*/
public void updateContent(String content) {
validateContent(content);
boolean wasPublished = isPublished();
this.content = content.trim();
// 발행된 콘텐츠의 내용이 변경되면 재검토 필요
if (wasPublished) {
this.status = ContentStatus.DRAFT;
}
}
/**
* 콘텐츠 상태 변경
*
* 비즈니스 규칙:
* - PUBLISHED 상태로 변경시 유효성 검증 수행
* - ARCHIVED 상태에서는 PUBLISHED로만 변경 가능
*
* @param status 새로운 상태
* @throws IllegalStateException 잘못된 상태 전환인 경우
*/
public void changeStatus(ContentStatus status) {
validateStatusTransition(this.status, status);
if (status == ContentStatus.PUBLISHED) {
validateForPublication();
}
this.status = status;
}
/**
* 홍보 기간 설정
*
* 비즈니스 규칙:
* - 시작일은 종료일보다 이전이어야 함
* - 과거 날짜로 설정 불가 (현재 시간 기준)
*
* @param startDate 홍보 시작일
* @param endDate 홍보 종료일
* @throws IllegalArgumentException 날짜가 유효하지 않은 경우
*/
public void setPromotionPeriod(LocalDateTime startDate, LocalDateTime endDate) {
validatePromotionPeriod(startDate, endDate);
this.promotionStartDate = startDate;
this.promotionEndDate = endDate;
}
/**
* 해시태그 추가
*
* @param hashtag 추가할 해시태그 (# 없이)
*/
public void addHashtag(String hashtag) {
if (hashtag != null && !hashtag.trim().isEmpty()) {
String cleanHashtag = hashtag.trim().replace("#", "");
if (!this.hashtags.contains(cleanHashtag)) {
this.hashtags.add(cleanHashtag);
}
}
}
/**
* 해시태그 제거
*
* @param hashtag 제거할 해시태그
*/
public void removeHashtag(String hashtag) {
if (hashtag != null) {
String cleanHashtag = hashtag.trim().replace("#", "");
this.hashtags.remove(cleanHashtag);
}
}
/**
* 이미지 추가
*
* @param imageUrl 이미지 URL
*/
public void addImage(String imageUrl) {
if (imageUrl != null && !imageUrl.trim().isEmpty()) {
if (!this.images.contains(imageUrl.trim())) {
this.images.add(imageUrl.trim());
}
}
}
/**
* 이미지 제거
*
* @param imageUrl 제거할 이미지 URL
*/
public void removeImage(String imageUrl) {
if (imageUrl != null) {
this.images.remove(imageUrl.trim());
}
}
// ==================== 도메인 조회 메서드 ====================
/**
* 발행 상태 확인
*
* @return 발행된 상태이면 true
*/
public boolean isPublished() {
return this.status == ContentStatus.PUBLISHED;
}
/**
* 수정 가능 상태 확인
*
* @return 임시저장 또는 예약발행 상태이면 true
*/
public boolean isEditable() {
return this.status == ContentStatus.DRAFT || this.status == ContentStatus.PUBLISHED;
}
/**
* 현재 홍보 진행 중인지 확인
*
* @return 홍보 기간 내이고 발행 상태이면 true
*/
public boolean isOngoingPromotion() {
if (!isPublished() || promotionStartDate == null || promotionEndDate == null) {
return false;
}
LocalDateTime now = LocalDateTime.now();
return now.isAfter(promotionStartDate) && now.isBefore(promotionEndDate);
}
/**
* 홍보 예정 상태 확인
*
* @return 홍보 시작 전이면 true
*/
public boolean isUpcomingPromotion() {
if (promotionStartDate == null) {
return false;
}
return LocalDateTime.now().isBefore(promotionStartDate);
}
/**
* 홍보 완료 상태 확인
*
* @return 홍보 종료 후이면 true
*/
public boolean isCompletedPromotion() {
if (promotionEndDate == null) {
return false;
}
return LocalDateTime.now().isAfter(promotionEndDate);
}
/**
* SNS 콘텐츠 여부 확인
*
* @return SNS 게시물이면 true
*/
public boolean isSnsContent() {
return this.contentType == ContentType.SNS_POST;
}
/**
* 포스터 콘텐츠 여부 확인
*
* @return 포스터이면 true
*/
public boolean isPosterContent() {
return this.contentType == ContentType.POSTER;
}
/**
* 이미지가 있는 콘텐츠인지 확인
*
* @return 이미지가 1개 이상 있으면 true
*/
public boolean hasImages() {
return this.images != null && !this.images.isEmpty();
}
/**
* 해시태그가 있는 콘텐츠인지 확인
*
* @return 해시태그가 1개 이상 있으면 true
*/
public boolean hasHashtags() {
return this.hashtags != null && !this.hashtags.isEmpty();
}
// ==================== 유효성 검증 메서드 ====================
/**
* 제목 유효성 검증
*/
private void validateTitle(String title) {
if (title == null || title.trim().isEmpty()) {
throw new IllegalArgumentException("제목은 필수 입력 사항입니다.");
}
if (title.trim().length() > 200) {
throw new IllegalArgumentException("제목은 200자를 초과할 수 없습니다.");
}
}
/**
* 내용 유효성 검증
*/
private void validateContent(String content) {
if (content == null || content.trim().isEmpty()) {
throw new IllegalArgumentException("콘텐츠 내용은 필수 입력 사항입니다.");
}
}
/**
* 홍보 기간 유효성 검증
*/
private void validatePromotionPeriod(LocalDateTime startDate, LocalDateTime endDate) {
if (startDate == null || endDate == null) {
throw new IllegalArgumentException("홍보 시작일과 종료일은 필수 입력 사항입니다.");
}
if (startDate.isAfter(endDate)) {
throw new IllegalArgumentException("홍보 시작일은 종료일보다 이전이어야 합니다.");
}
if (endDate.isBefore(LocalDateTime.now())) {
throw new IllegalArgumentException("홍보 종료일은 현재 시간 이후여야 합니다.");
}
}
/**
* 상태 전환 유효성 검증
*/
private void validateStatusTransition(ContentStatus from, ContentStatus to) {
if (from == ContentStatus.ARCHIVED && to != ContentStatus.PUBLISHED) {
throw new IllegalStateException("보관된 콘텐츠는 발행 상태로만 변경할 수 있습니다.");
}
}
/**
* 발행을 위한 유효성 검증
*/
private void validateForPublication() {
validateTitle(this.title);
validateContent(this.content);
if (this.promotionStartDate == null || this.promotionEndDate == null) {
throw new IllegalStateException("발행하려면 홍보 기간을 설정해야 합니다.");
}
if (this.contentType == ContentType.POSTER && !hasImages()) {
throw new IllegalStateException("포스터 콘텐츠는 이미지가 필수입니다.");
}
}
// ==================== 비즈니스 계산 메서드 ====================
/**
* 홍보 진행률 계산 (0-100%)
*
* @return 진행률
*/
public double calculateProgress() {
if (promotionStartDate == null || promotionEndDate == null) {
return 0.0;
}
LocalDateTime now = LocalDateTime.now();
if (now.isBefore(promotionStartDate)) {
return 0.0;
} else if (now.isAfter(promotionEndDate)) {
return 100.0;
}
long totalDuration = java.time.Duration.between(promotionStartDate, promotionEndDate).toHours();
long elapsedDuration = java.time.Duration.between(promotionStartDate, now).toHours();
if (totalDuration == 0) {
return 100.0;
}
return (double) elapsedDuration / totalDuration * 100.0;
}
/**
* 남은 홍보 일수 계산
*
* @return 남은 일수 (음수면 0)
*/
public long calculateRemainingDays() {
if (promotionEndDate == null) {
return 0L;
}
LocalDateTime now = LocalDateTime.now();
if (now.isAfter(promotionEndDate)) {
return 0L;
}
return java.time.Duration.between(now, promotionEndDate).toDays();
}
// ==================== 팩토리 메서드 ====================
/**
* SNS 콘텐츠 생성 팩토리 메서드
*/
public static Content createSnsContent(String title, String content, Platform platform,
Long storeId, CreationConditions conditions) {
Content snsContent = Content.builder()
.contentType(ContentType.SNS_POST)
.platform(platform)
.title(title)
.content(content)
.storeId(storeId)
.creationConditions(conditions)
.status(ContentStatus.DRAFT)
.hashtags(new ArrayList<>())
.images(new ArrayList<>())
.build();
// 유효성 검증
snsContent.validateTitle(title);
snsContent.validateContent(content);
return snsContent;
}
/**
* 포스터 콘텐츠 생성 팩토리 메서드
*/
public static Content createPosterContent(String title, String content, List<String> images,
Long storeId, CreationConditions conditions) {
if (images == null || images.isEmpty()) {
throw new IllegalArgumentException("포스터 콘텐츠는 이미지가 필수입니다.");
}
Content posterContent = Content.builder()
.contentType(ContentType.POSTER)
.platform(Platform.INSTAGRAM) // 기본값
.title(title)
.content(content)
.storeId(storeId)
.creationConditions(conditions)
.status(ContentStatus.DRAFT)
.hashtags(new ArrayList<>())
.images(new ArrayList<>(images))
.build();
// 유효성 검증
posterContent.validateTitle(title);
posterContent.validateContent(content);
return posterContent;
}
// ==================== Object 메서드 오버라이드 ====================
/**
* 비즈니스 키 기반 동등성 비교
* JPA 엔티티에서는 ID가 아닌 비즈니스 키 사용 권장
*/
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Content content = (Content) obj;
return id != null && id.equals(content.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
/**
* 디버깅용 toString (민감한 정보 제외)
*/
@Override
public String toString() {
return "Content{" +
"id=" + id +
", contentType=" + contentType +
", platform=" + platform +
", title='" + title + '\'' +
", status=" + status +
", storeId=" + storeId +
", promotionStartDate=" + promotionStartDate +
", promotionEndDate=" + promotionEndDate +
", createdAt=" + createdAt +
'}';
}
}
/*
==================== 데이터베이스 스키마 (참고용) ====================
CREATE TABLE contents (
content_id BIGINT NOT NULL AUTO_INCREMENT,
content_type VARCHAR(20) NOT NULL,
platform VARCHAR(20) NOT NULL,
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
tone_and_manner VARCHAR(50),
promotion_type VARCHAR(50),
emotion_intensity VARCHAR(50),
target_audience VARCHAR(50),
event_name VARCHAR(100),
store_id BIGINT NOT NULL,
promotion_start_date DATETIME,
promotion_end_date DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME,
PRIMARY KEY (content_id),
INDEX idx_store_id (store_id),
INDEX idx_content_type (content_type),
INDEX idx_platform (platform),
INDEX idx_status (status),
INDEX idx_promotion_dates (promotion_start_date, promotion_end_date),
INDEX idx_created_at (created_at)
);
CREATE TABLE content_hashtags (
content_id BIGINT NOT NULL,
hashtag VARCHAR(100) NOT NULL,
INDEX idx_content_hashtags (content_id),
FOREIGN KEY (content_id) REFERENCES contents(content_id) ON DELETE CASCADE
);
CREATE TABLE content_images (
content_id BIGINT NOT NULL,
image_url VARCHAR(500) NOT NULL,
INDEX idx_content_images (content_id),
FOREIGN KEY (content_id) REFERENCES contents(content_id) ON DELETE CASCADE
);
*/
@@ -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, "콘텐츠가 성공적으로 삭제되었습니다."));
}
}
@@ -0,0 +1,86 @@
package com.won.smarketing.content.presentation.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;
import java.util.List;
/**
* 콘텐츠 상세 응답 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "콘텐츠 상세 응답")
public class ContentDetailResponse {
@Schema(description = "콘텐츠 ID", example = "1")
private Long contentId;
@Schema(description = "콘텐츠 타입", example = "SNS_POST")
private String contentType;
@Schema(description = "플랫폼", example = "INSTAGRAM")
private String platform;
@Schema(description = "제목", example = "맛있는 신메뉴를 소개합니다!")
private String title;
@Schema(description = "콘텐츠 내용")
private String content;
@Schema(description = "해시태그 목록")
private List<String> hashtags;
@Schema(description = "이미지 URL 목록")
private List<String> images;
@Schema(description = "상태", example = "PUBLISHED")
private String status;
@Schema(description = "홍보 시작일")
private LocalDateTime promotionStartDate;
@Schema(description = "홍보 종료일")
private LocalDateTime promotionEndDate;
@Schema(description = "생성 조건")
private CreationConditionsDto creationConditions;
@Schema(description = "생성일시")
private LocalDateTime createdAt;
@Schema(description = "수정일시")
private LocalDateTime updatedAt;
/**
* 생성 조건 내부 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "콘텐츠 생성 조건")
public static class CreationConditionsDto {
@Schema(description = "톤앤매너", example = "친근함")
private String toneAndManner;
@Schema(description = "프로모션 유형", example = "할인 정보")
private String promotionType;
@Schema(description = "감정 강도", example = "보통")
private String emotionIntensity;
@Schema(description = "홍보 대상", example = "메뉴")
private String targetAudience;
@Schema(description = "이벤트명", example = "신메뉴 출시 이벤트")
private String eventName;
}
}
@@ -0,0 +1,37 @@
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 콘텐츠 목록 조회 요청 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "콘텐츠 목록 조회 요청")
public class ContentListRequest {
@Schema(description = "콘텐츠 타입", example = "SNS_POST")
private String contentType;
@Schema(description = "플랫폼", example = "INSTAGRAM")
private String platform;
@Schema(description = "조회 기간", example = "7days")
private String period;
@Schema(description = "정렬 기준", example = "createdAt")
private String sortBy;
@Schema(description = "정렬 방향", example = "DESC")
private String sortDirection;
@Schema(description = "페이지 번호", example = "0")
private Integer page;
@Schema(description = "페이지 크기", example = "20")
private Integer size;
}
@@ -0,0 +1,33 @@
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 콘텐츠 재생성 요청 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "콘텐츠 재생성 요청")
public class ContentRegenerateRequest {
@Schema(description = "원본 콘텐츠 ID", example = "1", required = true)
@NotNull(message = "원본 콘텐츠 ID는 필수입니다")
private Long originalContentId;
@Schema(description = "수정된 톤앤매너", example = "전문적")
private String toneAndManner;
@Schema(description = "수정된 프로모션 유형", example = "신메뉴 알림")
private String promotionType;
@Schema(description = "수정된 감정 강도", example = "열정적")
private String emotionIntensity;
@Schema(description = "추가 요구사항", example = "더 감성적으로 작성해주세요")
private String additionalRequirements;
}
@@ -0,0 +1,361 @@
// marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java
package com.won.smarketing.content.presentation.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;
import java.util.List;
/**
* 콘텐츠 응답 DTO
* 콘텐츠 목록 조회 시 사용되는 기본 응답 DTO
*
* 이 클래스는 콘텐츠의 핵심 정보만을 포함하여 목록 조회 시 성능을 최적화합니다.
* 상세 정보가 필요한 경우 ContentDetailResponse를 사용합니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "콘텐츠 응답")
public class ContentResponse {
// ==================== 기본 식별 정보 ====================
@Schema(description = "콘텐츠 ID", example = "1")
private Long contentId;
@Schema(description = "콘텐츠 타입", example = "SNS_POST",
allowableValues = {"SNS_POST", "POSTER"})
private String contentType;
@Schema(description = "플랫폼", example = "INSTAGRAM",
allowableValues = {"INSTAGRAM", "NAVER_BLOG", "FACEBOOK", "KAKAO_STORY"})
private String platform;
// ==================== 콘텐츠 정보 ====================
@Schema(description = "제목", example = "맛있는 신메뉴를 소개합니다!")
private String title;
@Schema(description = "콘텐츠 내용", example = "특별한 신메뉴가 출시되었습니다! 🍽️\n지금 바로 맛보세요!")
private String content;
@Schema(description = "해시태그 목록", example = "[\"#맛집\", \"#신메뉴\", \"#추천\", \"#인스타그램\"]")
private List<String> hashtags;
@Schema(description = "이미지 URL 목록",
example = "[\"https://example.com/image1.jpg\", \"https://example.com/image2.jpg\"]")
private List<String> images;
// ==================== 상태 관리 ====================
@Schema(description = "상태", example = "PUBLISHED",
allowableValues = {"DRAFT", "PUBLISHED", "SCHEDULED", "ARCHIVED"})
private String status;
@Schema(description = "상태 표시명", example = "발행완료")
private String statusDisplay;
// ==================== 홍보 기간 ====================
@Schema(description = "홍보 시작일", example = "2024-01-15T09:00:00")
private LocalDateTime promotionStartDate;
@Schema(description = "홍보 종료일", example = "2024-01-22T23:59:59")
private LocalDateTime promotionEndDate;
// ==================== 시간 정보 ====================
@Schema(description = "생성일시", example = "2024-01-15T10:30:00")
private LocalDateTime createdAt;
@Schema(description = "수정일시", example = "2024-01-15T14:20:00")
private LocalDateTime updatedAt;
// ==================== 계산된 필드들 ====================
@Schema(description = "홍보 진행 상태", example = "ONGOING",
allowableValues = {"UPCOMING", "ONGOING", "COMPLETED"})
private String promotionStatus;
@Schema(description = "남은 홍보 일수", example = "5")
private Long remainingDays;
@Schema(description = "홍보 진행률 (%)", example = "60.5")
private Double progressPercentage;
@Schema(description = "콘텐츠 요약 (첫 50자)", example = "특별한 신메뉴가 출시되었습니다! 지금 바로 맛보세요...")
private String contentSummary;
@Schema(description = "이미지 개수", example = "3")
private Integer imageCount;
@Schema(description = "해시태그 개수", example = "8")
private Integer hashtagCount;
// ==================== 비즈니스 메서드 ====================
/**
* 콘텐츠 요약 생성
* 콘텐츠가 길 경우 첫 50자만 표시하고 "..." 추가
*
* @param content 원본 콘텐츠
* @param maxLength 최대 길이
* @return 요약된 콘텐츠
*/
public static String createContentSummary(String content, int maxLength) {
if (content == null || content.length() <= maxLength) {
return content;
}
return content.substring(0, maxLength) + "...";
}
/**
* 홍보 상태 계산
* 현재 시간과 홍보 기간을 비교하여 상태 결정
*
* @param startDate 홍보 시작일
* @param endDate 홍보 종료일
* @return 홍보 상태
*/
public static String calculatePromotionStatus(LocalDateTime startDate, LocalDateTime endDate) {
if (startDate == null || endDate == null) {
return "UNKNOWN";
}
LocalDateTime now = LocalDateTime.now();
if (now.isBefore(startDate)) {
return "UPCOMING"; // 홍보 예정
} else if (now.isAfter(endDate)) {
return "COMPLETED"; // 홍보 완료
} else {
return "ONGOING"; // 홍보 진행중
}
}
/**
* 남은 일수 계산
* 홍보 종료일까지 남은 일수 계산
*
* @param endDate 홍보 종료일
* @return 남은 일수 (음수면 0 반환)
*/
public static Long calculateRemainingDays(LocalDateTime endDate) {
if (endDate == null) {
return 0L;
}
LocalDateTime now = LocalDateTime.now();
if (now.isAfter(endDate)) {
return 0L;
}
return java.time.Duration.between(now, endDate).toDays();
}
/**
* 진행률 계산
* 홍보 기간 대비 진행률 계산 (0-100%)
*
* @param startDate 홍보 시작일
* @param endDate 홍보 종료일
* @return 진행률 (0-100%)
*/
public static Double calculateProgressPercentage(LocalDateTime startDate, LocalDateTime endDate) {
if (startDate == null || endDate == null) {
return 0.0;
}
LocalDateTime now = LocalDateTime.now();
if (now.isBefore(startDate)) {
return 0.0; // 아직 시작 안함
} else if (now.isAfter(endDate)) {
return 100.0; // 완료
}
long totalDuration = java.time.Duration.between(startDate, endDate).toHours();
long elapsedDuration = java.time.Duration.between(startDate, now).toHours();
if (totalDuration == 0) {
return 100.0;
}
return (double) elapsedDuration / totalDuration * 100.0;
}
/**
* 상태 표시명 변환
* 영문 상태를 한글로 변환
*
* @param status 영문 상태
* @return 한글 상태명
*/
public static String getStatusDisplay(String status) {
if (status == null) {
return "알 수 없음";
}
switch (status) {
case "DRAFT":
return "임시저장";
case "PUBLISHED":
return "발행완료";
case "SCHEDULED":
return "예약발행";
case "ARCHIVED":
return "보관됨";
default:
return status;
}
}
// ==================== Builder 확장 메서드 ====================
/**
* 도메인 엔티티에서 ContentResponse 생성
* 계산된 필드들을 자동으로 설정
*
* @param content 콘텐츠 도메인 엔티티
* @return ContentResponse
*/
public static ContentResponse fromDomain(com.won.smarketing.content.domain.model.Content content) {
ContentResponseBuilder builder = 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())
.statusDisplay(getStatusDisplay(content.getStatus().name()))
.promotionStartDate(content.getPromotionStartDate())
.promotionEndDate(content.getPromotionEndDate())
.createdAt(content.getCreatedAt())
.updatedAt(content.getUpdatedAt());
// 계산된 필드들 설정
builder.contentSummary(createContentSummary(content.getContent(), 50));
builder.imageCount(content.getImages() != null ? content.getImages().size() : 0);
builder.hashtagCount(content.getHashtags() != null ? content.getHashtags().size() : 0);
// 홍보 관련 계산 필드들
builder.promotionStatus(calculatePromotionStatus(
content.getPromotionStartDate(),
content.getPromotionEndDate()));
builder.remainingDays(calculateRemainingDays(content.getPromotionEndDate()));
builder.progressPercentage(calculateProgressPercentage(
content.getPromotionStartDate(),
content.getPromotionEndDate()));
return builder.build();
}
// ==================== 유틸리티 메서드 ====================
/**
* 콘텐츠가 현재 활성 상태인지 확인
*
* @return 홍보 기간 내이고 발행 상태면 true
*/
public boolean isActive() {
return "PUBLISHED".equals(status) && "ONGOING".equals(promotionStatus);
}
/**
* 콘텐츠 수정 가능 여부 확인
*
* @return 임시저장 상태이거나 예약발행 상태면 true
*/
public boolean isEditable() {
return "DRAFT".equals(status) || "SCHEDULED".equals(status);
}
/**
* 이미지가 있는 콘텐츠인지 확인
*
* @return 이미지가 1개 이상 있으면 true
*/
public boolean hasImages() {
return images != null && !images.isEmpty();
}
/**
* 해시태그가 있는 콘텐츠인지 확인
*
* @return 해시태그가 1개 이상 있으면 true
*/
public boolean hasHashtags() {
return hashtags != null && !hashtags.isEmpty();
}
/**
* 디버깅용 toString (간소화된 정보만)
*/
@Override
public String toString() {
return "ContentResponse{" +
"contentId=" + contentId +
", contentType='" + contentType + '\'' +
", platform='" + platform + '\'' +
", title='" + title + '\'' +
", status='" + status + '\'' +
", promotionStatus='" + promotionStatus + '\'' +
", createdAt=" + createdAt +
'}';
}
}
/*
==================== 사용 예시 ====================
// 1. 도메인 엔티티에서 DTO 생성
Content domainContent = contentRepository.findById(contentId);
ContentResponse response = ContentResponse.fromDomain(domainContent);
// 2. 수동으로 빌더 사용
ContentResponse response = ContentResponse.builder()
.contentId(1L)
.contentType("SNS_POST")
.platform("INSTAGRAM")
.title("맛있는 신메뉴")
.content("특별한 신메뉴가 출시되었습니다!")
.status("PUBLISHED")
.build();
// 3. 비즈니스 로직 활용
boolean canEdit = response.isEditable();
boolean isLive = response.isActive();
String summary = response.getContentSummary();
==================== JSON 응답 예시 ====================
{
"contentId": 1,
"contentType": "SNS_POST",
"platform": "INSTAGRAM",
"title": "맛있는 신메뉴를 소개합니다!",
"content": "특별한 신메뉴가 출시되었습니다! 🍽️\n지금 바로 맛보세요!",
"hashtags": ["#맛집", "#신메뉴", "#추천", "#인스타그램"],
"images": ["https://example.com/image1.jpg"],
"status": "PUBLISHED",
"statusDisplay": "발행완료",
"promotionStartDate": "2024-01-15T09:00:00",
"promotionEndDate": "2024-01-22T23:59:59",
"createdAt": "2024-01-15T10:30:00",
"updatedAt": "2024-01-15T14:20:00",
"promotionStatus": "ONGOING",
"remainingDays": 5,
"progressPercentage": 60.5,
"contentSummary": "특별한 신메뉴가 출시되었습니다! 지금 바로 맛보세요...",
"imageCount": 1,
"hashtagCount": 4
}
*/
@@ -0,0 +1,41 @@
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* 콘텐츠 통계 응답 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "콘텐츠 통계 응답")
public class ContentStatisticsResponse {
@Schema(description = "총 콘텐츠 수", example = "150")
private Long totalContents;
@Schema(description = "이번 달 생성된 콘텐츠 수", example = "25")
private Long thisMonthContents;
@Schema(description = "발행된 콘텐츠 수", example = "120")
private Long publishedContents;
@Schema(description = "임시저장된 콘텐츠 수", example = "30")
private Long draftContents;
@Schema(description = "콘텐츠 타입별 통계")
private Map<String, Long> contentTypeStats;
@Schema(description = "플랫폼별 통계")
private Map<String, Long> platformStats;
@Schema(description = "월별 생성 통계 (최근 6개월)")
private Map<String, Long> monthlyStats;
}
@@ -0,0 +1,33 @@
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 콘텐츠 수정 요청 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "콘텐츠 수정 요청")
public class ContentUpdateRequest {
@Schema(description = "제목", example = "수정된 제목")
private String title;
@Schema(description = "콘텐츠 내용")
private String content;
@Schema(description = "홍보 시작일")
private LocalDateTime promotionStartDate;
@Schema(description = "홍보 종료일")
private LocalDateTime promotionEndDate;
@Schema(description = "상태", example = "PUBLISHED")
private String status;
}
@@ -0,0 +1,35 @@
package com.won.smarketing.content.presentation.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 ContentUpdateResponse {
@Schema(description = "콘텐츠 ID", example = "1")
private Long contentId;
@Schema(description = "수정된 제목", example = "수정된 제목")
private String title;
@Schema(description = "수정된 콘텐츠 내용")
private String content;
@Schema(description = "상태", example = "PUBLISHED")
private String status;
@Schema(description = "수정일시")
private LocalDateTime updatedAt;
}
@@ -0,0 +1,47 @@
package com.won.smarketing.content.presentation.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 OngoingContentResponse {
@Schema(description = "콘텐츠 ID", example = "1")
private Long contentId;
@Schema(description = "콘텐츠 타입", example = "SNS_POST")
private String contentType;
@Schema(description = "플랫폼", example = "INSTAGRAM")
private String platform;
@Schema(description = "제목", example = "진행 중인 이벤트")
private String title;
@Schema(description = "상태", example = "PUBLISHED")
private String status;
@Schema(description = "홍보 시작일")
private LocalDateTime promotionStartDate;
@Schema(description = "홍보 종료일")
private LocalDateTime promotionEndDate;
@Schema(description = "남은 일수", example = "5")
private Long remainingDays;
@Schema(description = "진행률 (%)", example = "60.5")
private Double progressPercentage;
}
@@ -0,0 +1,51 @@
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 포스터 콘텐츠 생성 요청 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "포스터 콘텐츠 생성 요청")
public class PosterContentCreateRequest {
@Schema(description = "홍보 대상", example = "메뉴", required = true)
@NotBlank(message = "홍보 대상은 필수입니다")
private String targetAudience;
@Schema(description = "홍보 시작일", required = true)
@NotNull(message = "홍보 시작일은 필수입니다")
private LocalDateTime promotionStartDate;
@Schema(description = "홍보 종료일", required = true)
@NotNull(message = "홍보 종료일은 필수입니다")
private LocalDateTime promotionEndDate;
@Schema(description = "이벤트명 (이벤트 홍보시)", example = "신메뉴 출시 이벤트")
private String eventName;
@Schema(description = "이미지 스타일", example = "모던")
private String imageStyle;
@Schema(description = "프로모션 유형", example = "할인 정보")
private String promotionType;
@Schema(description = "감정 강도", example = "보통")
private String emotionIntensity;
@Schema(description = "업로드된 이미지 URL 목록", required = true)
@NotNull(message = "이미지는 1개 이상 필수입니다")
@Size(min = 1, message = "이미지는 1개 이상 업로드해야 합니다")
private List<String> images;
}
@@ -0,0 +1,41 @@
package com.won.smarketing.content.presentation.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 PosterContentCreateResponse {
@Schema(description = "콘텐츠 ID", example = "1")
private Long contentId;
@Schema(description = "생성된 포스터 제목", example = "특별 이벤트 안내")
private String title;
@Schema(description = "생성된 포스터 텍스트 내용")
private String content;
@Schema(description = "포스터 이미지 URL 목록")
private List<String> posterImages;
@Schema(description = "원본 이미지 URL 목록")
private List<String> originalImages;
@Schema(description = "이미지 스타일", example = "모던")
private String imageStyle;
@Schema(description = "생성 상태", example = "DRAFT")
private String status;
}
@@ -0,0 +1,33 @@
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 포스터 콘텐츠 저장 요청 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "포스터 콘텐츠 저장 요청")
public class PosterContentSaveRequest {
@Schema(description = "콘텐츠 ID", example = "1", required = true)
@NotNull(message = "콘텐츠 ID는 필수입니다")
private Long contentId;
@Schema(description = "최종 제목", example = "특별 이벤트 안내")
private String finalTitle;
@Schema(description = "최종 콘텐츠 내용")
private String finalContent;
@Schema(description = "선택된 포스터 이미지 URL")
private String selectedPosterImage;
@Schema(description = "발행 상태", example = "PUBLISHED")
private String status;
}
@@ -0,0 +1,380 @@
package com.won.smarketing.content.presentation.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;
import java.util.List;
/**
* SNS 콘텐츠 생성 응답 DTO
*
* AI를 통해 SNS 콘텐츠를 생성한 후 클라이언트에게 반환되는 응답 정보입니다.
* 생성된 콘텐츠의 기본 정보와 함께 사용자가 추가 편집할 수 있는 정보를 포함합니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "SNS 콘텐츠 생성 응답")
public class SnsContentCreateResponse {
// ==================== 기본 식별 정보 ====================
@Schema(description = "생성된 콘텐츠 ID", example = "1")
private Long contentId;
@Schema(description = "콘텐츠 타입", example = "SNS_POST")
private String contentType;
@Schema(description = "대상 플랫폼", example = "INSTAGRAM",
allowableValues = {"INSTAGRAM", "NAVER_BLOG", "FACEBOOK", "KAKAO_STORY"})
private String platform;
// ==================== AI 생성 콘텐츠 ====================
@Schema(description = "AI가 생성한 콘텐츠 제목",
example = "맛있는 신메뉴를 소개합니다! ✨")
private String title;
@Schema(description = "AI가 생성한 콘텐츠 내용",
example = "안녕하세요! 😊\n\n특별한 신메뉴가 출시되었습니다!\n진짜 맛있어서 꼭 한번 드셔보세요 🍽️\n\n매장에서 기다리고 있을게요! 💫")
private String content;
@Schema(description = "AI가 생성한 해시태그 목록",
example = "[\"맛집\", \"신메뉴\", \"추천\", \"인스타그램\", \"일상\", \"좋아요\", \"팔로우\", \"맛있어요\"]")
private List<String> hashtags;
// ==================== 플랫폼별 최적화 정보 ====================
@Schema(description = "플랫폼별 최적화된 콘텐츠 길이", example = "280")
private Integer contentLength;
@Schema(description = "플랫폼별 권장 해시태그 개수", example = "8")
private Integer recommendedHashtagCount;
@Schema(description = "플랫폼별 최대 해시태그 개수", example = "15")
private Integer maxHashtagCount;
// ==================== 생성 조건 정보 ====================
@Schema(description = "콘텐츠 생성에 사용된 조건들")
private GenerationConditionsDto generationConditions;
// ==================== 상태 및 메타데이터 ====================
@Schema(description = "생성 상태", example = "DRAFT",
allowableValues = {"DRAFT", "PUBLISHED", "SCHEDULED"})
private String status;
@Schema(description = "생성 일시", example = "2024-01-15T10:30:00")
private LocalDateTime createdAt;
@Schema(description = "AI 모델 버전", example = "gpt-4-turbo")
private String aiModelVersion;
@Schema(description = "생성 시간 (초)", example = "3.5")
private Double generationTimeSeconds;
// ==================== 추가 정보 ====================
@Schema(description = "업로드된 원본 이미지 URL 목록")
private List<String> originalImages;
@Schema(description = "콘텐츠 품질 점수 (1-100)", example = "85")
private Integer qualityScore;
@Schema(description = "예상 참여율 (%)", example = "12.5")
private Double expectedEngagementRate;
@Schema(description = "콘텐츠 카테고리", example = "음식/메뉴소개")
private String category;
// ==================== 편집 가능 여부 ====================
@Schema(description = "제목 편집 가능 여부", example = "true")
@Builder.Default
private Boolean titleEditable = true;
@Schema(description = "내용 편집 가능 여부", example = "true")
@Builder.Default
private Boolean contentEditable = true;
@Schema(description = "해시태그 편집 가능 여부", example = "true")
@Builder.Default
private Boolean hashtagsEditable = true;
// ==================== 대안 콘텐츠 ====================
@Schema(description = "대안 제목 목록 (사용자 선택용)")
private List<String> alternativeTitles;
@Schema(description = "대안 해시태그 세트 목록")
private List<List<String>> alternativeHashtagSets;
// ==================== 내부 DTO 클래스 ====================
/**
* 콘텐츠 생성 조건 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "콘텐츠 생성 조건")
public static class GenerationConditionsDto {
@Schema(description = "홍보 대상", example = "메뉴")
private String targetAudience;
@Schema(description = "이벤트명", example = "신메뉴 출시 이벤트")
private String eventName;
@Schema(description = "톤앤매너", example = "친근함")
private String toneAndManner;
@Schema(description = "프로모션 유형", example = "할인 정보")
private String promotionType;
@Schema(description = "감정 강도", example = "보통")
private String emotionIntensity;
@Schema(description = "홍보 시작일", example = "2024-01-15T09:00:00")
private LocalDateTime promotionStartDate;
@Schema(description = "홍보 종료일", example = "2024-01-22T23:59:59")
private LocalDateTime promotionEndDate;
}
// ==================== 비즈니스 메서드 ====================
/**
* 플랫폼별 콘텐츠 최적화 여부 확인
*
* @return 콘텐츠가 플랫폼 권장 사항을 만족하면 true
*/
public boolean isOptimizedForPlatform() {
if (content == null || hashtags == null) {
return false;
}
// 플랫폼별 최적화 기준
switch (platform.toUpperCase()) {
case "INSTAGRAM":
return content.length() <= 2200 &&
hashtags.size() <= 15 &&
hashtags.size() >= 5;
case "NAVER_BLOG":
return content.length() >= 300 &&
hashtags.size() <= 10 &&
hashtags.size() >= 3;
case "FACEBOOK":
return content.length() <= 500 &&
hashtags.size() <= 5;
default:
return true;
}
}
/**
* 고품질 콘텐츠 여부 확인
*
* @return 품질 점수가 80점 이상이면 true
*/
public boolean isHighQuality() {
return qualityScore != null && qualityScore >= 80;
}
/**
* 참여율 예상 등급 반환
*
* @return 예상 참여율 등급 (HIGH, MEDIUM, LOW)
*/
public String getExpectedEngagementLevel() {
if (expectedEngagementRate == null) {
return "UNKNOWN";
}
if (expectedEngagementRate >= 15.0) {
return "HIGH";
} else if (expectedEngagementRate >= 8.0) {
return "MEDIUM";
} else {
return "LOW";
}
}
/**
* 해시태그를 문자열로 변환 (# 포함)
*
* @return #으로 시작하는 해시태그 문자열
*/
public String getHashtagsAsString() {
if (hashtags == null || hashtags.isEmpty()) {
return "";
}
return hashtags.stream()
.map(tag -> "#" + tag)
.reduce((a, b) -> a + " " + b)
.orElse("");
}
/**
* 콘텐츠 요약 생성
*
* @param maxLength 최대 길이
* @return 요약된 콘텐츠
*/
public String getContentSummary(int maxLength) {
if (content == null || content.length() <= maxLength) {
return content;
}
return content.substring(0, maxLength) + "...";
}
/**
* 플랫폼별 최적화 제안사항 반환
*
* @return 최적화 제안사항 목록
*/
public List<String> getOptimizationSuggestions() {
List<String> suggestions = new java.util.ArrayList<>();
if (!isOptimizedForPlatform()) {
switch (platform.toUpperCase()) {
case "INSTAGRAM":
if (content != null && content.length() > 2200) {
suggestions.add("콘텐츠 길이를 2200자 이하로 줄여주세요.");
}
if (hashtags != null && hashtags.size() > 15) {
suggestions.add("해시태그를 15개 이하로 줄여주세요.");
}
if (hashtags != null && hashtags.size() < 5) {
suggestions.add("해시태그를 5개 이상 추가해주세요.");
}
break;
case "NAVER_BLOG":
if (content != null && content.length() < 300) {
suggestions.add("블로그 포스팅을 위해 내용을 300자 이상으로 늘려주세요.");
}
if (hashtags != null && hashtags.size() > 10) {
suggestions.add("네이버 블로그는 해시태그를 10개 이하로 사용하는 것이 좋습니다.");
}
break;
case "FACEBOOK":
if (content != null && content.length() > 500) {
suggestions.add("페이스북에서는 500자 이하의 짧은 글이 더 효과적입니다.");
}
break;
}
}
return suggestions;
}
// ==================== 팩토리 메서드 ====================
/**
* 도메인 엔티티에서 SnsContentCreateResponse 생성
*
* @param content 콘텐츠 도메인 엔티티
* @param aiMetadata AI 생성 메타데이터
* @return SnsContentCreateResponse
*/
public static SnsContentCreateResponse fromDomain(
com.won.smarketing.content.domain.model.Content content,
AiGenerationMetadata aiMetadata) {
SnsContentCreateResponseBuilder builder = SnsContentCreateResponse.builder()
.contentId(content.getId())
.contentType(content.getContentType().name())
.platform(content.getPlatform().name())
.title(content.getTitle())
.content(content.getContent())
.hashtags(content.getHashtags())
.status(content.getStatus().name())
.createdAt(content.getCreatedAt())
.originalImages(content.getImages());
// 생성 조건 정보 설정
if (content.getCreationConditions() != null) {
builder.generationConditions(GenerationConditionsDto.builder()
.targetAudience(content.getCreationConditions().getTargetAudience())
.eventName(content.getCreationConditions().getEventName())
.toneAndManner(content.getCreationConditions().getToneAndManner())
.promotionType(content.getCreationConditions().getPromotionType())
.emotionIntensity(content.getCreationConditions().getEmotionIntensity())
.promotionStartDate(content.getPromotionStartDate())
.promotionEndDate(content.getPromotionEndDate())
.build());
}
// AI 메타데이터 설정
if (aiMetadata != null) {
builder.aiModelVersion(aiMetadata.getModelVersion())
.generationTimeSeconds(aiMetadata.getGenerationTime())
.qualityScore(aiMetadata.getQualityScore())
.expectedEngagementRate(aiMetadata.getExpectedEngagementRate())
.alternativeTitles(aiMetadata.getAlternativeTitles())
.alternativeHashtagSets(aiMetadata.getAlternativeHashtagSets());
}
// 플랫폼별 최적화 정보 설정
SnsContentCreateResponse response = builder.build();
response.setContentLength(response.getContent() != null ? response.getContent().length() : 0);
response.setRecommendedHashtagCount(getRecommendedHashtagCount(content.getPlatform().name()));
response.setMaxHashtagCount(getMaxHashtagCount(content.getPlatform().name()));
return response;
}
/**
* 플랫폼별 권장 해시태그 개수 반환
*/
private static Integer getRecommendedHashtagCount(String platform) {
switch (platform.toUpperCase()) {
case "INSTAGRAM": return 8;
case "NAVER_BLOG": return 5;
case "FACEBOOK": return 3;
case "KAKAO_STORY": return 5;
default: return 5;
}
}
/**
* 플랫폼별 최대 해시태그 개수 반환
*/
private static Integer getMaxHashtagCount(String platform) {
switch (platform.toUpperCase()) {
case "INSTAGRAM": return 15;
case "NAVER_BLOG": return 10;
case "FACEBOOK": return 5;
case "KAKAO_STORY": return 8;
default: return 10;
}
}
// ==================== AI 생성 메타데이터 DTO ====================
/**
* AI 생성 메타데이터
* AI 생성 과정에서 나온 부가 정보들
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class AiGenerationMetadata {
private String modelVersion;
private Double generationTime;
private Integer qualityScore;
private Double expectedEngagementRate;
private List<String> alternativeTitles;
private List<List<String>> alternativeHashtagSets;
private String category;
}
}
@@ -0,0 +1,35 @@
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* SNS 콘텐츠 저장 요청 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "SNS 콘텐츠 저장 요청")
public class SnsContentSaveRequest {
@Schema(description = "콘텐츠 ID", example = "1", required = true)
@NotNull(message = "콘텐츠 ID는 필수입니다")
private Long contentId;
@Schema(description = "최종 제목", example = "맛있는 신메뉴를 소개합니다!")
private String finalTitle;
@Schema(description = "최종 콘텐츠 내용")
private String finalContent;
@Schema(description = "발행 상태", example = "PUBLISHED")
private String status;
}
@@ -0,0 +1,42 @@
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
ai:
service:
url: ${AI_SERVICE_URL:http://localhost:8080/ai}
timeout: ${AI_SERVICE_TIMEOUT:30000}
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}
+4
View File
@@ -0,0 +1,4 @@
dependencies {
implementation project(':common')
runtimeOnly 'com.mysql:mysql-connector-j'
}
@@ -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,13 @@
package com.won.smarketing.member.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
/**
* JPA 설정 클래스
* JPA Auditing 기능 활성화
*/
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}
@@ -0,0 +1,64 @@
package com.won.smarketing.member.controller;
import com.won.smarketing.common.dto.ApiResponse;
import com.won.smarketing.member.dto.*;
import com.won.smarketing.member.service.AuthService;
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.*;
import jakarta.validation.Valid;
/**
* 인증을 위한 REST API 컨트롤러
* 로그인, 로그아웃, 토큰 갱신 기능 제공
*/
@Tag(name = "인증 관리", description = "로그인, 로그아웃, 토큰 관리 API")
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
/**
* 로그인
*
* @param request 로그인 요청 정보
* @return 로그인 성공 응답 (토큰 포함)
*/
@Operation(summary = "로그인", description = "사용자 ID와 패스워드로 로그인합니다.")
@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 새로운 토큰 정보
*/
@Operation(summary = "토큰 갱신", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다.")
@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,120 @@
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 lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
/**
* 회원 관리를 위한 REST API 컨트롤러
* 회원가입, 중복 확인, 패스워드 검증 기능 제공
*/
@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/user-id")
public ResponseEntity<ApiResponse<DuplicateCheckResponse>> checkUserIdDuplicate(
@Parameter(description = "확인할 사용자 ID", required = true)
@RequestParam String userId) {
boolean isDuplicate = memberService.checkDuplicate(userId);
DuplicateCheckResponse response = isDuplicate
? DuplicateCheckResponse.duplicate("이미 사용 중인 사용자 ID입니다.")
: DuplicateCheckResponse.available("사용 가능한 사용자 ID입니다.");
return ResponseEntity.ok(ApiResponse.success(response));
}
/**
* 이메일 중복 확인
*
* @param email 확인할 이메일
* @return 중복 확인 결과
*/
@Operation(summary = "이메일 중복 확인", description = "이메일의 중복 여부를 확인합니다.")
@GetMapping("/check-duplicate/email")
public ResponseEntity<ApiResponse<DuplicateCheckResponse>> checkEmailDuplicate(
@Parameter(description = "확인할 이메일", required = true)
@RequestParam String email) {
boolean isDuplicate = memberService.checkEmailDuplicate(email);
DuplicateCheckResponse response = isDuplicate
? DuplicateCheckResponse.duplicate("이미 사용 중인 이메일입니다.")
: DuplicateCheckResponse.available("사용 가능한 이메일입니다.");
return ResponseEntity.ok(ApiResponse.success(response));
}
/**
* 사업자번호 중복 확인
*
* @param businessNumber 확인할 사업자번호
* @return 중복 확인 결과
*/
@Operation(summary = "사업자번호 중복 확인", description = "사업자번호의 중복 여부를 확인합니다.")
@GetMapping("/check-duplicate/business-number")
public ResponseEntity<ApiResponse<DuplicateCheckResponse>> checkBusinessNumberDuplicate(
@Parameter(description = "확인할 사업자번호", required = true)
@RequestParam String businessNumber) {
boolean isDuplicate = memberService.checkBusinessNumberDuplicate(businessNumber);
DuplicateCheckResponse response = isDuplicate
? DuplicateCheckResponse.duplicate("이미 등록된 사업자번호입니다.")
: DuplicateCheckResponse.available("사용 가능한 사업자번호입니다.");
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,54 @@
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
* 사용자 ID, 이메일 등의 중복 확인 결과를 전달합니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "중복 확인 응답")
public class DuplicateCheckResponse {
@Schema(description = "중복 여부", example = "false")
private boolean isDuplicate;
@Schema(description = "메시지", example = "사용 가능한 ID입니다.")
private String message;
/**
* 중복된 경우의 응답 생성
*
* @param message 메시지
* @return 중복 응답
*/
public static DuplicateCheckResponse duplicate(String message) {
return DuplicateCheckResponse.builder()
.isDuplicate(true)
.message(message)
.build();
}
/**
* 사용 가능한 경우의 응답 생성
*
* @param message 메시지
* @return 사용 가능 응답
*/
public static DuplicateCheckResponse available(String message) {
return DuplicateCheckResponse.builder()
.isDuplicate(false)
.message(message)
.build();
}
}
@@ -0,0 +1,26 @@
package com.won.smarketing.member.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 로그인 요청 DTO
* 로그인 시 필요한 정보를 전달합니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "로그인 요청")
public class LoginRequest {
@Schema(description = "사용자 ID", example = "user123", required = true)
@NotBlank(message = "사용자 ID는 필수입니다")
private String userId;
@Schema(description = "패스워드", example = "password123!", required = true)
@NotBlank(message = "패스워드는 필수입니다")
private String password;
}
@@ -0,0 +1,47 @@
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
* 로그인 성공 시 토큰 정보를 전달합니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "로그인 응답")
public class LoginResponse {
@Schema(description = "액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
private String accessToken;
@Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
private String refreshToken;
@Schema(description = "토큰 만료 시간 (초)", example = "3600")
private long expiresIn;
@Schema(description = "사용자 정보")
private UserInfo userInfo;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "사용자 정보")
public static class UserInfo {
@Schema(description = "사용자 ID", example = "user123")
private String userId;
@Schema(description = "이름", example = "홍길동")
private String name;
@Schema(description = "이메일", example = "user@example.com")
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 jakarta.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,22 @@
package com.won.smarketing.member.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 패스워드 검증 요청 DTO
* 패스워드 규칙 검증을 위한 요청 정보를 전달합니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@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 jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 회원가입 요청 DTO
* 회원가입 시 필요한 정보를 전달합니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "회원가입 요청")
public class RegisterRequest {
@Schema(description = "사용자 ID", example = "user123", 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 = "패스워드는 필수입니다")
@Size(min = 8, max = 20, message = "패스워드는 8-20자 사이여야 합니다")
@Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]+$",
message = "패스워드는 영문, 숫자, 특수문자를 포함해야 합니다")
private String password;
@Schema(description = "이름", example = "홍길동", required = true)
@NotBlank(message = "이름은 필수입니다")
@Size(max = 50, message = "이름은 50자 이하여야 합니다")
private String name;
@Schema(description = "사업자등록번호", example = "123-45-67890")
@Pattern(regexp = "^\\d{3}-\\d{2}-\\d{5}$", message = "사업자등록번호 형식이 올바르지 않습니다 (000-00-00000)")
private String businessNumber;
@Schema(description = "이메일", example = "user@example.com", required = true)
@NotBlank(message = "이메일은 필수입니다")
@Email(message = "이메일 형식이 올바르지 않습니다")
@Size(max = 100, message = "이메일은 100자 이하여야 합니다")
private String email;
}
@@ -0,0 +1,22 @@
package com.won.smarketing.member.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 토큰 갱신 요청 DTO
* 리프레시 토큰을 사용한 액세스 토큰 갱신 요청 정보를 전달합니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "토큰 갱신 요청")
public class TokenRefreshRequest {
@Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", required = true)
@NotBlank(message = "리프레시 토큰은 필수입니다")
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
* 토큰 갱신 시 새로운 토큰 정보를 전달합니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "토큰 응답")
public class TokenResponse {
@Schema(description = "새로운 액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
private String accessToken;
@Schema(description = "새로운 리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
private String refreshToken;
@Schema(description = "토큰 만료 시간 (초)", example = "3600")
private long expiresIn;
}
@@ -0,0 +1,58 @@
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 = "오류 목록", example = "[\"영문이 포함되어야 합니다\", \"숫자가 포함되어야 합니다\"]")
private List<String> errors;
/**
* 유효한 경우의 응답 생성
*
* @param message 메시지
* @return 유효 응답
*/
public static ValidationResponse valid(String message) {
return ValidationResponse.builder()
.isValid(true)
.message(message)
.build();
}
/**
* 유효하지 않은 경우의 응답 생성
*
* @param message 메시지
* @param errors 오류 목록
* @return 무효 응답
*/
public static ValidationResponse invalid(String message, List<String> errors) {
return ValidationResponse.builder()
.isValid(false)
.message(message)
.errors(errors)
.build();
}
}
@@ -0,0 +1,82 @@
package com.won.smarketing.member.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 회원 엔티티
* 회원의 기본 정보와 사업자 정보를 관리
*/
@Entity
@Table(name = "members")
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EntityListeners(AuditingEntityListener.class)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
@Column(name = "user_id", nullable = false, unique = true, length = 50)
private String userId;
@Column(name = "password", nullable = false, length = 100)
private String password;
@Column(name = "name", nullable = false, length = 50)
private String name;
@Column(name = "business_number", length = 12)
private String businessNumber;
@Column(name = "email", nullable = false, unique = true, length = 100)
private String email;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
/**
* 회원 정보 업데이트
*
* @param name 이름
* @param email 이메일
* @param businessNumber 사업자번호
*/
public void updateProfile(String name, String email, String businessNumber) {
if (name != null && !name.trim().isEmpty()) {
this.name = name;
}
if (email != null && !email.trim().isEmpty()) {
this.email = email;
}
if (businessNumber != null && !businessNumber.trim().isEmpty()) {
this.businessNumber = businessNumber;
}
}
/**
* 패스워드 변경
*
* @param encodedPassword 암호화된 패스워드
*/
public void changePassword(String encodedPassword) {
this.password = encodedPassword;
}
}
@@ -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)
*/
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 로그인 응답 정보 (토큰 포함)
*/
LoginResponse login(LoginRequest request);
/**
* 로그아웃
*
* @param refreshToken 리프레시 토큰
*/
void logout(String refreshToken);
/**
* 토큰 갱신
*
* @param refreshToken 리프레시 토큰
* @return 새로운 토큰 정보
*/
TokenResponse refresh(String refreshToken);
}
@@ -0,0 +1,175 @@
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 lombok.extern.slf4j.Slf4j;
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;
/**
* 인증 서비스 구현체
* 로그인, 로그아웃, 토큰 갱신 기능 구현
*/
@Slf4j
@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:";
private static final String BLACKLIST_PREFIX = "blacklist:";
/**
* 로그인
*
* @param request 로그인 요청 정보
* @return 로그인 응답 정보 (토큰 포함)
*/
@Override
@Transactional
public LoginResponse login(LoginRequest request) {
log.info("로그인 시도: {}", request.getUserId());
// 회원 조회
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);
}
// 토큰 생성
String accessToken = jwtTokenProvider.generateAccessToken(member.getUserId());
String refreshToken = jwtTokenProvider.generateRefreshToken(member.getUserId());
// 리프레시 토큰을 Redis에 저장 (7일)
redisTemplate.opsForValue().set(
REFRESH_TOKEN_PREFIX + member.getUserId(),
refreshToken,
7,
TimeUnit.DAYS
);
log.info("로그인 성공: {}", request.getUserId());
return LoginResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.expiresIn(jwtTokenProvider.getAccessTokenValidityTime() / 1000)
.userInfo(LoginResponse.UserInfo.builder()
.userId(member.getUserId())
.name(member.getName())
.email(member.getEmail())
.build())
.build();
}
/**
* 로그아웃
*
* @param refreshToken 리프레시 토큰
*/
@Override
@Transactional
public void logout(String refreshToken) {
try {
if (jwtTokenProvider.validateToken(refreshToken)) {
String userId = jwtTokenProvider.getUserIdFromToken(refreshToken);
// Redis에서 리프레시 토큰 삭제
redisTemplate.delete(REFRESH_TOKEN_PREFIX + userId);
// 리프레시 토큰을 블랙리스트에 추가
redisTemplate.opsForValue().set(
BLACKLIST_PREFIX + refreshToken,
"logout",
7,
TimeUnit.DAYS
);
log.info("로그아웃 완료: {}", userId);
}
} catch (Exception ex) {
log.warn("로그아웃 처리 중 오류 발생: {}", ex.getMessage());
// 로그아웃은 실패해도 클라이언트에게는 성공으로 응답
}
}
/**
* 토큰 갱신
*
* @param refreshToken 리프레시 토큰
* @return 새로운 토큰 정보
*/
@Override
@Transactional
public TokenResponse refresh(String refreshToken) {
// 토큰 유효성 검증
if (!jwtTokenProvider.validateToken(refreshToken)) {
throw new BusinessException(ErrorCode.INVALID_TOKEN);
}
// 블랙리스트 확인
if (redisTemplate.hasKey(BLACKLIST_PREFIX + refreshToken)) {
throw new BusinessException(ErrorCode.INVALID_TOKEN);
}
String userId = jwtTokenProvider.getUserIdFromToken(refreshToken);
// Redis에 저장된 리프레시 토큰과 비교
String storedRefreshToken = redisTemplate.opsForValue().get(REFRESH_TOKEN_PREFIX + userId);
if (!refreshToken.equals(storedRefreshToken)) {
throw new BusinessException(ErrorCode.INVALID_TOKEN);
}
// 회원 존재 확인
if (!memberRepository.existsByUserId(userId)) {
throw new BusinessException(ErrorCode.MEMBER_NOT_FOUND);
}
// 새로운 토큰 생성
String newAccessToken = jwtTokenProvider.generateAccessToken(userId);
String newRefreshToken = jwtTokenProvider.generateRefreshToken(userId);
// 새로운 리프레시 토큰을 Redis에 저장
redisTemplate.opsForValue().set(
REFRESH_TOKEN_PREFIX + userId,
newRefreshToken,
7,
TimeUnit.DAYS
);
// 기존 리프레시 토큰을 블랙리스트에 추가
redisTemplate.opsForValue().set(
BLACKLIST_PREFIX + refreshToken,
"refreshed",
7,
TimeUnit.DAYS
);
log.info("토큰 갱신 완료: {}", userId);
return TokenResponse.builder()
.accessToken(newAccessToken)
.refreshToken(newRefreshToken)
.expiresIn(jwtTokenProvider.getAccessTokenValidityTime() / 1000)
.build();
}
}
@@ -0,0 +1,50 @@
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 중복 여부
*/
boolean checkDuplicate(String userId);
/**
* 이메일 중복 확인
*
* @param email 확인할 이메일
* @return 중복 여부
*/
boolean checkEmailDuplicate(String email);
/**
* 사업자번호 중복 확인
*
* @param businessNumber 확인할 사업자번호
* @return 중복 여부
*/
boolean checkBusinessNumberDuplicate(String businessNumber);
/**
* 패스워드 유효성 검증
*
* @param password 검증할 패스워드
* @return 검증 결과
*/
ValidationResponse validatePassword(String password);
}
@@ -0,0 +1,146 @@
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 lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
/**
* 회원 서비스 구현체
* 회원 등록, 중복 확인, 패스워드 검증 기능 구현
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberServiceImpl implements MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
// 패스워드 검증 패턴
private static final Pattern LETTER_PATTERN = Pattern.compile(".*[a-zA-Z].*");
private static final Pattern DIGIT_PATTERN = Pattern.compile(".*\\d.*");
private static final Pattern SPECIAL_CHAR_PATTERN = Pattern.compile(".*[@$!%*?&].*");
/**
* 회원 등록
*
* @param request 회원가입 요청 정보
*/
@Override
@Transactional
public void register(RegisterRequest request) {
log.info("회원 등록 시작: {}", request.getUserId());
// 중복 확인
if (memberRepository.existsByUserId(request.getUserId())) {
throw new BusinessException(ErrorCode.DUPLICATE_MEMBER_ID);
}
if (memberRepository.existsByEmail(request.getEmail())) {
throw new BusinessException(ErrorCode.DUPLICATE_EMAIL);
}
if (request.getBusinessNumber() != null &&
memberRepository.existsByBusinessNumber(request.getBusinessNumber())) {
throw new BusinessException(ErrorCode.DUPLICATE_BUSINESS_NUMBER);
}
// 회원 엔티티 생성 및 저장
Member member = Member.builder()
.userId(request.getUserId())
.password(passwordEncoder.encode(request.getPassword()))
.name(request.getName())
.businessNumber(request.getBusinessNumber())
.email(request.getEmail())
.build();
memberRepository.save(member);
log.info("회원 등록 완료: {}", request.getUserId());
}
/**
* 사용자 ID 중복 확인
*
* @param userId 확인할 사용자 ID
* @return 중복 여부
*/
@Override
public boolean checkDuplicate(String userId) {
return memberRepository.existsByUserId(userId);
}
/**
* 이메일 중복 확인
*
* @param email 확인할 이메일
* @return 중복 여부
*/
@Override
public boolean checkEmailDuplicate(String email) {
return memberRepository.existsByEmail(email);
}
/**
* 사업자번호 중복 확인
*
* @param businessNumber 확인할 사업자번호
* @return 중복 여부
*/
@Override
public boolean checkBusinessNumberDuplicate(String businessNumber) {
if (businessNumber == null || businessNumber.trim().isEmpty()) {
return false;
}
return memberRepository.existsByBusinessNumber(businessNumber);
}
/**
* 패스워드 유효성 검증
*
* @param password 검증할 패스워드
* @return 검증 결과
*/
@Override
public ValidationResponse validatePassword(String password) {
List<String> errors = new ArrayList<>();
// 길이 검증
if (password.length() < 8 || password.length() > 20) {
errors.add("패스워드는 8-20자 사이여야 합니다");
}
// 영문 포함 여부
if (!LETTER_PATTERN.matcher(password).matches()) {
errors.add("영문이 포함되어야 합니다");
}
// 숫자 포함 여부
if (!DIGIT_PATTERN.matcher(password).matches()) {
errors.add("숫자가 포함되어야 합니다");
}
// 특수문자 포함 여부
if (!SPECIAL_CHAR_PATTERN.matcher(password).matches()) {
errors.add("특수문자(@$!%*?&)가 포함되어야 합니다");
}
if (errors.isEmpty()) {
return ValidationResponse.valid("사용 가능한 패스워드입니다.");
} else {
return ValidationResponse.invalid("패스워드 규칙을 확인해 주세요.", errors);
}
}
}
@@ -0,0 +1,33 @@
server:
port: ${MEMBER_PORT:8081}
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}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: ${DDL_AUTO:update}
show-sql: ${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:}
jwt:
secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789}
access-token-validity: ${JWT_ACCESS_VALIDITY:3600000}
refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000}
logging:
level:
com.won.smarketing: ${LOG_LEVEL:DEBUG}
+6
View File
@@ -0,0 +1,6 @@
rootProject.name = 'smarketing'
include 'common'
include 'member'
include 'store'
include 'marketing-content'
include 'ai-recommend'
+4
View File
@@ -0,0 +1,4 @@
dependencies {
implementation project(':common')
runtimeOnly 'com.mysql:mysql-connector-j'
}
@@ -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,28 @@
package com.won.smarketing.store.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
/**
* JPA 설정 클래스
* JPA Auditing 기능 활성화
*/
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}리는 50자 이하여야 합니다")
private String category;
@Schema(description = "가격", example = "4500", required = true)
@NotNull(message = "가격은 필수입니다")
@Min(value = 0, message = "가격은 0원 이상이어야 합니다")
private Integer price;
@Schema(description = "메뉴 설명", example = "진한 맛의 아메리카노")
@Size(max = 500, message = "메뉴 설명은 500자 이하여야 합니다")
private String description;
@Schema(description = "이미지 URL", example = "https://example.com/americano.jpg")
@Size(max = 500, message = "이미지 URL은 500자 이하여야 합니다")
private String image;
}
@@ -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));
}
}

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