This commit is contained in:
OhSeongRak
2025-06-17 10:05:16 +09:00
commit 44d7312a85
178 changed files with 15106 additions and 0 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.cache.annotation.EnableCaching;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@SpringBootApplication(scanBasePackages = {
"com.won.smarketing.recommend",
"com.won.smarketing.common"
})
@EnableJpaAuditing
@EnableJpaRepositories(basePackages = "com.won.smarketing.recommend.infrastructure.persistence")
@EnableCaching
public class AIRecommendServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AIRecommendServiceApplication.class, args);
}
}
@@ -0,0 +1,168 @@
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.MenuData;
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
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.presentation.dto.MarketingTipResponse;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class MarketingTipService implements MarketingTipUseCase {
private final MarketingTipRepository marketingTipRepository;
private final StoreDataProvider storeDataProvider;
private final AiTipGenerator aiTipGenerator;
@Override
public MarketingTipResponse provideMarketingTip() {
String userId = getCurrentUserId();
log.info("마케팅 팁 제공: userId={}", userId);
try {
// 1. 사용자의 매장 정보 조회
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
// 2. 1시간 이내에 생성된 마케팅 팁이 있는지 DB에서 확인
Optional<MarketingTip> recentTip = findRecentMarketingTip(storeWithMenuData.getStoreData().getStoreId());
if (recentTip.isPresent()) {
log.info("1시간 이내에 생성된 마케팅 팁 발견: tipId={}", recentTip.get().getId().getValue());
log.info("1시간 이내에 생성된 마케팅 팁 발견: getTipContent()={}", recentTip.get().getTipContent());
return convertToResponse(recentTip.get(), storeWithMenuData.getStoreData(), true);
}
// 3. 1시간 이내 팁이 없으면 새로 생성
log.info("1시간 이내 마케팅 팁이 없어 새로 생성합니다: userId={}, storeId={}", userId, storeWithMenuData.getStoreData().getStoreId());
MarketingTip newTip = createNewMarketingTip(storeWithMenuData);
return convertToResponse(newTip, storeWithMenuData.getStoreData(), false);
} catch (Exception e) {
log.error("마케팅 팁 조회/생성 중 오류: userId={}", userId, e);
throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR);
}
}
/**
* DB에서 1시간 이내 생성된 마케팅 팁 조회
*/
private Optional<MarketingTip> findRecentMarketingTip(Long storeId) {
log.debug("DB에서 1시간 이내 마케팅 팁 조회: storeId={}", storeId);
// 최근 생성된 팁 1개 조회
Pageable pageable = PageRequest.of(0, 1);
Page<MarketingTip> recentTips = marketingTipRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable);
if (recentTips.isEmpty()) {
log.debug("매장의 마케팅 팁이 존재하지 않음: storeId={}", storeId);
return Optional.empty();
}
MarketingTip mostRecentTip = recentTips.getContent().get(0);
LocalDateTime oneHourAgo = LocalDateTime.now().minusHours(1);
// 1시간 이내에 생성된 팁인지 확인
if (mostRecentTip.getCreatedAt().isAfter(oneHourAgo)) {
log.debug("1시간 이내 마케팅 팁 발견: tipId={}, 생성시간={}",
mostRecentTip.getId().getValue(), mostRecentTip.getCreatedAt());
return Optional.of(mostRecentTip);
}
log.debug("가장 최근 팁이 1시간 이전에 생성됨: tipId={}, 생성시간={}",
mostRecentTip.getId().getValue(), mostRecentTip.getCreatedAt());
return Optional.empty();
}
/**
* 새로운 마케팅 팁 생성
*/
private MarketingTip createNewMarketingTip(StoreWithMenuData storeWithMenuData) {
log.info("새로운 마케팅 팁 생성 시작: storeName={}", storeWithMenuData.getStoreData().getStoreName());
// AI 서비스로 팁 생성
String aiGeneratedTip = aiTipGenerator.generateTip(storeWithMenuData);
log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length())));
// 도메인 객체 생성 및 저장
MarketingTip marketingTip = MarketingTip.builder()
.storeId(storeWithMenuData.getStoreData().getStoreId())
.tipContent(aiGeneratedTip)
.storeWithMenuData(storeWithMenuData)
.createdAt(LocalDateTime.now())
.build();
MarketingTip savedTip = marketingTipRepository.save(marketingTip);
log.info("새로운 마케팅 팁 저장 완료: tipId={}", savedTip.getId().getValue());
log.info("새로운 마케팅 팁 저장 완료: savedTip.getTipContent()={}", savedTip.getTipContent());
return savedTip;
}
/**
* 마케팅 팁을 응답 DTO로 변환 (전체 내용 포함)
*/
private MarketingTipResponse convertToResponse(MarketingTip marketingTip, StoreData storeData, boolean isRecentlyCreated) {
String tipSummary = generateTipSummary(marketingTip.getTipContent());
return MarketingTipResponse.builder()
.tipId(marketingTip.getId().getValue())
.tipSummary(tipSummary)
.tipContent(marketingTip.getTipContent()) // 🆕 전체 내용 포함
.storeInfo(MarketingTipResponse.StoreInfo.builder()
.storeName(storeData.getStoreName())
.businessType(storeData.getBusinessType())
.location(storeData.getLocation())
.build())
.createdAt(marketingTip.getCreatedAt())
.updatedAt(marketingTip.getUpdatedAt())
.isRecentlyCreated(isRecentlyCreated)
.build();
}
/**
* 마케팅 팁 요약 생성 (첫 50자 또는 첫 번째 문장)
*/
private String generateTipSummary(String fullContent) {
if (fullContent == null || fullContent.trim().isEmpty()) {
return "마케팅 팁이 생성되었습니다.";
}
// 첫 번째 문장으로 요약 (마침표 기준)
String[] sentences = fullContent.split("[.!?]");
String firstSentence = sentences.length > 0 ? sentences[0].trim() : fullContent;
// 50자 제한
if (firstSentence.length() > 50) {
return firstSentence.substring(0, 47) + "...";
}
return firstSentence;
}
/**
* 현재 로그인된 사용자 ID 조회
*/
private String getCurrentUserId() {
return SecurityContextHolder.getContext().getAuthentication().getName();
}
}
@@ -0,0 +1,12 @@
package com.won.smarketing.recommend.application.usecase;
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
public interface MarketingTipUseCase {
/**
* 마케팅 팁 제공
* 1시간 이내 팁이 있으면 기존 것 사용, 없으면 새로 생성
*/
MarketingTipResponse provideMarketingTip();
}
@@ -0,0 +1,13 @@
package com.won.smarketing.recommend.config;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;
/**
* 캐시 설정
*/
@Configuration
@EnableCaching
public class CacheConfig {
// 기본 Simple 캐시 사용
}
@@ -0,0 +1,12 @@
package com.won.smarketing.recommend.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* JPA 설정
*/
@Configuration
@EnableJpaRepositories
public class JpaConfig {
}
@@ -0,0 +1,29 @@
package com.won.smarketing.recommend.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
import io.netty.channel.ChannelOption;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import reactor.netty.http.client.HttpClient;
import java.time.Duration;
/**
* WebClient 설정 (간소화된 버전)
*/
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient() {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000)
.responseTimeout(Duration.ofMillis(30000));
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(1024 * 1024))
.build();
}
}
@@ -0,0 +1,36 @@
package com.won.smarketing.recommend.domain.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.cglib.core.Local;
import java.time.LocalDateTime;
/**
* 마케팅 팁 도메인 모델 (날씨 정보 제거)
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MarketingTip {
private TipId id;
private Long storeId;
private String tipSummary;
private String tipContent;
private StoreWithMenuData storeWithMenuData;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public static MarketingTip create(Long storeId, String tipContent, StoreWithMenuData storeWithMenuData) {
return MarketingTip.builder()
.storeId(storeId)
.tipContent(tipContent)
.storeWithMenuData(storeWithMenuData)
.createdAt(LocalDateTime.now())
.build();
}
}
@@ -0,0 +1,21 @@
package com.won.smarketing.recommend.domain.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 메뉴 데이터 값 객체
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MenuData {
private Long menuId;
private String menuName;
private String category;
private Integer price;
private String description;
}
@@ -0,0 +1,22 @@
package com.won.smarketing.recommend.domain.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 매장 데이터 값 객체
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class StoreData {
private Long storeId;
private String storeName;
private String businessType;
private String location;
private String description;
private Integer seatCount;
}
@@ -0,0 +1,13 @@
package com.won.smarketing.recommend.domain.model;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class StoreWithMenuData {
private StoreData storeData;
private List<MenuData> menuDataList;
}
@@ -0,0 +1,21 @@
package com.won.smarketing.recommend.domain.model;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 팁 ID 값 객체
*/
@Getter
@EqualsAndHashCode
@NoArgsConstructor
@AllArgsConstructor
public class TipId {
private Long value;
public static TipId of(Long value) {
return new TipId(value);
}
}
@@ -0,0 +1,19 @@
package com.won.smarketing.recommend.domain.repository;
import com.won.smarketing.recommend.domain.model.MarketingTip;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.Optional;
/**
* 마케팅 팁 레포지토리 인터페이스 (순수한 도메인 인터페이스)
*/
public interface MarketingTipRepository {
MarketingTip save(MarketingTip marketingTip);
Optional<MarketingTip> findById(Long tipId);
Page<MarketingTip> findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable);
}
@@ -0,0 +1,18 @@
package com.won.smarketing.recommend.domain.service;
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
/**
* AI 팁 생성 도메인 서비스 인터페이스 (단순화)
*/
public interface AiTipGenerator {
/**
* Python AI 서비스를 통한 마케팅 팁 생성
*
* @param storeWithMenuData 매장 및 메뉴 정보
* @return AI가 생성한 마케팅 팁
*/
String generateTip(StoreWithMenuData storeWithMenuData);
}
@@ -0,0 +1,13 @@
package com.won.smarketing.recommend.domain.service;
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
import java.util.List;
/**
* 매장 데이터 제공 도메인 서비스 인터페이스
*/
public interface StoreDataProvider {
StoreWithMenuData getStoreWithMenuData(String userId);
}
@@ -0,0 +1,143 @@
package com.won.smarketing.recommend.infrastructure.external;
import com.won.smarketing.recommend.domain.model.MenuData;
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
import com.won.smarketing.recommend.domain.service.AiTipGenerator;
import lombok.Getter;
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 java.time.Duration;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Python AI 팁 생성 구현체 (날씨 정보 제거)
*/
@Slf4j
@Service // 추가된 어노테이션
@RequiredArgsConstructor
public class PythonAiTipGenerator implements AiTipGenerator {
private final WebClient webClient;
@Value("${external.python-ai-service.base-url}")
private String pythonAiServiceBaseUrl;
@Value("${external.python-ai-service.api-key}")
private String pythonAiServiceApiKey;
@Value("${external.python-ai-service.timeout}")
private int timeout;
@Override
public String generateTip(StoreWithMenuData storeWithMenuData) {
try {
log.debug("Python AI 서비스 직접 호출: store={}", storeWithMenuData.getStoreData().getStoreName());
return callPythonAiService(storeWithMenuData);
} catch (Exception e) {
log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage());
return createFallbackTip(storeWithMenuData);
}
}
private String callPythonAiService(StoreWithMenuData storeWithMenuData) {
try {
StoreData storeData = storeWithMenuData.getStoreData();
List<MenuData> menuDataList = storeWithMenuData.getMenuDataList();
// 메뉴 데이터를 Map 형태로 변환
List<Map<String, Object>> menuList = menuDataList.stream()
.map(menu -> {
Map<String, Object> menuMap = new HashMap<>();
menuMap.put("menu_id", menu.getMenuId());
menuMap.put("menu_name", menu.getMenuName());
menuMap.put("category", menu.getCategory());
menuMap.put("price", menu.getPrice());
menuMap.put("description", menu.getDescription());
return menuMap;
})
.collect(Collectors.toList());
// Python AI 서비스로 전송할 데이터 (매장 정보 + 메뉴 정보)
Map<String, Object> requestData = new HashMap<>();
requestData.put("store_name", storeData.getStoreName());
requestData.put("business_type", storeData.getBusinessType());
requestData.put("location", storeData.getLocation());
requestData.put("seat_count", storeData.getSeatCount());
requestData.put("menu_list", menuList);
log.debug("Python AI 서비스 요청 데이터: {}", requestData);
PythonAiResponse response = webClient
.post()
.uri(pythonAiServiceBaseUrl + "/api/v1/generate-marketing-tip")
.header("Authorization", "Bearer " + pythonAiServiceApiKey)
.header("Content-Type", "application/json")
.bodyValue(requestData)
.retrieve()
.bodyToMono(PythonAiResponse.class)
.timeout(Duration.ofMillis(timeout))
.block();
if (response != null && response.getTip() != null && !response.getTip().trim().isEmpty()) {
log.debug("Python AI 서비스 응답 성공: tip length={}", response.getTip().length());
return response.getTip();
}
} catch (Exception e) {
log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage());
}
return createFallbackTip(storeWithMenuData);
}
/**
* 규칙 기반 Fallback 팁 생성 (날씨 정보 없이 매장 정보만 활용)
*/
private String createFallbackTip(StoreWithMenuData storeWithMenuData) {
String businessType = storeWithMenuData.getStoreData().getBusinessType();
String storeName = storeWithMenuData.getStoreData().getStoreName();
String location = storeWithMenuData.getStoreData().getLocation();
// 업종별 기본 팁 생성
if (businessType.contains("카페")) {
return String.format("%s만의 시그니처 음료와 디저트로 고객들에게 특별한 경험을 선사해보세요!", storeName);
} else if (businessType.contains("음식점") || businessType.contains("식당")) {
return String.format("%s의 대표 메뉴를 활용한 특별한 이벤트로 고객들의 관심을 끌어보세요!", storeName);
} else if (businessType.contains("베이커리") || businessType.contains("빵집")) {
return String.format("%s의 갓 구운 빵과 함께하는 따뜻한 서비스로 고객들의 마음을 사로잡아보세요!", storeName);
} else if (businessType.contains("치킨") || businessType.contains("튀김")) {
return String.format("%s의 바삭하고 맛있는 메뉴로 고객들에게 만족스러운 식사를 제공해보세요!", storeName);
}
// 지역별 팁
if (location.contains("강남") || location.contains("서초")) {
return String.format("%s에서 트렌디하고 세련된 서비스로 젊은 고객층을 공략해보세요!", storeName);
} else if (location.contains("홍대") || location.contains("신촌")) {
return String.format("%s에서 활기차고 개성 있는 이벤트로 대학생들의 관심을 끌어보세요!", storeName);
}
// 기본 팁
return String.format("%s만의 특별함을 살린 고객 맞춤 서비스로 단골 고객을 늘려보세요!", storeName);
}
@Getter
private static class PythonAiResponse {
private String tip;
private String status;
private String message;
private LocalDateTime generatedTip;
private String businessType;
private String aiModel;
}
}
@@ -0,0 +1,311 @@
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.MenuData;
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
import com.won.smarketing.recommend.domain.service.StoreDataProvider;
import jakarta.servlet.http.HttpServletRequest;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientException;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/**
* 매장 API 데이터 제공자 구현체
*/
@Slf4j
@Service // 추가된 어노테이션
@RequiredArgsConstructor
public class StoreApiDataProvider implements StoreDataProvider {
private final WebClient webClient;
@Value("${external.store-service.base-url}")
private String storeServiceBaseUrl;
@Value("${external.store-service.timeout}")
private int timeout;
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
public StoreWithMenuData getStoreWithMenuData(String userId) {
log.info("매장 정보와 메뉴 정보 통합 조회 시작: userId={}", userId);
try {
// 매장 정보와 메뉴 정보를 병렬로 조회
StoreData storeData = getStoreDataByUserId(userId);
List<MenuData> menuDataList = getMenusByStoreId(storeData.getStoreId());
StoreWithMenuData result = StoreWithMenuData.builder()
.storeData(storeData)
.menuDataList(menuDataList)
.build();
log.info("매장 정보와 메뉴 정보 통합 조회 완료: storeId={}, storeName={}, menuCount={}",
storeData.getStoreId(), storeData.getStoreName(), menuDataList.size());
return result;
} catch (Exception e) {
log.error("매장 정보와 메뉴 정보 통합 조회 실패, Mock 데이터 반환: storeId={}", userId, e);
// 실패 시 Mock 데이터 반환
return StoreWithMenuData.builder()
.storeData(createMockStoreData(userId))
.menuDataList(createMockMenuData(6L))
.build();
}
}
public StoreData getStoreDataByUserId(String userId) {
try {
log.debug("매장 정보 실시간 조회: userId={}", userId);
return callStoreServiceByUserId(userId);
} catch (Exception e) {
log.error("매장 정보 조회 실패, Mock 데이터 반환: userId={}, error={}", userId, e.getMessage());
return createMockStoreData(userId);
}
}
public List<MenuData> getMenusByStoreId(Long storeId) {
log.info("매장 메뉴 조회 시작: storeId={}", storeId);
try {
return callMenuService(storeId);
} catch (Exception e) {
log.error("메뉴 조회 실패, Mock 데이터 반환: storeId={}", storeId, e);
return createMockMenuData(storeId);
}
}
private StoreData callStoreServiceByUserId(String userId) {
try {
StoreApiResponse response = webClient
.get()
.uri(storeServiceBaseUrl + "/api/store")
.header("Authorization", "Bearer " + getCurrentJwtToken()) // JWT 토큰 추가
.retrieve()
.bodyToMono(StoreApiResponse.class)
.timeout(Duration.ofMillis(timeout))
.block();
log.info("response : {}", response.getData().getStoreName());
log.info("response : {}", response.getData().getStoreId());
if (response != null && response.getData() != null) {
StoreApiResponse.StoreInfo storeInfo = response.getData();
return StoreData.builder()
.storeId(storeInfo.getStoreId())
.storeName(storeInfo.getStoreName())
.businessType(storeInfo.getBusinessType())
.location(storeInfo.getAddress())
.description(storeInfo.getDescription())
.seatCount(storeInfo.getSeatCount())
.build();
}
} catch (WebClientResponseException e) {
if (e.getStatusCode().value() == 404) {
throw new BusinessException(ErrorCode.STORE_NOT_FOUND);
}
log.error("매장 서비스 호출 실패: {}", e.getMessage());
}
return createMockStoreData(userId);
}
private String getCurrentJwtToken() {
try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
log.warn("RequestAttributes를 찾을 수 없음 - HTTP 요청 컨텍스트 없음");
return null;
}
HttpServletRequest request = attributes.getRequest();
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
String token = bearerToken.substring(BEARER_PREFIX.length());
log.debug("JWT 토큰 추출 성공: {}...", token.substring(0, Math.min(10, token.length())));
return token;
} else {
log.warn("Authorization 헤더에서 Bearer 토큰을 찾을 수 없음: {}", bearerToken);
return null;
}
} catch (Exception e) {
log.error("JWT 토큰 추출 중 오류 발생: {}", e.getMessage());
return null;
}
}
private List<MenuData> callMenuService(Long storeId) {
try {
MenuApiResponse response = webClient
.get()
.uri(storeServiceBaseUrl + "/api/menu/store/" + storeId)
.retrieve()
.bodyToMono(MenuApiResponse.class)
.timeout(Duration.ofMillis(timeout))
.block();
if (response != null && response.getData() != null && !response.getData().isEmpty()) {
List<MenuData> menuDataList = response.getData().stream()
.map(this::toMenuData)
.collect(Collectors.toList());
log.info("매장 메뉴 조회 성공: storeId={}, menuCount={}", storeId, menuDataList.size());
return menuDataList;
}
} catch (WebClientResponseException e) {
if (e.getStatusCode().value() == 404) {
log.warn("매장의 메뉴 정보가 없습니다: storeId={}", storeId);
return Collections.emptyList();
}
log.error("메뉴 서비스 호출 실패: storeId={}, error={}", storeId, e.getMessage());
} catch (WebClientException e) {
log.error("메뉴 서비스 연결 실패: storeId={}, error={}", storeId, e.getMessage());
}
return createMockMenuData(storeId);
}
/**
* MenuResponse를 MenuData로 변환
*/
private MenuData toMenuData(MenuApiResponse.MenuInfo menuInfo) {
return MenuData.builder()
.menuId(menuInfo.getMenuId())
.menuName(menuInfo.getMenuName())
.category(menuInfo.getCategory())
.price(menuInfo.getPrice())
.description(menuInfo.getDescription())
.build();
}
private StoreData createMockStoreData(String userId) {
return StoreData.builder()
.storeName("테스트 카페 " + userId)
.businessType("카페")
.location("서울시 강남구")
.build();
}
private List<MenuData> createMockMenuData(Long storeId) {
log.info("Mock 메뉴 데이터 생성: storeId={}", storeId);
return List.of(
MenuData.builder()
.menuId(1L)
.menuName("아메리카노")
.category("음료")
.price(4000)
.description("깊고 진한 맛의 아메리카노")
.build(),
MenuData.builder()
.menuId(2L)
.menuName("카페라떼")
.category("음료")
.price(4500)
.description("부드러운 우유 거품이 올라간 카페라떼")
.build(),
MenuData.builder()
.menuId(3L)
.menuName("치즈케이크")
.category("디저트")
.price(6000)
.description("진한 치즈 맛의 수제 케이크")
.build()
);
}
@Getter
private static class StoreApiResponse {
private int status;
private String message;
private StoreInfo data;
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 StoreInfo getData() { return data; }
public void setData(StoreInfo data) { this.data = data; }
@Getter
static class StoreInfo {
private Long storeId;
private String storeName;
private String businessType;
private String address;
private String description;
private Integer seatCount;
}
}
/**
* Menu API 응답 DTO (새로 추가)
*/
private static class MenuApiResponse {
private List<MenuInfo> data;
private String message;
private boolean success;
public List<MenuInfo> getData() { return data; }
public void setData(List<MenuInfo> data) { this.data = data; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public boolean isSuccess() { return success; }
public void setSuccess(boolean success) { this.success = success; }
public static class MenuInfo {
private Long menuId;
private String menuName;
private String category;
private Integer price;
private String description;
private String image;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Long getMenuId() { return menuId; }
public void setMenuId(Long menuId) { this.menuId = menuId; }
public String getMenuName() { return menuName; }
public void setMenuName(String menuName) { this.menuName = menuName; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public Integer getPrice() { return price; }
public void setPrice(Integer price) { this.price = price; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getImage() { return image; }
public void setImage(String image) { this.image = image; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
}
}
@@ -0,0 +1,81 @@
package com.won.smarketing.recommend.infrastructure.persistence;
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 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 jakarta.persistence.*;
import java.time.LocalDateTime;
/**
* 마케팅 팁 JPA 엔티티
*/
@Entity
@Table(name = "marketing_tips")
@EntityListeners(AuditingEntityListener.class)
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MarketingTipEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "tip_id", nullable = false)
private Long id;
@Column(name = "user_id", nullable = false, length = 50)
private String userId;
@Column(name = "store_id", nullable = false)
private Long storeId;
@Column(name = "tip_summary")
private String tipSummary;
@Lob
@Column(name = "tip_content", nullable = false, columnDefinition = "TEXT")
private String tipContent;
@Column(name = "ai_model")
private String aiModel;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
public static MarketingTipEntity fromDomain(MarketingTip marketingTip, String userId) {
return MarketingTipEntity.builder()
.id(marketingTip.getId() != null ? marketingTip.getId().getValue() : null)
.userId(userId)
.storeId(marketingTip.getStoreId())
.tipContent(marketingTip.getTipContent())
.tipSummary(marketingTip.getTipSummary())
.createdAt(marketingTip.getCreatedAt())
.updatedAt(marketingTip.getUpdatedAt())
.build();
}
public MarketingTip toDomain(StoreData storeData) {
return MarketingTip.builder()
.id(this.id != null ? TipId.of(this.id) : null)
.storeId(this.storeId)
.tipSummary(this.tipSummary)
.tipContent(this.tipContent)
.createdAt(this.createdAt)
.updatedAt(this.updatedAt)
.build();
}
}
@@ -0,0 +1,40 @@
package com.won.smarketing.recommend.infrastructure.persistence;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* 마케팅 팁 JPA 레포지토리
*/
@Repository
public interface MarketingTipJpaRepository extends JpaRepository<MarketingTipEntity, Long> {
/**
* 매장별 마케팅 팁 조회 (기존 - storeId 기반)
*/
@Query("SELECT m FROM MarketingTipEntity m WHERE m.storeId = :storeId ORDER BY m.createdAt DESC")
Page<MarketingTipEntity> findByStoreIdOrderByCreatedAtDesc(@Param("storeId") Long storeId, Pageable pageable);
/**
* 사용자별 마케팅 팁 조회 (새로 추가 - userId 기반)
*/
@Query("SELECT m FROM MarketingTipEntity m WHERE m.userId = :userId ORDER BY m.createdAt DESC")
Page<MarketingTipEntity> findByUserIdOrderByCreatedAtDesc(@Param("userId") String userId, Pageable pageable);
/**
* 사용자의 가장 최근 마케팅 팁 조회
*/
@Query("SELECT m FROM MarketingTipEntity m WHERE m.userId = :userId ORDER BY m.createdAt DESC LIMIT 1")
Optional<MarketingTipEntity> findTopByUserIdOrderByCreatedAtDesc(@Param("userId") String userId);
/**
* 특정 팁이 해당 사용자의 것인지 확인
*/
boolean existsByIdAndUserId(Long id, String userId);
}
@@ -0,0 +1,88 @@
package com.won.smarketing.recommend.infrastructure.persistence;
import com.won.smarketing.recommend.domain.model.MarketingTip;
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
import com.won.smarketing.recommend.domain.repository.MarketingTipRepository;
import com.won.smarketing.recommend.domain.service.StoreDataProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Slf4j
@Repository
@RequiredArgsConstructor
public class MarketingTipRepositoryImpl implements MarketingTipRepository {
private final MarketingTipJpaRepository jpaRepository;
private final StoreDataProvider storeDataProvider;
@Override
public MarketingTip save(MarketingTip marketingTip) {
String userId = getCurrentUserId();
MarketingTipEntity entity = MarketingTipEntity.fromDomain(marketingTip, userId);
MarketingTipEntity savedEntity = jpaRepository.save(entity);
// Store 정보는 다시 조회해서 Domain에 설정
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
return savedEntity.toDomain(storeWithMenuData.getStoreData());
}
@Override
public Optional<MarketingTip> findById(Long tipId) {
return jpaRepository.findById(tipId)
.map(entity -> {
// Store 정보를 API로 조회
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(entity.getUserId());
return entity.toDomain(storeWithMenuData.getStoreData());
});
}
@Override
public Page<MarketingTip> findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable) {
// 기존 메서드는 호환성을 위해 유지하지만, 내부적으로는 userId로 조회
String userId = getCurrentUserId();
return findByUserIdOrderByCreatedAtDesc(userId, pageable);
}
/**
* 사용자별 마케팅 팁 조회 (새로 추가)
*/
public Page<MarketingTip> findByUserIdOrderByCreatedAtDesc(String userId, Pageable pageable) {
Page<MarketingTipEntity> entities = jpaRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable);
// Store 정보는 한 번만 조회 (같은 userId이므로)
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
return entities.map(entity -> entity.toDomain(storeWithMenuData.getStoreData()));
}
/**
* 사용자의 가장 최근 마케팅 팁 조회
*/
public Optional<MarketingTip> findMostRecentByUserId(String userId) {
return jpaRepository.findTopByUserIdOrderByCreatedAtDesc(userId)
.map(entity -> {
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
return entity.toDomain(storeWithMenuData.getStoreData());
});
}
/**
* 특정 팁이 해당 사용자의 것인지 확인
*/
public boolean isOwnedByUser(Long tipId, String userId) {
return jpaRepository.existsByIdAndUserId(tipId, userId);
}
/**
* 현재 로그인된 사용자 ID 조회
*/
private String getCurrentUserId() {
return SecurityContextHolder.getContext().getAuthentication().getName();
}
}
@@ -0,0 +1,34 @@
//package com.won.smarketing.recommend.presentation.controller;
//
//import org.springframework.web.bind.annotation.GetMapping;
//import org.springframework.web.bind.annotation.RestController;
//
//import java.time.LocalDateTime;
//import java.util.Map;
//
///**
// * 헬스체크 컨트롤러
// */
//@RestController
//public class HealthController {
//
// @GetMapping("/health")
// public Map<String, Object> health() {
// return Map.of(
// "status", "UP",
// "service", "ai-recommend-service",
// "timestamp", LocalDateTime.now(),
// "message", "AI 추천 서비스가 정상 동작 중입니다.",
// "features", Map.of(
// "store_integration", "매장 서비스 연동",
// "python_ai_integration", "Python AI 서비스 연동",
// "fallback_support", "Fallback 팁 생성 지원"
// )
// );
// }
//}
// }
//
// } catch (Exception e) {
// log.error("매장 정보 조회 실패, Mock 데이터 반환: storeId={}", storeId, e);
// return createMockStoreData(storeId);
@@ -0,0 +1,41 @@
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.MarketingTipResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
/**
* AI 마케팅 추천 컨트롤러 (단일 API)
*/
@Tag(name = "AI 추천", description = "AI 기반 마케팅 팁 추천 API")
@Slf4j
@RestController
@RequestMapping("/api/recommendations")
@RequiredArgsConstructor
public class RecommendationController {
private final MarketingTipUseCase marketingTipUseCase;
@Operation(
summary = "마케팅 팁 조회/생성",
description = "마케팅 팁 전체 내용 조회. 1시간 이내 생성된 팁이 있으면 기존 것 사용, 없으면 새로 생성"
)
@PostMapping("/marketing-tips")
public ResponseEntity<ApiResponse<MarketingTipResponse>> provideMarketingTip() {
log.info("마케팅 팁 제공 요청");
MarketingTipResponse response = marketingTipUseCase.provideMarketingTip();
log.info("마케팅 팁 제공 완료: tipId={}", response.getTipId());
return ResponseEntity.ok(ApiResponse.success(response));
}
}
@@ -0,0 +1,57 @@
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;
/**
* 마케팅 팁 응답 DTO (요약 + 상세 통합)
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "마케팅 팁 응답")
public class MarketingTipResponse {
@Schema(description = "팁 ID", example = "1")
private Long tipId;
@Schema(description = "마케팅 팁 요약 (1줄)", example = "가을 시즌 특별 음료로 고객들의 관심을 끌어보세요!")
private String tipSummary;
@Schema(description = "마케팅 팁 전체 내용", example = "가을이 다가오면서 고객들은 따뜻하고 계절감 있는 음료를 찾게 됩니다...")
private String tipContent;
@Schema(description = "매장 정보")
private StoreInfo storeInfo;
@Schema(description = "생성 시간", example = "2025-06-13T14:30:00")
private LocalDateTime createdAt;
@Schema(description = "수정 시간", example = "2025-06-13T14:30:00")
private LocalDateTime updatedAt;
@Schema(description = "1시간 이내 생성 여부", example = "true")
private boolean isRecentlyCreated;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "매장 정보")
public static class StoreInfo {
@Schema(description = "매장명", example = "민코의 카페")
private String storeName;
@Schema(description = "업종", example = "카페")
private String businessType;
@Schema(description = "위치", example = "서울시 강남구 테헤란로 123")
private String location;
}
}
@@ -0,0 +1,52 @@
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:AiRecommendationDB}
username: ${POSTGRES_USER:postgres}
password: ${POSTGRES_PASSWORD:postgres}
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:create-drop}
show-sql: ${JPA_SHOW_SQL:true}
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
external:
store-service:
base-url: ${STORE_SERVICE_URL:http://localhost:8082}
timeout: ${STORE_SERVICE_TIMEOUT:5000}
python-ai-service:
base-url: ${PYTHON_AI_SERVICE_URL:http://localhost:5001}
api-key: ${PYTHON_AI_API_KEY:dummy-key}
timeout: ${PYTHON_AI_TIMEOUT:30000}
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: always
logging:
level:
com.won.smarketing.recommend: ${LOG_LEVEL:DEBUG}
jwt:
secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789}
access-token-validity: ${JWT_ACCESS_VALIDITY:3600000}
refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000}
+55
View File
@@ -0,0 +1,55 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.4'
}
// 루트 프로젝트에서는 bootJar 태스크 비활성화
bootJar {
enabled = false
}
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,69 @@
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.RedisStandaloneConfiguration;
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;
@Value("${spring.data.redis.password:}")
private String redisPassword;
@Value("${spring.data.redis.ssl:true}")
private boolean useSsl;
/**
* Redis 연결 팩토리 설정
*
* @return Redis 연결 팩토리
*/
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
config.setHostName(redisHost);
config.setPort(redisPort);
// Azure Redis는 패스워드 인증 필수
if (redisPassword != null && !redisPassword.isEmpty()) {
config.setPassword(redisPassword);
}
LettuceConnectionFactory factory = new LettuceConnectionFactory(config);
// Azure Redis는 SSL 사용 (6380 포트)
factory.setUseSsl(useSsl);
factory.setValidateConnection(true);
return factory;
}
/**
* 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,58 @@
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 호출에 실패했습니다."),
FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "F001", "파일을 찾을 수 없습니다."),
FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "F002", "파일 업로드에 실패했습니다."),
FILE_SIZE_EXCEEDED(HttpStatus.NOT_FOUND, "F003", "파일 크기가 제한을 초과했습니다."),
INVALID_FILE_EXTENSION(HttpStatus.NOT_FOUND, "F004", "지원하지 않는 파일 확장자입니다."),
INVALID_FILE_TYPE(HttpStatus.NOT_FOUND, "F005", "지원하지 않는 파일 형식입니다."),
INVALID_FILE_NAME(HttpStatus.NOT_FOUND, "F006", "잘못된 파일명입니다."),
INVALID_FILE_URL(HttpStatus.NOT_FOUND, "F007", "잘못된 파일 URL입니다."),
STORAGE_CONTAINER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "F008", "스토리지 컨테이너 오류가 발생했습니다."),
// 공통 오류
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;
}
}
+217
View File
@@ -0,0 +1,217 @@
def PIPELINE_ID = "${env.BUILD_NUMBER}"
def getImageTag() {
def dateFormat = new java.text.SimpleDateFormat('yyyyMMddHHmmss')
def currentDate = new Date()
return dateFormat.format(currentDate)
}
podTemplate(
label: "${PIPELINE_ID}",
serviceAccount: 'jenkins',
containers: [
containerTemplate(name: 'gradle', image: 'gradle:jdk17', ttyEnabled: true, command: 'cat'),
containerTemplate(name: 'docker', image: 'docker:20.10.16-dind', ttyEnabled: true, privileged: true),
containerTemplate(name: 'azure-cli', image: 'hiondal/azure-kubectl:latest', command: 'cat', ttyEnabled: true),
containerTemplate(name: 'envsubst', image: "hiondal/envsubst", command: 'sleep', args: '1h')
],
volumes: [
emptyDirVolume(mountPath: '/home/gradle/.gradle', memory: false),
emptyDirVolume(mountPath: '/root/.azure', memory: false),
emptyDirVolume(mountPath: '/var/run', memory: false)
]
) {
node(PIPELINE_ID) {
def props
def imageTag = getImageTag()
def manifest = "deploy.yaml"
def namespace
def services = ['member', 'store', 'marketing-content', 'ai-recommend']
stage("Get Source") {
checkout scm
// smarketing-java 하위에 있는 설정 파일 읽기
props = readProperties file: "smarketing-java/deployment/deploy_env_vars"
namespace = "${props.namespace}"
echo "=== Build Information ==="
echo "Services: ${services}"
echo "Namespace: ${namespace}"
echo "Image Tag: ${imageTag}"
}
stage("Setup AKS") {
container('azure-cli') {
withCredentials([azureServicePrincipal('azure-credentials')]) {
sh """
echo "=== Azure 로그인 ==="
az login --service-principal -u \$AZURE_CLIENT_ID -p \$AZURE_CLIENT_SECRET -t \$AZURE_TENANT_ID
az account set --subscription 2513dd36-7978-48e3-9a7c-b221d4874f66
echo "=== AKS 인증정보 가져오기 ==="
az aks get-credentials --resource-group rg-digitalgarage-01 --name aks-digitalgarage-01 --overwrite-existing
echo "=== 네임스페이스 생성 ==="
kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply -f -
echo "=== Image Pull Secret 생성 ==="
kubectl create secret docker-registry acr-secret \\
--docker-server=${props.registry} \\
--docker-username=acrdigitalgarage02 \\
--docker-password=\$(az acr credential show --name acrdigitalgarage02 --query passwords[0].value -o tsv) \\
--namespace=${namespace} \\
--dry-run=client -o yaml | kubectl apply -f -
echo "=== 클러스터 상태 확인 ==="
kubectl get nodes
kubectl get ns ${namespace}
"""
}
}
}
stage('Build Applications') {
container('gradle') {
sh """
echo "=== smarketing-java 디렉토리로 이동 ==="
cd smarketing-java
echo "=== gradlew 권한 설정 ==="
chmod +x gradlew
echo "=== 전체 서비스 빌드 ==="
./gradlew :member:clean :member:build -x test
./gradlew :store:clean :store:build -x test
./gradlew :marketing-content:clean :marketing-content:build -x test
./gradlew :ai-recommend:clean :ai-recommend:build -x test
echo "=== 빌드 결과 확인 ==="
find . -name "*.jar" -path "*/build/libs/*" | grep -v 'plain.jar'
"""
}
}
stage('Build & Push Images') {
container('docker') {
sh """
echo "=== Docker 데몬 시작 대기 ==="
timeout 30 sh -c 'until docker info; do sleep 1; done'
"""
// 🔧 ACR Credential을 Jenkins에서 직접 사용
withCredentials([usernamePassword(
credentialsId: 'acr-credentials',
usernameVariable: 'ACR_USERNAME',
passwordVariable: 'ACR_PASSWORD'
)]) {
sh """
echo "=== Docker로 ACR 로그인 ==="
echo "\$ACR_PASSWORD" | docker login ${props.registry} --username \$ACR_USERNAME --password-stdin
"""
services.each { service ->
script {
def buildDir = "smarketing-java/${service}"
def fullImageName = "${props.registry}/${props.image_org}/${service}:${imageTag}"
echo "Building image for ${service}: ${fullImageName}"
// 실제 JAR 파일명 동적 탐지
def actualJarFile = sh(
script: """
cd ${buildDir}/build/libs
ls *.jar | grep -v 'plain.jar' | head -1
""",
returnStdout: true
).trim()
if (!actualJarFile) {
error "${service} JAR 파일을 찾을 수 없습니다"
}
echo "발견된 JAR 파일: ${actualJarFile}"
sh """
echo "=== ${service} 이미지 빌드 ==="
docker build \\
--build-arg BUILD_LIB_DIR="${buildDir}/build/libs" \\
--build-arg ARTIFACTORY_FILE="${actualJarFile}" \\
-f smarketing-java/deployment/container/Dockerfile \\
-t ${fullImageName} .
echo "=== ${service} 이미지 푸시 ==="
docker push ${fullImageName}
echo "Successfully built and pushed: ${fullImageName}"
"""
}
}
}
}
}
stage('Generate & Apply Manifest') {
container('envsubst') {
sh """
echo "=== 환경변수 설정 ==="
export namespace=${namespace}
export allowed_origins=${props.allowed_origins}
export jwt_secret_key=${props.jwt_secret_key}
export postgres_user=${props.postgres_user}
export postgres_password=${props.postgres_password}
export replicas=${props.replicas}
# 리소스 요구사항 조정 (작게)
export resources_requests_cpu=100m
export resources_requests_memory=128Mi
export resources_limits_cpu=500m
export resources_limits_memory=512Mi
# 이미지 경로 환경변수 설정
export member_image_path=${props.registry}/${props.image_org}/member:${imageTag}
export store_image_path=${props.registry}/${props.image_org}/store:${imageTag}
export marketing_content_image_path=${props.registry}/${props.image_org}/marketing-content:${imageTag}
export ai_recommend_image_path=${props.registry}/${props.image_org}/ai-recommend:${imageTag}
echo "=== Manifest 생성 ==="
envsubst < smarketing-java/deployment/${manifest}.template > smarketing-java/deployment/${manifest}
echo "=== Generated Manifest File ==="
cat smarketing-java/deployment/${manifest}
echo "==============================="
"""
}
container('azure-cli') {
sh """
echo "=== PostgreSQL 서비스 확인 ==="
kubectl get svc -n ${namespace} | grep postgresql || echo "PostgreSQL 서비스가 없습니다. 먼저 설치해주세요."
echo "=== Manifest 적용 ==="
kubectl apply -f smarketing-java/deployment/${manifest}
echo "=== 배포 상태 확인 (60초 대기) ==="
kubectl -n ${namespace} get deployments
kubectl -n ${namespace} get pods
echo "=== 각 서비스 배포 대기 (60초 timeout) ==="
timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/member --timeout=60s || echo "member deployment 대기 타임아웃"
timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/store --timeout=60s || echo "store deployment 대기 타임아웃"
timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/marketing-content --timeout=60s || echo "marketing-content deployment 대기 타임아웃"
timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/ai-recommend --timeout=60s || echo "ai-recommend deployment 대기 타임아웃"
echo "=== 최종 상태 ==="
kubectl -n ${namespace} get all
echo "=== 실패한 Pod 상세 정보 ==="
for pod in \$(kubectl -n ${namespace} get pods --field-selector=status.phase!=Running -o name 2>/dev/null || true); do
if [ ! -z "\$pod" ]; then
echo "=== 실패한 Pod: \$pod ==="
kubectl -n ${namespace} describe \$pod | tail -20
fi
done
"""
}
}
}
}
@@ -0,0 +1,44 @@
# Build stage
FROM eclipse-temurin:17-jre AS builder
ARG BUILD_LIB_DIR
ARG ARTIFACTORY_FILE
WORKDIR /app
COPY ${BUILD_LIB_DIR}/${ARTIFACTORY_FILE} app.jar
# Run stage
FROM eclipse-temurin:17-jre
# Install necessary packages
RUN apt-get update && apt-get install -y \
curl \
netcat-traditional \
&& rm -rf /var/lib/apt/lists/*
ENV USERNAME k8s
ENV ARTIFACTORY_HOME /home/${USERNAME}
ENV JAVA_OPTS=""
# Add a non-root user
RUN groupadd -r ${USERNAME} && useradd -r -g ${USERNAME} ${USERNAME} && \
mkdir -p ${ARTIFACTORY_HOME} && \
chown ${USERNAME}:${USERNAME} ${ARTIFACTORY_HOME}
WORKDIR ${ARTIFACTORY_HOME}
# Copy JAR from builder stage
COPY --from=builder /app/app.jar app.jar
RUN chown ${USERNAME}:${USERNAME} app.jar
# Switch to non-root user
USER ${USERNAME}
# Expose port
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
CMD curl -f http://localhost:8080/actuator/health || exit 1
# Run the application
ENTRYPOINT ["sh", "-c"]
CMD ["java ${JAVA_OPTS} -jar app.jar"]
@@ -0,0 +1,475 @@
# ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: common-config
namespace: ${namespace}
data:
ALLOWED_ORIGINS: ${allowed_origins}
JPA_DDL_AUTO: update
JPA_SHOW_SQL: 'true'
---
apiVersion: v1
kind: ConfigMap
metadata:
name: member-config
namespace: ${namespace}
data:
POSTGRES_DB: member
POSTGRES_HOST: member-postgresql
POSTGRES_PORT: '5432'
SERVER_PORT: '8081'
---
apiVersion: v1
kind: ConfigMap
metadata:
name: store-config
namespace: ${namespace}
data:
POSTGRES_DB: store
POSTGRES_HOST: store-postgresql
POSTGRES_PORT: '5432'
SERVER_PORT: '8082'
---
apiVersion: v1
kind: ConfigMap
metadata:
name: marketing-content-config
namespace: ${namespace}
data:
POSTGRES_DB: marketing_content
POSTGRES_HOST: marketing-content-postgresql
POSTGRES_PORT: '5432'
SERVER_PORT: '8083'
---
apiVersion: v1
kind: ConfigMap
metadata:
name: ai-recommend-config
namespace: ${namespace}
data:
POSTGRES_DB: ai_recommend
POSTGRES_HOST: ai-recommend-postgresql
POSTGRES_PORT: '5432'
SERVER_PORT: '8084'
---
# Secrets
apiVersion: v1
kind: Secret
metadata:
name: common-secret
namespace: ${namespace}
stringData:
JWT_SECRET_KEY: ${jwt_secret_key}
type: Opaque
---
apiVersion: v1
kind: Secret
metadata:
name: member-secret
namespace: ${namespace}
stringData:
JWT_ACCESS_TOKEN_VALIDITY: '3600000'
JWT_REFRESH_TOKEN_VALIDITY: '86400000'
POSTGRES_PASSWORD: ${postgres_password}
POSTGRES_USER: ${postgres_user}
type: Opaque
---
apiVersion: v1
kind: Secret
metadata:
name: store-secret
namespace: ${namespace}
stringData:
POSTGRES_PASSWORD: ${postgres_password}
POSTGRES_USER: ${postgres_user}
type: Opaque
---
apiVersion: v1
kind: Secret
metadata:
name: marketing-content-secret
namespace: ${namespace}
stringData:
POSTGRES_PASSWORD: ${postgres_password}
POSTGRES_USER: ${postgres_user}
type: Opaque
---
apiVersion: v1
kind: Secret
metadata:
name: ai-recommend-secret
namespace: ${namespace}
stringData:
POSTGRES_PASSWORD: ${postgres_password}
POSTGRES_USER: ${postgres_user}
type: Opaque
---
# Deployments
apiVersion: apps/v1
kind: Deployment
metadata:
name: member
namespace: ${namespace}
labels:
app: member
spec:
replicas: ${replicas}
selector:
matchLabels:
app: member
template:
metadata:
labels:
app: member
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: member
image: ${member_image_path}
imagePullPolicy: Always
ports:
- containerPort: 8081
resources:
requests:
cpu: ${resources_requests_cpu}
memory: ${resources_requests_memory}
limits:
cpu: ${resources_limits_cpu}
memory: ${resources_limits_memory}
envFrom:
- configMapRef:
name: common-config
- configMapRef:
name: member-config
- secretRef:
name: common-secret
- secretRef:
name: member-secret
startupProbe:
exec:
command:
- /bin/sh
- -c
- "nc -z member-postgresql 5432"
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 10
livenessProbe:
httpGet:
path: /actuator/health
port: 8081
initialDelaySeconds: 60
periodSeconds: 30
readinessProbe:
httpGet:
path: /actuator/health
port: 8081
initialDelaySeconds: 30
periodSeconds: 5
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: store
namespace: ${namespace}
labels:
app: store
spec:
replicas: ${replicas}
selector:
matchLabels:
app: store
template:
metadata:
labels:
app: store
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: store
image: ${store_image_path}
imagePullPolicy: Always
ports:
- containerPort: 8082
resources:
requests:
cpu: ${resources_requests_cpu}
memory: ${resources_requests_memory}
limits:
cpu: ${resources_limits_cpu}
memory: ${resources_limits_memory}
envFrom:
- configMapRef:
name: common-config
- configMapRef:
name: store-config
- secretRef:
name: common-secret
- secretRef:
name: store-secret
startupProbe:
exec:
command:
- /bin/sh
- -c
- "nc -z store-postgresql 5432"
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 10
livenessProbe:
httpGet:
path: /actuator/health
port: 8082
initialDelaySeconds: 60
periodSeconds: 30
readinessProbe:
httpGet:
path: /actuator/health
port: 8082
initialDelaySeconds: 30
periodSeconds: 5
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: marketing-content
namespace: ${namespace}
labels:
app: marketing-content
spec:
replicas: ${replicas}
selector:
matchLabels:
app: marketing-content
template:
metadata:
labels:
app: marketing-content
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: marketing-content
image: ${marketing_content_image_path}
imagePullPolicy: Always
ports:
- containerPort: 8083
resources:
requests:
cpu: ${resources_requests_cpu}
memory: ${resources_requests_memory}
limits:
cpu: ${resources_limits_cpu}
memory: ${resources_limits_memory}
envFrom:
- configMapRef:
name: common-config
- configMapRef:
name: marketing-content-config
- secretRef:
name: common-secret
- secretRef:
name: marketing-content-secret
startupProbe:
exec:
command:
- /bin/sh
- -c
- "nc -z marketing-content-postgresql 5432"
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 10
livenessProbe:
httpGet:
path: /actuator/health
port: 8083
initialDelaySeconds: 60
periodSeconds: 30
readinessProbe:
httpGet:
path: /actuator/health
port: 8083
initialDelaySeconds: 30
periodSeconds: 5
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ai-recommend
namespace: ${namespace}
labels:
app: ai-recommend
spec:
replicas: ${replicas}
selector:
matchLabels:
app: ai-recommend
template:
metadata:
labels:
app: ai-recommend
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: ai-recommend
image: ${ai_recommend_image_path}
imagePullPolicy: Always
ports:
- containerPort: 8084
resources:
requests:
cpu: ${resources_requests_cpu}
memory: ${resources_requests_memory}
limits:
cpu: ${resources_limits_cpu}
memory: ${resources_limits_memory}
envFrom:
- configMapRef:
name: common-config
- configMapRef:
name: ai-recommend-config
- secretRef:
name: common-secret
- secretRef:
name: ai-recommend-secret
startupProbe:
exec:
command:
- /bin/sh
- -c
- "nc -z ai-recommend-postgresql 5432"
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 10
livenessProbe:
httpGet:
path: /actuator/health
port: 8084
initialDelaySeconds: 60
periodSeconds: 30
readinessProbe:
httpGet:
path: /actuator/health
port: 8084
initialDelaySeconds: 30
periodSeconds: 5
---
# Services
apiVersion: v1
kind: Service
metadata:
name: member
namespace: ${namespace}
spec:
selector:
app: member
ports:
- port: 80
targetPort: 8081
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: store
namespace: ${namespace}
spec:
selector:
app: store
ports:
- port: 80
targetPort: 8082
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: marketing-content
namespace: ${namespace}
spec:
selector:
app: marketing-content
ports:
- port: 80
targetPort: 8083
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: ai-recommend
namespace: ${namespace}
spec:
selector:
app: ai-recommend
ports:
- port: 80
targetPort: 8084
type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: smarketing-backend
namespace: ${namespace}
annotations:
kubernetes.io/ingress.class: nginx
spec:
ingressClassName: nginx
rules:
- http:
paths:
- path: /api/auth
pathType: Prefix
backend:
service:
name: member
port:
number: 80
- path: /api/store
pathType: Prefix
backend:
service:
name: store
port:
number: 80
- path: /api/content
pathType: Prefix
backend:
service:
name: marketing-content
port:
number: 80
- path: /api/recommend
pathType: Prefix
backend:
service:
name: ai-recommend
port:
number: 80
@@ -0,0 +1,23 @@
# Team Settings
teamid=kros235
root_project=smarketing-backend
namespace=smarketing
# Container Registry Settings
registry=acrdigitalgarage02.azurecr.io
image_org=smarketing
# Application Settings
replicas=1
allowed_origins=http://20.249.171.38
# Security Settings
jwt_secret_key=8O2HQ13etL2BWZvYOiWsJ5uWFoLi6NBUG8divYVoCgtHVvlk3dqRksMl16toztDUeBTSIuOOPvHIrYq11G2BwQ
postgres_user=admin
postgres_password=Hi5Jessica!
# Resource Settings (리소스 요구사항 줄임)
resources_requests_cpu=100m
resources_requests_memory=128Mi
resources_limits_cpu=500m
resources_limits_memory=512Mi
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 'org.postgresql:postgresql'
}
@@ -0,0 +1,29 @@
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.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* 마케팅 콘텐츠 서비스 메인 애플리케이션 클래스
* Clean Architecture 패턴을 적용한 마케팅 콘텐츠 관리 서비스
*/
@SpringBootApplication(scanBasePackages = {
"com.won.smarketing.content",
"com.won.smarketing.common"
})
@EnableJpaRepositories(basePackages = {
"com.won.smarketing.content.infrastructure.repository"
})
@EntityScan(basePackages = {
"com.won.smarketing.content.infrastructure.entity"
})
@EnableJpaAuditing
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.getPromotionStartDate(), request.getPromotionEndDate());
Content updatedContent = contentRepository.save(content);
return ContentUpdateResponse.builder()
.contentId(updatedContent.getId())
//.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())
.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())
.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())
.contentType(content.getContentType().name())
.platform(content.getPlatform().name())
.title(content.getTitle())
.status(content.getStatus().name())
.promotionStartDate(content.getPromotionStartDate())
//.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())
.posterImage(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())
.fixedImages(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,26 @@
// marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java
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;
/**
* 포스터 콘텐츠 관련 UseCase 인터페이스
* Clean Architecture의 Application Layer에서 비즈니스 로직 정의
*/
public interface PosterContentUseCase {
/**
* 포스터 콘텐츠 생성
* @param request 포스터 콘텐츠 생성 요청
* @return 포스터 콘텐츠 생성 응답
*/
PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request);
/**
* 포스터 콘텐츠 저장
* @param request 포스터 콘텐츠 저장 요청
*/
void savePosterContent(PosterContentSaveRequest request);
}
@@ -0,0 +1,26 @@
// marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java
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 콘텐츠 관련 UseCase 인터페이스
* Clean Architecture의 Application Layer에서 비즈니스 로직 정의
*/
public interface SnsContentUseCase {
/**
* SNS 콘텐츠 생성
* @param request SNS 콘텐츠 생성 요청
* @return SNS 콘텐츠 생성 응답
*/
SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request);
/**
* SNS 콘텐츠 저장
* @param request SNS 콘텐츠 저장 요청
*/
void saveSnsContent(SnsContentSaveRequest request);
}
@@ -0,0 +1,9 @@
package com.won.smarketing.content.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan(basePackages = "com.won.smarketing.content")
public class ContentConfig {
}
@@ -0,0 +1,26 @@
// marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java
package com.won.smarketing.content.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* ObjectMapper 설정 클래스
*
* @author smarketing-team
* @version 1.0
*/
@Configuration
public class ObjectMapperConfig {
@Bean
public ObjectMapper objectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
return objectMapper;
}
}
@@ -0,0 +1,163 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java
package com.won.smarketing.content.domain.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 콘텐츠 도메인 모델
*
* Clean Architecture의 Domain Layer에 위치하는 핵심 엔티티
* JPA 애노테이션을 제거하여 순수 도메인 모델로 유지
* Infrastructure Layer에서 별도의 JPA 엔티티로 매핑
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Content {
// ==================== 기본키 및 식별자 ====================
private Long id;
// ==================== 콘텐츠 분류 ====================
private ContentType contentType;
private Platform platform;
// ==================== 콘텐츠 내용 ====================
private String title;
private String content;
// ==================== 멀티미디어 및 메타데이터 ====================
@Builder.Default
private List<String> hashtags = new ArrayList<>();
@Builder.Default
private List<String> images = new ArrayList<>();
// ==================== 상태 관리 ====================
private ContentStatus status;
// ==================== 생성 조건 ====================
private CreationConditions creationConditions;
// ==================== 매장 정보 ====================
private Long storeId;
// ==================== 프로모션 기간 ====================
private LocalDateTime promotionStartDate;
private LocalDateTime promotionEndDate;
// ==================== 메타데이터 ====================
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Content(ContentId of, ContentType contentType, Platform platform, String title, String content, List<String> strings, List<String> strings1, ContentStatus contentStatus, CreationConditions conditions, Long storeId, LocalDateTime createdAt, LocalDateTime updatedAt) {
}
// ==================== 비즈니스 메서드 ====================
/**
* 콘텐츠 제목 수정
* @param newTitle 새로운 제목
*/
public void updateTitle(String newTitle) {
if (newTitle == null || newTitle.trim().isEmpty()) {
throw new IllegalArgumentException("제목은 필수입니다.");
}
this.title = newTitle.trim();
this.updatedAt = LocalDateTime.now();
}
/**
* 콘텐츠 내용 수정
* @param newContent 새로운 내용
*/
public void updateContent(String newContent) {
this.content = newContent;
this.updatedAt = LocalDateTime.now();
}
/**
* 프로모션 기간 설정
* @param startDate 시작일
* @param endDate 종료일
*/
public void updatePeriod(LocalDateTime startDate, LocalDateTime endDate) {
if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
throw new IllegalArgumentException("시작일은 종료일보다 이후일 수 없습니다.");
}
this.promotionStartDate = startDate;
this.promotionEndDate = endDate;
this.updatedAt = LocalDateTime.now();
}
/**
* 콘텐츠 상태 변경
* @param newStatus 새로운 상태
*/
public void updateStatus(ContentStatus newStatus) {
if (newStatus == null) {
throw new IllegalArgumentException("상태는 필수입니다.");
}
this.status = newStatus;
this.updatedAt = LocalDateTime.now();
}
/**
* 해시태그 추가
* @param hashtag 추가할 해시태그
*/
public void addHashtag(String hashtag) {
if (hashtag != null && !hashtag.trim().isEmpty()) {
if (this.hashtags == null) {
this.hashtags = new ArrayList<>();
}
this.hashtags.add(hashtag.trim());
this.updatedAt = LocalDateTime.now();
}
}
/**
* 이미지 추가
* @param imageUrl 추가할 이미지 URL
*/
public void addImage(String imageUrl) {
if (imageUrl != null && !imageUrl.trim().isEmpty()) {
if (this.images == null) {
this.images = new ArrayList<>();
}
this.images.add(imageUrl.trim());
this.updatedAt = LocalDateTime.now();
}
}
/**
* 프로모션 진행 중 여부 확인
* @return 현재 시간이 프로모션 기간 내에 있으면 true
*/
public boolean isPromotionActive() {
if (promotionStartDate == null || promotionEndDate == null) {
return false;
}
LocalDateTime now = LocalDateTime.now();
return !now.isBefore(promotionStartDate) && !now.isAfter(promotionEndDate);
}
/**
* 콘텐츠 게시 가능 여부 확인
* @return 필수 정보가 모두 입력되어 있으면 true
*/
public boolean canBePublished() {
return title != null && !title.trim().isEmpty()
&& contentType != null
&& platform != null
&& storeId != null;
}
}
@@ -0,0 +1,51 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java
package com.won.smarketing.content.domain.model;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 콘텐츠 ID 값 객체
* Clean Architecture의 Domain Layer에서 식별자를 타입 안전하게 관리
*/
@Getter
@RequiredArgsConstructor
@EqualsAndHashCode
public class ContentId {
private final Long value;
/**
* Long 값으로부터 ContentId 생성
* @param value ID 값
* @return ContentId 인스턴스
*/
public static ContentId of(Long value) {
if (value == null || value <= 0) {
throw new IllegalArgumentException("ContentId는 양수여야 합니다.");
}
return new ContentId(value);
}
/**
* 새로운 ContentId 생성 (ID가 없는 경우)
* @return null 값을 가진 ContentId
*/
public static ContentId newId() {
return new ContentId(null);
}
/**
* ID 값 존재 여부 확인
* @return ID가 null이 아니면 true
*/
public boolean hasValue() {
return value != null;
}
@Override
public String toString() {
return "ContentId{" + "value=" + value + '}';
}
}
@@ -0,0 +1,40 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java
package com.won.smarketing.content.domain.model;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 콘텐츠 상태 열거형
* Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙
*/
@Getter
@RequiredArgsConstructor
public enum ContentStatus {
DRAFT("임시저장"),
PUBLISHED("게시됨"),
SCHEDULED("예약됨"),
DELETED("삭제됨"),
PROCESSING("처리중");
private final String displayName;
/**
* 문자열로부터 ContentStatus 변환
* @param value 문자열 값
* @return ContentStatus enum
* @throws IllegalArgumentException 유효하지 않은 값인 경우
*/
public static ContentStatus fromString(String value) {
if (value == null) {
throw new IllegalArgumentException("ContentStatus 값은 null일 수 없습니다.");
}
try {
return ContentStatus.valueOf(value.toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("유효하지 않은 ContentStatus 값입니다: " + value);
}
}
}
@@ -0,0 +1,39 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java
package com.won.smarketing.content.domain.model;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 콘텐츠 타입 열거형
* Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙
*/
@Getter
@RequiredArgsConstructor
public enum ContentType {
SNS("SNS 게시물"),
POSTER("홍보 포스터"),
VIDEO("동영상"),
BLOG("블로그 포스트");
private final String displayName;
/**
* 문자열로부터 ContentType 변환
* @param value 문자열 값
* @return ContentType enum
* @throws IllegalArgumentException 유효하지 않은 값인 경우
*/
public static ContentType fromString(String value) {
if (value == null) {
throw new IllegalArgumentException("ContentType 값은 null일 수 없습니다.");
}
try {
return ContentType.valueOf(value.toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("유효하지 않은 ContentType 값입니다: " + value);
}
}
}
@@ -0,0 +1,58 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java
package com.won.smarketing.content.domain.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
/**
* 콘텐츠 생성 조건 도메인 모델
* Clean Architecture의 Domain Layer에 위치하는 값 객체
*
* JPA 애노테이션을 제거하여 순수 도메인 모델로 유지
* Infrastructure Layer의 JPA 엔티티는 별도로 관리
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CreationConditions {
private String id;
private String category;
private String requirement;
private String toneAndManner;
private String emotionIntensity;
private String eventName;
private LocalDate startDate;
private LocalDate endDate;
private String photoStyle;
private String promotionType;
public CreationConditions(String category, String requirement, String toneAndManner, String emotionIntensity, String eventName, LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) {
}
/**
* 이벤트 기간 유효성 검증
* @return 시작일이 종료일보다 이전이거나 같으면 true
*/
public boolean isValidEventPeriod() {
if (startDate == null || endDate == null) {
return true;
}
return !startDate.isAfter(endDate);
}
/**
* 이벤트 조건 유무 확인
* @return 이벤트명이나 날짜가 설정되어 있으면 true
*/
public boolean hasEventInfo() {
return eventName != null && !eventName.trim().isEmpty()
|| startDate != null
|| endDate != null;
}
}
@@ -0,0 +1,41 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java
package com.won.smarketing.content.domain.model;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 플랫폼 열거형
* Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙
*/
@Getter
@RequiredArgsConstructor
public enum Platform {
INSTAGRAM("인스타그램"),
NAVER_BLOG("네이버 블로그"),
FACEBOOK("페이스북"),
KAKAO_STORY("카카오스토리"),
YOUTUBE("유튜브"),
GENERAL("일반");
private final String displayName;
/**
* 문자열로부터 Platform 변환
* @param value 문자열 값
* @return Platform enum
* @throws IllegalArgumentException 유효하지 않은 값인 경우
*/
public static Platform fromString(String value) {
if (value == null) {
throw new IllegalArgumentException("Platform 값은 null일 수 없습니다.");
}
try {
return Platform.valueOf(value.toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("유효하지 않은 Platform 값입니다: " + value);
}
}
}
@@ -0,0 +1,54 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java
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;
/**
* 콘텐츠 리포지토리 인터페이스
* Clean Architecture의 Domain Layer에서 데이터 접근 정의
*/
public interface ContentRepository {
/**
* 콘텐츠 저장
* @param content 저장할 콘텐츠
* @return 저장된 콘텐츠
*/
Content save(Content content);
/**
* ID로 콘텐츠 조회
* @param id 콘텐츠 ID
* @return 조회된 콘텐츠
*/
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);
/**
* ID로 콘텐츠 삭제
* @param id 삭제할 콘텐츠 ID
*/
void deleteById(ContentId id);
}
@@ -0,0 +1,38 @@
package com.won.smarketing.content.domain.repository;
import com.won.smarketing.content.infrastructure.entity.ContentEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Spring Data JPA ContentRepository
* JPA 기반 콘텐츠 데이터 접근
*/
@Repository
public interface SpringDataContentRepository extends JpaRepository<ContentEntity, Long> {
/**
* 매장별 콘텐츠 조회
*
* @param storeId 매장 ID
* @return 콘텐츠 목록
*/
List<ContentEntity> findByStoreId(Long storeId);
/**
* 콘텐츠 타입별 조회
*
* @param contentType 콘텐츠 타입
* @return 콘텐츠 목록
*/
List<ContentEntity> findByContentType(String contentType);
/**
* 플랫폼별 조회
*
* @param platform 플랫폼
* @return 콘텐츠 목록
*/
List<ContentEntity> findByPlatform(String platform);
}
@@ -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,84 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java
package com.won.smarketing.content.infrastructure.entity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDate;
/**
* 콘텐츠 생성 조건 JPA 엔티티
* Infrastructure Layer에서 데이터베이스 매핑을 담당
*/
@Entity
@Table(name = "content_conditions")
@Getter
@Setter
public class ContentConditionsJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "content_id", nullable = false)
private ContentJpaEntity content;
@Column(name = "category", length = 100)
private String category;
@Column(name = "requirement", columnDefinition = "TEXT")
private String requirement;
@Column(name = "tone_and_manner", length = 100)
private String toneAndManner;
@Column(name = "emotion_intensity", length = 50)
private String emotionIntensity;
@Column(name = "event_name", length = 200)
private String eventName;
@Column(name = "start_date")
private LocalDate startDate;
@Column(name = "end_date")
private LocalDate endDate;
@Column(name = "photo_style", length = 100)
private String photoStyle;
@Column(name = "promotion_type", length = 100)
private String promotionType;
// 생성자
public ContentConditionsJpaEntity(ContentJpaEntity content, String category, String requirement,
String toneAndManner, String emotionIntensity, String eventName,
LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) {
this.content = content;
this.category = category;
this.requirement = requirement;
this.toneAndManner = toneAndManner;
this.emotionIntensity = emotionIntensity;
this.eventName = eventName;
this.startDate = startDate;
this.endDate = endDate;
this.photoStyle = photoStyle;
this.promotionType = promotionType;
}
public ContentConditionsJpaEntity() {
}
// 팩토리 메서드
public static ContentConditionsJpaEntity create(ContentJpaEntity content, String category, String requirement,
String toneAndManner, String emotionIntensity, String eventName,
LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) {
return new ContentConditionsJpaEntity(content, category, requirement, toneAndManner, emotionIntensity,
eventName, startDate, endDate, photoStyle, promotionType);
}
}
@@ -0,0 +1,60 @@
package com.won.smarketing.content.infrastructure.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 콘텐츠 엔티티
* 콘텐츠 정보를 데이터베이스에 저장하기 위한 JPA 엔티티
*/
@Entity
@Table(name = "contents")
@Data
@NoArgsConstructor
@AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class ContentEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "content_type", nullable = false)
private String contentType;
@Column(name = "platform", nullable = false)
private String platform;
@Column(name = "title", nullable = false)
private String title;
@Column(name = "content", columnDefinition = "TEXT")
private String content;
@Column(name = "hashtags")
private String hashtags;
@Column(name = "images", columnDefinition = "TEXT")
private String images;
@Column(name = "status", nullable = false)
private String status;
@Column(name = "store_id", nullable = false)
private Long storeId;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}
@@ -0,0 +1,70 @@
package com.won.smarketing.content.infrastructure.entity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
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.Date;
/**
* 콘텐츠 JPA 엔티티
*/
@Entity
@Table(name = "contents")
@Getter
@Setter
@EntityListeners(AuditingEntityListener.class)
public class ContentJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "store_id", nullable = false)
private Long storeId;
@Column(name = "content_type", nullable = false, length = 50)
private String contentType;
@Column(name = "platform", length = 50)
private String platform;
@Column(name = "title", length = 500)
private String title;
@Column(name = "PromotionStartDate")
private LocalDateTime PromotionStartDate;
@Column(name = "PromotionEndDate")
private LocalDateTime PromotionEndDate;
@Column(name = "content", columnDefinition = "TEXT")
private String content;
@Column(name = "hashtags", columnDefinition = "TEXT")
private String hashtags;
@Column(name = "images", columnDefinition = "TEXT")
private String images;
@Column(name = "status", nullable = false, length = 20)
private String status;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
// CreationConditions와의 관계 - OneToOne으로 별도 엔티티로 관리
@OneToOne(mappedBy = "content", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private ContentConditionsJpaEntity conditions;
}
@@ -0,0 +1,32 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java
package com.won.smarketing.content.infrastructure.external;
import com.won.smarketing.content.domain.model.Platform;
import com.won.smarketing.content.domain.model.CreationConditions;
import java.util.List;
/**
* AI 콘텐츠 생성 인터페이스
* Clean Architecture의 Infrastructure Layer에서 외부 AI 서비스와의 연동 정의
*/
public interface AiContentGenerator {
/**
* SNS 콘텐츠 생성
* @param title 제목
* @param category 카테고리
* @param platform 플랫폼
* @param conditions 생성 조건
* @return 생성된 콘텐츠 텍스트
*/
String generateSnsContent(String title, String category, Platform platform, CreationConditions conditions);
/**
* 해시태그 생성
* @param content 콘텐츠 내용
* @param platform 플랫폼
* @return 생성된 해시태그 목록
*/
List<String> generateHashtags(String content, Platform platform);
}
@@ -0,0 +1,29 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java
package com.won.smarketing.content.infrastructure.external;
import com.won.smarketing.content.domain.model.CreationConditions;
import java.util.Map;
/**
* AI 포스터 생성 인터페이스
* Clean Architecture의 Infrastructure Layer에서 외부 AI 서비스와의 연동 정의
*/
public interface AiPosterGenerator {
/**
* 포스터 이미지 생성
* @param title 제목
* @param category 카테고리
* @param conditions 생성 조건
* @return 생성된 포스터 이미지 URL
*/
String generatePoster(String title, String category, CreationConditions conditions);
/**
* 포스터 다양한 사이즈 생성
* @param originalImage 원본 이미지 URL
* @return 사이즈별 이미지 URL 맵
*/
Map<String, String> generatePosterSizes(String originalImage);
}
@@ -0,0 +1,95 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java
package com.won.smarketing.content.infrastructure.external;
// 수정: domain 패키지의 인터페이스를 import
import com.won.smarketing.content.domain.service.AiContentGenerator;
import com.won.smarketing.content.domain.model.Platform;
import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.List;
/**
* Claude AI를 활용한 콘텐츠 생성 구현체
* Clean Architecture의 Infrastructure Layer에 위치
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class ClaudeAiContentGenerator implements AiContentGenerator {
/**
* SNS 콘텐츠 생성
*/
@Override
public String generateSnsContent(SnsContentCreateRequest request) {
try {
String prompt = buildContentPrompt(request);
return generateDummySnsContent(request.getTitle(), Platform.fromString(request.getPlatform()));
} catch (Exception e) {
log.error("AI 콘텐츠 생성 실패: {}", e.getMessage(), e);
return generateFallbackContent(request.getTitle(), Platform.fromString(request.getPlatform()));
}
}
/**
* 플랫폼별 해시태그 생성
*/
@Override
public List<String> generateHashtags(String content, Platform platform) {
try {
return generateDummyHashtags(platform);
} catch (Exception e) {
log.error("해시태그 생성 실패: {}", e.getMessage(), e);
return generateFallbackHashtags();
}
}
private String buildContentPrompt(SnsContentCreateRequest request) {
StringBuilder prompt = new StringBuilder();
prompt.append("제목: ").append(request.getTitle()).append("\n");
prompt.append("카테고리: ").append(request.getCategory()).append("\n");
prompt.append("플랫폼: ").append(request.getPlatform()).append("\n");
if (request.getRequirement() != null) {
prompt.append("요구사항: ").append(request.getRequirement()).append("\n");
}
if (request.getToneAndManner() != null) {
prompt.append("톤앤매너: ").append(request.getToneAndManner()).append("\n");
}
return prompt.toString();
}
private String generateDummySnsContent(String title, Platform platform) {
String baseContent = "🌟 " + title + "를 소개합니다! 🌟\n\n" +
"저희 매장에서 특별한 경험을 만나보세요.\n" +
"고객 여러분의 소중한 시간을 더욱 특별하게 만들어드리겠습니다.\n\n";
if (platform == Platform.INSTAGRAM) {
return baseContent + "더 많은 정보는 프로필 링크에서 확인하세요! 📸";
} else {
return baseContent + "자세한 내용은 저희 블로그를 방문해 주세요! ✨";
}
}
private String generateFallbackContent(String title, Platform platform) {
return title + "에 대한 멋진 콘텐츠입니다. 많은 관심 부탁드립니다!";
}
private List<String> generateDummyHashtags(Platform platform) {
if (platform == Platform.INSTAGRAM) {
return Arrays.asList("#맛집", "#데일리", "#소상공인", "#추천", "#인스타그램");
} else {
return Arrays.asList("#맛집추천", "#블로그", "#리뷰", "#맛있는곳", "#소상공인응원");
}
}
private List<String> generateFallbackHashtags() {
return Arrays.asList("#소상공인", "#마케팅", "#홍보");
}
}
@@ -0,0 +1,86 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java
package com.won.smarketing.content.infrastructure.external;
import com.won.smarketing.content.domain.service.AiPosterGenerator; // 도메인 인터페이스 import
import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/**
* Claude AI를 활용한 포스터 생성 구현체
* Clean Architecture의 Infrastructure Layer에 위치
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class ClaudeAiPosterGenerator implements AiPosterGenerator {
/**
* 포스터 생성
*
* @param request 포스터 생성 요청
* @return 생성된 포스터 이미지 URL
*/
@Override
public String generatePoster(PosterContentCreateRequest request) {
try {
// Claude AI API 호출 로직
String prompt = buildPosterPrompt(request);
// TODO: 실제 Claude AI API 호출
// 현재는 더미 데이터 반환
return generateDummyPosterUrl(request.getTitle());
} catch (Exception e) {
log.error("AI 포스터 생성 실패: {}", e.getMessage(), e);
return generateFallbackPosterUrl();
}
}
/**
* 다양한 사이즈의 포스터 생성
*
* @param baseImage 기본 이미지
* @return 사이즈별 포스터 URL 맵
*/
@Override
public Map<String, String> generatePosterSizes(String baseImage) {
Map<String, String> sizes = new HashMap<>();
// 다양한 사이즈 생성 (더미 구현)
sizes.put("instagram_square", baseImage + "_1080x1080.jpg");
sizes.put("instagram_story", baseImage + "_1080x1920.jpg");
sizes.put("facebook_post", baseImage + "_1200x630.jpg");
sizes.put("a4_poster", baseImage + "_2480x3508.jpg");
return sizes;
}
private String buildPosterPrompt(PosterContentCreateRequest request) {
StringBuilder prompt = new StringBuilder();
prompt.append("포스터 제목: ").append(request.getTitle()).append("\n");
prompt.append("카테고리: ").append(request.getCategory()).append("\n");
if (request.getRequirement() != null) {
prompt.append("요구사항: ").append(request.getRequirement()).append("\n");
}
if (request.getToneAndManner() != null) {
prompt.append("톤앤매너: ").append(request.getToneAndManner()).append("\n");
}
return prompt.toString();
}
private String generateDummyPosterUrl(String title) {
return "https://dummy-ai-service.com/posters/" + title.hashCode() + ".jpg";
}
private String generateFallbackPosterUrl() {
return "https://dummy-ai-service.com/posters/fallback.jpg";
}
}
@@ -0,0 +1,213 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java
package com.won.smarketing.content.infrastructure.mapper;
import com.won.smarketing.content.domain.model.*;
import com.won.smarketing.content.infrastructure.entity.ContentConditionsJpaEntity;
import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
/**
* 콘텐츠 도메인-엔티티 매퍼
* Clean Architecture에서 Infrastructure Layer와 Domain Layer 간 변환 담당
*
* @author smarketing-team
* @version 1.0
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class ContentMapper {
private final ObjectMapper objectMapper;
/**
* 도메인 모델을 JPA 엔티티로 변환
*
* @param content 도메인 콘텐츠
* @return JPA 엔티티
*/
public ContentJpaEntity toEntity(Content content) {
if (content == null) {
return null;
}
ContentJpaEntity entity = new ContentJpaEntity();
// 기본 필드 매핑
if (content.getId() != null) {
entity.setId(content.getId());
}
entity.setStoreId(content.getStoreId());
entity.setContentType(content.getContentType() != null ? content.getContentType().name() : null);
entity.setPlatform(content.getPlatform() != null ? content.getPlatform().name() : null);
entity.setTitle(content.getTitle());
entity.setContent(content.getContent());
entity.setStatus(content.getStatus() != null ? content.getStatus().name() : "DRAFT");
entity.setPromotionStartDate(content.getPromotionStartDate());
entity.setPromotionEndDate(content.getPromotionEndDate());
entity.setCreatedAt(content.getCreatedAt());
entity.setUpdatedAt(content.getUpdatedAt());
// 컬렉션 필드를 JSON으로 변환
entity.setHashtags(convertListToJson(content.getHashtags()));
entity.setImages(convertListToJson(content.getImages()));
// 생성 조건 정보 매핑
if (content.getCreationConditions() != null) {
ContentConditionsJpaEntity conditionsEntity = mapToConditionsEntity(content.getCreationConditions());
conditionsEntity.setContent(entity);
entity.setConditions(conditionsEntity);
}
return entity;
}
/**
* JPA 엔티티를 도메인 모델로 변환
*
* @param entity JPA 엔티티
* @return 도메인 모델
*/
public Content toDomain(ContentJpaEntity entity) {
if (entity == null) {
return null;
}
return Content.builder()
.id(entity.getId())
.storeId(entity.getStoreId())
.contentType(parseContentType(entity.getContentType()))
.platform(parsePlatform(entity.getPlatform()))
.title(entity.getTitle())
.content(entity.getContent())
.hashtags(convertJsonToList(entity.getHashtags()))
.images(convertJsonToList(entity.getImages()))
.status(parseContentStatus(entity.getStatus()))
.promotionStartDate(entity.getPromotionStartDate())
.promotionEndDate(entity.getPromotionEndDate())
.creationConditions(mapToConditionsDomain(entity.getConditions()))
.createdAt(entity.getCreatedAt())
.updatedAt(entity.getUpdatedAt())
.build();
}
/**
* CreationConditions 도메인을 JPA 엔티티로 변환
*/
private ContentConditionsJpaEntity mapToConditionsEntity(CreationConditions conditions) {
ContentConditionsJpaEntity entity = new ContentConditionsJpaEntity();
entity.setCategory(conditions.getCategory());
entity.setRequirement(conditions.getRequirement());
entity.setToneAndManner(conditions.getToneAndManner());
entity.setEmotionIntensity(conditions.getEmotionIntensity());
entity.setEventName(conditions.getEventName());
entity.setStartDate(conditions.getStartDate());
entity.setEndDate(conditions.getEndDate());
entity.setPhotoStyle(conditions.getPhotoStyle());
entity.setPromotionType(conditions.getPromotionType());
return entity;
}
/**
* CreationConditions JPA 엔티티를 도메인으로 변환
*/
private CreationConditions mapToConditionsDomain(ContentConditionsJpaEntity entity) {
if (entity == null) {
return null;
}
return CreationConditions.builder()
.category(entity.getCategory())
.requirement(entity.getRequirement())
.toneAndManner(entity.getToneAndManner())
.emotionIntensity(entity.getEmotionIntensity())
.eventName(entity.getEventName())
.startDate(entity.getStartDate())
.endDate(entity.getEndDate())
.photoStyle(entity.getPhotoStyle())
.promotionType(entity.getPromotionType())
.build();
}
/**
* List를 JSON 문자열로 변환
*/
private String convertListToJson(List<String> list) {
if (list == null || list.isEmpty()) {
return null;
}
try {
return objectMapper.writeValueAsString(list);
} catch (Exception e) {
log.warn("Failed to convert list to JSON: {}", e.getMessage());
return null;
}
}
/**
* JSON 문자열을 List로 변환
*/
private List<String> convertJsonToList(String json) {
if (json == null || json.trim().isEmpty()) {
return Collections.emptyList();
}
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
} catch (Exception e) {
log.warn("Failed to convert JSON to list: {}", e.getMessage());
return Collections.emptyList();
}
}
/**
* 문자열을 ContentType 열거형으로 변환
*/
private ContentType parseContentType(String contentType) {
if (contentType == null) {
return null;
}
try {
return ContentType.valueOf(contentType);
} catch (IllegalArgumentException e) {
log.warn("Unknown content type: {}", contentType);
return null;
}
}
/**
* 문자열을 Platform 열거형으로 변환
*/
private Platform parsePlatform(String platform) {
if (platform == null) {
return null;
}
try {
return Platform.valueOf(platform);
} catch (IllegalArgumentException e) {
log.warn("Unknown platform: {}", platform);
return null;
}
}
/**
* 문자열을 ContentStatus 열거형으로 변환
*/
private ContentStatus parseContentStatus(String status) {
if (status == null) {
return ContentStatus.DRAFT;
}
try {
return ContentStatus.valueOf(status);
} catch (IllegalArgumentException e) {
log.warn("Unknown content status: {}", status);
return ContentStatus.DRAFT;
}
}
}
@@ -0,0 +1,147 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java
package com.won.smarketing.content.infrastructure.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 com.won.smarketing.content.domain.repository.ContentRepository;
import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity;
import com.won.smarketing.content.infrastructure.mapper.ContentMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* JPA를 활용한 콘텐츠 리포지토리 구현체
* Clean Architecture의 Infrastructure Layer에 위치
* JPA 엔티티와 도메인 모델 간 변환을 위해 ContentMapper 사용
*/
@Repository
@RequiredArgsConstructor
@Slf4j
public class JpaContentRepository implements ContentRepository {
private final JpaContentRepositoryInterface jpaRepository;
private final ContentMapper contentMapper;
/**
* 콘텐츠 저장
* @param content 저장할 도메인 콘텐츠
* @return 저장된 도메인 콘텐츠
*/
@Override
public Content save(Content content) {
log.debug("Saving content: {}", content.getTitle());
// 도메인 모델을 JPA 엔티티로 변환
ContentJpaEntity entity = contentMapper.toEntity(content);
// JPA로 저장
ContentJpaEntity savedEntity = jpaRepository.save(entity);
// JPA 엔티티를 도메인 모델로 변환하여 반환
Content savedContent = contentMapper.toDomain(savedEntity);
log.debug("Content saved with ID: {}", savedContent.getId());
return savedContent;
}
/**
* ID로 콘텐츠 조회
* @param id 콘텐츠 ID
* @return 조회된 도메인 콘텐츠
*/
@Override
public Optional<Content> findById(ContentId id) {
log.debug("Finding content by ID: {}", id.getValue());
return jpaRepository.findById(id.getValue())
.map(contentMapper::toDomain);
}
/**
* 필터 조건으로 콘텐츠 목록 조회
* @param contentType 콘텐츠 타입
* @param platform 플랫폼
* @param period 기간 (현재는 사용하지 않음)
* @param sortBy 정렬 기준 (현재는 사용하지 않음)
* @return 도메인 콘텐츠 목록
*/
@Override
public List<Content> findByFilters(ContentType contentType, Platform platform, String period, String sortBy) {
log.debug("Finding contents with filters - contentType: {}, platform: {}", contentType, platform);
String contentTypeStr = contentType != null ? contentType.name() : null;
String platformStr = platform != null ? platform.name() : null;
List<ContentJpaEntity> entities = jpaRepository.findByFilters(contentTypeStr, platformStr, null);
return entities.stream()
.map(contentMapper::toDomain)
.collect(Collectors.toList());
}
/**
* 진행 중인 콘텐츠 목록 조회
* @param period 기간 (현재는 사용하지 않음)
* @return 진행 중인 도메인 콘텐츠 목록
*/
@Override
public List<Content> findOngoingContents(String period) {
log.debug("Finding ongoing contents");
List<ContentJpaEntity> entities = jpaRepository.findOngoingContents();
return entities.stream()
.map(contentMapper::toDomain)
.collect(Collectors.toList());
}
/**
* ID로 콘텐츠 삭제
* @param id 삭제할 콘텐츠 ID
*/
@Override
public void deleteById(ContentId id) {
log.debug("Deleting content by ID: {}", id.getValue());
jpaRepository.deleteById(id.getValue());
log.debug("Content deleted successfully");
}
/**
* 매장 ID로 콘텐츠 목록 조회 (추가 메서드)
* @param storeId 매장 ID
* @return 도메인 콘텐츠 목록
*/
public List<Content> findByStoreId(Long storeId) {
log.debug("Finding contents by store ID: {}", storeId);
List<ContentJpaEntity> entities = jpaRepository.findByStoreId(storeId);
return entities.stream()
.map(contentMapper::toDomain)
.collect(Collectors.toList());
}
/**
* 콘텐츠 타입으로 조회 (추가 메서드)
* @param contentType 콘텐츠 타입
* @return 도메인 콘텐츠 목록
*/
public List<Content> findByContentType(ContentType contentType) {
log.debug("Finding contents by type: {}", contentType);
List<ContentJpaEntity> entities = jpaRepository.findByContentType(contentType.name());
return entities.stream()
.map(contentMapper::toDomain)
.collect(Collectors.toList());
}
}
@@ -0,0 +1,87 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java
package com.won.smarketing.content.infrastructure.repository;
import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
/**
* Spring Data JPA 콘텐츠 리포지토리 인터페이스
* Clean Architecture의 Infrastructure Layer에 위치
* JPA 엔티티(ContentJpaEntity)를 사용하여 데이터베이스 접근
*/
public interface JpaContentRepositoryInterface extends JpaRepository<ContentJpaEntity, Long> {
/**
* 매장 ID로 콘텐츠 목록 조회
* @param storeId 매장 ID
* @return 콘텐츠 엔티티 목록
*/
List<ContentJpaEntity> findByStoreId(Long storeId);
/**
* 콘텐츠 타입으로 조회
* @param contentType 콘텐츠 타입
* @return 콘텐츠 엔티티 목록
*/
List<ContentJpaEntity> findByContentType(String contentType);
/**
* 플랫폼으로 조회
* @param platform 플랫폼
* @return 콘텐츠 엔티티 목록
*/
List<ContentJpaEntity> findByPlatform(String platform);
/**
* 상태로 조회
* @param status 상태
* @return 콘텐츠 엔티티 목록
*/
List<ContentJpaEntity> findByStatus(String status);
/**
* 필터 조건으로 콘텐츠 목록 조회
* @param contentType 콘텐츠 타입 (null 가능)
* @param platform 플랫폼 (null 가능)
* @param status 상태 (null 가능)
* @return 콘텐츠 엔티티 목록
*/
@Query("SELECT c FROM ContentJpaEntity c WHERE " +
"(:contentType IS NULL OR c.contentType = :contentType) AND " +
"(:platform IS NULL OR c.platform = :platform) AND " +
"(:status IS NULL OR c.status = :status) " +
"ORDER BY c.createdAt DESC")
List<ContentJpaEntity> findByFilters(@Param("contentType") String contentType,
@Param("platform") String platform,
@Param("status") String status);
/**
* 진행 중인 콘텐츠 목록 조회 (발행된 상태의 콘텐츠)
* @return 진행 중인 콘텐츠 엔티티 목록
*/
@Query("SELECT c FROM ContentJpaEntity c WHERE " +
"c.status IN ('PUBLISHED', 'SCHEDULED') " +
"ORDER BY c.createdAt DESC")
List<ContentJpaEntity> findOngoingContents();
/**
* 매장 ID와 콘텐츠 타입으로 조회
* @param storeId 매장 ID
* @param contentType 콘텐츠 타입
* @return 콘텐츠 엔티티 목록
*/
List<ContentJpaEntity> findByStoreIdAndContentType(Long storeId, String contentType);
/**
* 최근 생성된 콘텐츠 조회 (limit 적용)
* @param storeId 매장 ID
* @return 최근 콘텐츠 엔티티 목록
*/
@Query("SELECT c FROM ContentJpaEntity c WHERE c.storeId = :storeId " +
"ORDER BY c.createdAt DESC")
List<ContentJpaEntity> findRecentContentsByStoreId(@Param("storeId") Long storeId);
}
@@ -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 jakarta.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,364 @@
// 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;
@Schema(description = "조회수", example = "8")
private Integer viewCount;
// ==================== 비즈니스 메서드 ====================
/**
* 콘텐츠 요약 생성
* 콘텐츠가 길 경우 첫 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())
.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,45 @@
// marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
/**
* 콘텐츠 생성 조건 DTO
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "콘텐츠 생성 조건")
public class CreationConditionsDto {
@Schema(description = "카테고리", example = "음식")
private String category;
@Schema(description = "생성 요구사항", example = "젊은 고객층을 타겟으로 한 재미있는 콘텐츠")
private String requirement;
@Schema(description = "톤앤매너", example = "친근하고 활발한")
private String toneAndManner;
@Schema(description = "감정 강도", example = "보통")
private String emotionIntensity;
@Schema(description = "이벤트명", example = "신메뉴 출시 이벤트")
private String eventName;
@Schema(description = "시작일")
private LocalDate startDate;
@Schema(description = "종료일")
private LocalDate endDate;
@Schema(description = "사진 스타일", example = "모던하고 깔끔한")
private String photoStyle;
}
@@ -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,79 @@
// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java
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.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
* 포스터 콘텐츠 생성 요청 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "포스터 콘텐츠 생성 요청")
public class PosterContentCreateRequest {
@Schema(description = "매장 ID", example = "1", required = true)
@NotNull(message = "매장 ID는 필수입니다")
private Long storeId;
@Schema(description = "제목", example = "특별 이벤트 안내")
private String title;
@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;
// CreationConditions에 필요한 필드들
@Schema(description = "콘텐츠 카테고리", example = "이벤트")
private String category;
@Schema(description = "구체적인 요구사항", example = "신메뉴 출시 이벤트 포스터를 만들어주세요")
private String requirement;
@Schema(description = "톤앤매너", example = "전문적")
private String toneAndManner;
@Schema(description = "이벤트 시작일", example = "2024-01-15")
private LocalDate startDate;
@Schema(description = "이벤트 종료일", example = "2024-01-31")
private LocalDate endDate;
@Schema(description = "사진 스타일", example = "밝고 화사한")
private String photoStyle;
}
@@ -0,0 +1,49 @@
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;
import java.util.Map;
/**
* 포스터 콘텐츠 생성 응답 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 = "생성된 포스터 타입")
private String contentType;
@Schema(description = "포스터 이미지 URL")
private String posterImage;
@Schema(description = "원본 이미지 URL 목록")
private List<String> originalImages;
@Schema(description = "이미지 스타일", example = "모던")
private String imageStyle;
@Schema(description = "생성 상태", example = "DRAFT")
private String status;
@Schema(description = "포스터사이즈", example = "800x600")
private Map<String, String> posterSizes;
}
@@ -0,0 +1,66 @@
// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java
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;
import java.time.LocalDate;
import java.util.List;
/**
* 포스터 콘텐츠 저장 요청 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "포스터 콘텐츠 저장 요청")
public class PosterContentSaveRequest {
@Schema(description = "콘텐츠 ID", example = "1", required = true)
@NotNull(message = "콘텐츠 ID는 필수입니다")
private Long contentId;
@Schema(description = "매장 ID", example = "1", required = true)
@NotNull(message = "매장 ID는 필수입니다")
private Long storeId;
@Schema(description = "제목", example = "특별 이벤트 안내")
private String title;
@Schema(description = "콘텐츠 내용")
private String content;
@Schema(description = "선택된 포스터 이미지 URL")
private List<String> images;
@Schema(description = "발행 상태", example = "PUBLISHED")
private String status;
// CreationConditions에 필요한 필드들
@Schema(description = "콘텐츠 카테고리", example = "이벤트")
private String category;
@Schema(description = "구체적인 요구사항", example = "신메뉴 출시 이벤트 포스터를 만들어주세요")
private String requirement;
@Schema(description = "톤앤매너", example = "전문적")
private String toneAndManner;
@Schema(description = "감정 강도", example = "보통")
private String emotionIntensity;
@Schema(description = "이벤트명", example = "신메뉴 출시 이벤트")
private String eventName;
@Schema(description = "이벤트 시작일", example = "2024-01-15")
private LocalDate startDate;
@Schema(description = "이벤트 종료일", example = "2024-01-31")
private LocalDate endDate;
@Schema(description = "사진 스타일", example = "밝고 화사한")
private String photoStyle;
}
@@ -0,0 +1,160 @@
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.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.util.List;
/**
* SNS 콘텐츠 생성 요청 DTO
*
* AI 기반 SNS 콘텐츠 생성을 위한 요청 정보를 담고 있습니다.
* 사용자가 입력한 생성 조건을 바탕으로 AI가 적절한 SNS 콘텐츠를 생성합니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "SNS 콘텐츠 생성 요청")
public class SnsContentCreateRequest {
// ==================== 기본 정보 ====================
@Schema(description = "매장 ID", example = "1", required = true)
@NotNull(message = "매장 ID는 필수입니다")
private Long storeId;
@Schema(description = "대상 플랫폼",
example = "INSTAGRAM",
allowableValues = {"INSTAGRAM", "NAVER_BLOG", "FACEBOOK", "KAKAO_STORY"},
required = true)
@NotBlank(message = "플랫폼은 필수입니다")
private String platform;
@Schema(description = "콘텐츠 제목", example = "1", required = true)
@NotNull(message = "콘텐츠 제목은 필수입니다")
private String title;
// ==================== 콘텐츠 생성 조건 ====================
@Schema(description = "콘텐츠 카테고리",
example = "메뉴소개",
allowableValues = {"메뉴소개", "이벤트", "일상", "인테리어", "고객후기", "기타"})
private String category;
@Schema(description = "구체적인 요구사항 또는 홍보하고 싶은 내용",
example = "새로 출시된 시그니처 버거를 홍보하고 싶어요")
@Size(max = 500, message = "요구사항은 500자 이하로 입력해주세요")
private String requirement;
@Schema(description = "톤앤매너",
example = "친근함",
allowableValues = {"친근함", "전문적", "유머러스", "감성적", "트렌디"})
private String toneAndManner;
@Schema(description = "감정 강도",
example = "보통",
allowableValues = {"약함", "보통", "강함"})
private String emotionIntensity;
// ==================== 이벤트 정보 ====================
@Schema(description = "이벤트명 (이벤트 콘텐츠인 경우)",
example = "신메뉴 출시 이벤트")
@Size(max = 200, message = "이벤트명은 200자 이하로 입력해주세요")
private String eventName;
@Schema(description = "이벤트 시작일 (이벤트 콘텐츠인 경우)",
example = "2024-01-15")
private LocalDate startDate;
@Schema(description = "이벤트 종료일 (이벤트 콘텐츠인 경우)",
example = "2024-01-31")
private LocalDate endDate;
// ==================== 미디어 정보 ====================
@Schema(description = "업로드된 이미지 파일 경로 목록")
private List<String> images;
@Schema(description = "사진 스타일 선호도",
example = "밝고 화사한",
allowableValues = {"밝고 화사한", "차분하고 세련된", "빈티지한", "모던한", "자연스러운"})
private String photoStyle;
// ==================== 추가 옵션 ====================
@Schema(description = "해시태그 포함 여부", example = "true")
@Builder.Default
private Boolean includeHashtags = true;
@Schema(description = "이모지 포함 여부", example = "true")
@Builder.Default
private Boolean includeEmojis = true;
@Schema(description = "콜투액션 포함 여부 (좋아요, 팔로우 요청 등)", example = "true")
@Builder.Default
private Boolean includeCallToAction = true;
@Schema(description = "매장 위치 정보 포함 여부", example = "false")
@Builder.Default
private Boolean includeLocation = false;
// ==================== 플랫폼별 옵션 ====================
@Schema(description = "인스타그램 스토리용 여부 (Instagram인 경우)", example = "false")
@Builder.Default
private Boolean forInstagramStory = false;
@Schema(description = "네이버 블로그 포스팅용 여부 (Naver Blog인 경우)", example = "false")
@Builder.Default
private Boolean forNaverBlogPost = false;
// ==================== AI 생성 옵션 ====================
@Schema(description = "대안 제목 생성 개수", example = "3")
@Builder.Default
private Integer alternativeTitleCount = 3;
@Schema(description = "대안 해시태그 세트 생성 개수", example = "2")
@Builder.Default
private Integer alternativeHashtagSetCount = 2;
@Schema(description = "AI 모델 버전 지정 (없으면 기본값 사용)", example = "gpt-4-turbo")
private String preferredAiModel;
// ==================== 검증 메서드 ====================
/**
* 이벤트 날짜 유효성 검증
* 시작일이 종료일보다 이후인지 확인
*/
public boolean isValidEventDates() {
if (startDate != null && endDate != null) {
return !startDate.isAfter(endDate);
}
return true;
}
/**
* 플랫폼별 필수 조건 검증
*/
public boolean isValidForPlatform() {
if ("INSTAGRAM".equals(platform)) {
// 인스타그램은 이미지가 권장됨
return images != null && !images.isEmpty();
}
if ("NAVER_BLOG".equals(platform)) {
// 네이버 블로그는 상세한 내용이 필요
return requirement != null && requirement.length() >= 20;
}
return true;
}
}
@@ -0,0 +1,383 @@
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 = "보정된 이미지 URL 목록")
private List<String> fixedImages;
// ==================== 편집 가능 여부 ====================
@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,79 @@
// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java
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.LocalDate;
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 = "매장 ID", example = "1", required = true)
@NotNull(message = "매장 ID는 필수입니다")
private Long storeId;
@Schema(description = "플랫폼", example = "INSTAGRAM", required = true)
@NotBlank(message = "플랫폼은 필수입니다")
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 = "맛있는 신메뉴를 소개합니다!")
private String finalTitle;
@Schema(description = "최종 콘텐츠 내용")
private String finalContent;
@Schema(description = "발행 상태", example = "PUBLISHED")
private String status;
// CreationConditions에 필요한 필드들
@Schema(description = "콘텐츠 카테고리", example = "메뉴소개")
private String category;
@Schema(description = "구체적인 요구사항", example = "새로 출시된 시그니처 버거를 홍보하고 싶어요")
private String requirement;
@Schema(description = "톤앤매너", example = "친근함")
private String toneAndManner;
@Schema(description = "감정 강도", example = "보통")
private String emotionIntensity;
@Schema(description = "이벤트명", example = "신메뉴 출시 이벤트")
private String eventName;
@Schema(description = "이벤트 시작일", example = "2024-01-15")
private LocalDate startDate;
@Schema(description = "이벤트 종료일", example = "2024-01-31")
private LocalDate endDate;
}
@@ -0,0 +1,33 @@
server:
port: ${SERVER_PORT:8083}
spring:
application:
name: marketing-content-service
datasource:
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:MarketingContentDB}
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}
+81
View File
@@ -0,0 +1,81 @@
pipeline {
agent any
environment {
ACR_LOGIN_SERVER = 'acrsmarketing17567.azurecr.io'
IMAGE_NAME = 'member'
MANIFEST_REPO = 'https://github.com/won-ktds/smarketing-manifest.git'
MANIFEST_PATH = 'member/deployment.yaml'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Build') {
steps {
dir('member') {
sh './gradlew clean build -x test'
}
}
}
stage('Test') {
steps {
dir('member') {
sh './gradlew test'
}
}
}
stage('Build Docker Image') {
steps {
script {
def imageTag = "${BUILD_NUMBER}-${env.GIT_COMMIT.substring(0,8)}"
def fullImageName = "${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${imageTag}"
dir('member') {
sh "docker build -t ${fullImageName} ."
}
withCredentials([usernamePassword(credentialsId: 'acr-credentials', usernameVariable: 'ACR_USERNAME', passwordVariable: 'ACR_PASSWORD')]) {
sh "docker login ${ACR_LOGIN_SERVER} -u ${ACR_USERNAME} -p ${ACR_PASSWORD}"
sh "docker push ${fullImageName}"
}
env.IMAGE_TAG = imageTag
env.FULL_IMAGE_NAME = fullImageName
}
}
}
stage('Update Manifest') {
steps {
withCredentials([usernamePassword(credentialsId: 'github-credentials', usernameVariable: 'GIT_USERNAME', passwordVariable: 'GIT_TOKEN')]) {
sh '''
git clone https://${GIT_TOKEN}@github.com/won-ktds/smarketing-manifest.git manifest-repo
cd manifest-repo
# Update image tag in deployment.yaml
sed -i "s|image: .*|image: ${FULL_IMAGE_NAME}|g" ${MANIFEST_PATH}
git config user.email "jenkins@smarketing.com"
git config user.name "Jenkins"
git add .
git commit -m "Update ${IMAGE_NAME} image to ${IMAGE_TAG}"
git push origin main
'''
}
}
}
}
post {
always {
cleanWs()
}
}
}
+5
View File
@@ -0,0 +1,5 @@
dependencies {
implementation project(':common')
// 데이터베이스 의존성
runtimeOnly 'org.postgresql:postgresql'
}
@@ -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;
}

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