mirror of
https://github.com/won-ktds/smarketing-backend.git
synced 2026-06-12 20:39:09 +00:00
release
This commit is contained in:
@@ -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'
|
||||
}
|
||||
+20
@@ -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);
|
||||
}
|
||||
}
|
||||
+168
@@ -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();
|
||||
}
|
||||
}
|
||||
+12
@@ -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();
|
||||
}
|
||||
+13
@@ -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 캐시 사용
|
||||
}
|
||||
+12
@@ -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 {
|
||||
}
|
||||
+29
@@ -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();
|
||||
}
|
||||
}
|
||||
+36
@@ -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();
|
||||
}
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+13
@@ -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;
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
+18
@@ -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);
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
+143
@@ -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;
|
||||
}
|
||||
}
|
||||
+311
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -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();
|
||||
}
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
+88
@@ -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();
|
||||
}
|
||||
}
|
||||
+34
@@ -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);
|
||||
+41
@@ -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));
|
||||
}
|
||||
}
|
||||
+57
@@ -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}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
+69
@@ -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;
|
||||
}
|
||||
}
|
||||
+83
@@ -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;
|
||||
}
|
||||
}
|
||||
+43
@@ -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();
|
||||
}
|
||||
}
|
||||
+34
@@ -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;
|
||||
}
|
||||
}
|
||||
+58
@@ -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;
|
||||
}
|
||||
+79
@@ -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, "서버 내부 오류가 발생했습니다."));
|
||||
}
|
||||
}
|
||||
+82
@@ -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;
|
||||
}
|
||||
}
|
||||
+126
@@ -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
@@ -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
|
||||
Vendored
+251
@@ -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" "$@"
|
||||
Vendored
+94
@@ -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'
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
}
|
||||
+191
@@ -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();
|
||||
}
|
||||
}
|
||||
+108
@@ -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);
|
||||
}
|
||||
}
|
||||
+125
@@ -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);
|
||||
}
|
||||
}
|
||||
+55
@@ -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);
|
||||
}
|
||||
+26
@@ -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);
|
||||
}
|
||||
+26
@@ -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);
|
||||
}
|
||||
+9
@@ -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 {
|
||||
}
|
||||
+26
@@ -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;
|
||||
}
|
||||
}
|
||||
+163
@@ -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;
|
||||
}
|
||||
}
|
||||
+51
@@ -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 + '}';
|
||||
}
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -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;
|
||||
}
|
||||
}
|
||||
+41
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -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);
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
+84
@@ -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);
|
||||
}
|
||||
}
|
||||
+60
@@ -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;
|
||||
}
|
||||
+70
@@ -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;
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
+95
@@ -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("#소상공인", "#마케팅", "#홍보");
|
||||
}
|
||||
}
|
||||
+86
@@ -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";
|
||||
}
|
||||
}
|
||||
+213
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+147
@@ -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());
|
||||
}
|
||||
}
|
||||
+87
@@ -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);
|
||||
}
|
||||
+169
@@ -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, "콘텐츠가 성공적으로 삭제되었습니다."));
|
||||
}
|
||||
}
|
||||
+86
@@ -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;
|
||||
}
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
+364
@@ -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
|
||||
}
|
||||
*/
|
||||
+41
@@ -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;
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
+45
@@ -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;
|
||||
}
|
||||
+47
@@ -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;
|
||||
}
|
||||
+79
@@ -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;
|
||||
}
|
||||
+49
@@ -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;
|
||||
|
||||
}
|
||||
+66
@@ -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;
|
||||
}
|
||||
+160
@@ -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;
|
||||
}
|
||||
}
|
||||
+383
@@ -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;
|
||||
}
|
||||
}
|
||||
+79
@@ -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}
|
||||
Vendored
+81
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
dependencies {
|
||||
implementation project(':common')
|
||||
// 데이터베이스 의존성
|
||||
runtimeOnly 'org.postgresql:postgresql'
|
||||
}
|
||||
+20
@@ -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 {
|
||||
}
|
||||
+64
@@ -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, "토큰이 갱신되었습니다."));
|
||||
}
|
||||
}
|
||||
+120
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+54
@@ -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
Reference in New Issue
Block a user