fixed marketing-contents

This commit is contained in:
박서은 2025-06-12 10:40:55 +09:00
parent 1e4dd2c7b0
commit 8949c22928
8 changed files with 408 additions and 677 deletions

View File

@ -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,615 +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 = "id")
private Long id;
// ==================== 콘텐츠 분류 ====================
@Enumerated(EnumType.STRING)
@Column(name = "content_type", nullable = false, length = 20)
private ContentType contentType;
@Enumerated(EnumType.STRING)
@Column(name = "platform", nullable = false, length = 20)
private Platform platform;
// ==================== 콘텐츠 내용 ====================
@Column(name = "title", nullable = false, length = 200)
private String title;
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
private String content;
// ==================== 멀티미디어 메타데이터 ====================
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(
name = "content_hashtags",
joinColumns = @JoinColumn(name = "content_id"),
indexes = @Index(name = "idx_content_hashtags", columnList = "content_id")
)
@Column(name = "hashtag", length = 100)
@Builder.Default
private List<String> hashtags = new ArrayList<>();
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(
name = "content_images",
joinColumns = @JoinColumn(name = "content_id"),
indexes = @Index(name = "idx_content_images", columnList = "content_id")
)
@Column(name = "image_url", length = 500)
@Builder.Default
private List<String> images = new ArrayList<>();
// ==================== 상태 관리 ====================
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<String> strings, List<String> 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();
// }
//
// 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;
public void updateStatus(ContentStatus newStatus) {
if (newStatus == null) {
throw new IllegalArgumentException("상태는 필수입니다.");
}
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();
}
// ==================== 팩토리 메서드 ====================
/**
* SNS 콘텐츠 생성 팩토리 메서드
*/
public static Content createSnsContent(String title, String content, Platform platform,
Long storeId, CreationConditions conditions) {
Content snsContent = Content.builder()
// .contentType(ContentType.SNS_POST)
.platform(platform)
.title(title)
.content(content)
.storeId(storeId)
.creationConditions(conditions)
.status(ContentStatus.DRAFT)
.hashtags(new ArrayList<>())
.images(new ArrayList<>())
.build();
// 유효성 검증
snsContent.validateTitle(title);
snsContent.validateContent(content);
return snsContent;
}
/**
* 포스터 콘텐츠 생성 팩토리 메서드
*/
public static Content createPosterContent(String title, String content, List<String> images,
Long storeId, CreationConditions conditions) {
if (images == null || images.isEmpty()) {
throw new IllegalArgumentException("포스터 콘텐츠는 이미지가 필수입니다.");
}
Content posterContent = Content.builder()
.contentType(ContentType.POSTER)
.platform(Platform.INSTAGRAM) // 기본값
.title(title)
.content(content)
.storeId(storeId)
.creationConditions(conditions)
.status(ContentStatus.DRAFT)
.hashtags(new ArrayList<>())
.images(new ArrayList<>(images))
.build();
// 유효성 검증
posterContent.validateTitle(title);
posterContent.validateContent(content);
return posterContent;
}
// ==================== Object 메서드 오버라이드 ====================
/**
* 비즈니스 기반 동등성 비교
* JPA 엔티티에서는 ID가 아닌 비즈니스 사용 권장
*/
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Content content = (Content) obj;
return id != null && id.equals(content.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
/**
* 디버깅용 toString (민감한 정보 제외)
*/
@Override
public String toString() {
return "Content{" +
"id=" + id +
", contentType=" + contentType +
", platform=" + platform +
", title='" + title + '\'' +
", status=" + status +
", storeId=" + storeId +
", promotionStartDate=" + promotionStartDate +
", promotionEndDate=" + promotionEndDate +
", createdAt=" + createdAt +
'}';
public boolean canBePublished() {
return title != null && !title.trim().isEmpty()
&& contentType != null
&& platform != null
&& storeId != null;
}
}
/*
==================== 데이터베이스 스키마 (참고용) ====================
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
);
*/

View File

@ -1,53 +1,51 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java
package com.won.smarketing.content.domain.model;
import jakarta.persistence.Embeddable;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
/**
* 콘텐츠 ID 객체
* Clean Architecture의 Domain Layer에 위치하는 식별자
* Clean Architecture의 Domain Layer에 식별자를 타입 안전하게 관리
*/
@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@RequiredArgsConstructor
@EqualsAndHashCode
public class ContentId {
private Long value;
private final Long value;
/**
* ContentId 생성 팩토리 메서드
* Long 값으로부터 ContentId 생성
* @param value ID
* @return ContentId 인스턴스
*/
public static ContentId of(Long value) {
if (value == null || value <= 0) {
throw new IllegalArgumentException("ContentId 값은 양수여야 합니다.");
throw new IllegalArgumentException("ContentId 양수여야 합니다.");
}
return new ContentId(value);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ContentId contentId = (ContentId) o;
return Objects.equals(value, contentId.value);
/**
* 새로운 ContentId 생성 (ID가 없는 경우)
* @return null 값을 가진 ContentId
*/
public static ContentId newId() {
return new ContentId(null);
}
@Override
public int hashCode() {
return Objects.hash(value);
/**
* ID 존재 여부 확인
* @return ID가 null이 아니면 true
*/
public boolean hasValue() {
return value != null;
}
@Override
public String toString() {
return "ContentId{" + value + '}';
return "ContentId{" + "value=" + value + '}';
}
}

View File

@ -1,7 +1,6 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java
package com.won.smarketing.content.domain.model;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@ -12,57 +11,48 @@ import java.time.LocalDate;
/**
* 콘텐츠 생성 조건 도메인 모델
* Clean Architecture의 Domain Layer에 위치하는 객체
*
* JPA 애노테이션을 제거하여 순수 도메인 모델로 유지
* Infrastructure Layer의 JPA 엔티티는 별도로 관리
*/
@Entity
@Table(name = "contents_conditions")
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CreationConditions {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
//@OneToOne(mappedBy = "creationConditions")
@Column(name = "content", length = 100)
private Content content;
@Column(name = "category", length = 100)
private String id;
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 = 100)
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 = "promotionType", length = 100)
private String promotionType;
public CreationConditions(String category, String requirement, String toneAndManner, String emotionIntensity, String eventName, LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) {
}
// /**
// * 콘텐츠와의 연관관계 설정
// * @param content 연관된 콘텐츠
// */
// public void setContent(Content content) {
// this.content = content;
// }
/**
* 이벤트 기간 유효성 검증
* @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;
}
}

View File

@ -11,6 +11,7 @@ import java.time.LocalDate;
/**
* 콘텐츠 생성 조건 JPA 엔티티
* Infrastructure Layer에서 데이터베이스 매핑을 담당
*/
@Entity
@Table(name = "content_conditions")
@ -50,9 +51,34 @@ public class ContentConditionsJpaEntity {
@Column(name = "photo_style", length = 100)
private String photoStyle;
@Column(name = "target_audience", length = 200)
private String targetAudience;
@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);
}
}

View File

@ -10,6 +10,7 @@ import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
import java.util.Date;
/**
* 콘텐츠 JPA 엔티티
@ -37,6 +38,12 @@ 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;

View File

@ -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<String> list) {
if (list == null || list.isEmpty()) {
@ -128,7 +152,7 @@ public class ContentMapper {
}
/**
* JSON 문자열을 List로 변환합니다.
* JSON 문자열을 List로 변환
*/
private List<String> 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;
}
}
}

View File

@ -6,63 +6,100 @@ 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 저장된 콘텐츠
* @param content 저장할 도메인 콘텐츠
* @return 저장된 도메인 콘텐츠
*/
@Override
public Content save(Content content) {
return jpaRepository.save(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 조회된 콘텐츠
* @return 조회된 도메인 콘텐츠
*/
@Override
public Optional<Content> findById(ContentId id) {
return jpaRepository.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<Content> findByFilters(ContentType contentType, Platform platform, String period, String sortBy) {
return jpaRepository.findByFilters(contentType, platform, period, 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 진행 중인 콘텐츠 목록
* @param period 기간 (현재는 사용하지 않음)
* @return 진행 중인 도메인 콘텐츠 목록
*/
@Override
public List<Content> findOngoingContents(String period) {
return jpaRepository.findOngoingContents(period);
log.debug("Finding ongoing contents");
List<ContentJpaEntity> entities = jpaRepository.findOngoingContents();
return entities.stream()
.map(contentMapper::toDomain)
.collect(Collectors.toList());
}
/**
@ -71,6 +108,40 @@ public class JpaContentRepository implements ContentRepository {
*/
@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());
}
}

View File

@ -1,9 +1,7 @@
// 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.domain.model.Content;
import com.won.smarketing.content.domain.model.ContentType;
import com.won.smarketing.content.domain.model.Platform;
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;
@ -13,37 +11,77 @@ import java.util.List;
/**
* Spring Data JPA 콘텐츠 리포지토리 인터페이스
* Clean Architecture의 Infrastructure Layer에 위치
* JPA 엔티티(ContentJpaEntity) 사용하여 데이터베이스 접근
*/
public interface JpaContentRepositoryInterface extends JpaRepository<Content, Long> {
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 Content c WHERE " +
@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 " +
" (:period = 'week' AND c.createdAt >= CURRENT_DATE - 7) OR " +
" (:period = 'month' AND c.createdAt >= CURRENT_DATE - 30) OR " +
" (:period = 'year' AND c.createdAt >= CURRENT_DATE - 365)) " +
"ORDER BY " +
"CASE WHEN :sortBy = 'latest' THEN c.createdAt END DESC, " +
"CASE WHEN :sortBy = 'oldest' THEN c.createdAt END ASC, " +
"CASE WHEN :sortBy = 'title' THEN c.title END ASC")
List<Content> findByFilters(@Param("contentType") ContentType contentType,
@Param("platform") Platform platform,
@Param("period") String period,
@Param("sortBy") String sortBy);
"(: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 Content c WHERE " +
"c.status IN ('PUBLISHED', 'SCHEDULED') AND " +
"(:period IS NULL OR " +
" (:period = 'week' AND c.createdAt >= CURRENT_DATE - 7) OR " +
" (:period = 'month' AND c.createdAt >= CURRENT_DATE - 30) OR " +
" (:period = 'year' AND c.createdAt >= CURRENT_DATE - 365)) " +
@Query("SELECT c FROM ContentJpaEntity c WHERE " +
"c.status IN ('PUBLISHED', 'SCHEDULED') " +
"ORDER BY c.createdAt DESC")
List<Content> findOngoingContents(@Param("period") String period);
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);
}