diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index b7b3d1b..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# 디폴트 무시된 파일 -/shelf/ -/workspace.xml -# 환경에 따라 달라지는 Maven 홈 디렉터리 -/mavenHomeManager.xml diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index 9018a0d..0000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 6ed36dd..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..fc7acb6 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + "customColor": "", + "associatedIndex": 4 +} + + + + + + + + + + + + + + + true + true + false + false + + + + + + + 1749618504890 + + + + \ No newline at end of file diff --git a/smarketing-ai/app.py b/smarketing-ai/app.py index 35cf56c..ebe2175 100644 --- a/smarketing-ai/app.py +++ b/smarketing-ai/app.py @@ -9,10 +9,8 @@ import os from datetime import datetime import traceback from config.config import Config -# from services.content_service import ContentService from services.poster_service import PosterService from services.sns_content_service import SnsContentService -# from services.poster_generation_service import PosterGenerationService from models.request_models import ContentRequest, PosterRequest, SnsContentGetRequest, PosterContentGetRequest @@ -75,6 +73,7 @@ def create_app(): requirement=data.get('requirement'), toneAndManner=data.get('toneAndManner'), emotionIntensity=data.get('emotionIntensity'), + menuName=data.get('menuName'), eventName=data.get('eventName'), startDate=data.get('startDate'), endDate=data.get('endDate') @@ -124,6 +123,7 @@ def create_app(): requirement=data.get('requirement'), toneAndManner=data.get('toneAndManner'), emotionIntensity=data.get('emotionIntensity'), + menuName=data.get('menuName'), eventName=data.get('eventName'), startDate=data.get('startDate'), endDate=data.get('endDate') diff --git a/smarketing-ai/models/request_models.py b/smarketing-ai/models/request_models.py index 8816533..b47b257 100644 --- a/smarketing-ai/models/request_models.py +++ b/smarketing-ai/models/request_models.py @@ -17,6 +17,7 @@ class SnsContentGetRequest: requirement: Optional[str] = None toneAndManner: Optional[str] = None emotionIntensity: Optional[str] = None + menuName: Optional[str] = None eventName: Optional[str] = None startDate: Optional[str] = None endDate: Optional[str] = None @@ -33,6 +34,7 @@ class PosterContentGetRequest: requirement: Optional[str] = None toneAndManner: Optional[str] = None emotionIntensity: Optional[str] = None + menuName: Optional[str] = None eventName: Optional[str] = None startDate: Optional[str] = None endDate: Optional[str] = None diff --git a/smarketing-ai/services/sns_content_service.py b/smarketing-ai/services/sns_content_service.py index e5090e0..fc80913 100644 --- a/smarketing-ai/services/sns_content_service.py +++ b/smarketing-ai/services/sns_content_service.py @@ -205,6 +205,9 @@ class SnsContentService: """ metadata_html = '
' + if request.menuName: + metadata_html += f'
메뉴: {request.menuName}
' + if request.eventName: metadata_html += f'
이벤트: {request.eventName}
' diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java index 50d69a1..537a189 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java @@ -3,6 +3,7 @@ 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; /** @@ -17,8 +18,9 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories; "com.won.smarketing.content.infrastructure.repository" }) @EntityScan(basePackages = { - "com.won.smarketing.content.domain.model" + "com.won.smarketing.content.infrastructure.entity" }) +@EnableJpaAuditing public class MarketingContentServiceApplication { public static void main(String[] args) { diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java index fec5d4e..dd8e603 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java @@ -42,7 +42,7 @@ public class SnsContentService implements SnsContentUseCase { public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request) { // AI를 사용하여 SNS 콘텐츠 생성 String generatedContent = aiContentGenerator.generateSnsContent(request); - + // 플랫폼에 맞는 해시태그 생성 Platform platform = Platform.fromString(request.getPlatform()); List hashtags = aiContentGenerator.generateHashtags(generatedContent, platform); @@ -60,7 +60,7 @@ public class SnsContentService implements SnsContentUseCase { // 임시 콘텐츠 생성 (저장하지 않음) Content content = Content.builder() - .contentType(ContentType.SNS_POST) +// .contentType(ContentType.SNS_POST) .platform(platform) .title(request.getTitle()) .content(generatedContent) @@ -88,7 +88,7 @@ public class SnsContentService implements SnsContentUseCase { /** * SNS 콘텐츠 저장 - * + * * @param request SNS 콘텐츠 저장 요청 */ @Override @@ -107,7 +107,7 @@ public class SnsContentService implements SnsContentUseCase { // 콘텐츠 엔티티 생성 및 저장 Content content = Content.builder() - .contentType(ContentType.SNS_POST) +// .contentType(ContentType.SNS_POST) .platform(Platform.fromString(request.getPlatform())) .title(request.getTitle()) .content(request.getContent()) diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java index 973b234..6bf2960 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java @@ -1,3 +1,4 @@ +// 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; @@ -5,23 +6,21 @@ import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse; import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest; /** - * 포스터 콘텐츠 관련 Use Case 인터페이스 - * 홍보 포스터 생성 및 저장 기능 정의 + * 포스터 콘텐츠 관련 UseCase 인터페이스 + * Clean Architecture의 Application Layer에서 비즈니스 로직 정의 */ public interface PosterContentUseCase { - + /** * 포스터 콘텐츠 생성 - * * @param request 포스터 콘텐츠 생성 요청 - * @return 생성된 포스터 콘텐츠 정보 + * @return 포스터 콘텐츠 생성 응답 */ PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request); - + /** * 포스터 콘텐츠 저장 - * * @param request 포스터 콘텐츠 저장 요청 */ void savePosterContent(PosterContentSaveRequest request); -} +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java index e62902d..d2c6751 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java @@ -1,3 +1,4 @@ +// 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; @@ -5,23 +6,21 @@ import com.won.smarketing.content.presentation.dto.SnsContentCreateResponse; import com.won.smarketing.content.presentation.dto.SnsContentSaveRequest; /** - * SNS 콘텐츠 관련 Use Case 인터페이스 - * SNS 게시물 생성 및 저장 기능 정의 + * SNS 콘텐츠 관련 UseCase 인터페이스 + * Clean Architecture의 Application Layer에서 비즈니스 로직 정의 */ public interface SnsContentUseCase { - + /** * SNS 콘텐츠 생성 - * * @param request SNS 콘텐츠 생성 요청 - * @return 생성된 SNS 콘텐츠 정보 + * @return SNS 콘텐츠 생성 응답 */ SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request); - + /** * SNS 콘텐츠 저장 - * * @param request SNS 콘텐츠 저장 요청 */ void saveSnsContent(SnsContentSaveRequest request); -} +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ContentConfig.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ContentConfig.java new file mode 100644 index 0000000..3931d19 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ContentConfig.java @@ -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 { +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/JpaConfig.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/JpaConfig.java deleted file mode 100644 index e95312d..0000000 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/JpaConfig.java +++ /dev/null @@ -1,18 +0,0 @@ -// marketing-content/src/main/java/com/won/smarketing/content/config/JpaConfig.java -package com.won.smarketing.content.config; - -import org.springframework.boot.autoconfigure.domain.EntityScan; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.jpa.repository.config.EnableJpaRepositories; - -/** - * JPA 설정 클래스 - * - * @author smarketing-team - * @version 1.0 - */ -@Configuration -@EntityScan(basePackages = "com.won.smarketing.content.infrastructure.entity") -@EnableJpaRepositories(basePackages = "com.won.smarketing.content.infrastructure.repository") -public class JpaConfig { -} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java index 4e95d02..9a19b77 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java @@ -1,14 +1,10 @@ // marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java package com.won.smarketing.content.domain.model; -import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.annotation.LastModifiedDate; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; import java.util.ArrayList; @@ -17,616 +13,151 @@ import java.util.List; /** * 콘텐츠 도메인 모델 * - * 이 클래스는 마케팅 콘텐츠의 핵심 정보와 비즈니스 로직을 포함하는 - * DDD(Domain-Driven Design) 엔티티입니다. - * - * Clean Architecture의 Domain Layer에 위치하며, - * 비즈니스 규칙과 도메인 로직을 캡슐화합니다. + * Clean Architecture의 Domain Layer에 위치하는 핵심 엔티티 + * JPA 애노테이션을 제거하여 순수 도메인 모델로 유지 + * Infrastructure Layer에서 별도의 JPA 엔티티로 매핑 */ -@Entity -@Table( - name = "contents", - indexes = { - @Index(name = "idx_store_id", columnList = "store_id"), - @Index(name = "idx_content_type", columnList = "content_type"), - @Index(name = "idx_platform", columnList = "platform"), - @Index(name = "idx_status", columnList = "status"), - @Index(name = "idx_promotion_dates", columnList = "promotion_start_date, promotion_end_date"), - @Index(name = "idx_created_at", columnList = "created_at") - } -) @Getter @NoArgsConstructor @AllArgsConstructor @Builder -@EntityListeners(AuditingEntityListener.class) public class Content { // ==================== 기본키 및 식별자 ==================== - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "content_id") private Long id; // ==================== 콘텐츠 분류 ==================== - - @Enumerated(EnumType.STRING) - @Column(name = "content_type", nullable = false, length = 20) private ContentType contentType; - - @Enumerated(EnumType.STRING) - @Column(name = "platform", nullable = false, length = 20) private Platform platform; // ==================== 콘텐츠 내용 ==================== - - @Column(name = "title", nullable = false, length = 200) private String title; - - @Column(name = "content", nullable = false, columnDefinition = "TEXT") private String content; // ==================== 멀티미디어 및 메타데이터 ==================== - - @ElementCollection(fetch = FetchType.LAZY) - @CollectionTable( - name = "content_hashtags", - joinColumns = @JoinColumn(name = "content_id"), - indexes = @Index(name = "idx_content_hashtags", columnList = "content_id") - ) - @Column(name = "hashtag", length = 100) @Builder.Default private List hashtags = new ArrayList<>(); - @ElementCollection(fetch = FetchType.LAZY) - @CollectionTable( - name = "content_images", - joinColumns = @JoinColumn(name = "content_id"), - indexes = @Index(name = "idx_content_images", columnList = "content_id") - ) - @Column(name = "image_url", length = 500) @Builder.Default private List images = new ArrayList<>(); // ==================== 상태 관리 ==================== + private ContentStatus status; - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false, length = 20) - @Builder.Default - private ContentStatus status = ContentStatus.DRAFT; - - // ==================== AI 생성 조건 (Embedded) ==================== - - @Embedded - @AttributeOverrides({ - @AttributeOverride(name = "toneAndManner", column = @Column(name = "tone_and_manner", length = 50)), - @AttributeOverride(name = "promotionType", column = @Column(name = "promotion_type", length = 50)), - @AttributeOverride(name = "emotionIntensity", column = @Column(name = "emotion_intensity", length = 50)), - @AttributeOverride(name = "targetAudience", column = @Column(name = "target_audience", length = 50)), - @AttributeOverride(name = "eventName", column = @Column(name = "event_name", length = 100)) - }) + // ==================== 생성 조건 ==================== private CreationConditions creationConditions; - // ==================== 비즈니스 관계 ==================== - - @Column(name = "store_id", nullable = false) + // ==================== 매장 정보 ==================== private Long storeId; - // ==================== 홍보 기간 ==================== - - @Column(name = "promotion_start_date") + // ==================== 프로모션 기간 ==================== private LocalDateTime promotionStartDate; - - @Column(name = "promotion_end_date") private LocalDateTime promotionEndDate; - // ==================== 감사(Audit) 정보 ==================== - - @CreatedDate - @Column(name = "created_at", nullable = false, updatable = false) + // ==================== 메타데이터 ==================== private LocalDateTime createdAt; - - @LastModifiedDate - @Column(name = "updated_at") private LocalDateTime updatedAt; public Content(ContentId of, ContentType contentType, Platform platform, String title, String content, List strings, List strings1, ContentStatus contentStatus, CreationConditions conditions, Long storeId, LocalDateTime createdAt, LocalDateTime updatedAt) { } - // ==================== 비즈니스 로직 메서드 ==================== + // ==================== 비즈니스 메서드 ==================== /** * 콘텐츠 제목 수정 - * - * 비즈니스 규칙: - * - 제목은 null이거나 빈 값일 수 없음 - * - 200자를 초과할 수 없음 - * - 발행된 콘텐츠는 제목 변경 시 상태가 DRAFT로 변경됨 - * - * @param title 새로운 제목 - * @throws IllegalArgumentException 제목이 유효하지 않은 경우 + * @param newTitle 새로운 제목 */ - public void updateTitle(String title) { - validateTitle(title); - - boolean wasPublished = isPublished(); - this.title = title.trim(); - - // 발행된 콘텐츠의 제목이 변경되면 재검토 필요 - if (wasPublished) { - this.status = ContentStatus.DRAFT; + public void updateTitle(String newTitle) { + if (newTitle == null || newTitle.trim().isEmpty()) { + throw new IllegalArgumentException("제목은 필수입니다."); } + this.title = newTitle.trim(); + this.updatedAt = LocalDateTime.now(); } /** * 콘텐츠 내용 수정 - * - * 비즈니스 규칙: - * - 내용은 null이거나 빈 값일 수 없음 - * - 발행된 콘텐츠는 내용 변경 시 상태가 DRAFT로 변경됨 - * - * @param content 새로운 콘텐츠 내용 - * @throws IllegalArgumentException 내용이 유효하지 않은 경우 + * @param newContent 새로운 내용 */ - public void updateContent(String content) { - validateContent(content); + public void updateContent(String newContent) { + this.content = newContent; + this.updatedAt = LocalDateTime.now(); + } - boolean wasPublished = isPublished(); - this.content = content.trim(); - - // 발행된 콘텐츠의 내용이 변경되면 재검토 필요 - if (wasPublished) { - this.status = ContentStatus.DRAFT; + /** + * 프로모션 기간 설정 + * @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(); } /** * 콘텐츠 상태 변경 - * - * 비즈니스 규칙: - * - PUBLISHED 상태로 변경시 유효성 검증 수행 - * - ARCHIVED 상태에서는 PUBLISHED로만 변경 가능 - * - * @param status 새로운 상태 - * @throws IllegalStateException 잘못된 상태 전환인 경우 + * @param newStatus 새로운 상태 */ - public void changeStatus(ContentStatus status) { - validateStatusTransition(this.status, status); - - if (status == ContentStatus.PUBLISHED) { - validateForPublication(); + public void updateStatus(ContentStatus newStatus) { + if (newStatus == null) { + throw new IllegalArgumentException("상태는 필수입니다."); } - - this.status = status; - } - - /** - * 홍보 기간 설정 - * - * 비즈니스 규칙: - * - 시작일은 종료일보다 이전이어야 함 - * - 과거 날짜로 설정 불가 (현재 시간 기준) - * - * @param startDate 홍보 시작일 - * @param endDate 홍보 종료일 - * @throws IllegalArgumentException 날짜가 유효하지 않은 경우 - */ - public void setPromotionPeriod(LocalDateTime startDate, LocalDateTime endDate) { - validatePromotionPeriod(startDate, endDate); - - this.promotionStartDate = startDate; - this.promotionEndDate = endDate; - } - - /** - * 홍보 기간 설정 - * - * 비즈니스 규칙: - * - 시작일은 종료일보다 이전이어야 함 - * - 과거 날짜로 설정 불가 (현재 시간 기준) - * - * @param startDate 홍보 시작일 - * @param endDate 홍보 종료일 - * @throws IllegalArgumentException 날짜가 유효하지 않은 경우 - */ - public void updatePeriod(LocalDateTime startDate, LocalDateTime endDate) { - validatePromotionPeriod(startDate, endDate); - - this.promotionStartDate = startDate; - this.promotionEndDate = endDate; + this.status = newStatus; + this.updatedAt = LocalDateTime.now(); } /** * 해시태그 추가 - * - * @param hashtag 추가할 해시태그 (# 없이) + * @param hashtag 추가할 해시태그 */ public void addHashtag(String hashtag) { if (hashtag != null && !hashtag.trim().isEmpty()) { - String cleanHashtag = hashtag.trim().replace("#", ""); - if (!this.hashtags.contains(cleanHashtag)) { - this.hashtags.add(cleanHashtag); + if (this.hashtags == null) { + this.hashtags = new ArrayList<>(); } - } - } - - /** - * 해시태그 제거 - * - * @param hashtag 제거할 해시태그 - */ - public void removeHashtag(String hashtag) { - if (hashtag != null) { - String cleanHashtag = hashtag.trim().replace("#", ""); - this.hashtags.remove(cleanHashtag); + this.hashtags.add(hashtag.trim()); + this.updatedAt = LocalDateTime.now(); } } /** * 이미지 추가 - * - * @param imageUrl 이미지 URL + * @param imageUrl 추가할 이미지 URL */ public void addImage(String imageUrl) { if (imageUrl != null && !imageUrl.trim().isEmpty()) { - if (!this.images.contains(imageUrl.trim())) { - this.images.add(imageUrl.trim()); + if (this.images == null) { + this.images = new ArrayList<>(); } + this.images.add(imageUrl.trim()); + this.updatedAt = LocalDateTime.now(); } } /** - * 이미지 제거 - * - * @param imageUrl 제거할 이미지 URL + * 프로모션 진행 중 여부 확인 + * @return 현재 시간이 프로모션 기간 내에 있으면 true */ - public void removeImage(String imageUrl) { - if (imageUrl != null) { - this.images.remove(imageUrl.trim()); - } - } - - // ==================== 도메인 조회 메서드 ==================== - - /** - * 발행 상태 확인 - * - * @return 발행된 상태이면 true - */ - public boolean isPublished() { - return this.status == ContentStatus.PUBLISHED; - } - - /** - * 수정 가능 상태 확인 - * - * @return 임시저장 또는 예약발행 상태이면 true - */ - public boolean isEditable() { - return this.status == ContentStatus.DRAFT || this.status == ContentStatus.PUBLISHED; - } - - /** - * 현재 홍보 진행 중인지 확인 - * - * @return 홍보 기간 내이고 발행 상태이면 true - */ - public boolean isOngoingPromotion() { - if (!isPublished() || promotionStartDate == null || promotionEndDate == null) { - return false; - } - - LocalDateTime now = LocalDateTime.now(); - return now.isAfter(promotionStartDate) && now.isBefore(promotionEndDate); - } - - /** - * 홍보 예정 상태 확인 - * - * @return 홍보 시작 전이면 true - */ - public boolean isUpcomingPromotion() { - if (promotionStartDate == null) { - return false; - } - - return LocalDateTime.now().isBefore(promotionStartDate); - } - - /** - * 홍보 완료 상태 확인 - * - * @return 홍보 종료 후이면 true - */ - public boolean isCompletedPromotion() { - if (promotionEndDate == null) { - return false; - } - - return LocalDateTime.now().isAfter(promotionEndDate); - } - - /** - * SNS 콘텐츠 여부 확인 - * - * @return SNS 게시물이면 true - */ - public boolean isSnsContent() { - return this.contentType == ContentType.SNS_POST; - } - - /** - * 포스터 콘텐츠 여부 확인 - * - * @return 포스터이면 true - */ - public boolean isPosterContent() { - return this.contentType == ContentType.POSTER; - } - - /** - * 이미지가 있는 콘텐츠인지 확인 - * - * @return 이미지가 1개 이상 있으면 true - */ - public boolean hasImages() { - return this.images != null && !this.images.isEmpty(); - } - - /** - * 해시태그가 있는 콘텐츠인지 확인 - * - * @return 해시태그가 1개 이상 있으면 true - */ - public boolean hasHashtags() { - return this.hashtags != null && !this.hashtags.isEmpty(); - } - - // ==================== 유효성 검증 메서드 ==================== - - /** - * 제목 유효성 검증 - */ - private void validateTitle(String title) { - if (title == null || title.trim().isEmpty()) { - throw new IllegalArgumentException("제목은 필수 입력 사항입니다."); - } - if (title.trim().length() > 200) { - throw new IllegalArgumentException("제목은 200자를 초과할 수 없습니다."); - } - } - - /** - * 내용 유효성 검증 - */ - private void validateContent(String content) { - if (content == null || content.trim().isEmpty()) { - throw new IllegalArgumentException("콘텐츠 내용은 필수 입력 사항입니다."); - } - } - - /** - * 홍보 기간 유효성 검증 - */ - private void validatePromotionPeriod(LocalDateTime startDate, LocalDateTime endDate) { - if (startDate == null || endDate == null) { - throw new IllegalArgumentException("홍보 시작일과 종료일은 필수 입력 사항입니다."); - } - if (startDate.isAfter(endDate)) { - throw new IllegalArgumentException("홍보 시작일은 종료일보다 이전이어야 합니다."); - } - if (endDate.isBefore(LocalDateTime.now())) { - throw new IllegalArgumentException("홍보 종료일은 현재 시간 이후여야 합니다."); - } - } - - /** - * 상태 전환 유효성 검증 - */ - private void validateStatusTransition(ContentStatus from, ContentStatus to) { - if (from == ContentStatus.ARCHIVED && to != ContentStatus.PUBLISHED) { - throw new IllegalStateException("보관된 콘텐츠는 발행 상태로만 변경할 수 있습니다."); - } - } - - /** - * 발행을 위한 유효성 검증 - */ - private void validateForPublication() { - validateTitle(this.title); - validateContent(this.content); - - if (this.promotionStartDate == null || this.promotionEndDate == null) { - throw new IllegalStateException("발행하려면 홍보 기간을 설정해야 합니다."); - } - - if (this.contentType == ContentType.POSTER && !hasImages()) { - throw new IllegalStateException("포스터 콘텐츠는 이미지가 필수입니다."); - } - } - - // ==================== 비즈니스 계산 메서드 ==================== - - /** - * 홍보 진행률 계산 (0-100%) - * - * @return 진행률 - */ - public double calculateProgress() { + public boolean isPromotionActive() { if (promotionStartDate == null || promotionEndDate == null) { - return 0.0; + return false; } - LocalDateTime now = LocalDateTime.now(); - - if (now.isBefore(promotionStartDate)) { - return 0.0; - } else if (now.isAfter(promotionEndDate)) { - return 100.0; - } - - long totalDuration = java.time.Duration.between(promotionStartDate, promotionEndDate).toHours(); - long elapsedDuration = java.time.Duration.between(promotionStartDate, now).toHours(); - - if (totalDuration == 0) { - return 100.0; - } - - return (double) elapsedDuration / totalDuration * 100.0; + return !now.isBefore(promotionStartDate) && !now.isAfter(promotionEndDate); } /** - * 남은 홍보 일수 계산 - * - * @return 남은 일수 (음수면 0) + * 콘텐츠 게시 가능 여부 확인 + * @return 필수 정보가 모두 입력되어 있으면 true */ - public long calculateRemainingDays() { - if (promotionEndDate == null) { - return 0L; - } - - LocalDateTime now = LocalDateTime.now(); - if (now.isAfter(promotionEndDate)) { - return 0L; - } - - return java.time.Duration.between(now, promotionEndDate).toDays(); + public boolean canBePublished() { + return title != null && !title.trim().isEmpty() + && contentType != null + && platform != null + && storeId != null; } - - // ==================== 팩토리 메서드 ==================== - - /** - * SNS 콘텐츠 생성 팩토리 메서드 - */ - public static Content createSnsContent(String title, String content, Platform platform, - Long storeId, CreationConditions conditions) { - Content snsContent = Content.builder() - .contentType(ContentType.SNS_POST) - .platform(platform) - .title(title) - .content(content) - .storeId(storeId) - .creationConditions(conditions) - .status(ContentStatus.DRAFT) - .hashtags(new ArrayList<>()) - .images(new ArrayList<>()) - .build(); - - // 유효성 검증 - snsContent.validateTitle(title); - snsContent.validateContent(content); - - return snsContent; - } - - /** - * 포스터 콘텐츠 생성 팩토리 메서드 - */ - public static Content createPosterContent(String title, String content, List images, - Long storeId, CreationConditions conditions) { - if (images == null || images.isEmpty()) { - throw new IllegalArgumentException("포스터 콘텐츠는 이미지가 필수입니다."); - } - - Content posterContent = Content.builder() - .contentType(ContentType.POSTER) - .platform(Platform.INSTAGRAM) // 기본값 - .title(title) - .content(content) - .storeId(storeId) - .creationConditions(conditions) - .status(ContentStatus.DRAFT) - .hashtags(new ArrayList<>()) - .images(new ArrayList<>(images)) - .build(); - - // 유효성 검증 - posterContent.validateTitle(title); - posterContent.validateContent(content); - - return posterContent; - } - - // ==================== Object 메서드 오버라이드 ==================== - - /** - * 비즈니스 키 기반 동등성 비교 - * JPA 엔티티에서는 ID가 아닌 비즈니스 키 사용 권장 - */ - @Override - public boolean equals(Object obj) { - if (this == obj) return true; - if (obj == null || getClass() != obj.getClass()) return false; - - Content content = (Content) obj; - return id != null && id.equals(content.id); - } - - @Override - public int hashCode() { - return getClass().hashCode(); - } - - /** - * 디버깅용 toString (민감한 정보 제외) - */ - @Override - public String toString() { - return "Content{" + - "id=" + id + - ", contentType=" + contentType + - ", platform=" + platform + - ", title='" + title + '\'' + - ", status=" + status + - ", storeId=" + storeId + - ", promotionStartDate=" + promotionStartDate + - ", promotionEndDate=" + promotionEndDate + - ", createdAt=" + createdAt + - '}'; - } -} - -/* -==================== 데이터베이스 스키마 (참고용) ==================== - -CREATE TABLE contents ( - content_id BIGINT NOT NULL AUTO_INCREMENT, - content_type VARCHAR(20) NOT NULL, - platform VARCHAR(20) NOT NULL, - title VARCHAR(200) NOT NULL, - content TEXT NOT NULL, - status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', - tone_and_manner VARCHAR(50), - promotion_type VARCHAR(50), - emotion_intensity VARCHAR(50), - target_audience VARCHAR(50), - event_name VARCHAR(100), - store_id BIGINT NOT NULL, - promotion_start_date DATETIME, - promotion_end_date DATETIME, - created_at DATETIME NOT NULL, - updated_at DATETIME, - PRIMARY KEY (content_id), - INDEX idx_store_id (store_id), - INDEX idx_content_type (content_type), - INDEX idx_platform (platform), - INDEX idx_status (status), - INDEX idx_promotion_dates (promotion_start_date, promotion_end_date), - INDEX idx_created_at (created_at) -); - -CREATE TABLE content_hashtags ( - content_id BIGINT NOT NULL, - hashtag VARCHAR(100) NOT NULL, - INDEX idx_content_hashtags (content_id), - FOREIGN KEY (content_id) REFERENCES contents(content_id) ON DELETE CASCADE -); - -CREATE TABLE content_images ( - content_id BIGINT NOT NULL, - image_url VARCHAR(500) NOT NULL, - INDEX idx_content_images (content_id), - FOREIGN KEY (content_id) REFERENCES contents(content_id) ON DELETE CASCADE -); -*/ \ No newline at end of file +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java index 13bb3b0..2f07e2c 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java @@ -1,24 +1,24 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java package com.won.smarketing.content.domain.model; -import lombok.*; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; /** - * 콘텐츠 식별자 값 객체 - * 콘텐츠의 고유 식별자를 나타내는 도메인 객체 + * 콘텐츠 ID 값 객체 + * Clean Architecture의 Domain Layer에서 식별자를 타입 안전하게 관리 */ @Getter -@Setter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) +@RequiredArgsConstructor @EqualsAndHashCode public class ContentId { - private Long value; + private final Long value; /** - * ContentId 생성 팩토리 메서드 - * - * @param value 식별자 값 + * Long 값으로부터 ContentId 생성 + * @param value ID 값 * @return ContentId 인스턴스 */ public static ContentId of(Long value) { @@ -27,4 +27,25 @@ public class 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 + '}'; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java index c40ec47..b235310 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java @@ -1,3 +1,4 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java package com.won.smarketing.content.domain.model; import lombok.Getter; @@ -5,35 +6,35 @@ import lombok.RequiredArgsConstructor; /** * 콘텐츠 상태 열거형 - * 콘텐츠의 생명주기 상태 정의 + * Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙 */ @Getter @RequiredArgsConstructor public enum ContentStatus { - + DRAFT("임시저장"), - PUBLISHED("발행됨"), - ARCHIVED("보관됨"); + PUBLISHED("게시됨"), + SCHEDULED("예약됨"), + DELETED("삭제됨"), + PROCESSING("처리중"); private final String displayName; /** * 문자열로부터 ContentStatus 변환 - * - * @param status 상태 문자열 - * @return ContentStatus + * @param value 문자열 값 + * @return ContentStatus enum + * @throws IllegalArgumentException 유효하지 않은 값인 경우 */ - public static ContentStatus fromString(String status) { - if (status == null) { - return DRAFT; + public static ContentStatus fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("ContentStatus 값은 null일 수 없습니다."); } - - for (ContentStatus s : ContentStatus.values()) { - if (s.name().equalsIgnoreCase(status)) { - return s; - } + + try { + return ContentStatus.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("유효하지 않은 ContentStatus 값입니다: " + value); } - - throw new IllegalArgumentException("알 수 없는 콘텐츠 상태: " + status); } -} +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java index dd91b91..f70228b 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java @@ -1,3 +1,4 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java package com.won.smarketing.content.domain.model; import lombok.Getter; @@ -5,34 +6,34 @@ import lombok.RequiredArgsConstructor; /** * 콘텐츠 타입 열거형 - * 지원되는 마케팅 콘텐츠 유형 정의 + * Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙 */ @Getter @RequiredArgsConstructor public enum ContentType { - - SNS_POST("SNS 게시물"), - POSTER("홍보 포스터"); + + SNS("SNS 게시물"), + POSTER("홍보 포스터"), + VIDEO("동영상"), + BLOG("블로그 포스트"); private final String displayName; /** * 문자열로부터 ContentType 변환 - * - * @param type 타입 문자열 - * @return ContentType + * @param value 문자열 값 + * @return ContentType enum + * @throws IllegalArgumentException 유효하지 않은 값인 경우 */ - public static ContentType fromString(String type) { - if (type == null) { - return null; + public static ContentType fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("ContentType 값은 null일 수 없습니다."); } - - for (ContentType contentType : ContentType.values()) { - if (contentType.name().equalsIgnoreCase(type)) { - return contentType; - } + + try { + return ContentType.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("유효하지 않은 ContentType 값입니다: " + value); } - - throw new IllegalArgumentException("알 수 없는 콘텐츠 타입: " + type); } -} +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java index cf3c04e..d7a9543 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java @@ -1,66 +1,58 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java package com.won.smarketing.content.domain.model; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; import java.time.LocalDate; /** * 콘텐츠 생성 조건 도메인 모델 - * AI 콘텐츠 생성 시 사용되는 조건 정보 + * Clean Architecture의 Domain Layer에 위치하는 값 객체 + * + * JPA 애노테이션을 제거하여 순수 도메인 모델로 유지 + * Infrastructure Layer의 JPA 엔티티는 별도로 관리 */ @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor @AllArgsConstructor -@Builder(toBuilder = true) +@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 targetAudience; - - /** - * 프로모션 타입 - */ 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; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java index acd6b33..66e266c 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java @@ -1,3 +1,4 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java package com.won.smarketing.content.domain.model; import lombok.Getter; @@ -5,35 +6,36 @@ import lombok.RequiredArgsConstructor; /** * 플랫폼 열거형 - * 콘텐츠가 게시될 플랫폼 정의 + * Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙 */ @Getter @RequiredArgsConstructor public enum Platform { - + INSTAGRAM("인스타그램"), NAVER_BLOG("네이버 블로그"), - GENERAL("범용"); + FACEBOOK("페이스북"), + KAKAO_STORY("카카오스토리"), + YOUTUBE("유튜브"), + GENERAL("일반"); private final String displayName; /** * 문자열로부터 Platform 변환 - * - * @param platform 플랫폼 문자열 - * @return Platform + * @param value 문자열 값 + * @return Platform enum + * @throws IllegalArgumentException 유효하지 않은 값인 경우 */ - public static Platform fromString(String platform) { - if (platform == null) { - return GENERAL; + public static Platform fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("Platform 값은 null일 수 없습니다."); } - - for (Platform p : Platform.values()) { - if (p.name().equalsIgnoreCase(platform)) { - return p; - } + + try { + return Platform.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("유효하지 않은 Platform 값입니다: " + value); } - - throw new IllegalArgumentException("알 수 없는 플랫폼: " + platform); } -} +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java index 818506f..a2bfc43 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java @@ -1,40 +1,36 @@ +// 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 org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; /** - * 콘텐츠 저장소 인터페이스 - * 콘텐츠 도메인의 데이터 접근 추상화 + * 콘텐츠 리포지토리 인터페이스 + * Clean Architecture의 Domain Layer에서 데이터 접근 정의 */ -@Repository public interface ContentRepository { - + /** * 콘텐츠 저장 - * * @param content 저장할 콘텐츠 * @return 저장된 콘텐츠 */ Content save(Content content); - + /** - * 콘텐츠 ID로 조회 - * + * ID로 콘텐츠 조회 * @param id 콘텐츠 ID - * @return 콘텐츠 (Optional) + * @return 조회된 콘텐츠 */ Optional findById(ContentId id); - + /** * 필터 조건으로 콘텐츠 목록 조회 - * * @param contentType 콘텐츠 타입 * @param platform 플랫폼 * @param period 기간 @@ -42,19 +38,17 @@ public interface ContentRepository { * @return 콘텐츠 목록 */ List findByFilters(ContentType contentType, Platform platform, String period, String sortBy); - + /** * 진행 중인 콘텐츠 목록 조회 - * * @param period 기간 * @return 진행 중인 콘텐츠 목록 */ List findOngoingContents(String period); - + /** - * 콘텐츠 삭제 - * + * ID로 콘텐츠 삭제 * @param id 삭제할 콘텐츠 ID */ void deleteById(ContentId id); -} +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/SpringDataContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/SpringDataContentRepository.java new file mode 100644 index 0000000..d3a6e42 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/SpringDataContentRepository.java @@ -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 { + + /** + * 매장별 콘텐츠 조회 + * + * @param storeId 매장 ID + * @return 콘텐츠 목록 + */ + List findByStoreId(Long storeId); + + /** + * 콘텐츠 타입별 조회 + * + * @param contentType 콘텐츠 타입 + * @return 콘텐츠 목록 + */ + List findByContentType(String contentType); + + /** + * 플랫폼별 조회 + * + * @param platform 플랫폼 + * @return 콘텐츠 목록 + */ + List findByPlatform(String platform); +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java index 17f49f8..b549b05 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java @@ -1,6 +1,8 @@ +// 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; @@ -8,24 +10,21 @@ import lombok.Setter; import java.time.LocalDate; /** - * 콘텐츠 조건 JPA 엔티티 - * - * @author smarketing-team - * @version 1.0 + * 콘텐츠 생성 조건 JPA 엔티티 + * Infrastructure Layer에서 데이터베이스 매핑을 담당 */ @Entity -@Table(name = "contents_conditions") +@Table(name = "content_conditions") @Getter @Setter -@NoArgsConstructor public class ContentConditionsJpaEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @OneToOne - @JoinColumn(name = "content_id") + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id", nullable = false) private ContentJpaEntity content; @Column(name = "category", length = 100) @@ -37,7 +36,7 @@ public class ContentConditionsJpaEntity { @Column(name = "tone_and_manner", length = 100) private String toneAndManner; - @Column(name = "emotion_intensity", length = 100) + @Column(name = "emotion_intensity", length = 50) private String emotionIntensity; @Column(name = "event_name", length = 200) @@ -52,9 +51,34 @@ public class ContentConditionsJpaEntity { @Column(name = "photo_style", length = 100) private String photoStyle; - @Column(name = "TargetAudience", length = 100) - private String targetAudience; + @Column(name = "promotion_type", length = 100) + private String promotionType; - @Column(name = "PromotionType", 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); + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentEntity.java new file mode 100644 index 0000000..ba941d4 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentEntity.java @@ -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; +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java index 7f87560..bcc8499 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java @@ -1,27 +1,25 @@ -// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java package com.won.smarketing.content.infrastructure.entity; import jakarta.persistence.*; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; +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.List; +import java.util.Date; /** * 콘텐츠 JPA 엔티티 - * - * @author smarketing-team - * @version 1.0 */ @Entity @Table(name = "contents") @Getter @Setter -@NoArgsConstructor +@EntityListeners(AuditingEntityListener.class) public class ContentJpaEntity { @Id @@ -40,27 +38,33 @@ public class ContentJpaEntity { @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 = "JSON") + @Column(name = "hashtags", columnDefinition = "TEXT") private String hashtags; - @Column(name = "images", columnDefinition = "JSON") + @Column(name = "images", columnDefinition = "TEXT") private String images; - @Column(name = "status", length = 50) + @Column(name = "status", nullable = false, length = 20) private String status; - @CreationTimestamp - @Column(name = "created_at") + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; - @UpdateTimestamp + @LastModifiedDate @Column(name = "updated_at") private LocalDateTime updatedAt; - // 연관 엔티티 + // CreationConditions와의 관계 - OneToOne으로 별도 엔티티로 관리 @OneToOne(mappedBy = "content", cascade = CascadeType.ALL, fetch = FetchType.LAZY) private ContentConditionsJpaEntity conditions; } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java new file mode 100644 index 0000000..b1d0e6d --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java @@ -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 generateHashtags(String content, Platform platform); +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java new file mode 100644 index 0000000..8bbe931 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java @@ -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 generatePosterSizes(String originalImage); +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java new file mode 100644 index 0000000..9d72f1f --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java @@ -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 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 generateDummyHashtags(Platform platform) { + if (platform == Platform.INSTAGRAM) { + return Arrays.asList("#맛집", "#데일리", "#소상공인", "#추천", "#인스타그램"); + } else { + return Arrays.asList("#맛집추천", "#블로그", "#리뷰", "#맛있는곳", "#소상공인응원"); + } + } + + private List generateFallbackHashtags() { + return Arrays.asList("#소상공인", "#마케팅", "#홍보"); + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java new file mode 100644 index 0000000..7495966 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java @@ -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 generatePosterSizes(String baseImage) { + Map 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"; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java index 49cc6b4..44fdb68 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java @@ -1,3 +1,4 @@ +// 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.*; @@ -14,6 +15,7 @@ import java.util.List; /** * 콘텐츠 도메인-엔티티 매퍼 + * Clean Architecture에서 Infrastructure Layer와 Domain Layer 간 변환 담당 * * @author smarketing-team * @version 1.0 @@ -26,7 +28,7 @@ public class ContentMapper { private final ObjectMapper objectMapper; /** - * 도메인 모델을 JPA 엔티티로 변환합니다. + * 도메인 모델을 JPA 엔티티로 변환 * * @param content 도메인 콘텐츠 * @return JPA 엔티티 @@ -37,32 +39,30 @@ public class ContentMapper { } ContentJpaEntity entity = new ContentJpaEntity(); + + // 기본 필드 매핑 if (content.getId() != null) { entity.setId(content.getId()); } entity.setStoreId(content.getStoreId()); - entity.setContentType(content.getContentType().name()); + 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.setHashtags(convertListToJson(content.getHashtags())); - entity.setImages(convertListToJson(content.getImages())); - entity.setStatus(content.getStatus().name()); + 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 = new ContentConditionsJpaEntity(); + ContentConditionsJpaEntity conditionsEntity = mapToConditionsEntity(content.getCreationConditions()); conditionsEntity.setContent(entity); - conditionsEntity.setCategory(content.getCreationConditions().getCategory()); - conditionsEntity.setRequirement(content.getCreationConditions().getRequirement()); - conditionsEntity.setToneAndManner(content.getCreationConditions().getToneAndManner()); - conditionsEntity.setEmotionIntensity(content.getCreationConditions().getEmotionIntensity()); - conditionsEntity.setEventName(content.getCreationConditions().getEventName()); - conditionsEntity.setStartDate(content.getCreationConditions().getStartDate()); - conditionsEntity.setEndDate(content.getCreationConditions().getEndDate()); - conditionsEntity.setPhotoStyle(content.getCreationConditions().getPhotoStyle()); entity.setConditions(conditionsEntity); } @@ -70,50 +70,74 @@ public class ContentMapper { } /** - * JPA 엔티티를 도메인 모델로 변환합니다. + * JPA 엔티티를 도메인 모델로 변환 * * @param entity JPA 엔티티 - * @return 도메인 콘텐츠 + * @return 도메인 모델 */ public Content toDomain(ContentJpaEntity entity) { if (entity == null) { return null; } - CreationConditions conditions = null; - if (entity.getConditions() != null) { - conditions = new CreationConditions( - entity.getConditions().getCategory(), - entity.getConditions().getRequirement(), - entity.getConditions().getToneAndManner(), - entity.getConditions().getEmotionIntensity(), - entity.getConditions().getEventName(), - entity.getConditions().getStartDate(), - entity.getConditions().getEndDate(), - entity.getConditions().getPhotoStyle(), - entity.getConditions().getTargetAudience(), - entity.getConditions().getPromotionType() - ); - } - - return new Content( - ContentId.of(entity.getId()), - ContentType.valueOf(entity.getContentType()), - entity.getPlatform() != null ? Platform.valueOf(entity.getPlatform()) : null, - entity.getTitle(), - entity.getContent(), - convertJsonToList(entity.getHashtags()), - convertJsonToList(entity.getImages()), - ContentStatus.valueOf(entity.getStatus()), - conditions, - entity.getStoreId(), - entity.getCreatedAt(), - entity.getUpdatedAt() - ); + 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(); } /** - * List를 JSON 문자열로 변환합니다. + * 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 list) { if (list == null || list.isEmpty()) { @@ -128,7 +152,7 @@ public class ContentMapper { } /** - * JSON 문자열을 List로 변환합니다. + * JSON 문자열을 List로 변환 */ private List convertJsonToList(String json) { if (json == null || json.trim().isEmpty()) { @@ -141,4 +165,49 @@ public class ContentMapper { 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; + } + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java index 9396d4d..f3f38ed 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java @@ -1,3 +1,4 @@ +// 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; @@ -16,66 +17,69 @@ import java.util.Optional; import java.util.stream.Collectors; /** - * JPA 기반 콘텐츠 Repository 구현체 - * - * @author smarketing-team - * @version 1.0 + * JPA를 활용한 콘텐츠 리포지토리 구현체 + * Clean Architecture의 Infrastructure Layer에 위치 + * JPA 엔티티와 도메인 모델 간 변환을 위해 ContentMapper 사용 */ @Repository @RequiredArgsConstructor @Slf4j public class JpaContentRepository implements ContentRepository { - private final SpringDataContentRepository springDataContentRepository; + private final JpaContentRepositoryInterface jpaRepository; private final ContentMapper contentMapper; /** - * 콘텐츠를 저장합니다. - * - * @param content 저장할 콘텐츠 - * @return 저장된 콘텐츠 + * 콘텐츠 저장 + * @param content 저장할 도메인 콘텐츠 + * @return 저장된 도메인 콘텐츠 */ @Override public Content save(Content content) { - log.debug("Saving content: {}", content.getId()); + log.debug("Saving content: {}", content.getTitle()); + + // 도메인 모델을 JPA 엔티티로 변환 ContentJpaEntity entity = contentMapper.toEntity(content); - ContentJpaEntity savedEntity = springDataContentRepository.save(entity); - return contentMapper.toDomain(savedEntity); + + // JPA로 저장 + ContentJpaEntity savedEntity = jpaRepository.save(entity); + + // JPA 엔티티를 도메인 모델로 변환하여 반환 + Content savedContent = contentMapper.toDomain(savedEntity); + + log.debug("Content saved with ID: {}", savedContent.getId()); + return savedContent; } /** - * ID로 콘텐츠를 조회합니다. - * + * ID로 콘텐츠 조회 * @param id 콘텐츠 ID - * @return 조회된 콘텐츠 + * @return 조회된 도메인 콘텐츠 */ @Override public Optional findById(ContentId id) { - log.debug("Finding content by id: {}", id.getValue()); - return springDataContentRepository.findById(id.getValue()) + 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 콘텐츠 목록 + * @param period 기간 (현재는 사용하지 않음) + * @param sortBy 정렬 기준 (현재는 사용하지 않음) + * @return 도메인 콘텐츠 목록 */ @Override public List findByFilters(ContentType contentType, Platform platform, String period, String sortBy) { - log.debug("Finding contents by filters - type: {}, platform: {}, period: {}, sortBy: {}", - contentType, platform, period, sortBy); + log.debug("Finding contents with filters - contentType: {}, platform: {}", contentType, platform); - List entities = springDataContentRepository.findByFilters( - contentType != null ? contentType.name() : null, - platform != null ? platform.name() : null, - period, - sortBy - ); + String contentTypeStr = contentType != null ? contentType.name() : null; + String platformStr = platform != null ? platform.name() : null; + + List entities = jpaRepository.findByFilters(contentTypeStr, platformStr, null); return entities.stream() .map(contentMapper::toDomain) @@ -83,15 +87,15 @@ public class JpaContentRepository implements ContentRepository { } /** - * 진행 중인 콘텐츠 목록을 조회합니다. - * - * @param period 기간 - * @return 진행 중인 콘텐츠 목록 + * 진행 중인 콘텐츠 목록 조회 + * @param period 기간 (현재는 사용하지 않음) + * @return 진행 중인 도메인 콘텐츠 목록 */ @Override public List findOngoingContents(String period) { - log.debug("Finding ongoing contents for period: {}", period); - List entities = springDataContentRepository.findOngoingContents(period); + log.debug("Finding ongoing contents"); + + List entities = jpaRepository.findOngoingContents(); return entities.stream() .map(contentMapper::toDomain) @@ -99,13 +103,45 @@ public class JpaContentRepository implements ContentRepository { } /** - * ID로 콘텐츠를 삭제합니다. - * - * @param id 콘텐츠 ID + * ID로 콘텐츠 삭제 + * @param id 삭제할 콘텐츠 ID */ @Override public void deleteById(ContentId id) { - log.debug("Deleting content by id: {}", id.getValue()); - springDataContentRepository.deleteById(id.getValue()); + log.debug("Deleting content by ID: {}", id.getValue()); + + jpaRepository.deleteById(id.getValue()); + + log.debug("Content deleted successfully"); + } + + /** + * 매장 ID로 콘텐츠 목록 조회 (추가 메서드) + * @param storeId 매장 ID + * @return 도메인 콘텐츠 목록 + */ + public List findByStoreId(Long storeId) { + log.debug("Finding contents by store ID: {}", storeId); + + List entities = jpaRepository.findByStoreId(storeId); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); + } + + /** + * 콘텐츠 타입으로 조회 (추가 메서드) + * @param contentType 콘텐츠 타입 + * @return 도메인 콘텐츠 목록 + */ + public List findByContentType(ContentType contentType) { + log.debug("Finding contents by type: {}", contentType); + + List entities = jpaRepository.findByContentType(contentType.name()); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); } } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java new file mode 100644 index 0000000..37c4e74 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java @@ -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 { + + /** + * 매장 ID로 콘텐츠 목록 조회 + * @param storeId 매장 ID + * @return 콘텐츠 엔티티 목록 + */ + List findByStoreId(Long storeId); + + /** + * 콘텐츠 타입으로 조회 + * @param contentType 콘텐츠 타입 + * @return 콘텐츠 엔티티 목록 + */ + List findByContentType(String contentType); + + /** + * 플랫폼으로 조회 + * @param platform 플랫폼 + * @return 콘텐츠 엔티티 목록 + */ + List findByPlatform(String platform); + + /** + * 상태로 조회 + * @param status 상태 + * @return 콘텐츠 엔티티 목록 + */ + List 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 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 findOngoingContents(); + + /** + * 매장 ID와 콘텐츠 타입으로 조회 + * @param storeId 매장 ID + * @param contentType 콘텐츠 타입 + * @return 콘텐츠 엔티티 목록 + */ + List 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 findRecentContentsByStoreId(@Param("storeId") Long storeId); +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/SpringDataContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/SpringDataContentRepository.java deleted file mode 100644 index feba6b4..0000000 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/SpringDataContentRepository.java +++ /dev/null @@ -1,51 +0,0 @@ -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 org.springframework.stereotype.Repository; - -import java.util.List; - -/** - * Spring Data JPA 콘텐츠 Repository - * - * @author smarketing-team - * @version 1.0 - */ -@Repository -public interface SpringDataContentRepository extends JpaRepository { - - /** - * 필터 조건으로 콘텐츠를 조회합니다. - * - * @param contentType 콘텐츠 타입 - * @param platform 플랫폼 - * @param period 기간 - * @param sortBy 정렬 기준 - * @return 콘텐츠 목록 - */ - @Query("SELECT c FROM ContentJpaEntity c WHERE " + - "(:contentType IS NULL OR c.contentType = :contentType) AND " + - "(:platform IS NULL OR c.platform = :platform) AND " + - "(:period IS NULL OR DATE(c.createdAt) >= CURRENT_DATE - INTERVAL :period DAY) " + - "ORDER BY " + - "CASE WHEN :sortBy = 'latest' THEN c.createdAt END DESC, " + - "CASE WHEN :sortBy = 'oldest' THEN c.createdAt END ASC") - List findByFilters(@Param("contentType") String contentType, - @Param("platform") String platform, - @Param("period") String period, - @Param("sortBy") String sortBy); - - /** - * 진행 중인 콘텐츠를 조회합니다. - * - * @param period 기간 - * @return 진행 중인 콘텐츠 목록 - */ - @Query("SELECT c FROM ContentJpaEntity c " + - "WHERE c.status = 'PUBLISHED' AND " + - "(:period IS NULL OR DATE(c.createdAt) >= CURRENT_DATE - INTERVAL :period DAY)") - List findOngoingContents(@Param("period") String period); -} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java new file mode 100644 index 0000000..403cdfa --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java @@ -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; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java index ce5ee97..0acf9ec 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java @@ -306,7 +306,7 @@ public class SnsContentCreateResponse { // 생성 조건 정보 설정 if (content.getCreationConditions() != null) { builder.generationConditions(GenerationConditionsDto.builder() - .targetAudience(content.getCreationConditions().getTargetAudience()) + //.targetAudience(content.getCreationConditions().getTargetAudience()) .eventName(content.getCreationConditions().getEventName()) .toneAndManner(content.getCreationConditions().getToneAndManner()) .promotionType(content.getCreationConditions().getPromotionType()) diff --git a/smarketing-java/marketing-content/src/main/resources/application.yml b/smarketing-java/marketing-content/src/main/resources/application.yml index 9f7259f..10dc73d 100644 --- a/smarketing-java/marketing-content/src/main/resources/application.yml +++ b/smarketing-java/marketing-content/src/main/resources/application.yml @@ -11,27 +11,23 @@ spring: driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: ${JPA_DDL_AUTO:update} - show-sql: ${JPA_SHOW_SQL:true} + ddl-auto: ${DDL_AUTO:update} + show-sql: ${SHOW_SQL:true} properties: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true -ai: - service: - url: ${AI_SERVICE_URL:http://localhost:8080/ai} - timeout: ${AI_SERVICE_TIMEOUT:30000} + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} -external: - claude-ai: - api-key: ${CLAUDE_AI_API_KEY:your-claude-api-key} - base-url: ${CLAUDE_AI_BASE_URL:https://api.anthropic.com} - model: ${CLAUDE_AI_MODEL:claude-3-sonnet-20240229} - max-tokens: ${CLAUDE_AI_MAX_TOKENS:4000} jwt: secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789} access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} + logging: level: - com.won.smarketing.content: ${LOG_LEVEL:DEBUG} + com.won.smarketing: ${LOG_LEVEL:DEBUG}