This commit is contained in:
OhSeongRak
2025-06-17 10:05:16 +09:00
commit 44d7312a85
178 changed files with 15106 additions and 0 deletions
@@ -0,0 +1,4 @@
dependencies {
implementation project(':common')
runtimeOnly 'org.postgresql:postgresql'
}
@@ -0,0 +1,29 @@
package com.won.smarketing.content;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* 마케팅 콘텐츠 서비스 메인 애플리케이션 클래스
* Clean Architecture 패턴을 적용한 마케팅 콘텐츠 관리 서비스
*/
@SpringBootApplication(scanBasePackages = {
"com.won.smarketing.content",
"com.won.smarketing.common"
})
@EnableJpaRepositories(basePackages = {
"com.won.smarketing.content.infrastructure.repository"
})
@EntityScan(basePackages = {
"com.won.smarketing.content.infrastructure.entity"
})
@EnableJpaAuditing
public class MarketingContentServiceApplication {
public static void main(String[] args) {
SpringApplication.run(MarketingContentServiceApplication.class, args);
}
}
@@ -0,0 +1,191 @@
package com.won.smarketing.content.application.service;
import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.content.application.usecase.ContentQueryUseCase;
import com.won.smarketing.content.domain.model.*;
import com.won.smarketing.content.domain.repository.ContentRepository;
import com.won.smarketing.content.presentation.dto.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
import java.util.stream.Collectors;
/**
* 콘텐츠 조회 서비스 구현체
* 콘텐츠 수정, 조회, 삭제 기능 구현
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ContentQueryService implements ContentQueryUseCase {
private final ContentRepository contentRepository;
/**
* 콘텐츠 수정
*
* @param contentId 수정할 콘텐츠 ID
* @param request 콘텐츠 수정 요청
* @return 수정된 콘텐츠 정보
*/
@Override
@Transactional
public ContentUpdateResponse updateContent(Long contentId, ContentUpdateRequest request) {
Content content = contentRepository.findById(ContentId.of(contentId))
.orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND));
// 제목과 기간 업데이트
content.updateTitle(request.getTitle());
content.updatePeriod(request.getPromotionStartDate(), request.getPromotionEndDate());
Content updatedContent = contentRepository.save(content);
return ContentUpdateResponse.builder()
.contentId(updatedContent.getId())
//.contentType(updatedContent.getContentType().name())
//.platform(updatedContent.getPlatform().name())
.title(updatedContent.getTitle())
.content(updatedContent.getContent())
//.hashtags(updatedContent.getHashtags())
//.images(updatedContent.getImages())
.status(updatedContent.getStatus().name())
.updatedAt(updatedContent.getUpdatedAt())
.build();
}
/**
* 콘텐츠 목록 조회
*
* @param contentType 콘텐츠 타입
* @param platform 플랫폼
* @param period 기간
* @param sortBy 정렬 기준
* @return 콘텐츠 목록
*/
@Override
public List<ContentResponse> getContents(String contentType, String platform, String period, String sortBy) {
ContentType type = contentType != null ? ContentType.fromString(contentType) : null;
Platform platformEnum = platform != null ? Platform.fromString(platform) : null;
List<Content> contents = contentRepository.findByFilters(type, platformEnum, period, sortBy);
return contents.stream()
.map(this::toContentResponse)
.collect(Collectors.toList());
}
/**
* 진행 중인 콘텐츠 목록 조회
*
* @param period 기간
* @return 진행 중인 콘텐츠 목록
*/
@Override
public List<OngoingContentResponse> getOngoingContents(String period) {
List<Content> contents = contentRepository.findOngoingContents(period);
return contents.stream()
.map(this::toOngoingContentResponse)
.collect(Collectors.toList());
}
/**
* 콘텐츠 상세 조회
*
* @param contentId 콘텐츠 ID
* @return 콘텐츠 상세 정보
*/
@Override
public ContentDetailResponse getContentDetail(Long contentId) {
Content content = contentRepository.findById(ContentId.of(contentId))
.orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND));
return ContentDetailResponse.builder()
.contentId(content.getId())
.contentType(content.getContentType().name())
.platform(content.getPlatform().name())
.title(content.getTitle())
.content(content.getContent())
.hashtags(content.getHashtags())
.images(content.getImages())
.status(content.getStatus().name())
.creationConditions(toCreationConditionsDto(content.getCreationConditions()))
.createdAt(content.getCreatedAt())
.build();
}
/**
* 콘텐츠 삭제
*
* @param contentId 삭제할 콘텐츠 ID
*/
@Override
@Transactional
public void deleteContent(Long contentId) {
Content content = contentRepository.findById(ContentId.of(contentId))
.orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND));
contentRepository.deleteById(ContentId.of(contentId));
}
/**
* Content 엔티티를 ContentResponse DTO로 변환
*
* @param content Content 엔티티
* @return ContentResponse DTO
*/
private ContentResponse toContentResponse(Content content) {
return ContentResponse.builder()
.contentId(content.getId())
.contentType(content.getContentType().name())
.platform(content.getPlatform().name())
.title(content.getTitle())
.content(content.getContent())
.hashtags(content.getHashtags())
.images(content.getImages())
.status(content.getStatus().name())
.createdAt(content.getCreatedAt())
.viewCount(0) // TODO: 실제 조회 수 구현 필요
.build();
}
/**
* Content 엔티티를 OngoingContentResponse DTO로 변환
*
* @param content Content 엔티티
* @return OngoingContentResponse DTO
*/
private OngoingContentResponse toOngoingContentResponse(Content content) {
return OngoingContentResponse.builder()
.contentId(content.getId())
.contentType(content.getContentType().name())
.platform(content.getPlatform().name())
.title(content.getTitle())
.status(content.getStatus().name())
.promotionStartDate(content.getPromotionStartDate())
//.viewCount(0) // TODO: 실제 조회 수 구현 필요
.build();
}
/**
* CreationConditions를 DTO로 변환
*
* @param conditions CreationConditions 도메인 객체
* @return CreationConditionsDto
*/
private ContentDetailResponse.CreationConditionsDto toCreationConditionsDto(CreationConditions conditions) {
if (conditions == null) {
return null;
}
return ContentDetailResponse.CreationConditionsDto.builder()
.toneAndManner(conditions.getToneAndManner())
.emotionIntensity(conditions.getEmotionIntensity())
.eventName(conditions.getEventName())
.build();
}
}
@@ -0,0 +1,108 @@
package com.won.smarketing.content.application.service;
import com.won.smarketing.content.application.usecase.PosterContentUseCase;
import com.won.smarketing.content.domain.model.Content;
import com.won.smarketing.content.domain.model.ContentStatus;
import com.won.smarketing.content.domain.model.ContentType;
import com.won.smarketing.content.domain.model.CreationConditions;
import com.won.smarketing.content.domain.model.Platform;
import com.won.smarketing.content.domain.repository.ContentRepository;
import com.won.smarketing.content.domain.service.AiPosterGenerator;
import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest;
import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse;
import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 포스터 콘텐츠 서비스 구현체
* 홍보 포스터 생성 및 저장 기능 구현
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PosterContentService implements PosterContentUseCase {
private final ContentRepository contentRepository;
private final AiPosterGenerator aiPosterGenerator;
/**
* 포스터 콘텐츠 생성
*
* @param request 포스터 콘텐츠 생성 요청
* @return 생성된 포스터 콘텐츠 정보
*/
@Override
@Transactional
public PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request) {
// AI를 사용하여 포스터 생성
String generatedPoster = aiPosterGenerator.generatePoster(request);
// 다양한 사이즈의 포스터 생성
Map<String, String> posterSizes = aiPosterGenerator.generatePosterSizes(generatedPoster);
// 생성 조건 정보 구성
CreationConditions conditions = CreationConditions.builder()
.category(request.getCategory())
.requirement(request.getRequirement())
.toneAndManner(request.getToneAndManner())
.emotionIntensity(request.getEmotionIntensity())
.eventName(request.getEventName())
.startDate(request.getStartDate())
.endDate(request.getEndDate())
.photoStyle(request.getPhotoStyle())
.build();
return PosterContentCreateResponse.builder()
.contentId(null) // 임시 생성이므로 ID 없음
.contentType(ContentType.POSTER.name())
.title(request.getTitle())
.posterImage(generatedPoster)
.posterSizes(posterSizes)
.status(ContentStatus.DRAFT.name())
//.createdAt(LocalDateTime.now())
.build();
}
/**
* 포스터 콘텐츠 저장
*
* @param request 포스터 콘텐츠 저장 요청
*/
@Override
@Transactional
public void savePosterContent(PosterContentSaveRequest request) {
// 생성 조건 정보 구성
CreationConditions conditions = CreationConditions.builder()
.category(request.getCategory())
.requirement(request.getRequirement())
.toneAndManner(request.getToneAndManner())
.emotionIntensity(request.getEmotionIntensity())
.eventName(request.getEventName())
.startDate(request.getStartDate())
.endDate(request.getEndDate())
.photoStyle(request.getPhotoStyle())
.build();
// 콘텐츠 엔티티 생성 및 저장
Content content = Content.builder()
.contentType(ContentType.POSTER)
.platform(Platform.GENERAL) // 포스터는 범용
.title(request.getTitle())
.content(null) // 포스터는 이미지가 주 콘텐츠
.hashtags(null)
.images(request.getImages())
.status(ContentStatus.PUBLISHED)
.creationConditions(conditions)
.storeId(request.getStoreId())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
contentRepository.save(content);
}
}
@@ -0,0 +1,125 @@
package com.won.smarketing.content.application.service;
import com.won.smarketing.content.application.usecase.SnsContentUseCase;
import com.won.smarketing.content.domain.model.Content;
import com.won.smarketing.content.domain.model.ContentId;
import com.won.smarketing.content.domain.model.ContentStatus;
import com.won.smarketing.content.domain.model.ContentType;
import com.won.smarketing.content.domain.model.CreationConditions;
import com.won.smarketing.content.domain.model.Platform;
import com.won.smarketing.content.domain.repository.ContentRepository;
import com.won.smarketing.content.domain.service.AiContentGenerator;
import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest;
import com.won.smarketing.content.presentation.dto.SnsContentCreateResponse;
import com.won.smarketing.content.presentation.dto.SnsContentSaveRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* SNS 콘텐츠 서비스 구현체
* SNS 게시물 생성 및 저장 기능 구현
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class SnsContentService implements SnsContentUseCase {
private final ContentRepository contentRepository;
private final AiContentGenerator aiContentGenerator;
/**
* SNS 콘텐츠 생성
*
* @param request SNS 콘텐츠 생성 요청
* @return 생성된 SNS 콘텐츠 정보
*/
@Override
@Transactional
public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request) {
// AI를 사용하여 SNS 콘텐츠 생성
String generatedContent = aiContentGenerator.generateSnsContent(request);
// 플랫폼에 맞는 해시태그 생성
Platform platform = Platform.fromString(request.getPlatform());
List<String> hashtags = aiContentGenerator.generateHashtags(generatedContent, platform);
// 생성 조건 정보 구성
CreationConditions conditions = CreationConditions.builder()
.category(request.getCategory())
.requirement(request.getRequirement())
.toneAndManner(request.getToneAndManner())
.emotionIntensity(request.getEmotionIntensity())
.eventName(request.getEventName())
.startDate(request.getStartDate())
.endDate(request.getEndDate())
.build();
// 임시 콘텐츠 생성 (저장하지 않음)
Content content = Content.builder()
// .contentType(ContentType.SNS_POST)
.platform(platform)
.title(request.getTitle())
.content(generatedContent)
.hashtags(hashtags)
.images(request.getImages())
.status(ContentStatus.DRAFT)
.creationConditions(conditions)
.storeId(request.getStoreId())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
return SnsContentCreateResponse.builder()
.contentId(null) // 임시 생성이므로 ID 없음
.contentType(content.getContentType().name())
.platform(content.getPlatform().name())
.title(content.getTitle())
.content(content.getContent())
.hashtags(content.getHashtags())
.fixedImages(content.getImages())
.status(content.getStatus().name())
.createdAt(content.getCreatedAt())
.build();
}
/**
* SNS 콘텐츠 저장
*
* @param request SNS 콘텐츠 저장 요청
*/
@Override
@Transactional
public void saveSnsContent(SnsContentSaveRequest request) {
// 생성 조건 정보 구성
CreationConditions conditions = CreationConditions.builder()
.category(request.getCategory())
.requirement(request.getRequirement())
.toneAndManner(request.getToneAndManner())
.emotionIntensity(request.getEmotionIntensity())
.eventName(request.getEventName())
.startDate(request.getStartDate())
.endDate(request.getEndDate())
.build();
// 콘텐츠 엔티티 생성 및 저장
Content content = Content.builder()
// .contentType(ContentType.SNS_POST)
.platform(Platform.fromString(request.getPlatform()))
.title(request.getTitle())
.content(request.getContent())
.hashtags(request.getHashtags())
.images(request.getImages())
.status(ContentStatus.PUBLISHED)
.creationConditions(conditions)
.storeId(request.getStoreId())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
contentRepository.save(content);
}
}
@@ -0,0 +1,55 @@
package com.won.smarketing.content.application.usecase;
import com.won.smarketing.content.presentation.dto.*;
import java.util.List;
/**
* 콘텐츠 조회 관련 Use Case 인터페이스
* 콘텐츠 수정, 조회, 삭제 기능 정의
*/
public interface ContentQueryUseCase {
/**
* 콘텐츠 수정
*
* @param contentId 수정할 콘텐츠 ID
* @param request 콘텐츠 수정 요청
* @return 수정된 콘텐츠 정보
*/
ContentUpdateResponse updateContent(Long contentId, ContentUpdateRequest request);
/**
* 콘텐츠 목록 조회
*
* @param contentType 콘텐츠 타입
* @param platform 플랫폼
* @param period 기간
* @param sortBy 정렬 기준
* @return 콘텐츠 목록
*/
List<ContentResponse> getContents(String contentType, String platform, String period, String sortBy);
/**
* 진행 중인 콘텐츠 목록 조회
*
* @param period 기간
* @return 진행 중인 콘텐츠 목록
*/
List<OngoingContentResponse> getOngoingContents(String period);
/**
* 콘텐츠 상세 조회
*
* @param contentId 콘텐츠 ID
* @return 콘텐츠 상세 정보
*/
ContentDetailResponse getContentDetail(Long contentId);
/**
* 콘텐츠 삭제
*
* @param contentId 삭제할 콘텐츠 ID
*/
void deleteContent(Long contentId);
}
@@ -0,0 +1,26 @@
// marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java
package com.won.smarketing.content.application.usecase;
import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest;
import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse;
import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest;
/**
* 포스터 콘텐츠 관련 UseCase 인터페이스
* Clean Architecture의 Application Layer에서 비즈니스 로직 정의
*/
public interface PosterContentUseCase {
/**
* 포스터 콘텐츠 생성
* @param request 포스터 콘텐츠 생성 요청
* @return 포스터 콘텐츠 생성 응답
*/
PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request);
/**
* 포스터 콘텐츠 저장
* @param request 포스터 콘텐츠 저장 요청
*/
void savePosterContent(PosterContentSaveRequest request);
}
@@ -0,0 +1,26 @@
// marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java
package com.won.smarketing.content.application.usecase;
import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest;
import com.won.smarketing.content.presentation.dto.SnsContentCreateResponse;
import com.won.smarketing.content.presentation.dto.SnsContentSaveRequest;
/**
* SNS 콘텐츠 관련 UseCase 인터페이스
* Clean Architecture의 Application Layer에서 비즈니스 로직 정의
*/
public interface SnsContentUseCase {
/**
* SNS 콘텐츠 생성
* @param request SNS 콘텐츠 생성 요청
* @return SNS 콘텐츠 생성 응답
*/
SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request);
/**
* SNS 콘텐츠 저장
* @param request SNS 콘텐츠 저장 요청
*/
void saveSnsContent(SnsContentSaveRequest request);
}
@@ -0,0 +1,9 @@
package com.won.smarketing.content.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan(basePackages = "com.won.smarketing.content")
public class ContentConfig {
}
@@ -0,0 +1,26 @@
// marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java
package com.won.smarketing.content.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* ObjectMapper 설정 클래스
*
* @author smarketing-team
* @version 1.0
*/
@Configuration
public class ObjectMapperConfig {
@Bean
public ObjectMapper objectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
return objectMapper;
}
}
@@ -0,0 +1,163 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java
package com.won.smarketing.content.domain.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 콘텐츠 도메인 모델
*
* Clean Architecture의 Domain Layer에 위치하는 핵심 엔티티
* JPA 애노테이션을 제거하여 순수 도메인 모델로 유지
* Infrastructure Layer에서 별도의 JPA 엔티티로 매핑
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Content {
// ==================== 기본키 및 식별자 ====================
private Long id;
// ==================== 콘텐츠 분류 ====================
private ContentType contentType;
private Platform platform;
// ==================== 콘텐츠 내용 ====================
private String title;
private String content;
// ==================== 멀티미디어 및 메타데이터 ====================
@Builder.Default
private List<String> hashtags = new ArrayList<>();
@Builder.Default
private List<String> images = new ArrayList<>();
// ==================== 상태 관리 ====================
private ContentStatus status;
// ==================== 생성 조건 ====================
private CreationConditions creationConditions;
// ==================== 매장 정보 ====================
private Long storeId;
// ==================== 프로모션 기간 ====================
private LocalDateTime promotionStartDate;
private LocalDateTime promotionEndDate;
// ==================== 메타데이터 ====================
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Content(ContentId of, ContentType contentType, Platform platform, String title, String content, List<String> strings, List<String> strings1, ContentStatus contentStatus, CreationConditions conditions, Long storeId, LocalDateTime createdAt, LocalDateTime updatedAt) {
}
// ==================== 비즈니스 메서드 ====================
/**
* 콘텐츠 제목 수정
* @param newTitle 새로운 제목
*/
public void updateTitle(String newTitle) {
if (newTitle == null || newTitle.trim().isEmpty()) {
throw new IllegalArgumentException("제목은 필수입니다.");
}
this.title = newTitle.trim();
this.updatedAt = LocalDateTime.now();
}
/**
* 콘텐츠 내용 수정
* @param newContent 새로운 내용
*/
public void updateContent(String newContent) {
this.content = newContent;
this.updatedAt = LocalDateTime.now();
}
/**
* 프로모션 기간 설정
* @param startDate 시작일
* @param endDate 종료일
*/
public void updatePeriod(LocalDateTime startDate, LocalDateTime endDate) {
if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
throw new IllegalArgumentException("시작일은 종료일보다 이후일 수 없습니다.");
}
this.promotionStartDate = startDate;
this.promotionEndDate = endDate;
this.updatedAt = LocalDateTime.now();
}
/**
* 콘텐츠 상태 변경
* @param newStatus 새로운 상태
*/
public void updateStatus(ContentStatus newStatus) {
if (newStatus == null) {
throw new IllegalArgumentException("상태는 필수입니다.");
}
this.status = newStatus;
this.updatedAt = LocalDateTime.now();
}
/**
* 해시태그 추가
* @param hashtag 추가할 해시태그
*/
public void addHashtag(String hashtag) {
if (hashtag != null && !hashtag.trim().isEmpty()) {
if (this.hashtags == null) {
this.hashtags = new ArrayList<>();
}
this.hashtags.add(hashtag.trim());
this.updatedAt = LocalDateTime.now();
}
}
/**
* 이미지 추가
* @param imageUrl 추가할 이미지 URL
*/
public void addImage(String imageUrl) {
if (imageUrl != null && !imageUrl.trim().isEmpty()) {
if (this.images == null) {
this.images = new ArrayList<>();
}
this.images.add(imageUrl.trim());
this.updatedAt = LocalDateTime.now();
}
}
/**
* 프로모션 진행 중 여부 확인
* @return 현재 시간이 프로모션 기간 내에 있으면 true
*/
public boolean isPromotionActive() {
if (promotionStartDate == null || promotionEndDate == null) {
return false;
}
LocalDateTime now = LocalDateTime.now();
return !now.isBefore(promotionStartDate) && !now.isAfter(promotionEndDate);
}
/**
* 콘텐츠 게시 가능 여부 확인
* @return 필수 정보가 모두 입력되어 있으면 true
*/
public boolean canBePublished() {
return title != null && !title.trim().isEmpty()
&& contentType != null
&& platform != null
&& storeId != null;
}
}
@@ -0,0 +1,51 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java
package com.won.smarketing.content.domain.model;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 콘텐츠 ID 값 객체
* Clean Architecture의 Domain Layer에서 식별자를 타입 안전하게 관리
*/
@Getter
@RequiredArgsConstructor
@EqualsAndHashCode
public class ContentId {
private final Long value;
/**
* Long 값으로부터 ContentId 생성
* @param value ID 값
* @return ContentId 인스턴스
*/
public static ContentId of(Long value) {
if (value == null || value <= 0) {
throw new IllegalArgumentException("ContentId는 양수여야 합니다.");
}
return new ContentId(value);
}
/**
* 새로운 ContentId 생성 (ID가 없는 경우)
* @return null 값을 가진 ContentId
*/
public static ContentId newId() {
return new ContentId(null);
}
/**
* ID 값 존재 여부 확인
* @return ID가 null이 아니면 true
*/
public boolean hasValue() {
return value != null;
}
@Override
public String toString() {
return "ContentId{" + "value=" + value + '}';
}
}
@@ -0,0 +1,40 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java
package com.won.smarketing.content.domain.model;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 콘텐츠 상태 열거형
* Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙
*/
@Getter
@RequiredArgsConstructor
public enum ContentStatus {
DRAFT("임시저장"),
PUBLISHED("게시됨"),
SCHEDULED("예약됨"),
DELETED("삭제됨"),
PROCESSING("처리중");
private final String displayName;
/**
* 문자열로부터 ContentStatus 변환
* @param value 문자열 값
* @return ContentStatus enum
* @throws IllegalArgumentException 유효하지 않은 값인 경우
*/
public static ContentStatus fromString(String value) {
if (value == null) {
throw new IllegalArgumentException("ContentStatus 값은 null일 수 없습니다.");
}
try {
return ContentStatus.valueOf(value.toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("유효하지 않은 ContentStatus 값입니다: " + value);
}
}
}
@@ -0,0 +1,39 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java
package com.won.smarketing.content.domain.model;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 콘텐츠 타입 열거형
* Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙
*/
@Getter
@RequiredArgsConstructor
public enum ContentType {
SNS("SNS 게시물"),
POSTER("홍보 포스터"),
VIDEO("동영상"),
BLOG("블로그 포스트");
private final String displayName;
/**
* 문자열로부터 ContentType 변환
* @param value 문자열 값
* @return ContentType enum
* @throws IllegalArgumentException 유효하지 않은 값인 경우
*/
public static ContentType fromString(String value) {
if (value == null) {
throw new IllegalArgumentException("ContentType 값은 null일 수 없습니다.");
}
try {
return ContentType.valueOf(value.toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("유효하지 않은 ContentType 값입니다: " + value);
}
}
}
@@ -0,0 +1,58 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java
package com.won.smarketing.content.domain.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
/**
* 콘텐츠 생성 조건 도메인 모델
* Clean Architecture의 Domain Layer에 위치하는 값 객체
*
* JPA 애노테이션을 제거하여 순수 도메인 모델로 유지
* Infrastructure Layer의 JPA 엔티티는 별도로 관리
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CreationConditions {
private String id;
private String category;
private String requirement;
private String toneAndManner;
private String emotionIntensity;
private String eventName;
private LocalDate startDate;
private LocalDate endDate;
private String photoStyle;
private String promotionType;
public CreationConditions(String category, String requirement, String toneAndManner, String emotionIntensity, String eventName, LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) {
}
/**
* 이벤트 기간 유효성 검증
* @return 시작일이 종료일보다 이전이거나 같으면 true
*/
public boolean isValidEventPeriod() {
if (startDate == null || endDate == null) {
return true;
}
return !startDate.isAfter(endDate);
}
/**
* 이벤트 조건 유무 확인
* @return 이벤트명이나 날짜가 설정되어 있으면 true
*/
public boolean hasEventInfo() {
return eventName != null && !eventName.trim().isEmpty()
|| startDate != null
|| endDate != null;
}
}
@@ -0,0 +1,41 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java
package com.won.smarketing.content.domain.model;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 플랫폼 열거형
* Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙
*/
@Getter
@RequiredArgsConstructor
public enum Platform {
INSTAGRAM("인스타그램"),
NAVER_BLOG("네이버 블로그"),
FACEBOOK("페이스북"),
KAKAO_STORY("카카오스토리"),
YOUTUBE("유튜브"),
GENERAL("일반");
private final String displayName;
/**
* 문자열로부터 Platform 변환
* @param value 문자열 값
* @return Platform enum
* @throws IllegalArgumentException 유효하지 않은 값인 경우
*/
public static Platform fromString(String value) {
if (value == null) {
throw new IllegalArgumentException("Platform 값은 null일 수 없습니다.");
}
try {
return Platform.valueOf(value.toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("유효하지 않은 Platform 값입니다: " + value);
}
}
}
@@ -0,0 +1,54 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java
package com.won.smarketing.content.domain.repository;
import com.won.smarketing.content.domain.model.Content;
import com.won.smarketing.content.domain.model.ContentId;
import com.won.smarketing.content.domain.model.ContentType;
import com.won.smarketing.content.domain.model.Platform;
import java.util.List;
import java.util.Optional;
/**
* 콘텐츠 리포지토리 인터페이스
* Clean Architecture의 Domain Layer에서 데이터 접근 정의
*/
public interface ContentRepository {
/**
* 콘텐츠 저장
* @param content 저장할 콘텐츠
* @return 저장된 콘텐츠
*/
Content save(Content content);
/**
* ID로 콘텐츠 조회
* @param id 콘텐츠 ID
* @return 조회된 콘텐츠
*/
Optional<Content> findById(ContentId id);
/**
* 필터 조건으로 콘텐츠 목록 조회
* @param contentType 콘텐츠 타입
* @param platform 플랫폼
* @param period 기간
* @param sortBy 정렬 기준
* @return 콘텐츠 목록
*/
List<Content> findByFilters(ContentType contentType, Platform platform, String period, String sortBy);
/**
* 진행 중인 콘텐츠 목록 조회
* @param period 기간
* @return 진행 중인 콘텐츠 목록
*/
List<Content> findOngoingContents(String period);
/**
* ID로 콘텐츠 삭제
* @param id 삭제할 콘텐츠 ID
*/
void deleteById(ContentId id);
}
@@ -0,0 +1,38 @@
package com.won.smarketing.content.domain.repository;
import com.won.smarketing.content.infrastructure.entity.ContentEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Spring Data JPA ContentRepository
* JPA 기반 콘텐츠 데이터 접근
*/
@Repository
public interface SpringDataContentRepository extends JpaRepository<ContentEntity, Long> {
/**
* 매장별 콘텐츠 조회
*
* @param storeId 매장 ID
* @return 콘텐츠 목록
*/
List<ContentEntity> findByStoreId(Long storeId);
/**
* 콘텐츠 타입별 조회
*
* @param contentType 콘텐츠 타입
* @return 콘텐츠 목록
*/
List<ContentEntity> findByContentType(String contentType);
/**
* 플랫폼별 조회
*
* @param platform 플랫폼
* @return 콘텐츠 목록
*/
List<ContentEntity> findByPlatform(String platform);
}
@@ -0,0 +1,30 @@
package com.won.smarketing.content.domain.service;
import com.won.smarketing.content.domain.model.Platform;
import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest;
import java.util.List;
/**
* AI 콘텐츠 생성 도메인 서비스 인터페이스
* SNS 콘텐츠 생성 및 해시태그 생성 기능 정의
*/
public interface AiContentGenerator {
/**
* SNS 콘텐츠 생성
*
* @param request SNS 콘텐츠 생성 요청
* @return 생성된 콘텐츠
*/
String generateSnsContent(SnsContentCreateRequest request);
/**
* 플랫폼별 해시태그 생성
*
* @param content 콘텐츠 내용
* @param platform 플랫폼
* @return 해시태그 목록
*/
List<String> generateHashtags(String content, Platform platform);
}
@@ -0,0 +1,28 @@
package com.won.smarketing.content.domain.service;
import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest;
import java.util.Map;
/**
* AI 포스터 생성 도메인 서비스 인터페이스
* 홍보 포스터 생성 및 다양한 사이즈 생성 기능 정의
*/
public interface AiPosterGenerator {
/**
* 포스터 생성
*
* @param request 포스터 생성 요청
* @return 생성된 포스터 이미지 URL
*/
String generatePoster(PosterContentCreateRequest request);
/**
* 다양한 사이즈의 포스터 생성
*
* @param baseImage 기본 이미지
* @return 사이즈별 포스터 URL 맵
*/
Map<String, String> generatePosterSizes(String baseImage);
}
@@ -0,0 +1,84 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java
package com.won.smarketing.content.infrastructure.entity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDate;
/**
* 콘텐츠 생성 조건 JPA 엔티티
* Infrastructure Layer에서 데이터베이스 매핑을 담당
*/
@Entity
@Table(name = "content_conditions")
@Getter
@Setter
public class ContentConditionsJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "content_id", nullable = false)
private ContentJpaEntity content;
@Column(name = "category", length = 100)
private String category;
@Column(name = "requirement", columnDefinition = "TEXT")
private String requirement;
@Column(name = "tone_and_manner", length = 100)
private String toneAndManner;
@Column(name = "emotion_intensity", length = 50)
private String emotionIntensity;
@Column(name = "event_name", length = 200)
private String eventName;
@Column(name = "start_date")
private LocalDate startDate;
@Column(name = "end_date")
private LocalDate endDate;
@Column(name = "photo_style", length = 100)
private String photoStyle;
@Column(name = "promotion_type", length = 100)
private String promotionType;
// 생성자
public ContentConditionsJpaEntity(ContentJpaEntity content, String category, String requirement,
String toneAndManner, String emotionIntensity, String eventName,
LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) {
this.content = content;
this.category = category;
this.requirement = requirement;
this.toneAndManner = toneAndManner;
this.emotionIntensity = emotionIntensity;
this.eventName = eventName;
this.startDate = startDate;
this.endDate = endDate;
this.photoStyle = photoStyle;
this.promotionType = promotionType;
}
public ContentConditionsJpaEntity() {
}
// 팩토리 메서드
public static ContentConditionsJpaEntity create(ContentJpaEntity content, String category, String requirement,
String toneAndManner, String emotionIntensity, String eventName,
LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) {
return new ContentConditionsJpaEntity(content, category, requirement, toneAndManner, emotionIntensity,
eventName, startDate, endDate, photoStyle, promotionType);
}
}
@@ -0,0 +1,60 @@
package com.won.smarketing.content.infrastructure.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 콘텐츠 엔티티
* 콘텐츠 정보를 데이터베이스에 저장하기 위한 JPA 엔티티
*/
@Entity
@Table(name = "contents")
@Data
@NoArgsConstructor
@AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class ContentEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "content_type", nullable = false)
private String contentType;
@Column(name = "platform", nullable = false)
private String platform;
@Column(name = "title", nullable = false)
private String title;
@Column(name = "content", columnDefinition = "TEXT")
private String content;
@Column(name = "hashtags")
private String hashtags;
@Column(name = "images", columnDefinition = "TEXT")
private String images;
@Column(name = "status", nullable = false)
private String status;
@Column(name = "store_id", nullable = false)
private Long storeId;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}
@@ -0,0 +1,70 @@
package com.won.smarketing.content.infrastructure.entity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
import java.util.Date;
/**
* 콘텐츠 JPA 엔티티
*/
@Entity
@Table(name = "contents")
@Getter
@Setter
@EntityListeners(AuditingEntityListener.class)
public class ContentJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "store_id", nullable = false)
private Long storeId;
@Column(name = "content_type", nullable = false, length = 50)
private String contentType;
@Column(name = "platform", length = 50)
private String platform;
@Column(name = "title", length = 500)
private String title;
@Column(name = "PromotionStartDate")
private LocalDateTime PromotionStartDate;
@Column(name = "PromotionEndDate")
private LocalDateTime PromotionEndDate;
@Column(name = "content", columnDefinition = "TEXT")
private String content;
@Column(name = "hashtags", columnDefinition = "TEXT")
private String hashtags;
@Column(name = "images", columnDefinition = "TEXT")
private String images;
@Column(name = "status", nullable = false, length = 20)
private String status;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
// CreationConditions와의 관계 - OneToOne으로 별도 엔티티로 관리
@OneToOne(mappedBy = "content", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private ContentConditionsJpaEntity conditions;
}
@@ -0,0 +1,32 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java
package com.won.smarketing.content.infrastructure.external;
import com.won.smarketing.content.domain.model.Platform;
import com.won.smarketing.content.domain.model.CreationConditions;
import java.util.List;
/**
* AI 콘텐츠 생성 인터페이스
* Clean Architecture의 Infrastructure Layer에서 외부 AI 서비스와의 연동 정의
*/
public interface AiContentGenerator {
/**
* SNS 콘텐츠 생성
* @param title 제목
* @param category 카테고리
* @param platform 플랫폼
* @param conditions 생성 조건
* @return 생성된 콘텐츠 텍스트
*/
String generateSnsContent(String title, String category, Platform platform, CreationConditions conditions);
/**
* 해시태그 생성
* @param content 콘텐츠 내용
* @param platform 플랫폼
* @return 생성된 해시태그 목록
*/
List<String> generateHashtags(String content, Platform platform);
}
@@ -0,0 +1,29 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java
package com.won.smarketing.content.infrastructure.external;
import com.won.smarketing.content.domain.model.CreationConditions;
import java.util.Map;
/**
* AI 포스터 생성 인터페이스
* Clean Architecture의 Infrastructure Layer에서 외부 AI 서비스와의 연동 정의
*/
public interface AiPosterGenerator {
/**
* 포스터 이미지 생성
* @param title 제목
* @param category 카테고리
* @param conditions 생성 조건
* @return 생성된 포스터 이미지 URL
*/
String generatePoster(String title, String category, CreationConditions conditions);
/**
* 포스터 다양한 사이즈 생성
* @param originalImage 원본 이미지 URL
* @return 사이즈별 이미지 URL 맵
*/
Map<String, String> generatePosterSizes(String originalImage);
}
@@ -0,0 +1,95 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java
package com.won.smarketing.content.infrastructure.external;
// 수정: domain 패키지의 인터페이스를 import
import com.won.smarketing.content.domain.service.AiContentGenerator;
import com.won.smarketing.content.domain.model.Platform;
import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.List;
/**
* Claude AI를 활용한 콘텐츠 생성 구현체
* Clean Architecture의 Infrastructure Layer에 위치
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class ClaudeAiContentGenerator implements AiContentGenerator {
/**
* SNS 콘텐츠 생성
*/
@Override
public String generateSnsContent(SnsContentCreateRequest request) {
try {
String prompt = buildContentPrompt(request);
return generateDummySnsContent(request.getTitle(), Platform.fromString(request.getPlatform()));
} catch (Exception e) {
log.error("AI 콘텐츠 생성 실패: {}", e.getMessage(), e);
return generateFallbackContent(request.getTitle(), Platform.fromString(request.getPlatform()));
}
}
/**
* 플랫폼별 해시태그 생성
*/
@Override
public List<String> generateHashtags(String content, Platform platform) {
try {
return generateDummyHashtags(platform);
} catch (Exception e) {
log.error("해시태그 생성 실패: {}", e.getMessage(), e);
return generateFallbackHashtags();
}
}
private String buildContentPrompt(SnsContentCreateRequest request) {
StringBuilder prompt = new StringBuilder();
prompt.append("제목: ").append(request.getTitle()).append("\n");
prompt.append("카테고리: ").append(request.getCategory()).append("\n");
prompt.append("플랫폼: ").append(request.getPlatform()).append("\n");
if (request.getRequirement() != null) {
prompt.append("요구사항: ").append(request.getRequirement()).append("\n");
}
if (request.getToneAndManner() != null) {
prompt.append("톤앤매너: ").append(request.getToneAndManner()).append("\n");
}
return prompt.toString();
}
private String generateDummySnsContent(String title, Platform platform) {
String baseContent = "🌟 " + title + "를 소개합니다! 🌟\n\n" +
"저희 매장에서 특별한 경험을 만나보세요.\n" +
"고객 여러분의 소중한 시간을 더욱 특별하게 만들어드리겠습니다.\n\n";
if (platform == Platform.INSTAGRAM) {
return baseContent + "더 많은 정보는 프로필 링크에서 확인하세요! 📸";
} else {
return baseContent + "자세한 내용은 저희 블로그를 방문해 주세요! ✨";
}
}
private String generateFallbackContent(String title, Platform platform) {
return title + "에 대한 멋진 콘텐츠입니다. 많은 관심 부탁드립니다!";
}
private List<String> generateDummyHashtags(Platform platform) {
if (platform == Platform.INSTAGRAM) {
return Arrays.asList("#맛집", "#데일리", "#소상공인", "#추천", "#인스타그램");
} else {
return Arrays.asList("#맛집추천", "#블로그", "#리뷰", "#맛있는곳", "#소상공인응원");
}
}
private List<String> generateFallbackHashtags() {
return Arrays.asList("#소상공인", "#마케팅", "#홍보");
}
}
@@ -0,0 +1,86 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java
package com.won.smarketing.content.infrastructure.external;
import com.won.smarketing.content.domain.service.AiPosterGenerator; // 도메인 인터페이스 import
import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/**
* Claude AI를 활용한 포스터 생성 구현체
* Clean Architecture의 Infrastructure Layer에 위치
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class ClaudeAiPosterGenerator implements AiPosterGenerator {
/**
* 포스터 생성
*
* @param request 포스터 생성 요청
* @return 생성된 포스터 이미지 URL
*/
@Override
public String generatePoster(PosterContentCreateRequest request) {
try {
// Claude AI API 호출 로직
String prompt = buildPosterPrompt(request);
// TODO: 실제 Claude AI API 호출
// 현재는 더미 데이터 반환
return generateDummyPosterUrl(request.getTitle());
} catch (Exception e) {
log.error("AI 포스터 생성 실패: {}", e.getMessage(), e);
return generateFallbackPosterUrl();
}
}
/**
* 다양한 사이즈의 포스터 생성
*
* @param baseImage 기본 이미지
* @return 사이즈별 포스터 URL 맵
*/
@Override
public Map<String, String> generatePosterSizes(String baseImage) {
Map<String, String> sizes = new HashMap<>();
// 다양한 사이즈 생성 (더미 구현)
sizes.put("instagram_square", baseImage + "_1080x1080.jpg");
sizes.put("instagram_story", baseImage + "_1080x1920.jpg");
sizes.put("facebook_post", baseImage + "_1200x630.jpg");
sizes.put("a4_poster", baseImage + "_2480x3508.jpg");
return sizes;
}
private String buildPosterPrompt(PosterContentCreateRequest request) {
StringBuilder prompt = new StringBuilder();
prompt.append("포스터 제목: ").append(request.getTitle()).append("\n");
prompt.append("카테고리: ").append(request.getCategory()).append("\n");
if (request.getRequirement() != null) {
prompt.append("요구사항: ").append(request.getRequirement()).append("\n");
}
if (request.getToneAndManner() != null) {
prompt.append("톤앤매너: ").append(request.getToneAndManner()).append("\n");
}
return prompt.toString();
}
private String generateDummyPosterUrl(String title) {
return "https://dummy-ai-service.com/posters/" + title.hashCode() + ".jpg";
}
private String generateFallbackPosterUrl() {
return "https://dummy-ai-service.com/posters/fallback.jpg";
}
}
@@ -0,0 +1,213 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java
package com.won.smarketing.content.infrastructure.mapper;
import com.won.smarketing.content.domain.model.*;
import com.won.smarketing.content.infrastructure.entity.ContentConditionsJpaEntity;
import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
/**
* 콘텐츠 도메인-엔티티 매퍼
* Clean Architecture에서 Infrastructure Layer와 Domain Layer 간 변환 담당
*
* @author smarketing-team
* @version 1.0
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class ContentMapper {
private final ObjectMapper objectMapper;
/**
* 도메인 모델을 JPA 엔티티로 변환
*
* @param content 도메인 콘텐츠
* @return JPA 엔티티
*/
public ContentJpaEntity toEntity(Content content) {
if (content == null) {
return null;
}
ContentJpaEntity entity = new ContentJpaEntity();
// 기본 필드 매핑
if (content.getId() != null) {
entity.setId(content.getId());
}
entity.setStoreId(content.getStoreId());
entity.setContentType(content.getContentType() != null ? content.getContentType().name() : null);
entity.setPlatform(content.getPlatform() != null ? content.getPlatform().name() : null);
entity.setTitle(content.getTitle());
entity.setContent(content.getContent());
entity.setStatus(content.getStatus() != null ? content.getStatus().name() : "DRAFT");
entity.setPromotionStartDate(content.getPromotionStartDate());
entity.setPromotionEndDate(content.getPromotionEndDate());
entity.setCreatedAt(content.getCreatedAt());
entity.setUpdatedAt(content.getUpdatedAt());
// 컬렉션 필드를 JSON으로 변환
entity.setHashtags(convertListToJson(content.getHashtags()));
entity.setImages(convertListToJson(content.getImages()));
// 생성 조건 정보 매핑
if (content.getCreationConditions() != null) {
ContentConditionsJpaEntity conditionsEntity = mapToConditionsEntity(content.getCreationConditions());
conditionsEntity.setContent(entity);
entity.setConditions(conditionsEntity);
}
return entity;
}
/**
* JPA 엔티티를 도메인 모델로 변환
*
* @param entity JPA 엔티티
* @return 도메인 모델
*/
public Content toDomain(ContentJpaEntity entity) {
if (entity == null) {
return null;
}
return Content.builder()
.id(entity.getId())
.storeId(entity.getStoreId())
.contentType(parseContentType(entity.getContentType()))
.platform(parsePlatform(entity.getPlatform()))
.title(entity.getTitle())
.content(entity.getContent())
.hashtags(convertJsonToList(entity.getHashtags()))
.images(convertJsonToList(entity.getImages()))
.status(parseContentStatus(entity.getStatus()))
.promotionStartDate(entity.getPromotionStartDate())
.promotionEndDate(entity.getPromotionEndDate())
.creationConditions(mapToConditionsDomain(entity.getConditions()))
.createdAt(entity.getCreatedAt())
.updatedAt(entity.getUpdatedAt())
.build();
}
/**
* CreationConditions 도메인을 JPA 엔티티로 변환
*/
private ContentConditionsJpaEntity mapToConditionsEntity(CreationConditions conditions) {
ContentConditionsJpaEntity entity = new ContentConditionsJpaEntity();
entity.setCategory(conditions.getCategory());
entity.setRequirement(conditions.getRequirement());
entity.setToneAndManner(conditions.getToneAndManner());
entity.setEmotionIntensity(conditions.getEmotionIntensity());
entity.setEventName(conditions.getEventName());
entity.setStartDate(conditions.getStartDate());
entity.setEndDate(conditions.getEndDate());
entity.setPhotoStyle(conditions.getPhotoStyle());
entity.setPromotionType(conditions.getPromotionType());
return entity;
}
/**
* CreationConditions JPA 엔티티를 도메인으로 변환
*/
private CreationConditions mapToConditionsDomain(ContentConditionsJpaEntity entity) {
if (entity == null) {
return null;
}
return CreationConditions.builder()
.category(entity.getCategory())
.requirement(entity.getRequirement())
.toneAndManner(entity.getToneAndManner())
.emotionIntensity(entity.getEmotionIntensity())
.eventName(entity.getEventName())
.startDate(entity.getStartDate())
.endDate(entity.getEndDate())
.photoStyle(entity.getPhotoStyle())
.promotionType(entity.getPromotionType())
.build();
}
/**
* List를 JSON 문자열로 변환
*/
private String convertListToJson(List<String> list) {
if (list == null || list.isEmpty()) {
return null;
}
try {
return objectMapper.writeValueAsString(list);
} catch (Exception e) {
log.warn("Failed to convert list to JSON: {}", e.getMessage());
return null;
}
}
/**
* JSON 문자열을 List로 변환
*/
private List<String> convertJsonToList(String json) {
if (json == null || json.trim().isEmpty()) {
return Collections.emptyList();
}
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
} catch (Exception e) {
log.warn("Failed to convert JSON to list: {}", e.getMessage());
return Collections.emptyList();
}
}
/**
* 문자열을 ContentType 열거형으로 변환
*/
private ContentType parseContentType(String contentType) {
if (contentType == null) {
return null;
}
try {
return ContentType.valueOf(contentType);
} catch (IllegalArgumentException e) {
log.warn("Unknown content type: {}", contentType);
return null;
}
}
/**
* 문자열을 Platform 열거형으로 변환
*/
private Platform parsePlatform(String platform) {
if (platform == null) {
return null;
}
try {
return Platform.valueOf(platform);
} catch (IllegalArgumentException e) {
log.warn("Unknown platform: {}", platform);
return null;
}
}
/**
* 문자열을 ContentStatus 열거형으로 변환
*/
private ContentStatus parseContentStatus(String status) {
if (status == null) {
return ContentStatus.DRAFT;
}
try {
return ContentStatus.valueOf(status);
} catch (IllegalArgumentException e) {
log.warn("Unknown content status: {}", status);
return ContentStatus.DRAFT;
}
}
}
@@ -0,0 +1,147 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java
package com.won.smarketing.content.infrastructure.repository;
import com.won.smarketing.content.domain.model.Content;
import com.won.smarketing.content.domain.model.ContentId;
import com.won.smarketing.content.domain.model.ContentType;
import com.won.smarketing.content.domain.model.Platform;
import com.won.smarketing.content.domain.repository.ContentRepository;
import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity;
import com.won.smarketing.content.infrastructure.mapper.ContentMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* JPA를 활용한 콘텐츠 리포지토리 구현체
* Clean Architecture의 Infrastructure Layer에 위치
* JPA 엔티티와 도메인 모델 간 변환을 위해 ContentMapper 사용
*/
@Repository
@RequiredArgsConstructor
@Slf4j
public class JpaContentRepository implements ContentRepository {
private final JpaContentRepositoryInterface jpaRepository;
private final ContentMapper contentMapper;
/**
* 콘텐츠 저장
* @param content 저장할 도메인 콘텐츠
* @return 저장된 도메인 콘텐츠
*/
@Override
public Content save(Content content) {
log.debug("Saving content: {}", content.getTitle());
// 도메인 모델을 JPA 엔티티로 변환
ContentJpaEntity entity = contentMapper.toEntity(content);
// JPA로 저장
ContentJpaEntity savedEntity = jpaRepository.save(entity);
// JPA 엔티티를 도메인 모델로 변환하여 반환
Content savedContent = contentMapper.toDomain(savedEntity);
log.debug("Content saved with ID: {}", savedContent.getId());
return savedContent;
}
/**
* ID로 콘텐츠 조회
* @param id 콘텐츠 ID
* @return 조회된 도메인 콘텐츠
*/
@Override
public Optional<Content> findById(ContentId id) {
log.debug("Finding content by ID: {}", id.getValue());
return jpaRepository.findById(id.getValue())
.map(contentMapper::toDomain);
}
/**
* 필터 조건으로 콘텐츠 목록 조회
* @param contentType 콘텐츠 타입
* @param platform 플랫폼
* @param period 기간 (현재는 사용하지 않음)
* @param sortBy 정렬 기준 (현재는 사용하지 않음)
* @return 도메인 콘텐츠 목록
*/
@Override
public List<Content> findByFilters(ContentType contentType, Platform platform, String period, String sortBy) {
log.debug("Finding contents with filters - contentType: {}, platform: {}", contentType, platform);
String contentTypeStr = contentType != null ? contentType.name() : null;
String platformStr = platform != null ? platform.name() : null;
List<ContentJpaEntity> entities = jpaRepository.findByFilters(contentTypeStr, platformStr, null);
return entities.stream()
.map(contentMapper::toDomain)
.collect(Collectors.toList());
}
/**
* 진행 중인 콘텐츠 목록 조회
* @param period 기간 (현재는 사용하지 않음)
* @return 진행 중인 도메인 콘텐츠 목록
*/
@Override
public List<Content> findOngoingContents(String period) {
log.debug("Finding ongoing contents");
List<ContentJpaEntity> entities = jpaRepository.findOngoingContents();
return entities.stream()
.map(contentMapper::toDomain)
.collect(Collectors.toList());
}
/**
* ID로 콘텐츠 삭제
* @param id 삭제할 콘텐츠 ID
*/
@Override
public void deleteById(ContentId id) {
log.debug("Deleting content by ID: {}", id.getValue());
jpaRepository.deleteById(id.getValue());
log.debug("Content deleted successfully");
}
/**
* 매장 ID로 콘텐츠 목록 조회 (추가 메서드)
* @param storeId 매장 ID
* @return 도메인 콘텐츠 목록
*/
public List<Content> findByStoreId(Long storeId) {
log.debug("Finding contents by store ID: {}", storeId);
List<ContentJpaEntity> entities = jpaRepository.findByStoreId(storeId);
return entities.stream()
.map(contentMapper::toDomain)
.collect(Collectors.toList());
}
/**
* 콘텐츠 타입으로 조회 (추가 메서드)
* @param contentType 콘텐츠 타입
* @return 도메인 콘텐츠 목록
*/
public List<Content> findByContentType(ContentType contentType) {
log.debug("Finding contents by type: {}", contentType);
List<ContentJpaEntity> entities = jpaRepository.findByContentType(contentType.name());
return entities.stream()
.map(contentMapper::toDomain)
.collect(Collectors.toList());
}
}
@@ -0,0 +1,87 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java
package com.won.smarketing.content.infrastructure.repository;
import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
/**
* Spring Data JPA 콘텐츠 리포지토리 인터페이스
* Clean Architecture의 Infrastructure Layer에 위치
* JPA 엔티티(ContentJpaEntity)를 사용하여 데이터베이스 접근
*/
public interface JpaContentRepositoryInterface extends JpaRepository<ContentJpaEntity, Long> {
/**
* 매장 ID로 콘텐츠 목록 조회
* @param storeId 매장 ID
* @return 콘텐츠 엔티티 목록
*/
List<ContentJpaEntity> findByStoreId(Long storeId);
/**
* 콘텐츠 타입으로 조회
* @param contentType 콘텐츠 타입
* @return 콘텐츠 엔티티 목록
*/
List<ContentJpaEntity> findByContentType(String contentType);
/**
* 플랫폼으로 조회
* @param platform 플랫폼
* @return 콘텐츠 엔티티 목록
*/
List<ContentJpaEntity> findByPlatform(String platform);
/**
* 상태로 조회
* @param status 상태
* @return 콘텐츠 엔티티 목록
*/
List<ContentJpaEntity> findByStatus(String status);
/**
* 필터 조건으로 콘텐츠 목록 조회
* @param contentType 콘텐츠 타입 (null 가능)
* @param platform 플랫폼 (null 가능)
* @param status 상태 (null 가능)
* @return 콘텐츠 엔티티 목록
*/
@Query("SELECT c FROM ContentJpaEntity c WHERE " +
"(:contentType IS NULL OR c.contentType = :contentType) AND " +
"(:platform IS NULL OR c.platform = :platform) AND " +
"(:status IS NULL OR c.status = :status) " +
"ORDER BY c.createdAt DESC")
List<ContentJpaEntity> findByFilters(@Param("contentType") String contentType,
@Param("platform") String platform,
@Param("status") String status);
/**
* 진행 중인 콘텐츠 목록 조회 (발행된 상태의 콘텐츠)
* @return 진행 중인 콘텐츠 엔티티 목록
*/
@Query("SELECT c FROM ContentJpaEntity c WHERE " +
"c.status IN ('PUBLISHED', 'SCHEDULED') " +
"ORDER BY c.createdAt DESC")
List<ContentJpaEntity> findOngoingContents();
/**
* 매장 ID와 콘텐츠 타입으로 조회
* @param storeId 매장 ID
* @param contentType 콘텐츠 타입
* @return 콘텐츠 엔티티 목록
*/
List<ContentJpaEntity> findByStoreIdAndContentType(Long storeId, String contentType);
/**
* 최근 생성된 콘텐츠 조회 (limit 적용)
* @param storeId 매장 ID
* @return 최근 콘텐츠 엔티티 목록
*/
@Query("SELECT c FROM ContentJpaEntity c WHERE c.storeId = :storeId " +
"ORDER BY c.createdAt DESC")
List<ContentJpaEntity> findRecentContentsByStoreId(@Param("storeId") Long storeId);
}
@@ -0,0 +1,169 @@
package com.won.smarketing.content.presentation.controller;
import com.won.smarketing.common.dto.ApiResponse;
import com.won.smarketing.content.application.usecase.ContentQueryUseCase;
import com.won.smarketing.content.application.usecase.PosterContentUseCase;
import com.won.smarketing.content.application.usecase.SnsContentUseCase;
import com.won.smarketing.content.presentation.dto.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.List;
/**
* 마케팅 콘텐츠 관리를 위한 REST API 컨트롤러
* SNS 콘텐츠 생성, 포스터 생성, 콘텐츠 관리 기능 제공
*/
@Tag(name = "마케팅 콘텐츠 관리", description = "AI 기반 마케팅 콘텐츠 생성 및 관리 API")
@RestController
@RequestMapping("/api/content")
@RequiredArgsConstructor
public class ContentController {
private final SnsContentUseCase snsContentUseCase;
private final PosterContentUseCase posterContentUseCase;
private final ContentQueryUseCase contentQueryUseCase;
/**
* SNS 게시물 생성
*
* @param request SNS 콘텐츠 생성 요청
* @return 생성된 SNS 콘텐츠 정보
*/
@Operation(summary = "SNS 게시물 생성", description = "AI를 활용하여 SNS 게시물을 생성합니다.")
@PostMapping("/sns/generate")
public ResponseEntity<ApiResponse<SnsContentCreateResponse>> generateSnsContent(@Valid @RequestBody SnsContentCreateRequest request) {
SnsContentCreateResponse response = snsContentUseCase.generateSnsContent(request);
return ResponseEntity.ok(ApiResponse.success(response, "SNS 콘텐츠가 성공적으로 생성되었습니다."));
}
/**
* SNS 게시물 저장
*
* @param request SNS 콘텐츠 저장 요청
* @return 저장 성공 응답
*/
@Operation(summary = "SNS 게시물 저장", description = "생성된 SNS 게시물을 저장합니다.")
@PostMapping("/sns/save")
public ResponseEntity<ApiResponse<Void>> saveSnsContent(@Valid @RequestBody SnsContentSaveRequest request) {
snsContentUseCase.saveSnsContent(request);
return ResponseEntity.ok(ApiResponse.success(null, "SNS 콘텐츠가 성공적으로 저장되었습니다."));
}
/**
* 홍보 포스터 생성
*
* @param request 포스터 콘텐츠 생성 요청
* @return 생성된 포스터 콘텐츠 정보
*/
@Operation(summary = "홍보 포스터 생성", description = "AI를 활용하여 홍보 포스터를 생성합니다.")
@PostMapping("/poster/generate")
public ResponseEntity<ApiResponse<PosterContentCreateResponse>> generatePosterContent(@Valid @RequestBody PosterContentCreateRequest request) {
PosterContentCreateResponse response = posterContentUseCase.generatePosterContent(request);
return ResponseEntity.ok(ApiResponse.success(response, "포스터 콘텐츠가 성공적으로 생성되었습니다."));
}
/**
* 홍보 포스터 저장
*
* @param request 포스터 콘텐츠 저장 요청
* @return 저장 성공 응답
*/
@Operation(summary = "홍보 포스터 저장", description = "생성된 홍보 포스터를 저장합니다.")
@PostMapping("/poster/save")
public ResponseEntity<ApiResponse<Void>> savePosterContent(@Valid @RequestBody PosterContentSaveRequest request) {
posterContentUseCase.savePosterContent(request);
return ResponseEntity.ok(ApiResponse.success(null, "포스터 콘텐츠가 성공적으로 저장되었습니다."));
}
/**
* 콘텐츠 수정
*
* @param contentId 수정할 콘텐츠 ID
* @param request 콘텐츠 수정 요청
* @return 수정된 콘텐츠 정보
*/
@Operation(summary = "콘텐츠 수정", description = "기존 콘텐츠를 수정합니다.")
@PutMapping("/{contentId}")
public ResponseEntity<ApiResponse<ContentUpdateResponse>> updateContent(
@Parameter(description = "콘텐츠 ID", required = true)
@PathVariable Long contentId,
@Valid @RequestBody ContentUpdateRequest request) {
ContentUpdateResponse response = contentQueryUseCase.updateContent(contentId, request);
return ResponseEntity.ok(ApiResponse.success(response, "콘텐츠가 성공적으로 수정되었습니다."));
}
/**
* 콘텐츠 목록 조회
*
* @param contentType 콘텐츠 타입 필터
* @param platform 플랫폼 필터
* @param period 기간 필터
* @param sortBy 정렬 기준
* @return 콘텐츠 목록
*/
@Operation(summary = "콘텐츠 목록 조회", description = "다양한 필터와 정렬 옵션으로 콘텐츠 목록을 조회합니다.")
@GetMapping
public ResponseEntity<ApiResponse<List<ContentResponse>>> getContents(
@Parameter(description = "콘텐츠 타입")
@RequestParam(required = false) String contentType,
@Parameter(description = "플랫폼")
@RequestParam(required = false) String platform,
@Parameter(description = "기간")
@RequestParam(required = false) String period,
@Parameter(description = "정렬 기준")
@RequestParam(required = false) String sortBy) {
List<ContentResponse> response = contentQueryUseCase.getContents(contentType, platform, period, sortBy);
return ResponseEntity.ok(ApiResponse.success(response));
}
/**
* 진행 중인 콘텐츠 목록 조회
*
* @param period 기간 필터
* @return 진행 중인 콘텐츠 목록
*/
@Operation(summary = "진행 콘텐츠 조회", description = "현재 진행 중인 콘텐츠 목록을 조회합니다.")
@GetMapping("/ongoing")
public ResponseEntity<ApiResponse<List<OngoingContentResponse>>> getOngoingContents(
@Parameter(description = "기간")
@RequestParam(required = false) String period) {
List<OngoingContentResponse> response = contentQueryUseCase.getOngoingContents(period);
return ResponseEntity.ok(ApiResponse.success(response));
}
/**
* 콘텐츠 상세 조회
*
* @param contentId 조회할 콘텐츠 ID
* @return 콘텐츠 상세 정보
*/
@Operation(summary = "콘텐츠 상세 조회", description = "특정 콘텐츠의 상세 정보를 조회합니다.")
@GetMapping("/{contentId}")
public ResponseEntity<ApiResponse<ContentDetailResponse>> getContentDetail(
@Parameter(description = "콘텐츠 ID", required = true)
@PathVariable Long contentId) {
ContentDetailResponse response = contentQueryUseCase.getContentDetail(contentId);
return ResponseEntity.ok(ApiResponse.success(response));
}
/**
* 콘텐츠 삭제
*
* @param contentId 삭제할 콘텐츠 ID
* @return 삭제 성공 응답
*/
@Operation(summary = "콘텐츠 삭제", description = "콘텐츠를 삭제합니다.")
@DeleteMapping("/{contentId}")
public ResponseEntity<ApiResponse<Void>> deleteContent(
@Parameter(description = "콘텐츠 ID", required = true)
@PathVariable Long contentId) {
contentQueryUseCase.deleteContent(contentId);
return ResponseEntity.ok(ApiResponse.success(null, "콘텐츠가 성공적으로 삭제되었습니다."));
}
}
@@ -0,0 +1,86 @@
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 콘텐츠 상세 응답 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "콘텐츠 상세 응답")
public class ContentDetailResponse {
@Schema(description = "콘텐츠 ID", example = "1")
private Long contentId;
@Schema(description = "콘텐츠 타입", example = "SNS_POST")
private String contentType;
@Schema(description = "플랫폼", example = "INSTAGRAM")
private String platform;
@Schema(description = "제목", example = "맛있는 신메뉴를 소개합니다!")
private String title;
@Schema(description = "콘텐츠 내용")
private String content;
@Schema(description = "해시태그 목록")
private List<String> hashtags;
@Schema(description = "이미지 URL 목록")
private List<String> images;
@Schema(description = "상태", example = "PUBLISHED")
private String status;
@Schema(description = "홍보 시작일")
private LocalDateTime promotionStartDate;
@Schema(description = "홍보 종료일")
private LocalDateTime promotionEndDate;
@Schema(description = "생성 조건")
private CreationConditionsDto creationConditions;
@Schema(description = "생성일시")
private LocalDateTime createdAt;
@Schema(description = "수정일시")
private LocalDateTime updatedAt;
/**
* 생성 조건 내부 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "콘텐츠 생성 조건")
public static class CreationConditionsDto {
@Schema(description = "톤앤매너", example = "친근함")
private String toneAndManner;
@Schema(description = "프로모션 유형", example = "할인 정보")
private String promotionType;
@Schema(description = "감정 강도", example = "보통")
private String emotionIntensity;
@Schema(description = "홍보 대상", example = "메뉴")
private String targetAudience;
@Schema(description = "이벤트명", example = "신메뉴 출시 이벤트")
private String eventName;
}
}
@@ -0,0 +1,37 @@
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 콘텐츠 목록 조회 요청 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "콘텐츠 목록 조회 요청")
public class ContentListRequest {
@Schema(description = "콘텐츠 타입", example = "SNS_POST")
private String contentType;
@Schema(description = "플랫폼", example = "INSTAGRAM")
private String platform;
@Schema(description = "조회 기간", example = "7days")
private String period;
@Schema(description = "정렬 기준", example = "createdAt")
private String sortBy;
@Schema(description = "정렬 방향", example = "DESC")
private String sortDirection;
@Schema(description = "페이지 번호", example = "0")
private Integer page;
@Schema(description = "페이지 크기", example = "20")
private Integer size;
}
@@ -0,0 +1,33 @@
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 콘텐츠 재생성 요청 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "콘텐츠 재생성 요청")
public class ContentRegenerateRequest {
@Schema(description = "원본 콘텐츠 ID", example = "1", required = true)
@NotNull(message = "원본 콘텐츠 ID는 필수입니다")
private Long originalContentId;
@Schema(description = "수정된 톤앤매너", example = "전문적")
private String toneAndManner;
@Schema(description = "수정된 프로모션 유형", example = "신메뉴 알림")
private String promotionType;
@Schema(description = "수정된 감정 강도", example = "열정적")
private String emotionIntensity;
@Schema(description = "추가 요구사항", example = "더 감성적으로 작성해주세요")
private String additionalRequirements;
}
@@ -0,0 +1,364 @@
// marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 콘텐츠 응답 DTO
* 콘텐츠 목록 조회 시 사용되는 기본 응답 DTO
*
* 이 클래스는 콘텐츠의 핵심 정보만을 포함하여 목록 조회 시 성능을 최적화합니다.
* 상세 정보가 필요한 경우 ContentDetailResponse를 사용합니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "콘텐츠 응답")
public class ContentResponse {
// ==================== 기본 식별 정보 ====================
@Schema(description = "콘텐츠 ID", example = "1")
private Long contentId;
@Schema(description = "콘텐츠 타입", example = "SNS_POST",
allowableValues = {"SNS_POST", "POSTER"})
private String contentType;
@Schema(description = "플랫폼", example = "INSTAGRAM",
allowableValues = {"INSTAGRAM", "NAVER_BLOG", "FACEBOOK", "KAKAO_STORY"})
private String platform;
// ==================== 콘텐츠 정보 ====================
@Schema(description = "제목", example = "맛있는 신메뉴를 소개합니다!")
private String title;
@Schema(description = "콘텐츠 내용", example = "특별한 신메뉴가 출시되었습니다! 🍽️\n지금 바로 맛보세요!")
private String content;
@Schema(description = "해시태그 목록", example = "[\"#맛집\", \"#신메뉴\", \"#추천\", \"#인스타그램\"]")
private List<String> hashtags;
@Schema(description = "이미지 URL 목록",
example = "[\"https://example.com/image1.jpg\", \"https://example.com/image2.jpg\"]")
private List<String> images;
// ==================== 상태 관리 ====================
@Schema(description = "상태", example = "PUBLISHED",
allowableValues = {"DRAFT", "PUBLISHED", "SCHEDULED", "ARCHIVED"})
private String status;
@Schema(description = "상태 표시명", example = "발행완료")
private String statusDisplay;
// ==================== 홍보 기간 ====================
@Schema(description = "홍보 시작일", example = "2024-01-15T09:00:00")
private LocalDateTime promotionStartDate;
@Schema(description = "홍보 종료일", example = "2024-01-22T23:59:59")
private LocalDateTime promotionEndDate;
// ==================== 시간 정보 ====================
@Schema(description = "생성일시", example = "2024-01-15T10:30:00")
private LocalDateTime createdAt;
@Schema(description = "수정일시", example = "2024-01-15T14:20:00")
private LocalDateTime updatedAt;
// ==================== 계산된 필드들 ====================
@Schema(description = "홍보 진행 상태", example = "ONGOING",
allowableValues = {"UPCOMING", "ONGOING", "COMPLETED"})
private String promotionStatus;
@Schema(description = "남은 홍보 일수", example = "5")
private Long remainingDays;
@Schema(description = "홍보 진행률 (%)", example = "60.5")
private Double progressPercentage;
@Schema(description = "콘텐츠 요약 (첫 50자)", example = "특별한 신메뉴가 출시되었습니다! 지금 바로 맛보세요...")
private String contentSummary;
@Schema(description = "이미지 개수", example = "3")
private Integer imageCount;
@Schema(description = "해시태그 개수", example = "8")
private Integer hashtagCount;
@Schema(description = "조회수", example = "8")
private Integer viewCount;
// ==================== 비즈니스 메서드 ====================
/**
* 콘텐츠 요약 생성
* 콘텐츠가 길 경우 첫 50자만 표시하고 "..." 추가
*
* @param content 원본 콘텐츠
* @param maxLength 최대 길이
* @return 요약된 콘텐츠
*/
public static String createContentSummary(String content, int maxLength) {
if (content == null || content.length() <= maxLength) {
return content;
}
return content.substring(0, maxLength) + "...";
}
/**
* 홍보 상태 계산
* 현재 시간과 홍보 기간을 비교하여 상태 결정
*
* @param startDate 홍보 시작일
* @param endDate 홍보 종료일
* @return 홍보 상태
*/
public static String calculatePromotionStatus(LocalDateTime startDate, LocalDateTime endDate) {
if (startDate == null || endDate == null) {
return "UNKNOWN";
}
LocalDateTime now = LocalDateTime.now();
if (now.isBefore(startDate)) {
return "UPCOMING"; // 홍보 예정
} else if (now.isAfter(endDate)) {
return "COMPLETED"; // 홍보 완료
} else {
return "ONGOING"; // 홍보 진행중
}
}
/**
* 남은 일수 계산
* 홍보 종료일까지 남은 일수 계산
*
* @param endDate 홍보 종료일
* @return 남은 일수 (음수면 0 반환)
*/
public static Long calculateRemainingDays(LocalDateTime endDate) {
if (endDate == null) {
return 0L;
}
LocalDateTime now = LocalDateTime.now();
if (now.isAfter(endDate)) {
return 0L;
}
return java.time.Duration.between(now, endDate).toDays();
}
/**
* 진행률 계산
* 홍보 기간 대비 진행률 계산 (0-100%)
*
* @param startDate 홍보 시작일
* @param endDate 홍보 종료일
* @return 진행률 (0-100%)
*/
public static Double calculateProgressPercentage(LocalDateTime startDate, LocalDateTime endDate) {
if (startDate == null || endDate == null) {
return 0.0;
}
LocalDateTime now = LocalDateTime.now();
if (now.isBefore(startDate)) {
return 0.0; // 아직 시작 안함
} else if (now.isAfter(endDate)) {
return 100.0; // 완료
}
long totalDuration = java.time.Duration.between(startDate, endDate).toHours();
long elapsedDuration = java.time.Duration.between(startDate, now).toHours();
if (totalDuration == 0) {
return 100.0;
}
return (double) elapsedDuration / totalDuration * 100.0;
}
/**
* 상태 표시명 변환
* 영문 상태를 한글로 변환
*
* @param status 영문 상태
* @return 한글 상태명
*/
public static String getStatusDisplay(String status) {
if (status == null) {
return "알 수 없음";
}
switch (status) {
case "DRAFT":
return "임시저장";
case "PUBLISHED":
return "발행완료";
case "SCHEDULED":
return "예약발행";
case "ARCHIVED":
return "보관됨";
default:
return status;
}
}
// ==================== Builder 확장 메서드 ====================
/**
* 도메인 엔티티에서 ContentResponse 생성
* 계산된 필드들을 자동으로 설정
*
* @param content 콘텐츠 도메인 엔티티
* @return ContentResponse
*/
public static ContentResponse fromDomain(com.won.smarketing.content.domain.model.Content content) {
ContentResponseBuilder builder = ContentResponse.builder()
.contentId(content.getId())
.contentType(content.getContentType().name())
.platform(content.getPlatform().name())
.title(content.getTitle())
.content(content.getContent())
.hashtags(content.getHashtags())
.images(content.getImages())
.status(content.getStatus().name())
.statusDisplay(getStatusDisplay(content.getStatus().name()))
.promotionStartDate(content.getPromotionStartDate())
.promotionEndDate(content.getPromotionEndDate())
.createdAt(content.getCreatedAt())
.updatedAt(content.getUpdatedAt());
// 계산된 필드들 설정
builder.contentSummary(createContentSummary(content.getContent(), 50));
builder.imageCount(content.getImages() != null ? content.getImages().size() : 0);
builder.hashtagCount(content.getHashtags() != null ? content.getHashtags().size() : 0);
// 홍보 관련 계산 필드들
builder.promotionStatus(calculatePromotionStatus(
content.getPromotionStartDate(),
content.getPromotionEndDate()));
builder.remainingDays(calculateRemainingDays(content.getPromotionEndDate()));
builder.progressPercentage(calculateProgressPercentage(
content.getPromotionStartDate(),
content.getPromotionEndDate()));
return builder.build();
}
// ==================== 유틸리티 메서드 ====================
/**
* 콘텐츠가 현재 활성 상태인지 확인
*
* @return 홍보 기간 내이고 발행 상태면 true
*/
public boolean isActive() {
return "PUBLISHED".equals(status) && "ONGOING".equals(promotionStatus);
}
/**
* 콘텐츠 수정 가능 여부 확인
*
* @return 임시저장 상태이거나 예약발행 상태면 true
*/
public boolean isEditable() {
return "DRAFT".equals(status) || "SCHEDULED".equals(status);
}
/**
* 이미지가 있는 콘텐츠인지 확인
*
* @return 이미지가 1개 이상 있으면 true
*/
public boolean hasImages() {
return images != null && !images.isEmpty();
}
/**
* 해시태그가 있는 콘텐츠인지 확인
*
* @return 해시태그가 1개 이상 있으면 true
*/
public boolean hasHashtags() {
return hashtags != null && !hashtags.isEmpty();
}
/**
* 디버깅용 toString (간소화된 정보만)
*/
@Override
public String toString() {
return "ContentResponse{" +
"contentId=" + contentId +
", contentType='" + contentType + '\'' +
", platform='" + platform + '\'' +
", title='" + title + '\'' +
", status='" + status + '\'' +
", promotionStatus='" + promotionStatus + '\'' +
", createdAt=" + createdAt +
'}';
}
}
/*
==================== 사용 예시 ====================
// 1. 도메인 엔티티에서 DTO 생성
Content domainContent = contentRepository.findById(contentId);
ContentResponse response = ContentResponse.fromDomain(domainContent);
// 2. 수동으로 빌더 사용
ContentResponse response = ContentResponse.builder()
.contentId(1L)
.contentType("SNS_POST")
.platform("INSTAGRAM")
.title("맛있는 신메뉴")
.content("특별한 신메뉴가 출시되었습니다!")
.status("PUBLISHED")
.build();
// 3. 비즈니스 로직 활용
boolean canEdit = response.isEditable();
boolean isLive = response.isActive();
String summary = response.getContentSummary();
==================== JSON 응답 예시 ====================
{
"contentId": 1,
"contentType": "SNS_POST",
"platform": "INSTAGRAM",
"title": "맛있는 신메뉴를 소개합니다!",
"content": "특별한 신메뉴가 출시되었습니다! 🍽️\n지금 바로 맛보세요!",
"hashtags": ["#맛집", "#신메뉴", "#추천", "#인스타그램"],
"images": ["https://example.com/image1.jpg"],
"status": "PUBLISHED",
"statusDisplay": "발행완료",
"promotionStartDate": "2024-01-15T09:00:00",
"promotionEndDate": "2024-01-22T23:59:59",
"createdAt": "2024-01-15T10:30:00",
"updatedAt": "2024-01-15T14:20:00",
"promotionStatus": "ONGOING",
"remainingDays": 5,
"progressPercentage": 60.5,
"contentSummary": "특별한 신메뉴가 출시되었습니다! 지금 바로 맛보세요...",
"imageCount": 1,
"hashtagCount": 4
}
*/
@@ -0,0 +1,41 @@
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* 콘텐츠 통계 응답 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "콘텐츠 통계 응답")
public class ContentStatisticsResponse {
@Schema(description = "총 콘텐츠 수", example = "150")
private Long totalContents;
@Schema(description = "이번 달 생성된 콘텐츠 수", example = "25")
private Long thisMonthContents;
@Schema(description = "발행된 콘텐츠 수", example = "120")
private Long publishedContents;
@Schema(description = "임시저장된 콘텐츠 수", example = "30")
private Long draftContents;
@Schema(description = "콘텐츠 타입별 통계")
private Map<String, Long> contentTypeStats;
@Schema(description = "플랫폼별 통계")
private Map<String, Long> platformStats;
@Schema(description = "월별 생성 통계 (최근 6개월)")
private Map<String, Long> monthlyStats;
}
@@ -0,0 +1,33 @@
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 콘텐츠 수정 요청 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "콘텐츠 수정 요청")
public class ContentUpdateRequest {
@Schema(description = "제목", example = "수정된 제목")
private String title;
@Schema(description = "콘텐츠 내용")
private String content;
@Schema(description = "홍보 시작일")
private LocalDateTime promotionStartDate;
@Schema(description = "홍보 종료일")
private LocalDateTime promotionEndDate;
@Schema(description = "상태", example = "PUBLISHED")
private String status;
}
@@ -0,0 +1,35 @@
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 콘텐츠 수정 응답 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "콘텐츠 수정 응답")
public class ContentUpdateResponse {
@Schema(description = "콘텐츠 ID", example = "1")
private Long contentId;
@Schema(description = "수정된 제목", example = "수정된 제목")
private String title;
@Schema(description = "수정된 콘텐츠 내용")
private String content;
@Schema(description = "상태", example = "PUBLISHED")
private String status;
@Schema(description = "수정일시")
private LocalDateTime updatedAt;
}
@@ -0,0 +1,45 @@
// marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
/**
* 콘텐츠 생성 조건 DTO
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "콘텐츠 생성 조건")
public class CreationConditionsDto {
@Schema(description = "카테고리", example = "음식")
private String category;
@Schema(description = "생성 요구사항", example = "젊은 고객층을 타겟으로 한 재미있는 콘텐츠")
private String requirement;
@Schema(description = "톤앤매너", example = "친근하고 활발한")
private String toneAndManner;
@Schema(description = "감정 강도", example = "보통")
private String emotionIntensity;
@Schema(description = "이벤트명", example = "신메뉴 출시 이벤트")
private String eventName;
@Schema(description = "시작일")
private LocalDate startDate;
@Schema(description = "종료일")
private LocalDate endDate;
@Schema(description = "사진 스타일", example = "모던하고 깔끔한")
private String photoStyle;
}
@@ -0,0 +1,47 @@
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 진행 중인 콘텐츠 응답 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "진행 중인 콘텐츠 응답")
public class OngoingContentResponse {
@Schema(description = "콘텐츠 ID", example = "1")
private Long contentId;
@Schema(description = "콘텐츠 타입", example = "SNS_POST")
private String contentType;
@Schema(description = "플랫폼", example = "INSTAGRAM")
private String platform;
@Schema(description = "제목", example = "진행 중인 이벤트")
private String title;
@Schema(description = "상태", example = "PUBLISHED")
private String status;
@Schema(description = "홍보 시작일")
private LocalDateTime promotionStartDate;
@Schema(description = "홍보 종료일")
private LocalDateTime promotionEndDate;
@Schema(description = "남은 일수", example = "5")
private Long remainingDays;
@Schema(description = "진행률 (%)", example = "60.5")
private Double progressPercentage;
}
@@ -0,0 +1,79 @@
// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
* 포스터 콘텐츠 생성 요청 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "포스터 콘텐츠 생성 요청")
public class PosterContentCreateRequest {
@Schema(description = "매장 ID", example = "1", required = true)
@NotNull(message = "매장 ID는 필수입니다")
private Long storeId;
@Schema(description = "제목", example = "특별 이벤트 안내")
private String title;
@Schema(description = "홍보 대상", example = "메뉴", required = true)
@NotBlank(message = "홍보 대상은 필수입니다")
private String targetAudience;
@Schema(description = "홍보 시작일", required = true)
@NotNull(message = "홍보 시작일은 필수입니다")
private LocalDateTime promotionStartDate;
@Schema(description = "홍보 종료일", required = true)
@NotNull(message = "홍보 종료일은 필수입니다")
private LocalDateTime promotionEndDate;
@Schema(description = "이벤트명 (이벤트 홍보시)", example = "신메뉴 출시 이벤트")
private String eventName;
@Schema(description = "이미지 스타일", example = "모던")
private String imageStyle;
@Schema(description = "프로모션 유형", example = "할인 정보")
private String promotionType;
@Schema(description = "감정 강도", example = "보통")
private String emotionIntensity;
@Schema(description = "업로드된 이미지 URL 목록", required = true)
@NotNull(message = "이미지는 1개 이상 필수입니다")
@Size(min = 1, message = "이미지는 1개 이상 업로드해야 합니다")
private List<String> images;
// CreationConditions에 필요한 필드들
@Schema(description = "콘텐츠 카테고리", example = "이벤트")
private String category;
@Schema(description = "구체적인 요구사항", example = "신메뉴 출시 이벤트 포스터를 만들어주세요")
private String requirement;
@Schema(description = "톤앤매너", example = "전문적")
private String toneAndManner;
@Schema(description = "이벤트 시작일", example = "2024-01-15")
private LocalDate startDate;
@Schema(description = "이벤트 종료일", example = "2024-01-31")
private LocalDate endDate;
@Schema(description = "사진 스타일", example = "밝고 화사한")
private String photoStyle;
}
@@ -0,0 +1,49 @@
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
/**
* 포스터 콘텐츠 생성 응답 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "포스터 콘텐츠 생성 응답")
public class PosterContentCreateResponse {
@Schema(description = "콘텐츠 ID", example = "1")
private Long contentId;
@Schema(description = "생성된 포스터 제목", example = "특별 이벤트 안내")
private String title;
@Schema(description = "생성된 포스터 텍스트 내용")
private String content;
@Schema(description = "생성된 포스터 타입")
private String contentType;
@Schema(description = "포스터 이미지 URL")
private String posterImage;
@Schema(description = "원본 이미지 URL 목록")
private List<String> originalImages;
@Schema(description = "이미지 스타일", example = "모던")
private String imageStyle;
@Schema(description = "생성 상태", example = "DRAFT")
private String status;
@Schema(description = "포스터사이즈", example = "800x600")
private Map<String, String> posterSizes;
}
@@ -0,0 +1,66 @@
// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.util.List;
/**
* 포스터 콘텐츠 저장 요청 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "포스터 콘텐츠 저장 요청")
public class PosterContentSaveRequest {
@Schema(description = "콘텐츠 ID", example = "1", required = true)
@NotNull(message = "콘텐츠 ID는 필수입니다")
private Long contentId;
@Schema(description = "매장 ID", example = "1", required = true)
@NotNull(message = "매장 ID는 필수입니다")
private Long storeId;
@Schema(description = "제목", example = "특별 이벤트 안내")
private String title;
@Schema(description = "콘텐츠 내용")
private String content;
@Schema(description = "선택된 포스터 이미지 URL")
private List<String> images;
@Schema(description = "발행 상태", example = "PUBLISHED")
private String status;
// CreationConditions에 필요한 필드들
@Schema(description = "콘텐츠 카테고리", example = "이벤트")
private String category;
@Schema(description = "구체적인 요구사항", example = "신메뉴 출시 이벤트 포스터를 만들어주세요")
private String requirement;
@Schema(description = "톤앤매너", example = "전문적")
private String toneAndManner;
@Schema(description = "감정 강도", example = "보통")
private String emotionIntensity;
@Schema(description = "이벤트명", example = "신메뉴 출시 이벤트")
private String eventName;
@Schema(description = "이벤트 시작일", example = "2024-01-15")
private LocalDate startDate;
@Schema(description = "이벤트 종료일", example = "2024-01-31")
private LocalDate endDate;
@Schema(description = "사진 스타일", example = "밝고 화사한")
private String photoStyle;
}
@@ -0,0 +1,160 @@
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.util.List;
/**
* SNS 콘텐츠 생성 요청 DTO
*
* AI 기반 SNS 콘텐츠 생성을 위한 요청 정보를 담고 있습니다.
* 사용자가 입력한 생성 조건을 바탕으로 AI가 적절한 SNS 콘텐츠를 생성합니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "SNS 콘텐츠 생성 요청")
public class SnsContentCreateRequest {
// ==================== 기본 정보 ====================
@Schema(description = "매장 ID", example = "1", required = true)
@NotNull(message = "매장 ID는 필수입니다")
private Long storeId;
@Schema(description = "대상 플랫폼",
example = "INSTAGRAM",
allowableValues = {"INSTAGRAM", "NAVER_BLOG", "FACEBOOK", "KAKAO_STORY"},
required = true)
@NotBlank(message = "플랫폼은 필수입니다")
private String platform;
@Schema(description = "콘텐츠 제목", example = "1", required = true)
@NotNull(message = "콘텐츠 제목은 필수입니다")
private String title;
// ==================== 콘텐츠 생성 조건 ====================
@Schema(description = "콘텐츠 카테고리",
example = "메뉴소개",
allowableValues = {"메뉴소개", "이벤트", "일상", "인테리어", "고객후기", "기타"})
private String category;
@Schema(description = "구체적인 요구사항 또는 홍보하고 싶은 내용",
example = "새로 출시된 시그니처 버거를 홍보하고 싶어요")
@Size(max = 500, message = "요구사항은 500자 이하로 입력해주세요")
private String requirement;
@Schema(description = "톤앤매너",
example = "친근함",
allowableValues = {"친근함", "전문적", "유머러스", "감성적", "트렌디"})
private String toneAndManner;
@Schema(description = "감정 강도",
example = "보통",
allowableValues = {"약함", "보통", "강함"})
private String emotionIntensity;
// ==================== 이벤트 정보 ====================
@Schema(description = "이벤트명 (이벤트 콘텐츠인 경우)",
example = "신메뉴 출시 이벤트")
@Size(max = 200, message = "이벤트명은 200자 이하로 입력해주세요")
private String eventName;
@Schema(description = "이벤트 시작일 (이벤트 콘텐츠인 경우)",
example = "2024-01-15")
private LocalDate startDate;
@Schema(description = "이벤트 종료일 (이벤트 콘텐츠인 경우)",
example = "2024-01-31")
private LocalDate endDate;
// ==================== 미디어 정보 ====================
@Schema(description = "업로드된 이미지 파일 경로 목록")
private List<String> images;
@Schema(description = "사진 스타일 선호도",
example = "밝고 화사한",
allowableValues = {"밝고 화사한", "차분하고 세련된", "빈티지한", "모던한", "자연스러운"})
private String photoStyle;
// ==================== 추가 옵션 ====================
@Schema(description = "해시태그 포함 여부", example = "true")
@Builder.Default
private Boolean includeHashtags = true;
@Schema(description = "이모지 포함 여부", example = "true")
@Builder.Default
private Boolean includeEmojis = true;
@Schema(description = "콜투액션 포함 여부 (좋아요, 팔로우 요청 등)", example = "true")
@Builder.Default
private Boolean includeCallToAction = true;
@Schema(description = "매장 위치 정보 포함 여부", example = "false")
@Builder.Default
private Boolean includeLocation = false;
// ==================== 플랫폼별 옵션 ====================
@Schema(description = "인스타그램 스토리용 여부 (Instagram인 경우)", example = "false")
@Builder.Default
private Boolean forInstagramStory = false;
@Schema(description = "네이버 블로그 포스팅용 여부 (Naver Blog인 경우)", example = "false")
@Builder.Default
private Boolean forNaverBlogPost = false;
// ==================== AI 생성 옵션 ====================
@Schema(description = "대안 제목 생성 개수", example = "3")
@Builder.Default
private Integer alternativeTitleCount = 3;
@Schema(description = "대안 해시태그 세트 생성 개수", example = "2")
@Builder.Default
private Integer alternativeHashtagSetCount = 2;
@Schema(description = "AI 모델 버전 지정 (없으면 기본값 사용)", example = "gpt-4-turbo")
private String preferredAiModel;
// ==================== 검증 메서드 ====================
/**
* 이벤트 날짜 유효성 검증
* 시작일이 종료일보다 이후인지 확인
*/
public boolean isValidEventDates() {
if (startDate != null && endDate != null) {
return !startDate.isAfter(endDate);
}
return true;
}
/**
* 플랫폼별 필수 조건 검증
*/
public boolean isValidForPlatform() {
if ("INSTAGRAM".equals(platform)) {
// 인스타그램은 이미지가 권장됨
return images != null && !images.isEmpty();
}
if ("NAVER_BLOG".equals(platform)) {
// 네이버 블로그는 상세한 내용이 필요
return requirement != null && requirement.length() >= 20;
}
return true;
}
}
@@ -0,0 +1,383 @@
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* SNS 콘텐츠 생성 응답 DTO
*
* AI를 통해 SNS 콘텐츠를 생성한 후 클라이언트에게 반환되는 응답 정보입니다.
* 생성된 콘텐츠의 기본 정보와 함께 사용자가 추가 편집할 수 있는 정보를 포함합니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "SNS 콘텐츠 생성 응답")
public class SnsContentCreateResponse {
// ==================== 기본 식별 정보 ====================
@Schema(description = "생성된 콘텐츠 ID", example = "1")
private Long contentId;
@Schema(description = "콘텐츠 타입", example = "SNS_POST")
private String contentType;
@Schema(description = "대상 플랫폼", example = "INSTAGRAM",
allowableValues = {"INSTAGRAM", "NAVER_BLOG", "FACEBOOK", "KAKAO_STORY"})
private String platform;
// ==================== AI 생성 콘텐츠 ====================
@Schema(description = "AI가 생성한 콘텐츠 제목",
example = "맛있는 신메뉴를 소개합니다! ✨")
private String title;
@Schema(description = "AI가 생성한 콘텐츠 내용",
example = "안녕하세요! 😊\n\n특별한 신메뉴가 출시되었습니다!\n진짜 맛있어서 꼭 한번 드셔보세요 🍽️\n\n매장에서 기다리고 있을게요! 💫")
private String content;
@Schema(description = "AI가 생성한 해시태그 목록",
example = "[\"맛집\", \"신메뉴\", \"추천\", \"인스타그램\", \"일상\", \"좋아요\", \"팔로우\", \"맛있어요\"]")
private List<String> hashtags;
// ==================== 플랫폼별 최적화 정보 ====================
@Schema(description = "플랫폼별 최적화된 콘텐츠 길이", example = "280")
private Integer contentLength;
@Schema(description = "플랫폼별 권장 해시태그 개수", example = "8")
private Integer recommendedHashtagCount;
@Schema(description = "플랫폼별 최대 해시태그 개수", example = "15")
private Integer maxHashtagCount;
// ==================== 생성 조건 정보 ====================
@Schema(description = "콘텐츠 생성에 사용된 조건들")
private GenerationConditionsDto generationConditions;
// ==================== 상태 및 메타데이터 ====================
@Schema(description = "생성 상태", example = "DRAFT",
allowableValues = {"DRAFT", "PUBLISHED", "SCHEDULED"})
private String status;
@Schema(description = "생성 일시", example = "2024-01-15T10:30:00")
private LocalDateTime createdAt;
@Schema(description = "AI 모델 버전", example = "gpt-4-turbo")
private String aiModelVersion;
@Schema(description = "생성 시간 (초)", example = "3.5")
private Double generationTimeSeconds;
// ==================== 추가 정보 ====================
@Schema(description = "업로드된 원본 이미지 URL 목록")
private List<String> originalImages;
@Schema(description = "콘텐츠 품질 점수 (1-100)", example = "85")
private Integer qualityScore;
@Schema(description = "예상 참여율 (%)", example = "12.5")
private Double expectedEngagementRate;
@Schema(description = "콘텐츠 카테고리", example = "음식/메뉴소개")
private String category;
@Schema(description = "보정된 이미지 URL 목록")
private List<String> fixedImages;
// ==================== 편집 가능 여부 ====================
@Schema(description = "제목 편집 가능 여부", example = "true")
@Builder.Default
private Boolean titleEditable = true;
@Schema(description = "내용 편집 가능 여부", example = "true")
@Builder.Default
private Boolean contentEditable = true;
@Schema(description = "해시태그 편집 가능 여부", example = "true")
@Builder.Default
private Boolean hashtagsEditable = true;
// ==================== 대안 콘텐츠 ====================
@Schema(description = "대안 제목 목록 (사용자 선택용)")
private List<String> alternativeTitles;
@Schema(description = "대안 해시태그 세트 목록")
private List<List<String>> alternativeHashtagSets;
// ==================== 내부 DTO 클래스 ====================
/**
* 콘텐츠 생성 조건 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "콘텐츠 생성 조건")
public static class GenerationConditionsDto {
@Schema(description = "홍보 대상", example = "메뉴")
private String targetAudience;
@Schema(description = "이벤트명", example = "신메뉴 출시 이벤트")
private String eventName;
@Schema(description = "톤앤매너", example = "친근함")
private String toneAndManner;
@Schema(description = "프로모션 유형", example = "할인 정보")
private String promotionType;
@Schema(description = "감정 강도", example = "보통")
private String emotionIntensity;
@Schema(description = "홍보 시작일", example = "2024-01-15T09:00:00")
private LocalDateTime promotionStartDate;
@Schema(description = "홍보 종료일", example = "2024-01-22T23:59:59")
private LocalDateTime promotionEndDate;
}
// ==================== 비즈니스 메서드 ====================
/**
* 플랫폼별 콘텐츠 최적화 여부 확인
*
* @return 콘텐츠가 플랫폼 권장 사항을 만족하면 true
*/
public boolean isOptimizedForPlatform() {
if (content == null || hashtags == null) {
return false;
}
// 플랫폼별 최적화 기준
switch (platform.toUpperCase()) {
case "INSTAGRAM":
return content.length() <= 2200 &&
hashtags.size() <= 15 &&
hashtags.size() >= 5;
case "NAVER_BLOG":
return content.length() >= 300 &&
hashtags.size() <= 10 &&
hashtags.size() >= 3;
case "FACEBOOK":
return content.length() <= 500 &&
hashtags.size() <= 5;
default:
return true;
}
}
/**
* 고품질 콘텐츠 여부 확인
*
* @return 품질 점수가 80점 이상이면 true
*/
public boolean isHighQuality() {
return qualityScore != null && qualityScore >= 80;
}
/**
* 참여율 예상 등급 반환
*
* @return 예상 참여율 등급 (HIGH, MEDIUM, LOW)
*/
public String getExpectedEngagementLevel() {
if (expectedEngagementRate == null) {
return "UNKNOWN";
}
if (expectedEngagementRate >= 15.0) {
return "HIGH";
} else if (expectedEngagementRate >= 8.0) {
return "MEDIUM";
} else {
return "LOW";
}
}
/**
* 해시태그를 문자열로 변환 (# 포함)
*
* @return #으로 시작하는 해시태그 문자열
*/
public String getHashtagsAsString() {
if (hashtags == null || hashtags.isEmpty()) {
return "";
}
return hashtags.stream()
.map(tag -> "#" + tag)
.reduce((a, b) -> a + " " + b)
.orElse("");
}
/**
* 콘텐츠 요약 생성
*
* @param maxLength 최대 길이
* @return 요약된 콘텐츠
*/
public String getContentSummary(int maxLength) {
if (content == null || content.length() <= maxLength) {
return content;
}
return content.substring(0, maxLength) + "...";
}
/**
* 플랫폼별 최적화 제안사항 반환
*
* @return 최적화 제안사항 목록
*/
public List<String> getOptimizationSuggestions() {
List<String> suggestions = new java.util.ArrayList<>();
if (!isOptimizedForPlatform()) {
switch (platform.toUpperCase()) {
case "INSTAGRAM":
if (content != null && content.length() > 2200) {
suggestions.add("콘텐츠 길이를 2200자 이하로 줄여주세요.");
}
if (hashtags != null && hashtags.size() > 15) {
suggestions.add("해시태그를 15개 이하로 줄여주세요.");
}
if (hashtags != null && hashtags.size() < 5) {
suggestions.add("해시태그를 5개 이상 추가해주세요.");
}
break;
case "NAVER_BLOG":
if (content != null && content.length() < 300) {
suggestions.add("블로그 포스팅을 위해 내용을 300자 이상으로 늘려주세요.");
}
if (hashtags != null && hashtags.size() > 10) {
suggestions.add("네이버 블로그는 해시태그를 10개 이하로 사용하는 것이 좋습니다.");
}
break;
case "FACEBOOK":
if (content != null && content.length() > 500) {
suggestions.add("페이스북에서는 500자 이하의 짧은 글이 더 효과적입니다.");
}
break;
}
}
return suggestions;
}
// ==================== 팩토리 메서드 ====================
/**
* 도메인 엔티티에서 SnsContentCreateResponse 생성
*
* @param content 콘텐츠 도메인 엔티티
* @param aiMetadata AI 생성 메타데이터
* @return SnsContentCreateResponse
*/
public static SnsContentCreateResponse fromDomain(
com.won.smarketing.content.domain.model.Content content,
AiGenerationMetadata aiMetadata) {
SnsContentCreateResponseBuilder builder = SnsContentCreateResponse.builder()
.contentId(content.getId())
.contentType(content.getContentType().name())
.platform(content.getPlatform().name())
.title(content.getTitle())
.content(content.getContent())
.hashtags(content.getHashtags())
.status(content.getStatus().name())
.createdAt(content.getCreatedAt())
.originalImages(content.getImages());
// 생성 조건 정보 설정
if (content.getCreationConditions() != null) {
builder.generationConditions(GenerationConditionsDto.builder()
//.targetAudience(content.getCreationConditions().getTargetAudience())
.eventName(content.getCreationConditions().getEventName())
.toneAndManner(content.getCreationConditions().getToneAndManner())
.promotionType(content.getCreationConditions().getPromotionType())
.emotionIntensity(content.getCreationConditions().getEmotionIntensity())
.promotionStartDate(content.getPromotionStartDate())
.promotionEndDate(content.getPromotionEndDate())
.build());
}
// AI 메타데이터 설정
if (aiMetadata != null) {
builder.aiModelVersion(aiMetadata.getModelVersion())
.generationTimeSeconds(aiMetadata.getGenerationTime())
.qualityScore(aiMetadata.getQualityScore())
.expectedEngagementRate(aiMetadata.getExpectedEngagementRate())
.alternativeTitles(aiMetadata.getAlternativeTitles())
.alternativeHashtagSets(aiMetadata.getAlternativeHashtagSets());
}
// 플랫폼별 최적화 정보 설정
SnsContentCreateResponse response = builder.build();
response.setContentLength(response.getContent() != null ? response.getContent().length() : 0);
response.setRecommendedHashtagCount(getRecommendedHashtagCount(content.getPlatform().name()));
response.setMaxHashtagCount(getMaxHashtagCount(content.getPlatform().name()));
return response;
}
/**
* 플랫폼별 권장 해시태그 개수 반환
*/
private static Integer getRecommendedHashtagCount(String platform) {
switch (platform.toUpperCase()) {
case "INSTAGRAM": return 8;
case "NAVER_BLOG": return 5;
case "FACEBOOK": return 3;
case "KAKAO_STORY": return 5;
default: return 5;
}
}
/**
* 플랫폼별 최대 해시태그 개수 반환
*/
private static Integer getMaxHashtagCount(String platform) {
switch (platform.toUpperCase()) {
case "INSTAGRAM": return 15;
case "NAVER_BLOG": return 10;
case "FACEBOOK": return 5;
case "KAKAO_STORY": return 8;
default: return 10;
}
}
// ==================== AI 생성 메타데이터 DTO ====================
/**
* AI 생성 메타데이터
* AI 생성 과정에서 나온 부가 정보들
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class AiGenerationMetadata {
private String modelVersion;
private Double generationTime;
private Integer qualityScore;
private Double expectedEngagementRate;
private List<String> alternativeTitles;
private List<List<String>> alternativeHashtagSets;
private String category;
}
}
@@ -0,0 +1,79 @@
// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
* SNS 콘텐츠 저장 요청 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "SNS 콘텐츠 저장 요청")
public class SnsContentSaveRequest {
@Schema(description = "콘텐츠 ID", example = "1", required = true)
@NotNull(message = "콘텐츠 ID는 필수입니다")
private Long contentId;
@Schema(description = "매장 ID", example = "1", required = true)
@NotNull(message = "매장 ID는 필수입니다")
private Long storeId;
@Schema(description = "플랫폼", example = "INSTAGRAM", required = true)
@NotBlank(message = "플랫폼은 필수입니다")
private String platform;
@Schema(description = "제목", example = "맛있는 신메뉴를 소개합니다!")
private String title;
@Schema(description = "콘텐츠 내용")
private String content;
@Schema(description = "해시태그 목록")
private List<String> hashtags;
@Schema(description = "이미지 URL 목록")
private List<String> images;
@Schema(description = "최종 제목", example = "맛있는 신메뉴를 소개합니다!")
private String finalTitle;
@Schema(description = "최종 콘텐츠 내용")
private String finalContent;
@Schema(description = "발행 상태", example = "PUBLISHED")
private String status;
// CreationConditions에 필요한 필드들
@Schema(description = "콘텐츠 카테고리", example = "메뉴소개")
private String category;
@Schema(description = "구체적인 요구사항", example = "새로 출시된 시그니처 버거를 홍보하고 싶어요")
private String requirement;
@Schema(description = "톤앤매너", example = "친근함")
private String toneAndManner;
@Schema(description = "감정 강도", example = "보통")
private String emotionIntensity;
@Schema(description = "이벤트명", example = "신메뉴 출시 이벤트")
private String eventName;
@Schema(description = "이벤트 시작일", example = "2024-01-15")
private LocalDate startDate;
@Schema(description = "이벤트 종료일", example = "2024-01-31")
private LocalDate endDate;
}
@@ -0,0 +1,33 @@
server:
port: ${SERVER_PORT:8083}
spring:
application:
name: marketing-content-service
datasource:
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:MarketingContentDB}
username: ${POSTGRES_USER:postgres}
password: ${POSTGRES_PASSWORD:postgres}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: ${DDL_AUTO:update}
show-sql: ${SHOW_SQL:true}
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
jwt:
secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789}
access-token-validity: ${JWT_ACCESS_VALIDITY:3600000}
refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000}
logging:
level:
com.won.smarketing: ${LOG_LEVEL:DEBUG}